raw
1use std::{
2 collections::{HashMap, HashSet},
3 sync::Arc,
4};
5
6use camino::Utf8PathBuf;
7use petgraph::{
8 graph::{NodeIndex, UnGraph},
9 visit::EdgeRef as _,
10};
11use walkdir::{DirEntry, WalkDir};
12
13use crate::{
14 ItemPathBuf, VaultPathBuf,
15 file::File,
16 link::LinkKind,
17 note::{Note, NoteError},
18};
19
20#[allow(missing_docs)]
22#[derive(Debug, miette::Diagnostic, thiserror::Error)]
23pub enum VaultError {
24 #[error("failed to walk source directory")]
25 Walk(#[source] walkdir::Error),
26 #[error("path `{1}` is not valid utf-8")]
27 PathNotUtf8(#[source] camino::FromPathBufError, std::path::PathBuf),
28 #[error("failed to create relative path")]
29 StripPrefix(#[source] std::path::StripPrefixError),
30 #[error("failed to load note")]
31 Note(#[source] NoteError),
32}
33
34#[derive(Debug)]
36pub struct Vault {
37 pub path: VaultPathBuf,
39
40 pub files: HashMap<ItemPathBuf, File>,
42
43 pub notes: HashMap<ItemPathBuf, Note>,
45
46 pub tags: HashMap<String, HashSet<ItemPathBuf>>,
48
49 graph: UnGraph<ItemPathBuf, ()>,
50}
51
52impl Vault {
53 pub fn from_path(path: VaultPathBuf) -> Result<Self, VaultError> {
60 let mut this = Self {
61 path,
62
63 files: HashMap::new(),
64 notes: HashMap::new(),
65
66 tags: HashMap::new(),
67
68 graph: UnGraph::default(),
69 };
70
71 this.reload()?;
72
73 Ok(this)
74 }
75
76 pub fn reload(&mut self) -> Result<(), VaultError> {
83 self.files.clear();
84 self.notes.clear();
85 self.tags.clear();
86 self.graph.clear();
87
88 let obsidian_path = self.path.0.join(".obsidian");
89
90 for entry in WalkDir::new(&self.path.0) {
91 let entry = entry.map_err(VaultError::Walk)?;
92
93 if entry.file_name().to_string_lossy().starts_with('.') {
94 continue;
95 }
96
97 if entry.path().starts_with(&obsidian_path) {
98 continue;
99 }
100
101 if !entry.file_type().is_file() {
102 continue;
103 }
104
105 self.handle_entry(&entry)?;
106 }
107
108 self.map_tags();
109 self.map_links();
110
111 Ok(())
112 }
113
114 fn handle_entry(&mut self, entry: &DirEntry) -> Result<(), VaultError> {
115 let full = entry.path().to_path_buf();
116 let full = Utf8PathBuf::try_from(full.clone())
117 .map_err(|err| VaultError::PathNotUtf8(err, full))?;
118 let relative = full
119 .strip_prefix(&self.path.0)
120 .map_err(VaultError::StripPrefix)?;
121
122 let path = self.path.as_item(&full, relative);
123
124 let file = File::from_path(path);
125
126 tracing::debug!(path=%file.path.full, kind=?file.kind, "found vault file");
127
128 if file.kind.is_note() {
129 let index = self.graph.add_node(file.path.clone());
130
131 let _ = self.notes.insert(
132 file.path.clone(),
133 file.into_note(index).map_err(VaultError::Note)?,
134 );
135 } else {
136 let _ = self.files.insert(file.path.clone(), file);
137 }
138
139 Ok(())
140 }
141
142 fn map_tags(&mut self) {
143 for note in self.notes.values() {
144 let Some(tags) = note.tags() else {
145 continue;
146 };
147
148 for tag in tags {
149 self.tags
150 .entry(tag.clone())
151 .or_default()
152 .insert(note.path.clone());
153 }
154 }
155 }
156
157 fn map_links(&mut self) {
158 for note in self.notes.values() {
159 for link in ¬e.links {
160 if link.kind != LinkKind::Wiki {
161 continue;
162 }
163
164 if let Some((_, other)) = self.find_note_by_title(&link.href) {
165 self.graph.add_edge(note.index, other.index, ());
166 }
167 }
168 }
169 }
170
171 #[must_use]
173 pub fn find_note_by_name(&self, name: &str) -> Option<(ItemPathBuf, &Note)> {
174 for (path, note) in &self.notes {
175 if note.name == name {
176 return Some((path.clone(), note));
177 }
178 }
179
180 None
181 }
182
183 #[must_use]
185 pub fn find_note_by_title(&self, title: &str) -> Option<(ItemPathBuf, &Note)> {
186 for (path, note) in &self.notes {
187 if note.title() == title {
188 return Some((path.clone(), note));
189 }
190 }
191
192 None
193 }
194
195 pub fn get_note_references(&self, index: NodeIndex) -> impl Iterator<Item = &Note> {
197 self.graph
198 .edges(index)
199 .map(|edge| edge.target())
200 .filter_map(|id| self.graph.node_weight(id))
201 .filter_map(|path| self.notes.get(path))
202 }
203}
204
205#[derive(Debug)]
207pub struct SharedVault {
208 vault: Arc<parking_lot::RwLock<Vault>>,
209}
210
211impl SharedVault {
212 pub fn from_path(path: VaultPathBuf) -> Result<Self, VaultError> {
219 Ok(Self {
220 vault: Arc::new(parking_lot::RwLock::new(Vault::from_path(path)?)),
221 })
222 }
223
224 pub fn read(&self) -> parking_lot::RwLockReadGuard<'_, Vault> {
226 self.vault.read()
227 }
228
229 pub fn reload(&mut self) -> Result<(), VaultError> {
236 self.vault.write().reload()
237 }
238
239 #[must_use]
241 pub fn find_note_by_name(&self, name: &str) -> Option<(ItemPathBuf, Note)> {
242 self.read()
243 .find_note_by_name(name)
244 .map(|(path, note)| (path, note.clone()))
245 }
246
247 #[must_use]
249 pub fn find_note_by_title(&self, title: &str) -> Option<(ItemPathBuf, Note)> {
250 self.read()
251 .find_note_by_title(title)
252 .map(|(path, note)| (path, note.clone()))
253 }
254}
255
256impl Clone for SharedVault {
257 fn clone(&self) -> Self {
258 Self {
259 vault: self.vault.clone(),
260 }
261 }
262}
263