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}