1use std::{
2 path::{Path, PathBuf},
3 str::FromStr,
4};
5
6use lazy_static::lazy_static;
7use regex::{Regex, RegexBuilder};
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10use time::macros::format_description;
11use time::OffsetDateTime;
12
13pub mod cli;
15
16mod python;
18
19lazy_static! {
20 static ref FILENAME_RE: Regex = RegexBuilder::new(
21 r"
22 (\d{8}T\d{6})
23 --
24 (.*?)
25 __
26 (.*)
27 \.
28 ([a-z]+)
29 "
30 )
31 .ignore_whitespace(true)
32 .build()
33 .expect("syntax error in static regex");
34}
35
36#[derive(Debug, Error)]
37#[non_exhaustive]
39pub enum Error {
40 #[error("parse error")]
41 ParseError(String),
42 #[error("os error")]
43 OSError(String),
44}
45
46use Error::*;
47
48pub type Result<T> = std::result::Result<T, Error>;
50
51fn name_from_relative_path(relative_path: &Path) -> String {
52 let components: Vec<_> = relative_path.components().collect();
53 assert!(
54 components.len() >= 2,
55 "relative path should look like <year>/<id>"
56 );
57 let last: &Path = components
58 .last()
59 .expect("components cannot be empty")
60 .as_ref();
61 last.to_string_lossy().into_owned()
62}
63
64fn parse_file_name(name: &str) -> Result<Metadata> {
65 let captures = FILENAME_RE
66 .captures(name)
67 .ok_or_else(|| ParseError(format!("Filename {name} did not match expected regex")))?;
68
69 let id = captures
70 .get(1)
71 .expect("FILENAME_RE should contain the correct number of groups")
72 .as_str();
73 let id = Id::from_str(id)?;
74
75 let slug = captures
76 .get(2)
77 .expect("FILENAME_RE should contain the correct number of groups")
78 .as_str()
79 .to_owned();
80
81 let keywords: Vec<String> = captures
82 .get(3)
83 .expect("FILENAME_RE should contain the correct number of groups")
84 .as_str()
85 .split('_')
86 .map(|x| x.to_string())
87 .collect();
88
89 let extension = captures
90 .get(4)
91 .expect("FILENAME_RE should contain the correct number of groups")
92 .as_str()
93 .to_owned();
94
95 Ok(Metadata {
96 id,
97 slug,
98 title: None,
99 keywords,
100 extension,
101 })
102}
103
104fn try_extract_front_matter(contents: &str) -> Option<(FrontMatter, String)> {
105 let docs: Vec<_> = contents.splitn(3, "---\n").collect();
106 if docs.is_empty() {
107 println!("skipping empty front_matter");
108 return None;
109 }
110 if docs.len() < 2 {
111 println!("skipping invalid front_matter");
112 return None;
113 }
114 let first_doc = &docs[1];
115 let text = docs[2];
116 match FrontMatter::parse(first_doc) {
117 Ok(f) => Some((f, text.to_string())),
118 Err(ParseError(e)) => {
119 println!("skipping invalid front_matter: {}", e);
120 None
121 }
122 Err(_) => {
123 unreachable!()
124 }
125 }
126}
127
128#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
129pub struct Id(String);
133
134impl Id {
135 pub fn as_str(&self) -> &str {
136 self.0.as_str()
137 }
138
139 pub fn human_date(&self) -> String {
140 let ymd = &self.0[0..8];
141 let year = &ymd[0..4];
142 let month = &ymd[4..6];
143 let day = &ymd[6..8];
144
145 let hms = &self.0[9..];
146 let hours = &hms[0..2];
147 let minutes = &hms[2..4];
148 let seconds = &hms[4..6];
149
150 format!("{year}-{month}-{day} {hours}:{minutes}:{seconds}")
151 }
152
153 pub fn from_date(offsett_date_time: &OffsetDateTime) -> Self {
154 let format = format_description!("[year][month][day]T[hour][minute][second]");
155 let formatted_date = offsett_date_time.format(&format).unwrap();
156 Self::from_str(&formatted_date).unwrap()
157 }
158}
159
160impl FromStr for Id {
161 type Err = Error;
162
163 fn from_str(s: &str) -> Result<Self> {
164 let chars: Vec<char> = s.chars().collect();
165
166 if chars.len() != 15 {
167 return Err(ParseError(format!(
168 "value '{s}' should contain 15 characters, got {})",
169 chars.len()
170 )));
171 }
172
173 if chars[8] != 'T' {
174 return Err(ParseError(format!(
175 "value '{s}' should contain contain a 'T' in the middle, got {})",
176 chars[6]
177 )));
178 }
179
180 Ok(Self(s.to_string()))
181 }
182}
183
184#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
185pub struct Metadata {
190 id: Id,
191 title: Option<String>,
192 slug: String,
193 keywords: Vec<String>,
194 extension: String,
195}
196
197impl Metadata {
198 pub fn new(id: Id, title: String, keywords: Vec<String>, extension: String) -> Metadata {
199 let slug = slug::slugify(&title);
200 Metadata {
201 id,
202 title: Some(title),
203 slug,
204 keywords,
205 extension,
206 }
207 }
208
209 pub fn id(&self) -> &str {
210 self.id.as_str()
211 }
212
213 pub fn slug(&self) -> &str {
214 self.slug.as_ref()
215 }
216
217 pub fn title(&self) -> Option<&String> {
218 self.title.as_ref()
219 }
220
221 pub fn extension(&self) -> &str {
222 self.extension.as_str()
223 }
224
225 pub fn keywords(&self) -> &[String] {
226 &self.keywords
227 }
228
229 pub fn front_matter(&self) -> FrontMatter {
230 FrontMatter {
231 title: self.title.to_owned(),
232 date: self.id.human_date(),
233 keywords: self.keywords.join(" "),
234 }
235 }
236
237 pub fn relative_path(&self) -> PathBuf {
238 let Metadata {
239 id,
240 keywords,
241 slug,
242 extension,
243 ..
244 } = self;
245
246 let id = id.as_str();
247 let year = &id[0..4];
248 let year_path = PathBuf::from_str(year).expect("year should be ascii");
249
250 let keywords = keywords.join("_");
251
252 let file_path = PathBuf::from_str(&format!("{id}--{slug}__{keywords}.{extension}"))
253 .expect("filename should be valid utf-8");
254
255 year_path.join(file_path)
256 }
257}
258
259#[derive(Debug, Clone, Eq, PartialEq, PartialOrd, Ord, Deserialize, Serialize)]
260pub struct FrontMatter {
267 title: Option<String>,
268 date: String,
269 keywords: String,
270}
271
272impl FrontMatter {
273 pub fn title(&self) -> Option<&String> {
274 self.title.as_ref()
275 }
276
277 pub fn keywords(&self) -> Vec<String> {
278 self.keywords.split(' ').map(|x| x.to_string()).collect()
279 }
280
281 pub fn dump(&self) -> String {
282 serde_yaml::to_string(self).expect("front matter should always be serializable")
283 }
284
285 pub fn parse(front_matter: &str) -> Result<Self> {
286 serde_yaml::from_str(front_matter).map_err(|e| {
287 ParseError(format!(
288 "could not deserialize front matter\n{front_matter}\n{e})"
289 ))
290 })
291 }
292}
293
294#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
295pub struct Note {
299 metadata: Metadata,
300 text: String,
301}
302
303impl Note {
304 pub fn new(metadata: Metadata, text: String) -> Self {
305 Self { metadata, text }
306 }
307
308 fn relative_path(&self) -> PathBuf {
309 self.metadata.relative_path()
310 }
311
312 pub fn front_matter(&self) -> FrontMatter {
313 self.metadata.front_matter()
314 }
315
316 pub fn update(&mut self, front_matter: &FrontMatter) {
318 if let Some(new_title) = &front_matter.title {
319 self.metadata.title = Some(new_title.to_string());
320 let new_slug = slug::slugify(new_title);
321 self.metadata.slug = new_slug;
322 }
323 let new_keywords: Vec<_> = front_matter.keywords();
324 self.metadata.keywords = new_keywords;
325 }
326
327 pub fn metadata(&self) -> &Metadata {
328 &self.metadata
329 }
330
331 pub fn dump(&self) -> String {
332 let mut res = String::new();
333 let front_matter = self.metadata.front_matter();
335 res.push_str(&front_matter.dump());
336 res.push_str("---\n");
337 res.push_str(&self.text);
338 res
339 }
340}
341
342#[derive(Debug)]
343pub struct NotesRepository {
345 base_path: PathBuf,
346}
347
348impl NotesRepository {
349 pub fn open(base_path: impl AsRef<Path>) -> Result<Self> {
354 let base_path = base_path.as_ref();
355 if !base_path.is_dir() {
356 return Err(OSError(format!("{base_path:#?} should be a directory")));
359 }
360 Ok(NotesRepository {
361 base_path: base_path.to_owned(),
362 })
363 }
364
365 pub fn base_path(&self) -> &Path {
368 &self.base_path
369 }
370
371 pub fn import_from_markdown(&self, markdown_path: &Path) -> Result<PathBuf> {
374 let contents = std::fs::read_to_string(markdown_path)
375 .map_err(|e| Error::OSError(format!("while reading: {markdown_path:#?}: {e}")))?;
376 let (front_matter, text) = try_extract_front_matter(&contents).ok_or_else(|| {
377 Error::ParseError(format!(
378 "Could not extract front matter from {markdown_path:#?}"
379 ))
380 })?;
381
382 let title = front_matter.title().ok_or_else(|| {
383 Error::ParseError(format!(
384 "Front matter should in {markdown_path:#?} should contain a title"
385 ))
386 })?;
387 let keywords: Vec<_> = front_matter.keywords();
388 let now = OffsetDateTime::now_utc();
389 let id = Id::from_date(&now);
390 let extension = "md".to_owned();
391 let metadata = Metadata::new(id, title.to_string(), keywords, extension);
392
393 let note = Note::new(metadata, text);
394 self.save(¬e)?;
395
396 Ok(note.relative_path())
397 }
398
399 pub fn update(&self, relative_path: &Path) -> Result<PathBuf> {
404 let full_path = &self.base_path.join(relative_path);
405 let note = self.load(relative_path)?;
406
407 let new_relative_path = note.relative_path();
408 let new_full_path = &self.base_path.join(&new_relative_path);
409 if full_path != new_full_path {
410 println!("{full_path:#?} -> {new_full_path:#?}");
411 std::fs::rename(full_path, new_full_path)
412 .map_err(|e| Error::OSError(format!("Could not rename note: {e}")))?;
413 }
414
415 Ok(new_full_path.to_path_buf())
416 }
417
418 pub fn load(&self, relative_path: &Path) -> Result<Note> {
420 if !relative_path.is_relative() {
421 return Err(OSError(format!(
422 "Expecting a relative path when loading, get {relative_path:+?}"
423 )));
424 }
425 let full_path = &self.base_path.join(relative_path);
426 let contents = std::fs::read_to_string(full_path)
427 .map_err(|e| OSError(format!("While loading note from {full_path:?}: {e}")))?;
428 let file_name = &name_from_relative_path(relative_path);
429 let metadata = parse_file_name(file_name)?;
430 let mut note = Note {
431 metadata,
432 text: contents,
433 };
434 if let Some((front_matter, text)) = try_extract_front_matter(¬e.text) {
435 note.update(&front_matter);
436 note.text = text;
437 }
438 Ok(note)
439 }
440
441 pub fn save(&self, note: &Note) -> Result<PathBuf> {
444 let relative_path = ¬e.relative_path();
445 let full_path = &self.base_path.join(relative_path);
446
447 let parent_path = full_path.parent().expect("full path should have a parent");
448
449 if parent_path.exists() {
450 if parent_path.is_file() {
451 return Err(OSError(format!(
452 "Cannot use {parent_path:?} as year path because there's a file here)"
453 )));
454 }
455 } else {
456 println!("Creating {parent_path:?}");
457 std::fs::create_dir_all(&parent_path).map_err(|e| {
458 OSError(format!(
459 "While creating parent path {parent_path:?}for note :{e}"
460 ))
461 })?;
462 }
463
464 let to_write = note.dump();
465
466 std::fs::write(full_path, &to_write)
467 .map_err(|e| OSError(format!("While saving note in {full_path:?}: {e}")))?;
468 Ok(relative_path.to_path_buf())
469 }
470}
471
472#[cfg(test)]
473mod tests {
474
475 use super::*;
476
477 fn make_note() -> Note {
478 let id = Id::from_str("20220707T142708").unwrap();
479 let slug = "this-is-a-title".to_owned();
480 let title = Some("This is a title".to_owned());
481 let keywords = vec!["k1".to_owned(), "k2".to_owned()];
482 let extension = "md".to_owned();
483 let metadata = Metadata {
484 id,
485 slug,
486 title,
487 keywords,
488 extension,
489 };
490
491 Note {
492 metadata,
493 text: "This is my note".to_owned(),
494 }
495 }
496
497 #[test]
498 fn test_slugify_title_when_creating_metadata() {
499 let id = Id::from_str("20220707T142708").unwrap();
500 let title = "This is a title".to_owned();
501 let keywords = vec!["k1".to_owned(), "k2".to_owned()];
502 let extension = "md".to_owned();
503 let metadata = Metadata::new(id, title, keywords, extension);
504
505 assert_eq!(metadata.slug(), "this-is-a-title");
506 }
507
508 #[test]
509 fn test_parse_metadata_from_file_name() {
510 let name = "20220707T142708--this-is-a-title__k1_k2.md";
511
512 let metadata = parse_file_name(name).unwrap();
513
514 assert_eq!(metadata.id(), "20220707T142708");
515 assert_eq!(metadata.slug(), "this-is-a-title");
516 assert_eq!(metadata.extension(), "md");
517 assert_eq!(metadata.keywords(), &["k1", "k2"]);
518 }
519
520 #[test]
521 fn test_generate_suitable_file_path_for_note() {
522 let note = make_note();
523 assert_eq!(
524 note.relative_path().to_string_lossy(),
525 "2022/20220707T142708--this-is-a-title__k1_k2.md"
526 );
527 }
528
529 #[test]
530 fn test_error_when_trying_to_load_notes_from_a_file() {
531 NotesRepository::open("src/lib.rs").unwrap_err();
532 }
533
534 #[test]
535 fn test_saving_and_loading() {
536 let temp_dir = tempfile::Builder::new()
537 .prefix("test-denotes")
538 .tempdir()
539 .unwrap();
540 let notes = NotesRepository::open(&temp_dir).unwrap();
541 let note = make_note();
542 notes.save(¬e).unwrap();
543
544 let relative_path = ¬e.relative_path();
545 let saved = notes.load(relative_path).unwrap();
546 assert_eq!(note, saved);
547 }
548
549 #[test]
550 fn test_generating_front_matter() {
551 let note = make_note();
552 let original = note.front_matter();
553 let dumped = original.dump();
554
555 let parsed = FrontMatter::parse(&dumped).unwrap();
556 assert_eq!(&parsed.title, &original.title);
557 }
558
559 #[test]
560 #[ignore]
561 fn test_load_front_matter_from_contents() {
562 let note = make_note();
563 let _contents = note.dump();
564 }
565}