use std::{collections::HashMap, borrow::Cow}; use html::{media::builders, inline_text::Anchor, content::builders::ArticleBuilder}; use microformats::types::{Class, KnownClass, Item, PropertyValue, temporal::Value as Temporal, Fragment}; #[derive(Debug, thiserror::Error)] pub enum Error { #[error("wrong mf2 class, expected {expected:?}, got {got:?}")] WrongClass { expected: Vec, got: Vec }, #[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, name: String, note: Option, photo: Image, pronouns: Vec } impl TryFrom for Card { type Error = Error; fn try_from(card: Item) -> Result { 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.take(); 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() .and_then(|v| match v { PropertyValue::Plain(plain) => Some(Ok(plain)), other => Some(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) => Ok(Image::Accessible { src: image.src, alt: image.alt }), 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::, _>>()? }) } } 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 for Card { type Error = Error; fn try_from(v: PropertyValue) -> Result { match v { PropertyValue::Item(item) => item.take().try_into(), other => Err(Error::WrongValueType { expected: "h-card", got: other }) } } } pub struct Cite { uid: url::Url, url: Vec, in_reply_to: Option>, author: Card, published: Option>, content: Content } impl TryFrom for Cite { type Error = Error; fn try_from(cite: Item) -> Result { 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 for Citation { type Error = Error; fn try_from(v: PropertyValue) -> Result { match v { PropertyValue::Url(url) => Ok(Self::Brief(url)), PropertyValue::Item(item) => Ok(Self::Full(item.take().try_into()?)), other => Err(Error::WrongValueType { expected: "url or h-cite", got: other }) } } } pub struct Content(Fragment); impl From 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, in_reply_to: Option, author: Card, category: Vec, syndication: Vec, published: chrono::DateTime, content: Content } impl TryFrom for Entry { type Error = Error; fn try_from(entry: Item) -> Result { 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.take(); 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::, _>>()?, 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::, _>>()?, published: props.remove("published") .unwrap_or_default() .into_iter() .next() .map(|v| -> Result, Error> { match v { PropertyValue::Temporal(Temporal::Timestamp(dt)) => { // This is incredibly sketchy. let (date, time, offset) = (dt.date.clone().unwrap().data, dt.as_time().unwrap().data.clone(), dt.as_time().unwrap().offset.unwrap().data); date.and_time(time).and_local_timezone(offset).single().ok_or_else(|| Error::WrongValueType { expected: "datetime with timezone", got: PropertyValue::Temporal(Temporal::Timestamp(dt)) }) }, 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(|div| { div .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("%Y-%m-%d %a %H:%M:%S %z").to_string()) .date_time(self.published.to_rfc3339_opts( chrono::SecondsFormat::Secs, false )) .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); } main.push(self.content.0.html) }) } }