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
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 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)?;
// Parse markdown into AST
let arena = comrak::Arena::new();
let root = comrak::parse_document(
&arena,
&content,
&comrak::Options {
extension: comrak::ExtensionOptionsBuilder::default()
.wikilinks_title_after_pipe(true)
.build()
// ExtensionOptionsBuilderError is sadly not public...
.map_err(|_e| error::RucolaError::ComrakError)?,
..Default::default()
},
);
Ok(Self {
// 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.
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![],
})
.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)),
_ => 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()
);
}
}