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, 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(|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("%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); } // XXX .text() and .push() are completely equivalent // since .text() does no escaping main.push(self.content.0.html) }) .footer(|footer| footer) } }