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