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