wayver's git archive


an obsidian renderer
git clone https://git.wayver.dev/sable

sable-vault/src/vault.rs@main

raw
Date Commit Message Author Files + -
2026-02-23 01:55 initial mvp wayverd 139 17808 0
...

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/// Errors that could oocur while loading a [`Vault`].
21#[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/// Structured view of a Obsidian Vault
35#[derive(Debug)]
36pub struct Vault {
37    /// The path to the root of the Vault.
38    pub path: VaultPathBuf,
39
40    /// The (non Obsidian) files of the Vault.
41    pub files: HashMap<ItemPathBuf, File>,
42
43    /// The parsed notes of the Vault.
44    pub notes: HashMap<ItemPathBuf, Note>,
45
46    /// All the tags used in the Vault and their locations.
47    pub tags: HashMap<String, HashSet<ItemPathBuf>>,
48
49    graph: UnGraph<ItemPathBuf, ()>,
50}
51
52impl Vault {
53    /// Create a [`Vault`] from a [`VaultPathBuf`], reading the vault's contents from the disk.
54    ///
55    /// # Errors
56    ///
57    /// This will fail if its unable to do IO
58    /// or is unable to parse any of the supported Obsidian types.
59    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    /// Clears the currrent Vault and reloads it from disk.
77    ///
78    /// # Errors
79    ///
80    /// This will fail if its unable to do IO
81    /// or is unable to parse any of the supported Obsidian types.
82    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 &note.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    /// Locate a note by its filename/original name.
172    #[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    /// Locate a note by its title.
184    #[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    /// Returns a iterator of all the [`Note`]'s that link to a given [`Note`].
196    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/// Utility wrapper of [`Vault`] (basically [`Arc`] `<` [`RwLock`] `<` [`Vault`] `>>`)
206#[derive(Debug)]
207pub struct SharedVault {
208    vault: Arc<parking_lot::RwLock<Vault>>,
209}
210
211impl SharedVault {
212    /// Create a [`SharedVault`] from a [`VaultPathBuf`], reading the vault's contents from the disk.
213    ///
214    /// # Errors
215    ///
216    /// This will fail if its unable to do IO
217    /// or is unable to parse any of the supported Obsidian types.
218    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    /// Take a read reference to the innter [`Vault`].
225    pub fn read(&self) -> parking_lot::RwLockReadGuard<'_, Vault> {
226        self.vault.read()
227    }
228
229    /// Clears the currrent Vault and reloads it from disk.
230    ///
231    /// # Errors
232    ///
233    /// This will fail if its unable to do IO
234    /// or is unable to parse any of the supported Obsidian types.
235    pub fn reload(&mut self) -> Result<(), VaultError> {
236        self.vault.write().reload()
237    }
238
239    /// Locate a note by its filename/original name.
240    #[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    /// Locate a note by its title.
248    #[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