use crate::{
entry::{Entry, EntryKind},
error::{Error, Result},
};
use chrono::{DateTime, SecondsFormat, TimeZone, Utc};
use std::{collections::HashMap, fs, path::Path};
pub(crate) const KIND_SECTIONS: &[(EntryKind, &str, &str)] = &[
(EntryKind::Note, "notes", "Notes"),
(EntryKind::Archive, "archives", "Archives"),
(EntryKind::Topic, "topics", "Topics"),
];
const META_OPEN: &str = "<div id=\"meta\">";
const META_CLOSE: &str = "</div>";
pub(crate) const BOOK_TOML: &str = "[book]\ntitle = \"Memory\"\nsrc = \".\"\n\n[output.html]\n";
pub(crate) fn validate_name(name: &str) -> Result<()> {
if name.is_empty()
|| name == "."
|| name == ".."
|| name.starts_with('.')
|| name.ends_with('.')
|| name.ends_with(' ')
|| name.contains('/')
|| name.contains('\\')
|| name.contains('\0')
|| name.contains("..")
|| name.chars().any(|c| c.is_control())
{
return Err(Error::InvalidName(name.to_owned()));
}
Ok(())
}
pub(crate) fn serialize_entry(e: &Entry) -> String {
let iso = format_ts(e.created_at);
let mut out = String::new();
out.push_str(META_OPEN);
out.push('\n');
out.push_str("<dl>\n");
out.push_str(" <dt>Created</dt>\n");
out.push_str(&format!(
" <dd><time datetime=\"{iso}\">{iso}</time></dd>\n"
));
if !e.aliases.is_empty() {
out.push_str(" <dt>Aliases</dt>\n");
out.push_str(" <dd>\n <ul>\n");
for a in &e.aliases {
out.push_str(&format!(" <li>{}</li>\n", html_escape(a)));
}
out.push_str(" </ul>\n </dd>\n");
}
out.push_str("</dl>\n");
out.push_str(META_CLOSE);
out.push_str("\n\n");
let body = e.content.trim_start_matches('\n');
out.push_str(body);
if !out.ends_with('\n') {
out.push('\n');
}
out
}
pub(crate) struct Parsed {
pub(crate) created_at: Option<u64>,
pub(crate) aliases: Vec<String>,
pub(crate) content: String,
}
pub(crate) fn parse_entry(text: &str) -> Parsed {
let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
let Some(rest) = text.strip_prefix(META_OPEN) else {
return Parsed {
created_at: None,
aliases: Vec::new(),
content: text.trim_end().to_owned(),
};
};
let Some(end) = rest.find(META_CLOSE) else {
return Parsed {
created_at: None,
aliases: Vec::new(),
content: text.trim_end().to_owned(),
};
};
let meta = &rest[..end];
let body = rest[end + META_CLOSE.len()..].trim_start().trim_end();
Parsed {
created_at: extract_created(meta),
aliases: extract_aliases(meta),
content: body.to_owned(),
}
}
fn extract_created(meta: &str) -> Option<u64> {
let attr_start = meta.find("datetime=\"")? + "datetime=\"".len();
let attr_len = meta[attr_start..].find('"')?;
let iso = &meta[attr_start..attr_start + attr_len];
let dt = DateTime::parse_from_rfc3339(iso).ok()?;
let secs = dt.timestamp();
if secs < 0 { None } else { Some(secs as u64) }
}
fn extract_aliases(meta: &str) -> Vec<String> {
let Some(header) = meta.find("<dt>Aliases</dt>") else {
return Vec::new();
};
let after = &meta[header..];
let bound = after[1..]
.find("<dt>")
.map(|i| i + 1)
.unwrap_or(after.len());
let row = &after[..bound];
let mut out = Vec::new();
let mut rest = row;
while let Some(li_start) = rest.find("<li>") {
let open_end = li_start + "<li>".len();
let Some(li_end) = rest[open_end..].find("</li>") else {
break;
};
let inner = &rest[open_end..open_end + li_end];
out.push(html_unescape(inner.trim()));
rest = &rest[open_end + li_end + "</li>".len()..];
}
out
}
fn format_ts(secs: u64) -> String {
let dt: DateTime<Utc> = Utc
.timestamp_opt(secs as i64, 0)
.single()
.unwrap_or_else(|| Utc.timestamp_opt(0, 0).unwrap());
dt.to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn html_unescape(s: &str) -> String {
s.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
}
pub(crate) fn build_summary(by_kind: &HashMap<EntryKind, Vec<&Entry>>) -> String {
let mut out = String::from("# Summary\n\n");
for (kind, dir, title) in KIND_SECTIONS {
let Some(entries) = by_kind.get(kind) else {
continue;
};
if entries.is_empty() {
continue;
}
let mut sorted: Vec<&&Entry> = entries.iter().collect();
sorted.sort_by(|a, b| a.name.cmp(&b.name));
out.push_str("# ");
out.push_str(title);
out.push_str("\n\n");
for e in sorted {
out.push_str(&format!("- [{name}]({dir}/{name}.md)\n", name = e.name));
}
out.push('\n');
}
out
}
pub(crate) struct Loaded {
pub(crate) kind: EntryKind,
pub(crate) name: String,
pub(crate) content: String,
pub(crate) aliases: Vec<String>,
pub(crate) created_at: Option<u64>,
}
pub(crate) fn read_tree(dir: &Path) -> Result<Vec<Loaded>> {
let mut out = Vec::new();
for (kind, sub, _) in KIND_SECTIONS {
let path = dir.join(sub);
if !path.is_dir() {
continue;
}
let mut names: Vec<_> = fs::read_dir(&path)?.collect::<std::io::Result<Vec<_>>>()?;
names.sort_by_key(|e| e.file_name());
for ent in names {
let p = ent.path();
if p.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(name) = p.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
continue;
};
if name.is_empty() {
continue;
}
let raw = fs::read_to_string(&p)?;
let parsed = parse_entry(&raw);
out.push(Loaded {
kind: *kind,
name,
content: parsed.content,
aliases: parsed.aliases,
created_at: parsed.created_at,
});
}
}
Ok(out)
}