basalt_core/obsidian/
vault.rs

1use std::{
2    cmp::Ordering,
3    fs::{self, read_dir},
4    path::{Path, PathBuf},
5    result,
6    time::SystemTime,
7};
8
9use serde::{Deserialize, Deserializer};
10
11use crate::obsidian::Note;
12
13/// Represents a single Obsidian vault.
14///
15/// A vault is a folder containing notes and other metadata.
16#[derive(Debug, Clone, Default, PartialEq)]
17pub struct Vault {
18    /// The name of the vault, inferred from its directory name.
19    pub name: String,
20
21    /// Filesystem path to the vault's directory.
22    pub path: PathBuf,
23
24    /// Whether the vault is marked 'open' by Obsidian.
25    pub open: bool,
26
27    /// Timestamp of last update or creation.
28    pub ts: u64,
29}
30
31impl Vault {
32    /// Returns an iterator over Markdown (`.md`) files in this vault as [`Note`] structs.
33    ///
34    /// # Examples
35    ///
36    /// ```
37    /// use basalt_core::obsidian::{Vault, Note};
38    ///
39    /// let vault = Vault {
40    ///     name: "MyVault".into(),
41    ///     path: "path/to/my_vault".into(),
42    ///     ..Default::default()
43    /// };
44    ///
45    /// assert_eq!(vault.notes().collect::<Vec<_>>(), vec![]);
46    /// ```
47    pub fn notes(&self) -> impl Iterator<Item = Note> {
48        read_dir(&self.path)
49            .into_iter()
50            .flatten()
51            .filter_map(|entry| Option::<Note>::from(DirEntry::from(entry.ok()?)))
52    }
53
54    /// Returns a sorted vector [`Vec<Note>`] of all notes in the vault, sorted according to the
55    /// provided comparison function.
56    ///
57    /// # Examples
58    ///
59    /// ```
60    /// use std::cmp::Ordering;
61    /// use basalt_core::obsidian::{Vault, Note};
62    ///
63    /// let vault = Vault {
64    ///     name: "MyVault".to_string(),
65    ///     path: "path/to/my_vault".into(),
66    ///     ..Default::default()
67    /// };
68    ///
69    /// let alphabetically = |a: &Note, b: &Note| a.name.to_lowercase().cmp(&b.name.to_lowercase());
70    ///
71    /// _ = vault.notes_sorted_by(alphabetically);
72    /// ```
73    pub fn notes_sorted_by(&self, compare: impl Fn(&Note, &Note) -> Ordering) -> Vec<Note> {
74        let mut notes: Vec<Note> = self.notes().collect();
75        notes.sort_by(compare);
76        notes
77    }
78}
79
80impl<'de> Deserialize<'de> for Vault {
81    fn deserialize<D>(deserializer: D) -> result::Result<Self, D::Error>
82    where
83        D: Deserializer<'de>,
84    {
85        #[derive(Deserialize)]
86        struct Json {
87            path: PathBuf,
88            open: Option<bool>,
89            ts: u64,
90        }
91
92        impl TryInto<Vault> for Json {
93            type Error = String;
94            fn try_into(self) -> result::Result<Vault, Self::Error> {
95                let path = Path::new(&self.path);
96                let name = path
97                    .file_name()
98                    .ok_or_else(|| String::from("unable to retrieve vault name"))?
99                    .to_string_lossy()
100                    .to_string();
101                Ok(Vault {
102                    name,
103                    path: self.path,
104                    open: self.open.unwrap_or_default(),
105                    ts: self.ts,
106                })
107            }
108        }
109
110        Json::from(Deserialize::deserialize(deserializer)?)
111            .try_into()
112            .map_err(serde::de::Error::custom)
113    }
114}
115
116impl Default for Note {
117    fn default() -> Self {
118        Self {
119            name: String::default(),
120            path: PathBuf::default(),
121            created: SystemTime::now(),
122        }
123    }
124}
125
126/// Internal wrapper for directory entries to implement custom conversion between [`fs::DirEntry`]
127/// and [`Option<Note>`].
128#[derive(Debug)]
129struct DirEntry(fs::DirEntry);
130
131impl From<fs::DirEntry> for DirEntry {
132    fn from(value: fs::DirEntry) -> Self {
133        DirEntry(value)
134    }
135}
136
137impl From<DirEntry> for Option<Note> {
138    /// Transforms path with extension `.md` into [`Option<Note>`].
139    fn from(value: DirEntry) -> Option<Note> {
140        let dir = value.0;
141        let created = dir.metadata().ok()?.created().ok()?;
142        let path = dir.path();
143
144        if path.extension()? != "md" {
145            return None;
146        }
147
148        let name = path
149            .with_extension("")
150            .file_name()
151            .map(|file_name| file_name.to_string_lossy().into_owned())?;
152
153        Some(Note {
154            name,
155            path,
156            created,
157        })
158    }
159}