wayver's git archive


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

sable-vault/src/note.rs@337ba67f65eaa17b44e371af7c0f0c761d6aa914

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

1use std::{collections::BTreeMap, sync::LazyLock};
2
3use camino::Utf8PathBuf;
4use convert_case::{Case, Casing as _};
5use petgraph::graph::NodeIndex;
6use regex::Regex;
7use serde::Deserialize as _;
8
9use crate::{
10    ItemPath, ItemPathBuf,
11    link::Link,
12    utils::{FileMetadata, Heading, extract_headings, make_table_of_contents},
13};
14
15/// Errors that could occur while parsing an Obsidian [`Note`].
16#[allow(missing_docs)]
17#[derive(Debug, miette::Diagnostic, thiserror::Error)]
18pub enum NoteError {
19    #[error("failed to read frontmatter of note `{0}`")]
20    FrontmatterParse(Utf8PathBuf, #[source] sable_frontmatter::MetadataError),
21    #[error("failed to deserialize recognized obsidian properties of note `{0}`")]
22    PropertiesParse(Utf8PathBuf, #[source] serde_json::Error),
23    #[error("failed to read note at `{0}`")]
24    IoRead(Utf8PathBuf, #[source] std::io::Error),
25    #[error("failed to get metadata of note `{0}`")]
26    GetMetadata(Utf8PathBuf, #[source] crate::utils::Error),
27}
28
29/// Recognized [`Note`] frontmatter properties.
30#[allow(missing_docs)]
31#[derive(Debug, Clone, Default, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32pub struct NoteProperties {
33    pub aliases: Option<Vec<String>>,
34    pub css_classes: Option<Vec<String>>,
35    pub tags: Option<Vec<String>>,
36
37    pub template: Option<String>,
38    pub title: Option<String>,
39
40    pub draft: Option<bool>,
41
42    #[serde(flatten)]
43    other: BTreeMap<String, sable_frontmatter::Metadata>,
44}
45
46impl NoteProperties {
47    pub fn get(&self, key: &str) -> Option<&sable_frontmatter::Metadata> {
48        self.other.get(key)
49    }
50}
51
52/// Predescribed [`Note`] context for [`tera`] templates or related engines.
53///
54/// [`tera`]: https://docs.rs/tera/
55#[derive(Debug, serde::Serialize)]
56pub struct NoteContext<'n> {
57    /// The path of the [`Note`].
58    pub path: ItemPath<'n>,
59    /// The graph index of the [`Note`].
60    pub index: NodeIndex,
61
62    /// The filename of the [`Note`].
63    pub name: &'n str,
64    /// The title of the [`Note`].
65    pub title: &'n str,
66
67    /// The file metadata of the [`Note`].
68    pub metadata: &'n FileMetadata,
69    /// The frontmatter properties of the [`Note`].
70    pub properties: &'n NoteProperties,
71
72    /// Structured table of contents of the [`Note`].
73    pub toc: &'n [Heading],
74
75    /// The raw markdown of the [`Note`].
76    pub contents: &'n str,
77}
78
79/// Predescribed [`Note`] context for [`tera`] templates or related engines.
80///
81/// [`tera`]: https://docs.rs/tera/
82#[derive(Debug, serde::Deserialize)]
83pub struct NoteContextOwned {
84    /// The path of the [`Note`].
85    pub path: ItemPathBuf,
86    /// The graph index of the [`Note`].
87    pub index: NodeIndex,
88
89    /// The filename of the [`Note`].
90    pub name: String,
91    /// The title of the [`Note`].
92    pub title: String,
93
94    /// The file metadata of the [`Note`].
95    pub metadata: FileMetadata,
96    /// The frontmatter properties of the [`Note`].
97    pub properties: NoteProperties,
98
99    /// Structured table of contents of the [`Note`].
100    pub toc: Vec<Heading>,
101
102    /// The raw markdown of the [`Note`].
103    pub contents: String,
104}
105
106/// A parsed Obsidian note.
107#[derive(Debug, Clone, Hash, PartialEq, Eq)]
108pub struct Note {
109    /// The path of the [`Note`].
110    pub path: ItemPathBuf,
111    /// The graph index of the [`Note`].
112    pub index: NodeIndex,
113
114    /// The filename of the [`Note`].
115    pub name: String,
116
117    /// The file metadata of the [`Note`].
118    pub metadata: FileMetadata,
119    /// The frontmatter properties of the [`Note`].
120    pub properties: NoteProperties,
121
122    /// Structured table of contents of the [`Note`].
123    pub toc: Vec<Heading>,
124    /// Extracted links from the [`Note`]'s markdown.
125    pub links: Vec<Link>,
126
127    /// The raw markdown of the [`Note`].
128    pub contents: String,
129}
130
131impl Note {
132    /// Parse a Obsidian note from a [`ItemPathBuf`] and the notes raw content.
133    ///
134    /// # Errors
135    ///
136    /// This will error if its unable to deserialize the frontmatter,
137    /// or it was unable to get file system metadata of the note.
138    pub fn from_str(path: ItemPathBuf, index: NodeIndex, raw: &str) -> Result<Self, NoteError> {
139        let (frontmatter, contents) = sable_frontmatter::parse(raw)
140            .map(|(frontmatter, contents)| (frontmatter, contents.to_string()))
141            .map_err(|err| NoteError::FrontmatterParse(path.relative.to_path_buf(), err))?;
142
143        let mut properties = if let Some(frontmatter) = frontmatter.as_ref() {
144            NoteProperties::deserialize(frontmatter)
145                .map_err(|err| NoteError::PropertiesParse(path.relative.to_path_buf(), err))?
146        } else {
147            NoteProperties::default()
148        };
149
150        let metadata = FileMetadata::new(&path.full)
151            .map_err(|err| NoteError::GetMetadata(path.relative.to_path_buf(), err))?;
152
153        let name = path.full.file_stem().map_or_else(
154            || {
155                tracing::warn!(path=%path.relative, "unable to convert note file name into title");
156
157                String::new()
158            },
159            |t| t.to_case(Case::Title),
160        );
161
162        // TODO: use [`Path::file_prefix`] when it stablizes
163        // TODO: check contents for `h1` header to use as the title
164        properties.title.get_or_insert_with(|| name.clone());
165
166        if let Some(mut tags) = extract_tags(contents.as_str()) {
167            properties.tags.get_or_insert_default().append(&mut tags);
168        }
169
170        let headings = extract_headings(&contents);
171        let toc = make_table_of_contents(headings);
172
173        let links = Link::collect(&contents);
174
175        Ok(Self {
176            path,
177            index,
178
179            name,
180
181            metadata,
182            properties,
183
184            toc,
185            links,
186
187            contents,
188        })
189    }
190
191    /// Load and parse a Obsidian note from a [`ItemPathBuf`].
192    ///
193    /// # Errors
194    ///
195    /// This will error if it failed to read the note from the file system,
196    ///  its unable to deserialize the frontmatter,
197    /// or it was unable to get file system metadata of the note.
198    pub fn from_path(path: ItemPathBuf, index: NodeIndex) -> Result<Self, NoteError> {
199        let contents = std::fs::read_to_string(&path.full)
200            .map_err(|err| NoteError::IoRead(path.full.clone(), err))?;
201
202        Self::from_str(path, index, &contents)
203    }
204
205    /// Returns the template property of this [`Note`]'s frontmatter.
206    #[must_use]
207    pub fn template(&self) -> Option<&str> {
208        self.properties.template.as_deref()
209    }
210
211    /// Returns the tags property of this [`Note`]'s frontmatter.
212    #[must_use]
213    pub fn tags(&self) -> Option<&[String]> {
214        self.properties.tags.as_deref()
215    }
216
217    /// Returns a reference to the [`Note`]'s path information.
218    #[must_use]
219    pub fn path(&self) -> ItemPath<'_> {
220        self.path.as_ref()
221    }
222
223    /// Returns a reference to the [`Note`]'s filename.
224    #[must_use]
225    pub const fn name(&self) -> &str {
226        self.name.as_str()
227    }
228
229    /// Returns a reference to the [`Note`]'s title.
230    #[must_use]
231    pub fn title(&self) -> &str {
232        self.properties.title.as_deref().unwrap_or_default()
233    }
234
235    /// Create a template engine compatible render context reference.
236    #[must_use]
237    pub fn as_context(&self) -> NoteContext<'_> {
238        NoteContext {
239            path: self.path(),
240            index: self.index,
241
242            name: self.name(),
243            title: self.title(),
244
245            metadata: &self.metadata,
246            properties: &self.properties,
247
248            toc: &self.toc,
249
250            contents: &self.contents,
251        }
252    }
253}
254
255/// Finds Obsidian `#tags`, and converts them into a list without preceding `#`.
256pub fn extract_tags(content: &str) -> Option<Vec<String>> {
257    static REGEX: LazyLock<Regex> = LazyLock::new(|| {
258        Regex::new(r"(?:#([^\s#]+))").expect("failed to construct obsidian tag regex")
259    });
260
261    fn handle(captures: &regex::Captures<'_>) -> Option<String> {
262        let mut iter = captures.iter();
263
264        let _match = iter.next()??;
265        let group = iter.next()??;
266
267        let rest = group.as_str().to_string();
268
269        Some(rest)
270    }
271
272    if !REGEX.is_match(content.as_ref()) {
273        return None;
274    }
275
276    let captures_list = REGEX.captures_iter(content.as_ref()).collect::<Vec<_>>();
277
278    let mut tags = Vec::with_capacity(captures_list.len());
279
280    for captures in captures_list.into_iter().rev() {
281        if let Some(tag) = handle(&captures) {
282            tags.push(tag);
283        }
284    }
285
286    Some(tags)
287}
288
289// pub fn extract_tags(content: Cow<'_, str>) -> (Cow<'_, str>, Option<Vec<String>>) {
290//     static REGEX: LazyLock<Regex> = LazyLock::new(|| {
291//         Regex::new(r#"(?:#([^\s#]+))"#).expect("failed to construct obsidian tag regex")
292//     });
293
294//     fn handle(new: &str, captures: &regex::Captures<'_>) -> Option<(String, String)> {
295//         let mut iter = captures.iter();
296
297//         let r#match = iter.next()??;
298//         let group = iter.next()??;
299
300//         let rest = group.as_str().to_string();
301
302//         let span = r#match.range();
303//         let updated = [&new[0..span.start], "", &new[span.end..]].concat();
304
305//         Some((updated, rest))
306//     }
307
308//     if !REGEX.is_match(content.as_ref()) {
309//         return (content, None);
310//     }
311
312//     let mut new = content.as_ref().to_string();
313
314//     let captures = REGEX.captures_iter(content.as_ref()).collect::<Vec<_>>();
315
316//     let mut tags = Vec::with_capacity(captures.len());
317
318//     for captures in captures.into_iter().rev() {
319//         if let Some((updated, tag)) = handle(&new, &captures) {
320//             new = updated;
321//             tags.push(tag);
322//         }
323//     }
324
325//     (Cow::from(new), Some(tags))
326// }
327