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 a [`Vec`] of 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(), vec![]);
45    /// ```
46    pub fn notes(&self) -> Vec<Note> {
47        read_dir(&self.path)
48            .into_iter()
49            .flatten()
50            .filter_map(|entry| Option::<Note>::from(DirEntry::from(entry.ok()?)))
51            .collect()
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();
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 TryFrom<Json> for Vault {
93            type Error = String;
94            fn try_from(value: Json) -> Result<Self, Self::Error> {
95                let path = Path::new(&value.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: value.path,
104                    open: value.open.unwrap_or_default(),
105                    ts: value.ts,
106                })
107            }
108        }
109
110        let deserialized: Json = Deserialize::deserialize(deserializer)?;
111        deserialized.try_into().map_err(serde::de::Error::custom)
112    }
113}
114
115/// Internal wrapper for directory entries to implement custom conversion between [`fs::DirEntry`]
116/// and [`Option<Note>`].
117#[derive(Debug)]
118struct DirEntry(fs::DirEntry);
119
120impl AsRef<fs::DirEntry> for DirEntry {
121    fn as_ref(&self) -> &fs::DirEntry {
122        &self.0
123    }
124}
125
126impl From<fs::DirEntry> for DirEntry {
127    fn from(value: fs::DirEntry) -> Self {
128        DirEntry(value)
129    }
130}
131
132impl From<DirEntry> for Option<Note> {
133    /// Transforms path with extension `.md` into [`Option<Note>`].
134    fn from(value: DirEntry) -> Option<Note> {
135        let path = value.as_ref().path();
136
137        if path.extension()? != "md" {
138            return None;
139        }
140
141        let name = path
142            .with_extension("")
143            .file_name()
144            .map(|file_name| file_name.to_string_lossy().into_owned())?;
145
146        Some(Note { name, path })
147    }
148}