A file-based task manager
0
fork

Configure Feed

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

at 23b332da978ceeffd6ef98947e8a13a29b0f4772 273 lines 8.0 kB view raw
1#![allow(dead_code)] 2use nix::fcntl::{Flock, FlockArg}; 3 4use crate::errors::{Error, Result}; 5use crate::stack::TaskStack; 6use crate::{fzf, util}; 7use std::fmt::Display; 8use std::fs::{self, File}; 9use std::io::{BufRead as _, BufReader, Read, Seek, SeekFrom}; 10use std::path::PathBuf; 11use std::str::FromStr; 12use std::{fs::OpenOptions, io::Write}; 13 14const INDEXFILE: &str = "index"; 15const TITLECACHEFILE: &str = "cache"; 16/// A unique identifier for a task. When referenced in text, it is prefixed with `tsk-`. 17#[derive(Clone, Copy, Debug, Eq, PartialEq)] 18pub struct Id(pub u32); 19 20impl FromStr for Id { 21 type Err = Error; 22 23 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { 24 let s = s 25 .strip_prefix("tsk-") 26 .ok_or(Self::Err::Parse(format!("expected tsk- prefix. Got {s}")))?; 27 Ok(Self(s.parse()?)) 28 } 29} 30 31impl Display for Id { 32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 33 write!(f, "tsk-{}", self.0) 34 } 35} 36 37impl From<u32> for Id { 38 fn from(value: u32) -> Self { 39 Id(value) 40 } 41} 42 43impl Id { 44 pub fn to_filename(&self) -> String { 45 format!("tsk-{}.tsk", self.0) 46 } 47} 48 49pub enum TaskIdentifier { 50 Id(Id), 51 Relative(u32), 52 Find, 53} 54 55impl From<Id> for TaskIdentifier { 56 fn from(value: Id) -> Self { 57 TaskIdentifier::Id(value) 58 } 59} 60 61pub struct Workspace { 62 /// The path to the workspace root, excluding the .tsk directory. This should *contain* the 63 /// .tsk directory. 64 path: PathBuf, 65} 66 67impl Workspace { 68 pub fn init(path: PathBuf) -> Result<()> { 69 // TODO: detect if in a git repo and add .tsk/ to `.git/info/exclude` 70 let tsk_dir = path.join(".tsk"); 71 if tsk_dir.exists() { 72 return Err(Error::AlreadyInitialized); 73 } 74 std::fs::create_dir(&tsk_dir)?; 75 // Create the tasks directory 76 std::fs::create_dir(&tsk_dir.join("tasks"))?; 77 // Create the archive directory 78 std::fs::create_dir(&tsk_dir.join("archive"))?; 79 let mut next = OpenOptions::new() 80 .read(true) 81 .write(true) 82 .create(true) 83 .open(tsk_dir.join("next"))?; 84 next.write_all(b"1\n")?; 85 Ok(()) 86 } 87 88 pub fn from_path(path: PathBuf) -> Result<Self> { 89 let tsk_dir = util::find_parent_with_dir(path, ".tsk")?.ok_or(Error::Uninitialized)?; 90 Ok(Self { path: tsk_dir }) 91 } 92 93 fn resolve(&self, identifier: TaskIdentifier) -> Result<Id> { 94 match identifier { 95 TaskIdentifier::Id(id) => Ok(id), 96 TaskIdentifier::Relative(r) => { 97 let stack = self.read_stack()?; 98 let stack_item = stack.get(r as usize).ok_or(Error::NoTasks)?; 99 Ok(stack_item.id) 100 } 101 TaskIdentifier::Find => self.search(None, false, false)?.ok_or(Error::NotSelected), 102 } 103 } 104 105 pub fn next_id(&self) -> Result<Id> { 106 let mut file = util::flopen(self.path.join("next"), FlockArg::LockExclusive)?; 107 let mut buf = String::new(); 108 file.read_to_string(&mut buf)?; 109 let id = buf.trim().parse::<u32>()?; 110 // reset the files contents 111 file.set_len(0)?; 112 file.seek(SeekFrom::Start(0))?; 113 // store the *next* if 114 file.write_all(format!("{}\n", id + 1).as_bytes())?; 115 Ok(Id(id)) 116 } 117 118 pub fn new_task(&self, title: String, body: String) -> Result<Task> { 119 // WARN: we could improperly increment the id if the task is not written to disk/errors. 120 // But who cares 121 let id = self.next_id()?; 122 let task_path = self.path.join("tasks").join(format!("tsk-{}.tsk", id.0)); 123 let mut file = util::flopen(task_path.clone(), FlockArg::LockExclusive)?; 124 file.write_all(format!("{title}\n\n{body}").as_bytes())?; 125 // create a hardlink to the archive dir 126 fs::hard_link( 127 task_path, 128 self.path.join("archive").join(format!("tsk-{}.tsk", id.0)), 129 )?; 130 Ok(Task { 131 id, 132 title, 133 body, 134 file, 135 }) 136 } 137 138 pub fn task(&self, identifier: TaskIdentifier) -> Result<Task> { 139 let id = self.resolve(identifier)?; 140 141 let file = util::flopen( 142 self.path.join("tasks").join(format!("tsk-{}.tsk", id.0)), 143 FlockArg::LockExclusive, 144 )?; 145 let mut title = String::new(); 146 let mut body = String::new(); 147 let mut reader = BufReader::new(&*file); 148 reader.read_line(&mut title)?; 149 reader.read_to_string(&mut body)?; 150 drop(reader); 151 Ok(Task { 152 id, 153 title, 154 body, 155 file, 156 }) 157 } 158 159 pub fn read_stack(&self) -> Result<TaskStack> { 160 TaskStack::from_tskdir(&self.path) 161 } 162 163 pub fn push_task(&self, task: Task) -> Result<()> { 164 let mut stack = TaskStack::from_tskdir(&self.path)?; 165 stack.push(task.try_into()?); 166 stack.save()?; 167 Ok(()) 168 } 169 170 pub fn swap_top(&self) -> Result<()> { 171 let mut stack = TaskStack::from_tskdir(&self.path)?; 172 stack.swap(); 173 stack.save()?; 174 Ok(()) 175 } 176 177 pub fn rot(&self) -> Result<()> { 178 let mut stack = TaskStack::from_tskdir(&self.path)?; 179 let top = stack.pop(); 180 let second = stack.pop(); 181 let third = stack.pop(); 182 183 if top.is_none() || second.is_none() || third.is_none() { 184 return Ok(()); 185 } 186 187 stack.push(second.unwrap()); 188 stack.push(top.unwrap()); 189 stack.push(third.unwrap()); 190 stack.save()?; 191 Ok(()) 192 } 193 194 /// The inverse of tor. Pushes the top item behind the second item, shifting #2 and #3 to #1 195 /// and #2 respectively. 196 pub fn tor(&self) -> Result<()> { 197 let mut stack = TaskStack::from_tskdir(&self.path)?; 198 let top = stack.pop(); 199 let second = stack.pop(); 200 let third = stack.pop(); 201 202 if top.is_none() || second.is_none() || third.is_none() { 203 return Ok(()); 204 } 205 206 stack.push(top.unwrap()); 207 stack.push(third.unwrap()); 208 stack.push(second.unwrap()); 209 stack.save()?; 210 Ok(()) 211 } 212 213 pub fn drop(&self) -> Result<Option<Id>> { 214 let mut stack = self.read_stack()?; 215 if let Some(stack_item) = stack.pop() { 216 let task_path = self 217 .path 218 .join("tasks") 219 .join(format!("{}.tsk", stack_item.id)); 220 fs::remove_file(task_path)?; 221 stack.save()?; 222 Ok(Some(stack_item.id)) 223 } else { 224 Ok(None) 225 } 226 } 227 228 pub fn search( 229 &self, 230 stack: Option<TaskStack>, 231 _search_body: bool, 232 _include_archived: bool, 233 ) -> Result<Option<Id>> { 234 let stack = if let Some(stack) = stack { 235 stack 236 } else { 237 self.read_stack()? 238 }; 239 Ok(fzf::select(stack)?.map(|si| si.id)) 240 } 241 242 pub fn reprioritize(&self, identifier: TaskIdentifier) -> Result<()> { 243 let id = self.resolve(identifier)?; 244 let mut stack = self.read_stack()?; 245 let index = &stack.iter().map(|i| i.id).position(|i| i == id); 246 if let Some(index) = index { 247 let prioritized_task = stack.remove(*index); 248 // unwrap here is safe because we just searched for the index and know it exists 249 stack.push(prioritized_task.unwrap()); 250 stack.save()?; 251 } 252 Ok(()) 253 } 254} 255 256pub struct Task { 257 pub id: Id, 258 pub title: String, 259 pub body: String, 260 pub file: Flock<File>, 261} 262 263impl Task { 264 /// Consumes a task and saves it to disk. 265 pub fn save(mut self) -> Result<()> { 266 self.file.set_len(0)?; 267 self.file.seek(SeekFrom::Start(0))?; 268 self.file.write_all(self.title.trim().as_bytes())?; 269 self.file.write_all(b"\n\n")?; 270 self.file.write_all(self.body.trim().as_bytes())?; 271 Ok(()) 272 } 273}