1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
use ratatui::{prelude::*, widgets::*};
use std::{fmt::Debug, fs, path};
use itertools::Itertools;
use crate::{error, ui};
/// An abstract representation of a note that contains statistics about it but _not_ the full text.
#[derive(Clone, Debug, Default)]
pub struct Note {
/// The title of the note.
pub display_name: String,
/// The name of the file the note is saved in.
pub name: String,
/// All tags contained at any part of the note.
pub tags: Vec<String>,
/// All links contained within the note - no external (e.g. web) links.
pub links: Vec<String>,
/// The number of words.
pub words: usize,
/// The number of characters.
pub characters: usize,
/// A copy of the path leading to this note.
pub path: path::PathBuf,
}
impl Note {
/// Opens the file from the given path (if possible) and extracts metadata.
pub fn from_path(path: &path::Path) -> error::Result<Self> {
// Open the file.
let content = fs::read_to_string(path)?;
// Create a regex to check for YAML front matter.
let regex = regex::Regex::new("---\n((.|\n)*)\n---\n((.|\n)*)")?;
// Extract both the YAML front matter, if present, and the main content.
let (yaml, content) = if let Some(matches) = regex.captures(&content) {
// If the regex matched, YAML front matter was present.
(
// The 1st capture group is the front matter.
matches.get(1).map(|m| m.as_str().to_owned()),
// The 3rd capture group is the actual content.
matches.get(3).unwrap().as_str().to_owned(),
)
} else {
// If the regex didn't match, then just use the content.
(None, content)
};
// Parse markdown into AST
let arena = comrak::Arena::new();
let root = comrak::parse_document(
&arena,
&content,
&comrak::Options {
extension: comrak::ExtensionOptions::builder()
.wikilinks_title_after_pipe(true)
.build(),
..Default::default()
},
);
// Parse YAML.
let (title, tags) = if let Some(yaml) = yaml {
let docs = yaml_rust::YamlLoader::load_from_str(&yaml)?;
let doc = &docs[0];
// Check if there was a title specified.
let title = doc["title"].as_str().map(|s| s.to_owned());
// Check if tags were specified.
let tags = doc["tags"]
// Convert the entry into a vec - if the entry isn't there, use an empty vec.
.as_vec()
.unwrap_or(&Vec::new())
.iter()
// Convert the individual entries into strs, as rust-yaml doesn't do nested lists.
.flat_map(|v| v.as_str())
// Convert those into Strings and prepend the #.
.flat_map(|s| {
// Entries of sublists will appear as separated by ` - `, so split by that.
let parts = s.split(" - ").collect_vec();
if parts.is_empty() {
// This should not happen.
Vec::new()
} else if parts.len() == 1 {
// Only one parts => There were not subtags. Simply prepend a `#`.
vec![format!("#{}", s)]
} else {
// More than 1 part => There were subtags.
let mut res = Vec::new();
// Iterate through all of the substrings except for the first, which is the supertag.
for subtag in parts.iter().skip(1) {
res.push(format!("#{}/{}", parts[0], subtag));
}
res
}
})
// Collect all tags in a vec.
.collect_vec();
(title, tags)
} else {
(None, Vec::new())
};
Ok(Self {
// Name: Check if there was one specified in the YAML fronmatter.
// If not, remove file extension.
display_name: title.unwrap_or(
path.file_stem()
.map(|os| os.to_string_lossy().to_string())
.ok_or_else(|| error::RucolaError::NoteNameCannotBeRead(path.to_path_buf()))?,
),
// File name: Remove file extension.
name: path
.file_stem()
.map(|os| os.to_string_lossy().to_string())
.ok_or_else(|| error::RucolaError::NoteNameCannotBeRead(path.to_path_buf()))?,
// Path: Already given - convert to owned version.
path: path.canonicalize().unwrap_or(path.to_path_buf()),
// Tags: Go though all text nodes in the AST, split them at whitespace and look for those starting with a hash.
// Finally, append tags specified in the YAML frontmatter.
tags: root
.descendants()
.flat_map(|node| match &node.data.borrow().value {
comrak::nodes::NodeValue::Text(content) => content
.split_whitespace()
.filter(|s| s.starts_with('#'))
.map(|s| s.to_owned())
.collect_vec(),
_ => vec![],
})
.chain(tags)
.collect(),
// Links: Go though all wikilinks in the syntax tree and map them
links: root
.descendants()
.flat_map(|node| match &node.data.borrow().value {
comrak::nodes::NodeValue::WikiLink(link) => Some(super::name_to_id(&link.url)),
comrak::nodes::NodeValue::Link(link) => {
if !link.url.contains('/') && !link.url.contains('.') {
Some(super::name_to_id(&link.url))
} else {
None
}
}
_ => None,
})
.collect(),
// Words: Split at whitespace, grouping multiple consecutive instances of whitespace together.
// See definition of `split_whitespace` for criteria.
words: content.split_whitespace().count(),
// Characters: Simply use the length of the string.
characters: content.len(),
})
}
/// Converts this note to a small ratatui table displaying its most vital stats.
pub fn to_stats_table(&self, styles: &ui::UiStyles) -> Table<'_> {
let stats_widths = [
Constraint::Length(8),
Constraint::Length(12),
Constraint::Length(8),
Constraint::Min(20),
];
// Display the note's tags
let tags = self
.tags
.iter()
.enumerate()
.flat_map(|(index, s)| {
[
Span::styled(if index == 0 { "" } else { ", " }, styles.text_style),
Span::styled(s.as_str(), styles.subtitle_style),
]
})
.collect_vec();
// Stats Area
let stats_rows = [
Row::new(vec![
Cell::from("Words:").style(styles.text_style),
Cell::from(format!("{:7}", self.words)).style(styles.text_style),
Cell::from("Tags:").style(styles.text_style),
Cell::from(Line::from(tags)).style(styles.text_style),
]),
Row::new(vec![
Cell::from("Chars:").style(styles.text_style),
Cell::from(format!("{:7}", self.characters)).style(styles.text_style),
Cell::from("Path:").style(styles.text_style),
Cell::from(self.path.to_str().unwrap_or_default()).style(styles.text_style),
]),
];
Table::new(stats_rows, stats_widths).column_spacing(1)
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
#[test]
fn test_loading() {
let _note =
crate::data::Note::from_path(Path::new("./tests/common/notes/Books.md")).unwrap();
}
#[test]
fn test_values() {
let note =
crate::data::Note::from_path(Path::new("./tests/common/notes/math/Chart.md")).unwrap();
assert_eq!(note.name, String::from("Chart"));
assert_eq!(
note.tags,
vec![String::from("#diffgeo"), String::from("#topology")]
);
assert_eq!(
note.links,
vec![String::from("manifold"), String::from("diffeomorphism")]
);
assert_eq!(note.words, 115);
assert_eq!(note.characters, 678);
assert_eq!(
note.path,
PathBuf::from("./tests/common/notes/math/Chart.md")
.canonicalize()
.unwrap()
);
}
#[test]
fn test_yaml_name() {
let note =
crate::data::Note::from_path(Path::new("./tests/common/notes/note25.md")).unwrap();
assert_eq!(note.display_name, String::from("YAML Format"));
assert_eq!(note.name, String::from("note25"));
assert_eq!(
note.path,
PathBuf::from("./tests/common/notes/note25.md")
.canonicalize()
.unwrap()
);
}
#[test]
fn test_yaml_tags() {
let note =
crate::data::Note::from_path(Path::new("./tests/common/notes/note25.md")).unwrap();
assert_eq!(
note.tags,
vec![
String::from("#test"),
String::from("#files/yaml"),
String::from("#files/markdown"),
String::from("#abbreviations")
]
);
}
}