1use color_eyre::eyre::{bail, Context, Result};
2use file_lock::{FileLock, FileOptions};
3use glob::glob;
4use markdown::{self, mdast::Node};
5use serde::{Deserialize, Serialize};
6use simple_file_rotation::FileRotation;
7use std::{
8 collections::BTreeMap,
9 fmt::Display,
10 fs::File,
11 io::{Read, Write},
12 path::PathBuf,
13};
14use thiserror::Error;
15use uuid::Uuid;
16
17use crate::{
18 metadata::MetadataKeyValuePair,
19 settings::Settings,
20 task::{load_task, task_pathbuf_from_id, Task},
21};
22
23#[cfg(feature = "notify")]
24use crate::notify::DatabaseFileType;
25
26#[derive(Error, Debug, PartialEq, Eq)]
28pub enum NoteError {
29 #[error("error while parsing action points")]
31 ActionPointParseError,
32 #[cfg(feature = "notify")]
34 #[error("notifier result kind is not for a Note")]
35 IncompatibleNotifyKind,
36}
37
38#[derive(Debug, Serialize, Deserialize)]
40pub struct Note {
41 pub task_id: Uuid,
43 pub markdown: Option<String>,
45 pub metadata: BTreeMap<String, String>,
47}
48
49impl Display for Note {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 write!(f, "{}", self.to_yaml_string().unwrap())
52 }
53}
54
55impl Note {
56 #[cfg(feature = "notify")]
58 pub fn from_notify_event(event: DatabaseFileType, settings: &Settings) -> Result<Note> {
59 match event {
60 DatabaseFileType::Note(uuid) => load_note(&uuid.to_string(), settings),
61 _ => bail!(NoteError::IncompatibleNotifyKind)
62 }
63 }
64
65 pub fn new(task_id: &Uuid) -> Self {
67 let mut metadata: BTreeMap<String, String> = BTreeMap::new();
68 let timestamp = chrono::offset::Local::now();
69 metadata.insert(
70 String::from("tsk-rs-note-create-time"),
71 timestamp.to_rfc3339(),
72 );
73
74 Self {
75 task_id: *task_id,
76 markdown: None,
77 metadata,
78 }
79 }
80
81 pub fn from_yaml_string(yaml_string: &str) -> Result<Self> {
83 serde_yaml::from_str(yaml_string).with_context(|| "while deserializing note yaml string")
84 }
85
86 pub fn to_yaml_string(&self) -> Result<String> {
88 serde_yaml::to_string(self).with_context(|| "while serializing note struct as YAML")
89 }
90
91 pub fn load_yaml_file_from(note_pathbuf: &PathBuf) -> Result<Self> {
93 let note: Note;
94 {
95 let mut file = File::open(note_pathbuf)
96 .with_context(|| "while opening note yaml file for reading")?;
97 let mut note_yaml: String = String::new();
98 file.read_to_string(&mut note_yaml)
99 .with_context(|| "while reading note yaml file")?;
100 note = Note::from_yaml_string(¬e_yaml)
101 .with_context(|| "while serializing yaml into note struct")?;
102 }
103 Ok(note)
104 }
105
106 pub fn save_yaml_file_to(&mut self, note_pathbuf: &PathBuf, rotate: &usize) -> Result<()> {
108 if note_pathbuf.is_file() && rotate > &0 {
110 FileRotation::new(¬e_pathbuf)
111 .max_old_files(*rotate)
112 .file_extension("yaml".to_string())
113 .rotate()
114 .with_context(|| "while rotating note data file backups")?;
115 }
116
117 let should_we_block = true;
118 let options = FileOptions::new()
119 .write(true)
120 .create(true)
121 .truncate(true)
122 .append(false);
123 {
124 let mut filelock = FileLock::lock(note_pathbuf, should_we_block, options)
125 .with_context(|| "while opening note yaml file")?;
126 filelock
127 .file
128 .write_all(
129 self.to_yaml_string()
130 .with_context(|| "while serializing note struct to yaml")?
131 .as_bytes(),
132 )
133 .with_context(|| "while writing to note yaml file")?;
134 filelock
135 .file
136 .flush()
137 .with_context(|| "while flushing os caches to disk")?;
138 filelock
139 .file
140 .sync_all()
141 .with_context(|| "while syncing filesystem metadata")?;
142 }
143
144 Ok(())
145 }
146
147 pub fn get_action_points(&self) -> Result<Option<Vec<ActionPoint>>> {
149 if let Some(markdown_body) = self.markdown.clone() {
150 let parse_result = markdown::to_mdast(&markdown_body, &markdown::ParseOptions::gfm());
151 if parse_result.is_err() {
152 panic!("error on parse")
153 }
154 let root_node = parse_result.unwrap();
155 return parse_md_component(&self.task_id, &root_node);
156 }
157 Ok(None)
158 }
159
160 pub fn set_characteristic(&mut self, metadata: &Option<Vec<MetadataKeyValuePair>>) -> bool {
162 let mut modified = false;
163
164 if let Some(metadata) = metadata {
165 for new_metadata in metadata {
166 self.metadata
167 .insert(new_metadata.key.clone(), new_metadata.value.clone());
168 modified = true;
169 }
170 }
171
172 modified
173 }
174
175 pub fn unset_characteristic(&mut self, metadata: &Option<Vec<String>>) -> bool {
177 let mut modified = false;
178
179 if let Some(metadata) = metadata {
180 for remove_metadata in metadata {
181 let old = self.metadata.remove(remove_metadata);
182 if old.is_some() {
183 modified = true;
184 }
185 }
186 }
187
188 modified
189 }
190}
191
192fn parse_md_component(task_id: &Uuid, node: &Node) -> Result<Option<Vec<ActionPoint>>> {
193 let mut found_action_points = vec![];
194
195 if let Some(child_nodes) = node.children() {
196 for child_node in child_nodes {
197 found_action_points
198 .append(&mut parse_md_component(task_id, child_node)?.unwrap_or_default());
199 }
200 }
201
202 if let Node::ListItem(list_node) = node {
203 if list_node.checked.is_some() {
204 let action_description_paragraphs = list_node.children.clone().pop().unwrap();
205 let action_description = match action_description_paragraphs
206 .children()
207 .unwrap()
208 .to_owned()
209 .pop()
210 .unwrap()
211 {
212 Node::Text(item_text) => item_text.value,
213 _ => bail!(NoteError::ActionPointParseError),
214 };
215 found_action_points.push(ActionPoint {
216 id: Uuid::new_v5(
217 &Uuid::NAMESPACE_URL,
218 format!("tsk-rs://{}/{}", task_id, action_description).as_bytes(),
219 ),
220 description: action_description,
221 checked: list_node.checked.unwrap(),
222 });
223 }
224 }
225
226 if found_action_points.is_empty() {
227 Ok(None)
228 } else {
229 Ok(Some(found_action_points))
230 }
231}
232
233#[derive(Debug)]
235pub struct ActionPoint {
236 pub id: Uuid,
238 pub description: String,
240 pub checked: bool,
242}
243
244pub fn note_pathbuf_from_id(id: &String, settings: &Settings) -> Result<PathBuf> {
246 Ok(settings
247 .note_db_pathbuf()?
248 .join(PathBuf::from(format!("{}.yaml", id))))
249}
250
251pub fn note_pathbuf_from_note(note: &Note, settings: &Settings) -> Result<PathBuf> {
253 note_pathbuf_from_id(¬e.task_id.to_string(), settings)
254}
255
256pub fn load_note(id: &String, settings: &Settings) -> Result<Note> {
258 let note_pathbuf =
259 note_pathbuf_from_id(id, settings).with_context(|| "while building path of the file")?;
260 let note = Note::load_yaml_file_from(¬e_pathbuf)
261 .with_context(|| "while loading note yaml file for editing")?;
262 Ok(note)
263}
264
265pub fn save_note(note: &mut Note, settings: &Settings) -> Result<()> {
267 let note_pathbuf = note_pathbuf_from_note(note, settings)?;
268 note.save_yaml_file_to(¬e_pathbuf, &settings.data.rotate)
269 .with_context(|| "while saving note yaml file")?;
270 Ok(())
271}
272
273pub struct FoundNote {
275 pub note: Note,
277 pub task: Option<Task>,
279}
280
281pub fn amount_of_notes(settings: &Settings, include_backups: bool) -> Result<usize> {
283 let mut notes: usize = 0;
284 let task_pathbuf: PathBuf = note_pathbuf_from_id(&"*".to_string(), settings)?;
285 for note_filename in glob(task_pathbuf.to_str().unwrap())
286 .with_context(|| "while traversing task data directory files")?
287 {
288 if note_filename
290 .as_ref()
291 .unwrap()
292 .file_name()
293 .unwrap()
294 .to_string_lossy()
295 .split('.')
296 .collect::<Vec<_>>()[1]
297 != "yaml"
298 && !include_backups
299 {
300 continue;
301 }
302 notes += 1;
303 }
304 Ok(notes)
305}
306
307pub fn list_notes(
309 id: &Option<String>,
310 orphaned: &bool,
311 completed: &bool,
312 settings: &Settings,
313) -> Result<Vec<FoundNote>> {
314 let note_pathbuf: PathBuf = if id.is_some() {
315 note_pathbuf_from_id(&format!("*{}*", id.as_ref().unwrap()), settings)?
316 } else {
317 note_pathbuf_from_id(&"*".to_string(), settings)?
318 };
319
320 let mut found_notes: Vec<FoundNote> = vec![];
321
322 for note_filename in glob(note_pathbuf.to_str().unwrap())
323 .with_context(|| "while traversing note data directory files")?
324 {
325 if note_filename
327 .as_ref()
328 .unwrap()
329 .file_name()
330 .unwrap()
331 .to_string_lossy()
332 .split('.')
333 .collect::<Vec<_>>()[1]
334 != "yaml"
335 {
336 continue;
337 }
338
339 let note = Note::load_yaml_file_from(¬e_filename?)
340 .with_context(|| "while loading note from disk")?;
341
342 let task_pathbuf = task_pathbuf_from_id(¬e.task_id.to_string(), settings)?;
343 let mut task: Option<Task> = None;
344 if task_pathbuf.is_file() {
345 task = Some(load_task(¬e.task_id.to_string(), settings)?);
346 }
347
348 if let Some(task) = task {
349 let mut show_note = false;
350 if task.done && *completed {
352 show_note = true;
354 }
355 if !task.done {
356 show_note = true;
358 }
359
360 if show_note {
361 found_notes.push(FoundNote {
362 note,
363 task: Some(task),
364 });
365 }
366 } else if *orphaned {
367 found_notes.push(FoundNote { note, task: None });
369 }
370 }
371
372 Ok(found_notes)
373}
374
375#[cfg(test)]
376mod tests {
377 use chrono::{DateTime, Datelike};
378
379 use super::*;
380
381 static YAMLTESTINPUT: &str = "task_id: bd6f75aa-8c8d-47fb-b905-d9f7b15c782d\nmarkdown: fubar\nmetadata:\n tsk-rs-note-create-time: 2022-08-06T07:55:26.568460389+00:00\n x-fuu: bar\n";
382
383 #[test]
384 fn test_from_yaml() {
385 let note = Note::from_yaml_string(YAMLTESTINPUT).unwrap();
386
387 assert_eq!(
388 note.task_id,
389 Uuid::parse_str("bd6f75aa-8c8d-47fb-b905-d9f7b15c782d").unwrap()
390 );
391 assert_eq!(note.markdown, Some("fubar".to_string()));
392
393 let timestamp =
394 DateTime::parse_from_rfc3339(note.metadata.get("tsk-rs-note-create-time").unwrap())
395 .unwrap();
396 assert_eq!(timestamp.year(), 2022);
397 assert_eq!(timestamp.month(), 8);
398 assert_eq!(timestamp.day(), 6);
399 }
400
401 #[test]
402 fn test_to_yaml() {
403 let mut note = Note::new(&Uuid::parse_str("bd6f75aa-8c8d-47fb-b905-d9f7b15c782d").unwrap());
404 note.markdown = Some("fubar".to_string());
405 note.metadata.insert("x-fuu".to_string(), "bar".to_string());
406 note.metadata.insert(
408 "tsk-rs-note-create-time".to_string(),
409 "2022-08-06T07:55:26.568460389+00:00".to_string(),
410 );
411
412 let yaml = note.to_yaml_string().unwrap();
413 assert_eq!(yaml, YAMLTESTINPUT);
414 }
415}
416
417