denote/
lib.rs

1use std::{
2    path::{Path, PathBuf},
3    str::FromStr,
4};
5
6use lazy_static::lazy_static;
7use regex::{Regex, RegexBuilder};
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10use time::macros::format_description;
11use time::OffsetDateTime;
12
13/// Tools for command-line usage
14pub mod cli;
15
16/// Python bindings
17mod python;
18
19lazy_static! {
20    static ref FILENAME_RE: Regex = RegexBuilder::new(
21        r"
22          (\d{8}T\d{6})
23          --
24          (.*?)
25          __
26          (.*)
27          \.
28          ([a-z]+)
29        "
30    )
31    .ignore_whitespace(true)
32    .build()
33    .expect("syntax error in static regex");
34}
35
36#[derive(Debug, Error)]
37/// Variant of the errors returned by the libray
38#[non_exhaustive]
39pub enum Error {
40    #[error("parse error")]
41    ParseError(String),
42    #[error("os error")]
43    OSError(String),
44}
45
46use Error::*;
47
48/// Result type for this library
49pub type Result<T> = std::result::Result<T, Error>;
50
51fn name_from_relative_path(relative_path: &Path) -> String {
52    let components: Vec<_> = relative_path.components().collect();
53    assert!(
54        components.len() >= 2,
55        "relative path should look like <year>/<id>"
56    );
57    let last: &Path = components
58        .last()
59        .expect("components cannot be empty")
60        .as_ref();
61    last.to_string_lossy().into_owned()
62}
63
64fn parse_file_name(name: &str) -> Result<Metadata> {
65    let captures = FILENAME_RE
66        .captures(name)
67        .ok_or_else(|| ParseError(format!("Filename {name} did not match expected regex")))?;
68
69    let id = captures
70        .get(1)
71        .expect("FILENAME_RE should contain the correct number of groups")
72        .as_str();
73    let id = Id::from_str(id)?;
74
75    let slug = captures
76        .get(2)
77        .expect("FILENAME_RE should contain the correct number of groups")
78        .as_str()
79        .to_owned();
80
81    let keywords: Vec<String> = captures
82        .get(3)
83        .expect("FILENAME_RE should contain the correct number of groups")
84        .as_str()
85        .split('_')
86        .map(|x| x.to_string())
87        .collect();
88
89    let extension = captures
90        .get(4)
91        .expect("FILENAME_RE should contain the correct number of groups")
92        .as_str()
93        .to_owned();
94
95    Ok(Metadata {
96        id,
97        slug,
98        title: None,
99        keywords,
100        extension,
101    })
102}
103
104fn try_extract_front_matter(contents: &str) -> Option<(FrontMatter, String)> {
105    let docs: Vec<_> = contents.splitn(3, "---\n").collect();
106    if docs.is_empty() {
107        println!("skipping empty front_matter");
108        return None;
109    }
110    if docs.len() < 2 {
111        println!("skipping invalid front_matter");
112        return None;
113    }
114    let first_doc = &docs[1];
115    let text = docs[2];
116    match FrontMatter::parse(first_doc) {
117        Ok(f) => Some((f, text.to_string())),
118        Err(ParseError(e)) => {
119            println!("skipping invalid front_matter: {}", e);
120            None
121        }
122        Err(_) => {
123            unreachable!()
124        }
125    }
126}
127
128#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
129/// A new-type on top of String so that only valid Ids can
130/// be used
131/// As a reminder, the Id in denote is YYYYMMDDTHHMMSS
132pub struct Id(String);
133
134impl Id {
135    pub fn as_str(&self) -> &str {
136        self.0.as_str()
137    }
138
139    pub fn human_date(&self) -> String {
140        let ymd = &self.0[0..8];
141        let year = &ymd[0..4];
142        let month = &ymd[4..6];
143        let day = &ymd[6..8];
144
145        let hms = &self.0[9..];
146        let hours = &hms[0..2];
147        let minutes = &hms[2..4];
148        let seconds = &hms[4..6];
149
150        format!("{year}-{month}-{day} {hours}:{minutes}:{seconds}")
151    }
152
153    pub fn from_date(offsett_date_time: &OffsetDateTime) -> Self {
154        let format = format_description!("[year][month][day]T[hour][minute][second]");
155        let formatted_date = offsett_date_time.format(&format).unwrap();
156        Self::from_str(&formatted_date).unwrap()
157    }
158}
159
160impl FromStr for Id {
161    type Err = Error;
162
163    fn from_str(s: &str) -> Result<Self> {
164        let chars: Vec<char> = s.chars().collect();
165
166        if chars.len() != 15 {
167            return Err(ParseError(format!(
168                "value '{s}' should contain 15 characters, got {})",
169                chars.len()
170            )));
171        }
172
173        if chars[8] != 'T' {
174            return Err(ParseError(format!(
175                "value '{s}' should contain contain a 'T' in the middle, got {})",
176                chars[6]
177            )));
178        }
179
180        Ok(Self(s.to_string()))
181    }
182}
183
184#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
185/// Contain all the metadata about a note.
186/// Some of it come from the front matter, like the title,
187/// but some other come from the filename, like the slug, the extension,
188/// or the keywords
189pub struct Metadata {
190    id: Id,
191    title: Option<String>,
192    slug: String,
193    keywords: Vec<String>,
194    extension: String,
195}
196
197impl Metadata {
198    pub fn new(id: Id, title: String, keywords: Vec<String>, extension: String) -> Metadata {
199        let slug = slug::slugify(&title);
200        Metadata {
201            id,
202            title: Some(title),
203            slug,
204            keywords,
205            extension,
206        }
207    }
208
209    pub fn id(&self) -> &str {
210        self.id.as_str()
211    }
212
213    pub fn slug(&self) -> &str {
214        self.slug.as_ref()
215    }
216
217    pub fn title(&self) -> Option<&String> {
218        self.title.as_ref()
219    }
220
221    pub fn extension(&self) -> &str {
222        self.extension.as_str()
223    }
224
225    pub fn keywords(&self) -> &[String] {
226        &self.keywords
227    }
228
229    pub fn front_matter(&self) -> FrontMatter {
230        FrontMatter {
231            title: self.title.to_owned(),
232            date: self.id.human_date(),
233            keywords: self.keywords.join(" "),
234        }
235    }
236
237    pub fn relative_path(&self) -> PathBuf {
238        let Metadata {
239            id,
240            keywords,
241            slug,
242            extension,
243            ..
244        } = self;
245
246        let id = id.as_str();
247        let year = &id[0..4];
248        let year_path = PathBuf::from_str(year).expect("year should be ascii");
249
250        let keywords = keywords.join("_");
251
252        let file_path = PathBuf::from_str(&format!("{id}--{slug}__{keywords}.{extension}"))
253            .expect("filename should be valid utf-8");
254
255        year_path.join(file_path)
256    }
257}
258
259#[derive(Debug, Clone, Eq, PartialEq, PartialOrd, Ord, Deserialize, Serialize)]
260/// The front matter of a note.
261/// Currently using YAML
262/// Note that `keywords` is list of words separated by spaces,
263/// which is find because we don't allow spaces in keywords.
264///
265/// The title may not be set
266pub struct FrontMatter {
267    title: Option<String>,
268    date: String,
269    keywords: String,
270}
271
272impl FrontMatter {
273    pub fn title(&self) -> Option<&String> {
274        self.title.as_ref()
275    }
276
277    pub fn keywords(&self) -> Vec<String> {
278        self.keywords.split(' ').map(|x| x.to_string()).collect()
279    }
280
281    pub fn dump(&self) -> String {
282        serde_yaml::to_string(self).expect("front matter should always be serializable")
283    }
284
285    pub fn parse(front_matter: &str) -> Result<Self> {
286        serde_yaml::from_str(front_matter).map_err(|e| {
287            ParseError(format!(
288                "could not deserialize front matter\n{front_matter}\n{e})"
289            ))
290        })
291    }
292}
293
294#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
295/// A Note has some metadata and some text
296/// Note that the metada is different from the frontmatter, it does
297/// contain exacly the same data
298pub struct Note {
299    metadata: Metadata,
300    text: String,
301}
302
303impl Note {
304    pub fn new(metadata: Metadata, text: String) -> Self {
305        Self { metadata, text }
306    }
307
308    fn relative_path(&self) -> PathBuf {
309        self.metadata.relative_path()
310    }
311
312    pub fn front_matter(&self) -> FrontMatter {
313        self.metadata.front_matter()
314    }
315
316    /// Update the metadata when the front matter changes
317    pub fn update(&mut self, front_matter: &FrontMatter) {
318        if let Some(new_title) = &front_matter.title {
319            self.metadata.title = Some(new_title.to_string());
320            let new_slug = slug::slugify(new_title);
321            self.metadata.slug = new_slug;
322        }
323        let new_keywords: Vec<_> = front_matter.keywords();
324        self.metadata.keywords = new_keywords;
325    }
326
327    pub fn metadata(&self) -> &Metadata {
328        &self.metadata
329    }
330
331    pub fn dump(&self) -> String {
332        let mut res = String::new();
333        // Note: serde_yaml writes a leading `---`
334        let front_matter = self.metadata.front_matter();
335        res.push_str(&front_matter.dump());
336        res.push_str("---\n");
337        res.push_str(&self.text);
338        res
339    }
340}
341
342#[derive(Debug)]
343/// Store the notes with the proper file names inside a `base_path`
344pub struct NotesRepository {
345    base_path: PathBuf,
346}
347
348impl NotesRepository {
349    /// Open a new repository given a base_path
350    /// Base path should contain one folder per year,
351    /// and the filename in each `<year>`` folder should match
352    /// the denote naming convention
353    pub fn open(base_path: impl AsRef<Path>) -> Result<Self> {
354        let base_path = base_path.as_ref();
355        if !base_path.is_dir() {
356            // Note: use ErrorKind::IsADirectory when this variant is
357            // stablelized
358            return Err(OSError(format!("{base_path:#?} should be a directory")));
359        }
360        Ok(NotesRepository {
361            base_path: base_path.to_owned(),
362        })
363    }
364
365    /// The base path of the repository, where the `<year>` directories
366    /// are created
367    pub fn base_path(&self) -> &Path {
368        &self.base_path
369    }
370
371    /// Import a plain md file and save it with the correct name
372    /// Called by cli::new_note
373    pub fn import_from_markdown(&self, markdown_path: &Path) -> Result<PathBuf> {
374        let contents = std::fs::read_to_string(markdown_path)
375            .map_err(|e| Error::OSError(format!("while reading: {markdown_path:#?}: {e}")))?;
376        let (front_matter, text) = try_extract_front_matter(&contents).ok_or_else(|| {
377            Error::ParseError(format!(
378                "Could not extract front matter from {markdown_path:#?}"
379            ))
380        })?;
381
382        let title = front_matter.title().ok_or_else(|| {
383            Error::ParseError(format!(
384                "Front matter should in {markdown_path:#?} should contain a title"
385            ))
386        })?;
387        let keywords: Vec<_> = front_matter.keywords();
388        let now = OffsetDateTime::now_utc();
389        let id = Id::from_date(&now);
390        let extension = "md".to_owned();
391        let metadata = Metadata::new(id, title.to_string(), keywords, extension);
392
393        let note = Note::new(metadata, text);
394        self.save(&note)?;
395
396        Ok(note.relative_path())
397    }
398
399    /// To be called when the markdown file has changed - this will
400    /// handle the rename automatically - note that the ID won't change,
401    /// this is by design
402    /// Return the new note path (which may have changed)
403    pub fn update(&self, relative_path: &Path) -> Result<PathBuf> {
404        let full_path = &self.base_path.join(relative_path);
405        let note = self.load(relative_path)?;
406
407        let new_relative_path = note.relative_path();
408        let new_full_path = &self.base_path.join(&new_relative_path);
409        if full_path != new_full_path {
410            println!("{full_path:#?} -> {new_full_path:#?}");
411            std::fs::rename(full_path, new_full_path)
412                .map_err(|e| Error::OSError(format!("Could not rename note: {e}")))?;
413        }
414
415        Ok(new_full_path.to_path_buf())
416    }
417
418    /// Load a note file
419    pub fn load(&self, relative_path: &Path) -> Result<Note> {
420        if !relative_path.is_relative() {
421            return Err(OSError(format!(
422                "Expecting a relative path when loading, get {relative_path:+?}"
423            )));
424        }
425        let full_path = &self.base_path.join(relative_path);
426        let contents = std::fs::read_to_string(full_path)
427            .map_err(|e| OSError(format!("While loading note from {full_path:?}: {e}")))?;
428        let file_name = &name_from_relative_path(relative_path);
429        let metadata = parse_file_name(file_name)?;
430        let mut note = Note {
431            metadata,
432            text: contents,
433        };
434        if let Some((front_matter, text)) = try_extract_front_matter(&note.text) {
435            note.update(&front_matter);
436            note.text = text;
437        }
438        Ok(note)
439    }
440
441    /// Save a note in the repository
442    /// Create `<year>` directory when needed
443    pub fn save(&self, note: &Note) -> Result<PathBuf> {
444        let relative_path = &note.relative_path();
445        let full_path = &self.base_path.join(relative_path);
446
447        let parent_path = full_path.parent().expect("full path should have a parent");
448
449        if parent_path.exists() {
450            if parent_path.is_file() {
451                return Err(OSError(format!(
452                    "Cannot use {parent_path:?} as year path because there's a file here)"
453                )));
454            }
455        } else {
456            println!("Creating {parent_path:?}");
457            std::fs::create_dir_all(&parent_path).map_err(|e| {
458                OSError(format!(
459                    "While creating parent path {parent_path:?}for note :{e}"
460                ))
461            })?;
462        }
463
464        let to_write = note.dump();
465
466        std::fs::write(full_path, &to_write)
467            .map_err(|e| OSError(format!("While saving note in {full_path:?}: {e}")))?;
468        Ok(relative_path.to_path_buf())
469    }
470}
471
472#[cfg(test)]
473mod tests {
474
475    use super::*;
476
477    fn make_note() -> Note {
478        let id = Id::from_str("20220707T142708").unwrap();
479        let slug = "this-is-a-title".to_owned();
480        let title = Some("This is a title".to_owned());
481        let keywords = vec!["k1".to_owned(), "k2".to_owned()];
482        let extension = "md".to_owned();
483        let metadata = Metadata {
484            id,
485            slug,
486            title,
487            keywords,
488            extension,
489        };
490
491        Note {
492            metadata,
493            text: "This is my note".to_owned(),
494        }
495    }
496
497    #[test]
498    fn test_slugify_title_when_creating_metadata() {
499        let id = Id::from_str("20220707T142708").unwrap();
500        let title = "This is a title".to_owned();
501        let keywords = vec!["k1".to_owned(), "k2".to_owned()];
502        let extension = "md".to_owned();
503        let metadata = Metadata::new(id, title, keywords, extension);
504
505        assert_eq!(metadata.slug(), "this-is-a-title");
506    }
507
508    #[test]
509    fn test_parse_metadata_from_file_name() {
510        let name = "20220707T142708--this-is-a-title__k1_k2.md";
511
512        let metadata = parse_file_name(name).unwrap();
513
514        assert_eq!(metadata.id(), "20220707T142708");
515        assert_eq!(metadata.slug(), "this-is-a-title");
516        assert_eq!(metadata.extension(), "md");
517        assert_eq!(metadata.keywords(), &["k1", "k2"]);
518    }
519
520    #[test]
521    fn test_generate_suitable_file_path_for_note() {
522        let note = make_note();
523        assert_eq!(
524            note.relative_path().to_string_lossy(),
525            "2022/20220707T142708--this-is-a-title__k1_k2.md"
526        );
527    }
528
529    #[test]
530    fn test_error_when_trying_to_load_notes_from_a_file() {
531        NotesRepository::open("src/lib.rs").unwrap_err();
532    }
533
534    #[test]
535    fn test_saving_and_loading() {
536        let temp_dir = tempfile::Builder::new()
537            .prefix("test-denotes")
538            .tempdir()
539            .unwrap();
540        let notes = NotesRepository::open(&temp_dir).unwrap();
541        let note = make_note();
542        notes.save(&note).unwrap();
543
544        let relative_path = &note.relative_path();
545        let saved = notes.load(relative_path).unwrap();
546        assert_eq!(note, saved);
547    }
548
549    #[test]
550    fn test_generating_front_matter() {
551        let note = make_note();
552        let original = note.front_matter();
553        let dumped = original.dump();
554
555        let parsed = FrontMatter::parse(&dumped).unwrap();
556        assert_eq!(&parsed.title, &original.title);
557    }
558
559    #[test]
560    #[ignore]
561    fn test_load_front_matter_from_contents() {
562        let note = make_note();
563        let _contents = note.dump();
564    }
565}