use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::Serialize;
pub const DIR: &str = "notes";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Note {
pub id: String,
pub title: String,
pub created: String,
pub machine: String,
pub body: String,
}
pub fn check_id(id: &str) -> Result<String> {
if id.is_empty() || !id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
bail!("{id:?} is not a note id: lowercase letters, digits and '-'");
}
Ok(id.to_string())
}
pub fn new_id(now: chrono::DateTime<chrono::Utc>) -> String {
let random: u16 = rand::random();
format!("{}-{random:04x}", now.format("%Y%m%d-%H%M%S"))
}
pub fn dir_of(root: &Path) -> PathBuf {
wsb_prj::settings::dir_of(root).join(DIR)
}
pub fn path_of(root: &Path, id: &str) -> PathBuf {
dir_of(root).join(format!("{id}.md"))
}
impl Note {
pub fn new(title: &str, body: &str, machine: &str) -> Result<Self> {
let now = chrono::Utc::now();
let note = Self {
id: new_id(now),
title: title.to_string(),
created: now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
machine: machine.to_string(),
body: body.to_string(),
};
note.check()?;
Ok(note)
}
fn check(&self) -> Result<()> {
check_id(&self.id)?;
if self.title.trim().is_empty() {
bail!("a note needs a title; an untitled note is one nobody can find");
}
if self.title.contains('\n') || self.title.contains('\r') {
bail!("a note's title is one line");
}
if self.machine.trim().is_empty() || self.machine.contains('\n') {
bail!("a note names the machine it was written on, in one line");
}
chrono::DateTime::parse_from_rfc3339(&self.created)
.with_context(|| format!("{:?} is not when a note was written (RFC 3339)", self.created))?;
Ok(())
}
pub fn render(&self) -> String {
let mut text = format!(
"---\ntitle: {}\ncreated: {}\nmachine: {}\n---\n",
self.title, self.created, self.machine
);
text.push_str(&self.body);
if !self.body.is_empty() && !self.body.ends_with('\n') {
text.push('\n');
}
text
}
pub fn parse(id: &str, text: &str) -> Result<Self> {
let rest = text
.strip_prefix("---\n")
.context("a note starts with a --- header")?;
let (header, body) = rest
.split_once("\n---\n")
.map(|(header, body)| (header, body.to_string()))
.or_else(|| rest.strip_suffix("\n---").map(|header| (header, String::new())))
.context("a note's header ends with ---")?;
let mut title = None;
let mut created = None;
let mut machine = None;
for line in header.lines() {
let (key, value) = line
.split_once(": ")
.with_context(|| format!("{line:?} in a note's header is not `key: value`"))?;
let slot = match key {
"title" => &mut title,
"created" => &mut created,
"machine" => &mut machine,
other => bail!("{other:?} is not a key of a note's header: title, created, machine"),
};
if slot.replace(value.to_string()).is_some() {
bail!("{key} is given twice in a note's header");
}
}
let note = Self {
id: id.to_string(),
title: title.context("a note's header has no title")?,
created: created.context("a note's header has no created")?,
machine: machine.context("a note's header has no machine")?,
body,
};
note.check()?;
Ok(note)
}
pub fn read(root: &Path, id: &str) -> Result<Option<Self>> {
let path = path_of(root, &check_id(id)?);
match std::fs::read_to_string(&path) {
Ok(text) => Self::parse(id, &text)
.map(Some)
.with_context(|| format!("{} is not a note wsb can read", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("cannot read {}", path.display())),
}
}
pub fn write(&self, root: &Path) -> Result<PathBuf> {
self.check()?;
let path = path_of(root, &self.id);
super::write_file(&path, &self.render())?;
Ok(path)
}
pub fn all(root: &Path) -> Result<Vec<Self>> {
let dir = dir_of(root);
let mut notes = Vec::new();
for (id, path) in super::files_with_extension(&dir, "md")? {
let text = std::fs::read_to_string(&path).with_context(|| format!("cannot read {}", path.display()))?;
notes.push(
Self::parse(&id, &text).with_context(|| format!("{} is not a note wsb can read", path.display()))?,
);
}
notes.sort_by(|a, b| a.id.cmp(&b.id));
Ok(notes)
}
}