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
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
use ratatui::{prelude::*, widgets::*};
use std::{fmt::Debug, fs, path, time};
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, serde::Serialize, serde::Deserialize)]
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,
/// The date and time when the note was last modified.
pub last_modification: Option<time::SystemTime>,
/// Whether or not the note contains (valid) YAML frontmatter. If it does, this is the index of the beginning of the actual content.
pub yaml_frontmatter: Option<usize>,
}
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)?;
// Attempt to identify YAML frontmatter
let (title, tags, begin_content) =
// File needs to start with three dashes.
if content.starts_with("---\n") {
// Then search for the next three dashes.
let break_position = content.find("\n---\n");
// If they exist,
if let Some(break_position) = break_position {
// Take everything in between
let possible_frontmatter = content.split_at(break_position).0;
// Attempt to parse it as YAML.
if let Ok((title, tags)) =
Self::parse_yaml(possible_frontmatter.trim_start_matches("---\n"))
{
// If it worked, return the parsed data and the start of the actual note.
(title, tags, Some(break_position + 5))
} else {
// Fail case: Parsing failed.
(None, Vec::new(), None)
}
} else {
// Fail case: File has no second ---.
(None, Vec::new(), None)
}
} else {
// Fail case: File doesn't start with ---.
(None, Vec::new(), None)
};
// Parse markdown into AST
let arena = comrak::Arena::new();
let root = comrak::parse_document(
&arena,
content.split_at(begin_content.unwrap_or(0)).1,
&comrak::Options {
extension: comrak::ExtensionOptions::builder()
.wikilinks_title_after_pipe(true)
.build(),
..Default::default()
},
);
// Parse YAML.
Ok(Self {
// Name: Check if there was one specified in the YAML frontmatter.
// If not, get the name from the path.
display_name: title.unwrap_or(super::path_to_name(path)?),
// File name: Get it from the path.
name: super::path_to_name(path)?,
// Path: Already given - convert to owned version.
path: path.canonicalize().unwrap_or(path.to_path_buf()),
// Modification: Can be read from the metadata of the path.
last_modification: path.metadata().and_then(|m| m.modified()).ok(),
// Tags: Go through 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 through 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('/') {
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(),
// YAML: We already set this bool.
yaml_frontmatter: begin_content,
})
}
/// 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("Changed:").style(styles.text_style),
Cell::from(
self.last_modification
.map(|st| {
Into::<chrono::DateTime<chrono::offset::Local>>::into(st)
.format("%Y-%m-%d %H:%M")
.to_string()
})
.unwrap_or("".to_owned().to_string()),
)
.style(styles.text_style),
]),
Row::new(vec![
Cell::from("").style(styles.text_style),
Cell::from("").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)
}
/// Takes a str that possibly contains YAML frontmatter and attempts to parse it into a title and a list of tags.
fn parse_yaml(yaml: &str) -> Result<(Option<String>, Vec<String>), error::RucolaError> {
let docs = yaml_rust::YamlLoader::load_from_str(yaml)?;
let doc = &docs
.first()
.ok_or(error::RucolaError::YamlDocsError(yaml.to_owned()))?;
// 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();
Ok((title, tags))
}
}
#[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("#funny abbreviations")
]
);
}
}