1use std::collections::BTreeMap;
7
8use crate::value::{BaseDate, BaseLink, Value};
9
10#[derive(Debug, Clone, Default)]
12pub struct Note {
13 pub properties: BTreeMap<String, Value>,
16 pub name: String,
19 pub basename: String,
21 pub path: String,
23 pub folder: String,
25 pub ext: String,
27 pub size: u64,
29 pub ctime: Option<BaseDate>,
31 pub mtime: Option<BaseDate>,
33 pub tags: Vec<String>,
35 pub links: Vec<BaseLink>,
37 pub slug: String,
40 pub body: String,
44}
45
46impl Note {
47 pub fn file_property(&self, field: &str) -> Value {
49 match field {
50 "name" => Value::Str(self.name.clone()),
51 "basename" => Value::Str(self.basename.clone()),
52 "path" => Value::Str(self.path.clone()),
53 "folder" => Value::Str(self.folder.clone()),
54 "ext" => Value::Str(self.ext.clone()),
55 "size" => Value::Number(self.size as f64),
56 "ctime" => self.ctime.map(Value::Date).unwrap_or(Value::Null),
57 "mtime" => self.mtime.map(Value::Date).unwrap_or(Value::Null),
58 "tags" => Value::List(self.tags.iter().cloned().map(Value::Str).collect()),
59 "links" => Value::List(self.links.iter().cloned().map(Value::Link).collect()),
60 "properties" => Value::Object(self.properties.clone()),
62 _ => Value::Null,
63 }
64 }
65
66 pub fn note_property(&self, name: &str) -> Value {
68 self.properties.get(name).cloned().unwrap_or(Value::Null)
69 }
70
71 pub fn has_tag(&self, want: &str) -> bool {
74 let want = want.trim_start_matches('#');
75 self.tags
76 .iter()
77 .any(|t| t == want || t.starts_with(&format!("{want}/")))
78 }
79
80 pub fn in_folder(&self, folder: &str) -> bool {
82 let folder = folder.trim_matches('/');
83 if folder.is_empty() {
84 return true;
85 }
86 self.folder == folder || self.folder.starts_with(&format!("{folder}/"))
87 }
88
89 pub fn has_link(&self, target: &BaseLink) -> bool {
91 self.links.iter().any(|l| l.same_target(target))
92 }
93}
94
95#[derive(Debug, Clone, Default)]
97pub struct Corpus {
98 pub notes: Vec<Note>,
99}
100
101impl Corpus {
102 pub fn new(notes: Vec<Note>) -> Self {
103 Self { notes }
104 }
105
106 pub fn backlinks_to(&self, target: &BaseLink) -> Vec<&Note> {
108 self.notes.iter().filter(|n| n.has_link(target)).collect()
109 }
110}
111
112pub fn properties_from_yaml(fm: &serde_yml::Value) -> BTreeMap<String, Value> {
118 let mut out = BTreeMap::new();
119 if let serde_yml::Value::Mapping(map) = fm {
120 for (k, v) in map {
122 out.insert(k.to_string(), value_from_yaml(v));
123 }
124 }
125 out
126}
127
128pub fn value_from_yaml(v: &serde_yml::Value) -> Value {
131 match v {
132 serde_yml::Value::Null => Value::Null,
133 serde_yml::Value::Bool(b) => Value::Bool(*b),
134 serde_yml::Value::Number(_) => v.as_f64().map(Value::Number).unwrap_or(Value::Null),
135 serde_yml::Value::String(s) => scalar_string_to_value(s),
136 serde_yml::Value::Sequence(items) => {
137 Value::List(items.iter().map(value_from_yaml).collect())
138 }
139 serde_yml::Value::Mapping(map) => {
140 let mut obj = BTreeMap::new();
141 for (k, val) in map {
142 obj.insert(k.to_string(), value_from_yaml(val));
143 }
144 Value::Object(obj)
145 }
146 other => other
148 .as_str()
149 .map(scalar_string_to_value)
150 .unwrap_or(Value::Null),
151 }
152}
153
154pub fn scalar_string_to_value(s: &str) -> Value {
157 let trimmed = s.trim();
158 if let Some(link) = parse_wikilink(trimmed) {
159 return Value::Link(link);
160 }
161 if let Some(date) = parse_date(trimmed) {
162 return Value::Date(date);
163 }
164 Value::Str(s.to_string())
165}
166
167pub fn parse_wikilink(s: &str) -> Option<BaseLink> {
170 let inner = s.strip_prefix("[[")?.strip_suffix("]]")?;
171 if inner.contains("[[") {
172 return None; }
174 let (target, display) = match inner.split_once('|') {
175 Some((t, d)) => (t.trim(), Some(d.trim().to_string())),
176 None => (inner.trim(), None),
177 };
178 let target = target.split(['#', '^']).next().unwrap_or(target).trim();
180 let target = target.strip_suffix(".md").unwrap_or(target);
181 Some(BaseLink {
182 path: target.to_string(),
183 display,
184 })
185}
186
187pub fn parse_date(s: &str) -> Option<BaseDate> {
191 let s = s.trim();
192 let (date_part, time_part) = match s.split_once(['T', ' ']) {
193 Some((d, t)) => (d, Some(t)),
194 None => (s, None),
195 };
196 let mut dp = date_part.split('-');
197 let year: i64 = dp.next()?.parse().ok()?;
198 let month: u32 = dp.next()?.parse().ok()?;
199 let day: u32 = dp.next()?.parse().ok()?;
200 if dp.next().is_some() || !(1..=12).contains(&month) {
201 return None;
202 }
203 if day < 1 || day > days_in_month(year, month) {
207 return None;
208 }
209 if !(1000..=9999).contains(&year) {
211 return None;
212 }
213 let (mut hour, mut minute, mut second, mut millisecond, has_time) =
214 (0u32, 0u32, 0u32, 0u32, time_part.is_some());
215 if let Some(t) = time_part {
216 let t = t.trim().trim_end_matches('Z');
220 let t = t.split(['+', '-']).next().unwrap_or(t);
221 let mut tp = t.split(':');
222 hour = tp.next()?.trim().parse().ok()?;
223 minute = tp.next().unwrap_or("0").parse().ok()?;
224 let sec_field = tp.next().unwrap_or("0");
226 let (sec_str, ms) = match sec_field.split_once('.') {
227 Some((s, frac)) => {
228 let mut f = frac.chars().take(3).collect::<String>();
230 while f.len() < 3 {
231 f.push('0');
232 }
233 (s, f.parse::<u32>().ok()?)
234 }
235 None => (sec_field, 0),
236 };
237 second = sec_str.parse().ok()?;
238 millisecond = ms;
239 if hour > 23 || minute > 59 || second > 59 {
240 return None;
241 }
242 }
243 Some(BaseDate {
244 year,
245 month,
246 day,
247 hour,
248 minute,
249 second,
250 millisecond,
251 has_time,
252 })
253}
254
255fn days_in_month(year: i64, month: u32) -> u32 {
258 match month {
259 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
260 4 | 6 | 9 | 11 => 30,
261 2 => {
262 let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
263 if leap {
264 29
265 } else {
266 28
267 }
268 }
269 _ => 0,
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn wikilink_parsing() {
279 let l = parse_wikilink("[[Books]]").unwrap();
280 assert_eq!(l.path, "Books");
281 assert_eq!(l.display, None);
282 let l2 = parse_wikilink("[[Categories/Books|Books]]").unwrap();
283 assert_eq!(l2.path, "Categories/Books");
284 assert_eq!(l2.display.as_deref(), Some("Books"));
285 assert!(parse_wikilink("not a link").is_none());
286 assert_eq!(parse_wikilink("[[Note#Heading]]").unwrap().path, "Note");
288 }
289
290 #[test]
291 fn date_parsing() {
292 let d = parse_date("2024-01-02").unwrap();
293 assert_eq!((d.year, d.month, d.day), (2024, 1, 2));
294 assert!(!d.has_time);
295 let dt = parse_date("2024-01-02 15:04:05").unwrap();
296 assert_eq!((dt.hour, dt.minute, dt.second), (15, 4, 5));
297 assert!(dt.has_time);
298 let iso = parse_date("2024-01-02T15:04").unwrap();
299 assert_eq!((iso.hour, iso.minute), (15, 4));
300 assert!(parse_date("hello").is_none());
302 assert!(parse_date("12-34-56").is_none());
303 assert!(parse_date("2024-13-01").is_none());
304 }
305
306 #[test]
307 fn parse_date_handles_timezones_and_fractions() {
308 let d = parse_date("2024-01-02T15:04:05-05:00").unwrap();
310 assert_eq!((d.hour, d.minute, d.second), (15, 4, 5));
311 assert!(parse_date("2024-01-02T15:04:05+05:30").is_some());
313 assert!(parse_date("2024-01-02T15:04:05Z").is_some());
314 let f = parse_date("2024-01-02T15:04:05.123").unwrap();
316 assert_eq!(f.millisecond, 123);
317 assert_eq!(f.second, 5);
318 }
319
320 #[test]
321 fn parse_date_rejects_impossible_calendar_dates() {
322 assert!(parse_date("2024-02-30").is_none()); assert!(parse_date("2023-02-29").is_none()); assert!(parse_date("2024-02-29").is_some()); assert!(parse_date("2024-04-31").is_none()); assert!(parse_date("2024-04-30").is_some());
327 }
328
329 #[test]
330 fn yaml_to_values_infers_types() {
331 let fm: serde_yml::Value = serde_yml::from_str(
332 "title: Hello\ncount: 3\ndone: true\ncats:\n - \"[[Categories/Books|Books]]\"\ncreated: 2024-05-06\n",
333 )
334 .unwrap();
335 let props = properties_from_yaml(&fm);
336 assert!(matches!(props.get("title"), Some(Value::Str(s)) if s == "Hello"));
337 assert!(matches!(props.get("count"), Some(Value::Number(n)) if *n == 3.0));
338 assert!(matches!(props.get("done"), Some(Value::Bool(true))));
339 match props.get("cats") {
340 Some(Value::List(items)) => {
341 assert!(matches!(&items[0], Value::Link(l) if l.path == "Categories/Books"));
342 }
343 other => panic!("expected list, got {other:?}"),
344 }
345 assert!(matches!(props.get("created"), Some(Value::Date(_))));
347 }
348
349 #[test]
350 fn has_tag_matches_parents() {
351 let mut n = Note::default();
352 n.tags = vec!["project/active".into()];
353 assert!(n.has_tag("project"));
354 assert!(n.has_tag("project/active"));
355 assert!(n.has_tag("#project"));
356 assert!(!n.has_tag("proj"));
357 }
358
359 #[test]
360 fn in_folder_matches_subfolders() {
361 let mut n = Note::default();
362 n.folder = "Home/Things".into();
363 assert!(n.in_folder("Home"));
364 assert!(n.in_folder("Home/Things"));
365 assert!(!n.in_folder("Misc"));
366 assert!(n.in_folder("")); }
368}