A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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