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::{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}