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}