A file-based task manager
1#![allow(dead_code)] 2use nix::fcntl::{Flock, FlockArg}; 3use xattr::FileExt; 4 5use crate::attrs::Attrs; 6use crate::errors::{Error, Result}; 7use crate::stack::{StackItem, TaskStack}; 8use crate::task::parse as parse_task; 9use crate::{fzf, util}; 10use std::collections::{vec_deque, BTreeMap, HashSet}; 11use std::ffi::OsString; 12use std::fmt::Display; 13use std::fs::{remove_file, File}; 14use std::io::{BufRead as _, BufReader, Read, Seek, SeekFrom}; 15use std::ops::Deref; 16use std::os::unix::fs::symlink; 17use std::path::PathBuf; 18use std::str::FromStr; 19use std::{fs::OpenOptions, io::Write}; 20 21const INDEXFILE: &str = "index"; 22const TITLECACHEFILE: &str = "cache"; 23const XATTRPREFIX: &str = "user.tsk."; 24const 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)] 27pub struct Id(pub u32); 28 29impl FromStr for Id { 30 type Err = Error; 31 32 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { 33 let s = s 34 .trim() 35 .strip_prefix("tsk-") 36 .ok_or(Self::Err::Parse(format!("expected tsk- prefix. Got {s}")))?; 37 Ok(Self(s.parse()?)) 38 } 39} 40 41impl Display for Id { 42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 43 write!(f, "tsk-{}", self.0) 44 } 45} 46 47impl From<u32> for Id { 48 fn from(value: u32) -> Self { 49 Id(value) 50 } 51} 52 53impl Id { 54 /// Returns the filename for a task with this id. 55 pub fn filename(&self) -> String { 56 format!("tsk-{}.tsk", self.0) 57 } 58} 59 60pub enum TaskIdentifier { 61 Id(Id), 62 Relative(u32), 63 Find { search_body: bool, archived: bool }, 64} 65 66impl From<Id> for TaskIdentifier { 67 fn from(value: Id) -> Self { 68 TaskIdentifier::Id(value) 69 } 70} 71 72pub struct Workspace { 73 /// The path to the workspace root, excluding the .tsk directory. This should *contain* the 74 /// .tsk directory. 75 path: PathBuf, 76} 77 78impl Workspace { 79 pub fn init(path: PathBuf) -> Result<()> { 80 // TODO: detect if in a git repo and add .tsk/ to `.git/info/exclude` 81 let tsk_dir = path.join(".tsk"); 82 if tsk_dir.exists() { 83 return Err(Error::AlreadyInitialized); 84 } 85 std::fs::create_dir(&tsk_dir)?; 86 // Create the tasks directory 87 std::fs::create_dir(tsk_dir.join("tasks"))?; 88 // Create the archive directory 89 std::fs::create_dir(tsk_dir.join("archive"))?; 90 let mut next = OpenOptions::new() 91 .read(true) 92 .write(true) 93 .create(true) 94 .truncate(true) 95 .open(tsk_dir.join("next"))?; 96 next.write_all(b"1\n")?; 97 Ok(()) 98 } 99 100 pub fn from_path(path: PathBuf) -> Result<Self> { 101 let tsk_dir = util::find_parent_with_dir(path, ".tsk")?.ok_or(Error::Uninitialized)?; 102 Ok(Self { path: tsk_dir }) 103 } 104 105 fn resolve(&self, identifier: TaskIdentifier) -> Result<Id> { 106 match identifier { 107 TaskIdentifier::Id(id) => Ok(id), 108 TaskIdentifier::Relative(r) => { 109 let stack = self.read_stack()?; 110 let stack_item = stack.get(r as usize).ok_or(Error::NoTasks)?; 111 Ok(stack_item.id) 112 } 113 TaskIdentifier::Find { 114 search_body, 115 archived, 116 } => self 117 .search(None, search_body, archived)? 118 .ok_or(Error::NotSelected), 119 } 120 } 121 122 /// Increments the `next` counter and returns the previous value. 123 pub fn next_id(&self) -> Result<Id> { 124 let mut file = util::flopen(self.path.join("next"), FlockArg::LockExclusive)?; 125 let mut buf = String::new(); 126 file.read_to_string(&mut buf)?; 127 let id = buf.trim().parse::<u32>()?; 128 // reset the files contents 129 file.set_len(0)?; 130 file.seek(SeekFrom::Start(0))?; 131 // store the *next* if 132 file.write_all(format!("{}\n", id + 1).as_bytes())?; 133 Ok(Id(id)) 134 } 135 136 pub fn new_task(&self, title: String, body: String) -> Result<Task> { 137 // WARN: we could improperly increment the id if the task is not written to disk/errors. 138 // But who cares 139 let id = self.next_id()?; 140 let task_name = format!("tsk-{}.tsk", id.0); 141 // the task goes in the archive first 142 let task_path = self.path.join("archive").join(&task_name); 143 let mut file = util::flopen(task_path.clone(), FlockArg::LockExclusive)?; 144 file.write_all(format!("{title}\n\n{body}").as_bytes())?; 145 // create a hardlink to the task dir to mark it as "open" 146 symlink( 147 PathBuf::from("../archive").join(&task_name), 148 self.path.join("tasks").join(task_name), 149 )?; 150 Ok(Task { 151 id, 152 title, 153 body, 154 file, 155 attributes: Default::default(), 156 }) 157 } 158 159 pub fn task(&self, identifier: TaskIdentifier) -> Result<Task> { 160 let id = self.resolve(identifier)?; 161 162 let file = util::flopen( 163 self.path.join("tasks").join(format!("tsk-{}.tsk", id.0)), 164 FlockArg::LockExclusive, 165 )?; 166 let mut title = String::new(); 167 let mut body = String::new(); 168 let mut reader = BufReader::new(&*file); 169 reader.read_line(&mut title)?; 170 reader.read_to_string(&mut body)?; 171 drop(reader); 172 let mut read_attributes = BTreeMap::new(); 173 if let Ok(attrs) = file.list_xattr() { 174 for attr in attrs { 175 if let Some((key, value)) = Self::read_xattr(&file, attr) { 176 read_attributes.insert(key, value); 177 } 178 } 179 } 180 Ok(Task { 181 id, 182 file, 183 title: title.trim().to_string(), 184 body: body.trim().to_string(), 185 attributes: Attrs::from_written(read_attributes), 186 }) 187 } 188 189 pub fn handle_metadata(&self, tsk: &Task, pre_links: Option<HashSet<Id>>) -> Result<()> { 190 // Parse the task and update any backlinks 191 if let Some(parsed_task) = parse_task(&tsk.to_string()) { 192 let internal_links = parsed_task.intenal_links(); 193 for link in &internal_links { 194 self.add_backlink(*link, tsk.id)?; 195 } 196 if let Some(pre_links) = pre_links { 197 let removed_links = pre_links.difference(&internal_links); 198 for link in removed_links { 199 self.remove_backlink(*link, tsk.id)?; 200 } 201 } 202 } 203 Ok(()) 204 } 205 206 fn add_backlink(&self, to: Id, from: Id) -> Result<()> { 207 let to_task = self.task(TaskIdentifier::Id(to))?; 208 let (_, current_backlinks_text) = 209 Self::read_xattr(&to_task.file, BACKREFXATTR.into()).unwrap_or_default(); 210 let mut backlinks: HashSet<Id> = current_backlinks_text 211 .split(',') 212 .filter_map(|s| Id::from_str(s).ok()) 213 .collect(); 214 backlinks.insert(from); 215 Self::set_xattr( 216 &to_task.file, 217 BACKREFXATTR, 218 &itertools::join(backlinks, ","), 219 ) 220 } 221 222 fn remove_backlink(&self, to: Id, from: Id) -> Result<()> { 223 let to_task = self.task(TaskIdentifier::Id(to))?; 224 let (_, current_backlinks_text) = 225 Self::read_xattr(&to_task.file, BACKREFXATTR.into()).unwrap_or_default(); 226 let mut backlinks: HashSet<Id> = current_backlinks_text 227 .split(',') 228 .filter_map(|s| Id::from_str(s).ok()) 229 .collect(); 230 backlinks.remove(&from); 231 Self::set_xattr( 232 &to_task.file, 233 BACKREFXATTR, 234 &itertools::join(backlinks, ","), 235 ) 236 } 237 238 /// Reads an xattr from a file, stripping the prefix for 239 fn read_xattr<D: Deref<Target = File>>(file: &D, key: OsString) -> Option<(String, String)> { 240 // this *shouldn't* allocate, but it does O(n) scan the str for UTF-8 correctness 241 let parsedkey = key.as_os_str().to_str()?.strip_prefix(XATTRPREFIX)?; 242 let valuebytes = file.get_xattr(&key).ok().flatten()?; 243 Some((parsedkey.to_string(), String::from_utf8(valuebytes).ok()?)) 244 } 245 246 fn set_xattr<D: Deref<Target = File>>(file: &D, key: &str, value: &str) -> Result<()> { 247 let key = if !key.starts_with(XATTRPREFIX) { 248 format!("{XATTRPREFIX}.{key}") 249 } else { 250 key.to_string() 251 }; 252 Ok(file.set_xattr(key, value.as_bytes())?) 253 } 254 255 pub fn read_stack(&self) -> Result<TaskStack> { 256 TaskStack::from_tskdir(&self.path) 257 } 258 259 pub fn push_task(&self, task: Task) -> Result<()> { 260 let mut stack = TaskStack::from_tskdir(&self.path)?; 261 stack.push(task.try_into()?); 262 stack.save()?; 263 Ok(()) 264 } 265 266 pub fn append_task(&self, task: Task) -> Result<()> { 267 let mut stack = TaskStack::from_tskdir(&self.path)?; 268 stack.push_back(task.try_into()?); 269 stack.save()?; 270 Ok(()) 271 } 272 273 pub fn swap_top(&self) -> Result<()> { 274 let mut stack = TaskStack::from_tskdir(&self.path)?; 275 stack.swap(); 276 stack.save()?; 277 Ok(()) 278 } 279 280 pub fn rot(&self) -> Result<()> { 281 let mut stack = TaskStack::from_tskdir(&self.path)?; 282 let top = stack.pop(); 283 let second = stack.pop(); 284 let third = stack.pop(); 285 286 if top.is_none() || second.is_none() || third.is_none() { 287 return Ok(()); 288 } 289 290 // unwrap is ok here because we checked above 291 stack.push(second.unwrap()); 292 stack.push(top.unwrap()); 293 stack.push(third.unwrap()); 294 stack.save()?; 295 Ok(()) 296 } 297 298 /// The inverse of tor. Pushes the top item behind the second item, shifting #2 and #3 to #1 299 /// and #2 respectively. 300 pub fn tor(&self) -> Result<()> { 301 let mut stack = TaskStack::from_tskdir(&self.path)?; 302 let top = stack.pop(); 303 let second = stack.pop(); 304 let third = stack.pop(); 305 306 if top.is_none() || second.is_none() || third.is_none() { 307 return Ok(()); 308 } 309 310 stack.push(top.unwrap()); 311 stack.push(third.unwrap()); 312 stack.push(second.unwrap()); 313 stack.save()?; 314 Ok(()) 315 } 316 317 pub fn drop(&self, identifier: TaskIdentifier) -> Result<Option<Id>> { 318 let id = self.resolve(identifier)?; 319 let mut stack = self.read_stack()?; 320 let index = &stack.iter().map(|i| i.id).position(|i| i == id); 321 // TODO: remove the softlink in .tsk/tasks 322 let task = if let Some(index) = index { 323 let prioritized_task = stack.remove(*index); 324 stack.save()?; 325 prioritized_task.map(|t| t.id) 326 } else { 327 None 328 }; 329 remove_file(self.path.join("tasks").join(format!("{id}.tsk")))?; 330 Ok(task) 331 } 332 333 pub fn search( 334 &self, 335 stack: Option<TaskStack>, 336 search_body: bool, 337 _include_archived: bool, 338 ) -> Result<Option<Id>> { 339 let stack = if let Some(stack) = stack { 340 stack 341 } else { 342 self.read_stack()? 343 }; 344 if search_body { 345 let loader = LazyTaskLoader { 346 files: stack.into_iter(), 347 workspace: self, 348 }; 349 // search the entirety of a task 350 Ok(fzf::select(loader)?.map(|bt| bt.id)) 351 } else { 352 // just search the stack 353 Ok(fzf::select(stack)?.map(|si| si.id)) 354 } 355 } 356 357 pub fn prioritize(&self, identifier: TaskIdentifier) -> Result<()> { 358 let id = self.resolve(identifier)?; 359 let mut stack = self.read_stack()?; 360 let index = &stack.iter().map(|i| i.id).position(|i| i == id); 361 if let Some(index) = index { 362 let prioritized_task = stack.remove(*index); 363 // unwrap here is safe because we just searched for the index and know it exists 364 stack.push(prioritized_task.unwrap()); 365 stack.save()?; 366 } 367 Ok(()) 368 } 369 370 pub fn deprioritize(&self, identifier: TaskIdentifier) -> Result<()> { 371 let id = self.resolve(identifier)?; 372 let mut stack = self.read_stack()?; 373 let index = &stack.iter().map(|i| i.id).position(|i| i == id); 374 if let Some(index) = index { 375 let deprioritized_task = stack.remove(*index); 376 // unwrap here is safe because we just searched for the index and know it exists 377 stack.push_back(deprioritized_task.unwrap()); 378 stack.save()?; 379 } 380 Ok(()) 381 } 382} 383 384pub struct Task { 385 pub id: Id, 386 pub title: String, 387 pub body: String, 388 pub file: Flock<File>, 389 pub attributes: Attrs, 390} 391 392impl Display for Task { 393 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 394 write!(f, "{}\n\n{}", self.title, &self.body) 395 } 396} 397 398impl Task { 399 /// Consumes a task and saves it to disk. 400 pub fn save(mut self) -> Result<()> { 401 self.file.set_len(0)?; 402 self.file.seek(SeekFrom::Start(0))?; 403 self.file.write_all(self.title.trim().as_bytes())?; 404 self.file.write_all(b"\n\n")?; 405 self.file.write_all(self.body.trim().as_bytes())?; 406 Ok(()) 407 } 408 409 fn bare(self) -> SearchTask { 410 SearchTask { 411 id: self.id, 412 title: self.title, 413 body: self.body, 414 } 415 } 416} 417 418/// A task container without a file handle 419pub struct SearchTask { 420 pub id: Id, 421 pub title: String, 422 pub body: String, 423} 424 425impl FromStr for SearchTask { 426 type Err = Error; 427 428 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { 429 let (tsk_id, task_content) = s.split_once('\t').ok_or(Error::Parse( 430 "Missing TSK-ID or content or task parse.".to_owned(), 431 ))?; 432 let (title, body) = task_content 433 .split_once('\t') 434 .ok_or(Error::Parse("Missing body for task parse.".to_owned()))?; 435 Ok(Self { 436 id: tsk_id.parse()?, 437 title: title.to_string(), 438 body: body.to_string(), 439 }) 440 } 441} 442 443impl Display for SearchTask { 444 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 445 write!( 446 f, 447 "{}\t{}\t{}", 448 self.id, 449 self.title.trim(), 450 self.body.replace('\n', " ").replace('\r', "") 451 ) 452 } 453} 454 455struct LazyTaskLoader<'a> { 456 files: vec_deque::IntoIter<StackItem>, 457 workspace: &'a Workspace, 458} 459 460impl Iterator for LazyTaskLoader<'_> { 461 type Item = SearchTask; 462 463 fn next(&mut self) -> Option<Self::Item> { 464 let stack_item = self.files.next()?; 465 let task = self 466 .workspace 467 .task(TaskIdentifier::Id(stack_item.id)) 468 .ok()?; 469 Some(task.bare()) 470 } 471} 472 473#[cfg(test)] 474mod test { 475 use super::*; 476 477 #[test] 478 fn test_bare_task_display() { 479 let task = SearchTask { 480 id: Id(123), 481 title: "Hello, world".to_string(), 482 body: "The body of the task.\nAnother line\r\nis here.".to_string(), 483 }; 484 assert_eq!( 485 "tsk-123\tHello, world\tThe body of the task. Another line is here.", 486 task.to_string() 487 ); 488 } 489 490 #[test] 491 fn test_task_display() { 492 let task = Task { 493 id: Id(123), 494 title: "Hello, world".to_string(), 495 body: "The body of the task.".to_string(), 496 file: util::flopen("/dev/null".into(), FlockArg::LockShared).unwrap(), 497 attributes: Default::default(), 498 }; 499 assert_eq!("Hello, world\n\nThe body of the task.", task.to_string()); 500 } 501}