use std::{borrow::Cow, collections::HashMap};
use html::{
content::builders::{ArticleBuilder, SectionBuilder},
inline_text::Anchor,
media::builders,
};
use microformats::types::{
temporal::Value as Temporal, Class, Fragment, Item, KnownClass, PropertyValue,
};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("wrong mf2 class, expected {expected:?}, got {got:?}")]
WrongClass {
expected: Vec<KnownClass>,
got: Vec<Class>,
},
#[error("entry lacks `uid` property")]
NoUid,
#[error("unexpected type of property value: expected {expected}, got {got:?}")]
WrongValueType {
expected: &'static str,
got: PropertyValue,
},
#[error("missing property: {0}")]
MissingProperty(&'static str),
}
pub enum Image {
Plain(url::Url),
Accessible { src: url::Url, alt: String },
}
impl Image {
pub fn build(
self,
img: &mut html::media::builders::ImageBuilder,
) -> &mut html::media::builders::ImageBuilder {
match self {
Image::Plain(url) => img.src(String::from(url)),
Image::Accessible { src, alt } => img.src(String::from(src)).alt(alt),
}
}
}
pub struct Card {
uid: url::Url,
urls: Vec<url::Url>,
name: String,
note: Option<String>,
photo: Image,
pronouns: Vec<String>,
}
impl TryFrom<Item> for Card {
type Error = Error;
fn try_from(card: Item) -> Result<Self, Self::Error> {
if card.r#type.as_slice() != [Class::Known(KnownClass::Card)] {
return Err(Error::WrongClass {
expected: vec![KnownClass::Card],
got: card.r#type,
});
}
let mut props = card.properties;
let uid = {
let uids = props.remove("uid").ok_or(Error::NoUid)?;
if let Some(PropertyValue::Url(uid)) = uids.into_iter().take(1).next() {
uid
} else {
return Err(Error::NoUid);
}
};
Ok(Self {
uid,
urls: props
.remove("url")
.unwrap_or_default()
.into_iter()
.filter_map(|v| {
if let PropertyValue::Url(url) = v {
Some(url)
} else {
None
}
})
.collect(),
name: props
.remove("name")
.unwrap_or_default()
.into_iter()
.next()
.ok_or(Error::MissingProperty("name"))
.and_then(|v| match v {
PropertyValue::Plain(plain) => Ok(plain),
other => Err(Error::WrongValueType {
expected: "string",
got: other,
}),
})?,
note: props
.remove("note")
.unwrap_or_default()
.into_iter()
.next()
.map(|v| match v {
PropertyValue::Plain(plain) => Ok(plain),
other => Err(Error::WrongValueType {
expected: "string",
got: other,
}),
})
.transpose()?,
photo: props
.remove("photo")
.unwrap_or_default()
.into_iter()
.next()
.ok_or(Error::MissingProperty("photo"))
.and_then(|v| match v {
PropertyValue::Url(url) => Ok(Image::Plain(url)),
PropertyValue::Image(image) => match image.alt {
Some(alt) => Ok(Image::Accessible {
src: image.value,
alt,
}),
None => Ok(Image::Plain(image.value))
},
other => Err(Error::WrongValueType {
expected: "string",
got: other,
}),
})?,
pronouns: props
.remove("pronoun")
.unwrap_or_default()
.into_iter()
.map(|v| match v {
PropertyValue::Plain(plain) => Ok(plain),
other => Err(Error::WrongValueType {
expected: "string",
got: other,
}),
})
.collect::<Result<Vec<String>, _>>()?,
})
}
}
impl Card {
pub fn build_section(
self,
section: &mut html::content::builders::SectionBuilder,
) -> &mut html::content::builders::SectionBuilder {
section.class("mini-h-card").anchor(|a| {
a.class("larger u-author")
.href(String::from(self.uid))
.image(move |img| self.photo.build(img).loading("lazy"))
.text(self.name)
})
}
pub fn build(
self,
article: &mut html::content::builders::ArticleBuilder,
) -> &mut html::content::builders::ArticleBuilder {
let urls: Vec<_> = self.urls.into_iter().filter(|u| *u != self.uid).collect();
article
.class("h-card")
.image(move |builder| self.photo.build(builder))
.heading_1(move |builder| {
builder.anchor(|builder| {
builder
.class("u-url u-uid p-name")
.href(String::from(self.uid))
.text(self.name)
})
});
if !self.pronouns.is_empty() {
article.span(move |span| {
span.text("(");
self.pronouns.into_iter().for_each(|p| {
span.text(p);
});
span.text(")")
});
}
if let Some(note) = self.note {
article.paragraph(move |p| p.class("p-note").text(note));
}
if !urls.is_empty() {
article.paragraph(|p| p.text("Can be found elsewhere at:"));
article.unordered_list(move |ul| {
for url in urls {
let url = String::from(url);
ul.list_item(move |li| {
li.push(Anchor::builder().href(url.clone()).text(url).build())
});
}
ul
});
}
article
}
}
impl TryFrom<PropertyValue> for Card {
type Error = Error;
fn try_from(v: PropertyValue) -> Result<Self, Self::Error> {
match v {
PropertyValue::Item(item) => item.try_into(),
other => Err(Error::WrongValueType {
expected: "h-card",
got: other,
}),
}
}
}
pub struct Cite {
uid: url::Url,
url: Vec<url::Url>,
in_reply_to: Option<Vec<Citation>>,
author: Card,
published: Option<time::OffsetDateTime>,
content: Content,
}
impl TryFrom<Item> for Cite {
type Error = Error;
fn try_from(cite: Item) -> Result<Self, Self::Error> {
if cite.r#type.as_slice() != [Class::Known(KnownClass::Cite)] {
return Err(Error::WrongClass {
expected: vec![KnownClass::Cite],
got: cite.r#type,
});
}
todo!()
}
}
pub enum Citation {
Brief(url::Url),
Full(Cite),
}
impl TryFrom<PropertyValue> for Citation {
type Error = Error;
fn try_from(v: PropertyValue) -> Result<Self, Self::Error> {
match v {
PropertyValue::Url(url) => Ok(Self::Brief(url)),
PropertyValue::Item(item) => Ok(Self::Full(item.try_into()?)),
other => Err(Error::WrongValueType {
expected: "url or h-cite",
got: other,
}),
}
}
}
pub struct Content(Fragment);
impl From<Content> for html::content::Main {
fn from(content: Content) -> Self {
let mut builder = Self::builder();
builder.class("e-content").text(content.0.html);
if let Some(lang) = content.0.lang {
builder.lang(Cow::Owned(lang));
}
builder.build()
}
}
pub struct Entry {
uid: url::Url,
url: Vec<url::Url>,
in_reply_to: Option<Citation>,
author: Card,
category: Vec<String>,
syndication: Vec<url::Url>,
published: time::OffsetDateTime,
content: Content,
}
impl TryFrom<Item> for Entry {
type Error = Error;
fn try_from(entry: Item) -> Result<Self, Self::Error> {
if entry.r#type.as_slice() != [Class::Known(KnownClass::Entry)] {
return Err(Error::WrongClass {
expected: vec![KnownClass::Entry],
got: entry.r#type,
});
}
let mut props = entry.properties;
let uid = {
let uids = props.remove("uid").ok_or(Error::NoUid)?;
if let Some(PropertyValue::Url(uid)) = uids.into_iter().take(1).next() {
uid
} else {
return Err(Error::NoUid);
}
};
Ok(Entry {
uid,
url: props
.remove("url")
.unwrap_or_default()
.into_iter()
.filter_map(|v| {
if let PropertyValue::Url(url) = v {
Some(url)
} else {
None
}
})
.collect(),
in_reply_to: props
.remove("in-reply-to")
.unwrap_or_default()
.into_iter()
.next()
.map(|v| v.try_into())
.transpose()?,
author: props
.remove("author")
.unwrap_or_default()
.into_iter()
.next()
.map(|v| v.try_into())
.transpose()?
.ok_or(Error::MissingProperty("author"))?,
category: props
.remove("category")
.unwrap_or_default()
.into_iter()
.map(|v| match v {
PropertyValue::Plain(string) => Ok(string),
other => Err(Error::WrongValueType {
expected: "string",
got: other,
}),
})
.collect::<Result<Vec<_>, _>>()?,
syndication: props
.remove("syndication")
.unwrap_or_default()
.into_iter()
.map(|v| match v {
PropertyValue::Url(url) => Ok(url),
other => Err(Error::WrongValueType {
expected: "link",
got: other,
}),
})
.collect::<Result<Vec<_>, _>>()?,
published: props
.remove("published")
.unwrap_or_default()
.into_iter()
.next()
.map(
|v| -> Result<time::OffsetDateTime, Error> {
match v {
PropertyValue::Temporal(Temporal::Timestamp(ref dt)) => {
// This is incredibly sketchy.
let (date, time, offset) = (
dt.date.to_owned().ok_or_else(|| Error::WrongValueType {
expected: "timestamp (date, time, offset)",
got: v.clone()
})?.data,
dt.time.to_owned().ok_or_else(|| Error::WrongValueType {
expected: "timestamp (date, time, offset)",
got: v.clone()
})?.data,
dt.offset.to_owned().ok_or_else(|| Error::WrongValueType {
expected: "timestamp (date, time, offset)",
got: v.clone()
})?.data,
);
Ok(date.with_time(time).assume_offset(offset))
}
other => Err(Error::WrongValueType {
expected: "timestamp",
got: other,
}),
}
},
)
.ok_or(Error::MissingProperty("published"))??,
content: props
.remove("content")
.unwrap_or_default()
.into_iter()
.next()
.ok_or(Error::MissingProperty("content"))
.and_then(|v| match v {
PropertyValue::Fragment(fragment) => Ok(Content(fragment)),
other => Err(Error::WrongValueType {
expected: "html",
got: other,
}),
})?,
})
}
}
impl Entry {
pub fn build(self, article: &mut ArticleBuilder) -> &mut ArticleBuilder {
article
.class("h-entry")
.header(|header| {
header
.class("metadata")
.section(|section| self.author.build_section(section))
.section(|section| {
section
.division(|div| {
div.anchor(|a| {
a.class("u-url u-uid").href(String::from(self.uid)).push(
html::inline_text::Time::builder()
.text(
self.published
.format(&time::format_description::well_known::Rfc2822)
.unwrap()
)
.date_time(self.published.format(&time::format_description::well_known::Rfc3339).unwrap())
.build(),
)
})
})
.division(|div| {
div.text("Tagged").unordered_list(|ul| {
for category in self.category {
ul.list_item(|li| li.class("p-category").text(category));
}
ul
})
})
})
})
.main(|main| {
if let Some(lang) = self.content.0.lang {
main.lang(lang);
}
// XXX .text() and .push() are completely equivalent
// since .text() does no escaping
main.push(self.content.0.html)
})
.footer(|footer| footer)
}
}