raw
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#[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#[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#[derive(Debug, serde::Serialize)]
56pub struct NoteContext<'n> {
57 pub path: ItemPath<'n>,
59 pub index: NodeIndex,
61
62 pub name: &'n str,
64 pub title: &'n str,
66
67 pub metadata: &'n FileMetadata,
69 pub properties: &'n NoteProperties,
71
72 pub toc: &'n [Heading],
74
75 pub contents: &'n str,
77}
78
79#[derive(Debug, serde::Deserialize)]
83pub struct NoteContextOwned {
84 pub path: ItemPathBuf,
86 pub index: NodeIndex,
88
89 pub name: String,
91 pub title: String,
93
94 pub metadata: FileMetadata,
96 pub properties: NoteProperties,
98
99 pub toc: Vec<Heading>,
101
102 pub contents: String,
104}
105
106#[derive(Debug, Clone, Hash, PartialEq, Eq)]
108pub struct Note {
109 pub path: ItemPathBuf,
111 pub index: NodeIndex,
113
114 pub name: String,
116
117 pub metadata: FileMetadata,
119 pub properties: NoteProperties,
121
122 pub toc: Vec<Heading>,
124 pub links: Vec<Link>,
126
127 pub contents: String,
129}
130
131impl Note {
132 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 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 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 #[must_use]
207 pub fn template(&self) -> Option<&str> {
208 self.properties.template.as_deref()
209 }
210
211 #[must_use]
213 pub fn tags(&self) -> Option<&[String]> {
214 self.properties.tags.as_deref()
215 }
216
217 #[must_use]
219 pub fn path(&self) -> ItemPath<'_> {
220 self.path.as_ref()
221 }
222
223 #[must_use]
225 pub const fn name(&self) -> &str {
226 self.name.as_str()
227 }
228
229 #[must_use]
231 pub fn title(&self) -> &str {
232 self.properties.title.as_deref().unwrap_or_default()
233 }
234
235 #[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
255pub 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: ®ex::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
294
297
300
302
305
308
312
314
316
318
325