use std::{borrow::Cow, collections::HashMap, fmt, fs, io, path::Path, str::FromStr};
use chrono::{DateTime, Locale, Utc};
use minijinja::{State, Value, value::ViaDeserialize};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Visitor};
pub struct Parsed<T: Serialize + for<'a> Deserialize<'a>> {
meta: DefaultMetadata<T>,
body: String,
}
#[derive(Serialize, Deserialize)]
struct DefaultMetadata<T> {
#[serde(default, serialize_with = "locale_ser", deserialize_with = "locale_de")]
locale: Option<Locale>,
#[serde(flatten)]
extra: T,
}
impl<T: Serialize + for<'a> Deserialize<'a>> Parsed<T> {
pub fn load(file: &Path) -> io::Result<Self> {
let body = fs::read_to_string(file)?;
let (meta, template) = body.split_once("\n---\n").unwrap_or(("", &body));
let meta = toml::from_str(meta)
.map_err(|e| io::Error::other(format!("invalid metadata: {e}")))?;
Ok(Self {
meta,
body: template.to_string(),
})
}
pub fn template_data(&self) -> HashMap<&'static str, Value> {
let mut res = HashMap::new();
if let Some(locale) = &self.meta.locale {
res.insert("locale", Value::from_safe_string(locale.to_string()));
}
res
}
pub fn extra(&self) -> &T {
&self.meta.extra
}
pub fn body(&self) -> &str {
&self.body
}
pub fn into_both(self) -> (T, String) {
(self.meta.extra, self.body)
}
}
pub(crate) fn filter_strftime(
state: &State,
value: ViaDeserialize<DateTime<Utc>>,
fmt: Option<Cow<'_, str>>,
) -> String {
let locale: Locale = state
.lookup("locale")
.and_then(|v| v.as_str().and_then(|s| s.parse().ok()))
.unwrap_or(Locale::POSIX);
let fmt = fmt.unwrap_or("%Y-%m-%d".into());
value.0.format_localized(&fmt, locale).to_string()
}
fn locale_ser<S: Serializer>(l: &Option<Locale>, s: S) -> Result<S::Ok, S::Error> {
if let Some(l) = l {
s.serialize_str(&format!("{l}"))
} else {
s.serialize_none()
}
}
fn locale_de<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Locale>, D::Error> {
struct LocVisitor;
impl<'de> Visitor<'de> for LocVisitor {
type Value = Option<Locale>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "a string with a known locale")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
match Locale::from_str(v) {
Ok(v) => Ok(Some(v)),
Err(_) => Err(E::custom(format!("unrecognized locale: {v:?}"))),
}
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(None)
}
}
d.deserialize_str(LocVisitor)
}
pub(crate) fn get_preview(body: &str) -> (&str, bool) {
const DEFAULT: (&str, bool) = ("(preview not available)", true);
const IS_TEXT: &[&str] = &["p"];
fn is_text(tag: &tl::HTMLTag<'_>) -> bool {
IS_TEXT.iter().any(|t| tag.name() == *t)
}
let Ok(dom) = tl::parse(body, tl::ParserOptions::new()) else {
return DEFAULT;
};
let mut children = dom.children().iter();
let mut start = None;
let mut end = None;
for node in &mut children {
let node = node.get(dom.parser()).unwrap();
let Some(tag) = node.as_tag() else {
continue;
};
let (s, e) = tag.boundaries(dom.parser());
start.get_or_insert(s);
if !is_text(tag) {
continue;
}
end = Some(e);
break;
}
let start = start.unwrap_or(0);
let Some(end) = end else {
return DEFAULT;
};
let cutoff = children.any(|e| {
let Some(tag) = e.get(dom.parser()).unwrap().as_tag() else {
return false;
};
is_text(tag)
});
(body[start..=end].into(), cutoff)
}