tsk_rs/
note.rs

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/// Errors that can happen during Note handling
27#[derive(Error, Debug, PartialEq, Eq)]
28pub enum NoteError {
29    /// [ActionPoint] parsing error from note
30    #[error("error while parsing action points")]
31    ActionPointParseError,
32    /// Conversion error from notify event kind. Needs to be Note for Note.
33    #[cfg(feature = "notify")]
34    #[error("notifier result kind is not for a Note")]
35    IncompatibleNotifyKind,
36}
37
38/// Note abstraction
39#[derive(Debug, Serialize, Deserialize)]
40pub struct Note {
41    /// Unique identifier of the Note. Usually identical with the task this note belongs to.
42    pub task_id: Uuid,
43    /// Markdown formatted string that contains the notes
44    pub markdown: Option<String>,
45    /// Metadata for the note
46    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    /// Instantiate note by loading it from disk based on notifier event
57    #[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    /// Create a new note
66    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    /// Serialize note from YAML string
82    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    /// Deserialize note as YAML string
87    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    /// Load task from YAML file at disk
92    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(&note_yaml)
101                .with_context(|| "while serializing yaml into note struct")?;
102        }
103        Ok(note)
104    }
105
106    /// Save task as YAML file to the disk
107    pub fn save_yaml_file_to(&mut self, note_pathbuf: &PathBuf, rotate: &usize) -> Result<()> {
108        // rotate existing file with same name if present
109        if note_pathbuf.is_file() && rotate > &0 {
110            FileRotation::new(&note_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    /// Parse action points from the Markdown formatted string which is the note itself
148    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    /// Set characterists for the note e.g metadata
161    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    /// Unset characteristics for the note e.g metadata
176    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/// ActionPoint abstraction
234#[derive(Debug)]
235pub struct ActionPoint {
236    /// Unique id of this action point
237    pub id: Uuid,
238    /// Description parsed from the Markdown syntax next to the checkbox
239    pub description: String,
240    /// Is the action point completed aka is the Markdown checkbox checked?
241    pub checked: bool,
242}
243
244/// Get the note files path based on an ID string
245pub 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
251/// Get the note files path based on the ID of a Note
252pub fn note_pathbuf_from_note(note: &Note, settings: &Settings) -> Result<PathBuf> {
253    note_pathbuf_from_id(&note.task_id.to_string(), settings)
254}
255
256/// Read note from the disk
257pub 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(&note_pathbuf)
261        .with_context(|| "while loading note yaml file for editing")?;
262    Ok(note)
263}
264
265/// Save note to the disk
266pub 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(&note_pathbuf, &settings.data.rotate)
269        .with_context(|| "while saving note yaml file")?;
270    Ok(())
271}
272
273/// Abstraction for the link between note and the task the note belongs to (if any)
274pub struct FoundNote {
275    /// Note that reflects the note
276    pub note: Note,
277    /// Optional task (if not deleted and therefore existing one) the note belongs to.
278    pub task: Option<Task>,
279}
280
281/// Get the amount of notes on disk
282pub 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 the filename is u-u-i-d.3.yaml for example it is a backup file and should be disregarded
289        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
307/// List notes stored on disk based on a search criteria
308pub 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 the filename is u-u-i-d.3.yaml for example it is a backup file and should be disregarded
326        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(&note_filename?)
340            .with_context(|| "while loading note from disk")?;
341
342        let task_pathbuf = task_pathbuf_from_id(&note.task_id.to_string(), settings)?;
343        let mut task: Option<Task> = None;
344        if task_pathbuf.is_file() {
345            task = Some(load_task(&note.task_id.to_string(), settings)?);
346        }
347
348        if let Some(task) = task {
349            let mut show_note = false;
350            // there is a task file
351            if task.done && *completed {
352                // .. but the task is completed. however completed is true so we show it
353                show_note = true;
354            }
355            if !task.done {
356                // .. task is not done so show it
357                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            // there is no task file anymore, and orphaned is true so we add it anyway to the return value
368            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        // replace the create timestamp metadata to match test input
407        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// eof