#![allow(clippy::borrow_interior_mutable_const)]
use crate::mf2::types;
use regex::Regex;
use std::{
collections::{HashMap, HashSet},
iter::FromIterator,
str::FromStr,
sync::OnceLock,
};
use url::Url;
fn is_valid_url(value: &str) -> bool {
Url::parse(value).is_ok()
}
fn normalize_for_comparison(s: &str) -> String {
static RE_WHITESPACE: OnceLock<Regex> = OnceLock::new();
let re = RE_WHITESPACE
.get_or_init(|| Regex::new(r"\s+").expect("Failed to compile whitespace regex"));
re.replace_all(s.trim(), " ").to_string()
}
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize,
)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum Type {
Like,
Bookmark,
Reply,
#[cfg(feature = "reaction")]
Reaction,
Repost,
#[serde(rename = "thread")]
Thread,
#[default]
Note,
Article,
Photo,
#[serde(rename = "screenshot")]
Screenshot,
Video,
Audio,
Media,
#[serde(rename = "poll")]
Poll,
Quotation,
#[serde(rename = "gameplay")]
GamePlay,
#[serde(rename = "rsvp")]
RSVP,
#[serde(rename = "checkin")]
CheckIn,
Listen,
Watch,
Review,
Read,
Jam,
Follow,
Event,
Issue,
Venue,
Collection,
Presentation,
Exercise,
Recipe,
Wish,
Edit,
Sleep,
Session,
Snark,
Donation,
Want,
Mention,
Invite,
Other(String),
}
impl Type {
const REACTIONS: [Self; 13] = [
Type::Reply,
Type::Like,
Type::RSVP,
Type::Reaction,
Type::Review,
Type::Bookmark,
Type::Repost,
Type::Quotation,
Type::Issue,
Type::Edit,
Type::Follow,
Type::Listen,
Type::Invite,
];
pub fn is_reaction(&self) -> bool {
Self::REACTIONS.contains(self)
}
}
impl std::fmt::Display for Type {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let type_str = if let Self::Other(s) = self {
s.to_string()
} else {
serde_json::to_value(self)
.map(|v| v.to_string().trim_matches('"').to_string())
.unwrap_or_else(|_| "other".to_string())
};
f.write_str(&type_str)
}
}
impl FromStr for Type {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(serde_json::from_str(&format!("\"{}\"", s))
.unwrap_or_else(|_| Type::Other(s.to_string())))
}
}
#[test]
fn type_to_string() {
assert_eq!(Type::Note.to_string(), "note");
assert_eq!(Type::Mention.to_string(), "mention");
assert_eq!(Type::RSVP.to_string(), "rsvp");
assert_eq!(Type::CheckIn.to_string(), "checkin");
assert_eq!(Type::GamePlay.to_string(), "gameplay");
assert_eq!(Type::Other("magic".to_string()).to_string(), "magic");
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(untagged, rename_all = "kebab-case")]
pub enum PostType {
Simple(Type),
Expanded {
name: String,
#[serde(rename = "type")]
kind: Type,
#[serde(default = "default_class")]
h: microformats::types::Class,
#[serde(default)]
properties: Vec<String>,
#[serde(default)]
required_properties: Vec<String>,
},
}
fn default_class() -> types::Class {
types::Class::Known(types::KnownClass::Entry)
}
impl From<PostType> for Type {
fn from(post_type: PostType) -> Type {
match post_type {
PostType::Simple(kind) => kind,
PostType::Expanded { kind, .. } => kind,
}
}
}
impl From<&PostType> for Type {
fn from(post_type: &PostType) -> Type {
match post_type {
PostType::Simple(kind) => kind.clone(),
PostType::Expanded { kind, .. } => kind.clone(),
}
}
}
impl PartialEq for PostType {
fn eq(&self, other: &Self) -> bool {
let ltype: Type = self.into();
let rtype: Type = other.into();
ltype == rtype
}
}
impl PostType {
pub fn name(&self) -> String {
match self {
Self::Simple(simple_type) => simple_type.to_string(),
Self::Expanded { name, .. } => name.to_string(),
}
}
pub fn kind(&self) -> String {
match self {
Self::Simple(simple_type) => simple_type.to_string(),
Self::Expanded { kind, .. } => kind.to_string(),
}
}
}
#[test]
fn post_type_name() {
assert_eq!(PostType::Simple(Type::Note).name(), "note".to_string());
assert_eq!(PostType::Simple(Type::RSVP).name(), "rsvp".to_string());
}
fn properties_from_type() -> HashMap<String, Type> {
HashMap::from_iter(
vec![
("like-of".to_owned(), Type::Like),
("in-reply-to".to_owned(), Type::Reply),
("bookmark-of".to_owned(), Type::Bookmark),
("repost-of".to_owned(), Type::Repost),
("quotation-of".to_owned(), Type::Quotation),
("gameplay-of".to_owned(), Type::GamePlay),
("follow-of".to_owned(), Type::Follow),
("jam-of".to_owned(), Type::Jam),
("listen-of".to_owned(), Type::Listen),
("rsvp".to_owned(), Type::RSVP),
("photo".to_owned(), Type::Photo),
("screenshot-of".to_owned(), Type::Screenshot),
("video".to_owned(), Type::Video),
("audio".to_owned(), Type::Audio),
("checkin".to_owned(), Type::CheckIn),
("read-of".to_owned(), Type::Read),
("media".to_owned(), Type::Media),
("mention-of".to_owned(), Type::Mention),
("poll-of".to_owned(), Type::Poll),
("thread-of".to_owned(), Type::Thread),
]
.iter()
.cloned(),
)
}
static REPLY_CONTEXT_PROPERTIES: [&str; 13] = [
"in-reply-to",
"like-of",
"bookmark-of",
"repost-of",
"quotation-of",
"follow-of",
"listen-of",
"gameplay-of",
"mention-of",
"rsvp",
"read-of",
"checkin",
"thread-of",
];
pub fn is_reply_context_property(property_name: &str) -> bool {
REPLY_CONTEXT_PROPERTIES.contains(&property_name)
}
pub fn resolve_reaction_property_name(property_names: &[&str]) -> Option<Type> {
let hashed = REPLY_CONTEXT_PROPERTIES
.iter()
.map(|s| s.to_string())
.collect::<HashSet<String>>();
let got = property_names.iter().map(|s| s.to_string()).collect();
let mut reaction_types = hashed.intersection(&got).cloned().collect::<Vec<_>>();
reaction_types.sort();
reaction_types.dedup();
resolve_from_property_names(reaction_types)
.filter(|v| !matches!(v, Type::Note) || !matches!(v, Type::Article))
.or(Some(Type::Mention))
}
#[test]
fn resolve_reaction_property_name_test() {
assert_eq!(
resolve_reaction_property_name(&["content", "in-reply-to"]),
Some(Type::Reply)
);
assert_eq!(
resolve_reaction_property_name(&["url", "gameplay-of"]),
Some(Type::GamePlay)
);
}
pub fn type_to_reaction_property_name(t: Type) -> String {
if t == Type::Reply {
"comment".to_string()
} else if t.is_reaction() {
t.to_string()
} else {
"mention".to_string()
}
}
#[test]
fn type_to_reaction_property_name_test() {
assert_eq!(type_to_reaction_property_name(Type::Like), "like");
#[cfg(feature = "reaction")]
assert_eq!(type_to_reaction_property_name(Type::Reaction), "reaction");
assert_eq!(type_to_reaction_property_name(Type::Reply), "comment");
assert_eq!(type_to_reaction_property_name(Type::Note), "mention");
assert_eq!(type_to_reaction_property_name(Type::Article), "mention");
assert_eq!(
type_to_reaction_property_name(Type::Other("grr".to_string())),
"mention"
);
}
const PTD_COMPLAINT_CLASSES: [types::Class; 3] = [
types::Class::Known(types::KnownClass::Entry),
types::Class::Known(types::KnownClass::Cite),
types::Class::Known(types::KnownClass::Review),
];
fn get_first_url_value(item: &types::Item, property: &str) -> Option<String> {
item.properties
.get(property)
.and_then(|values| values.first())
.and_then(|v| match v {
types::PropertyValue::Url(u) => Some(u.to_string()),
types::PropertyValue::Plain(s) => Some(s.to_string()),
_ => None,
})
.filter(|s| is_valid_url(s))
}
fn get_first_text_value(item: &types::Item, property: &str) -> Option<String> {
item.properties
.get(property)
.and_then(|values| values.first())
.and_then(|v| match v {
types::PropertyValue::Plain(s) => Some(s.to_string()),
types::PropertyValue::Fragment(f) => Some(f.value.clone()),
_ => None,
})
.filter(|s| !s.trim().is_empty())
}
fn has_valid_rsvp_value(item: &types::Item) -> bool {
get_first_text_value(item, "rsvp")
.map(|v| {
matches!(
v.to_lowercase().as_str(),
"yes" | "no" | "maybe" | "interested"
)
})
.unwrap_or(false)
}
fn detect_note_vs_article(item: &types::Item) -> Type {
let content =
get_first_text_value(item, "content").or_else(|| get_first_text_value(item, "summary"));
let name = get_first_text_value(item, "name");
match (name, content) {
(None, _) => Type::Note,
(Some(n), _) if n.trim().is_empty() => Type::Note,
(Some(name), Some(content)) => {
let normalized_name = normalize_for_comparison(&name);
let normalized_content = normalize_for_comparison(&content);
if normalized_content.starts_with(&normalized_name) {
Type::Note
} else {
Type::Article
}
}
(Some(_), None) => Type::Article,
}
}
pub fn resolve_from_object(item_mf2: types::Item) -> Option<Type> {
if item_mf2.r#type == vec![types::Class::Known(types::KnownClass::Event)] {
return Some(Type::Event);
}
if !PTD_COMPLAINT_CLASSES
.iter()
.any(|klass| item_mf2.r#type.contains(klass))
{
return None;
}
if has_valid_rsvp_value(&item_mf2) {
return Some(Type::RSVP);
}
if get_first_url_value(&item_mf2, "in-reply-to").is_some() {
#[cfg(feature = "reaction")]
if has_reaction_emoji_as_content(item_mf2.clone()) {
return Some(Type::Reaction);
}
return Some(Type::Reply);
}
if get_first_url_value(&item_mf2, "repost-of").is_some() {
return Some(Type::Repost);
}
if get_first_url_value(&item_mf2, "like-of").is_some() {
return Some(Type::Like);
}
if get_first_url_value(&item_mf2, "video").is_some() {
return Some(Type::Video);
}
if get_first_url_value(&item_mf2, "photo").is_some() {
return Some(Type::Photo);
}
if get_first_url_value(&item_mf2, "bookmark-of").is_some() {
return Some(Type::Bookmark);
}
if get_first_url_value(&item_mf2, "quotation-of").is_some() {
return Some(Type::Quotation);
}
if get_first_url_value(&item_mf2, "follow-of").is_some() {
return Some(Type::Follow);
}
if get_first_url_value(&item_mf2, "listen-of").is_some() {
return Some(Type::Listen);
}
if get_first_url_value(&item_mf2, "watch-of").is_some() {
return Some(Type::Watch);
}
if get_first_url_value(&item_mf2, "read-of").is_some() {
return Some(Type::Read);
}
if get_first_url_value(&item_mf2, "jam-of").is_some() {
return Some(Type::Jam);
}
if get_first_url_value(&item_mf2, "gameplay-of").is_some() {
return Some(Type::GamePlay);
}
if get_first_url_value(&item_mf2, "checkin").is_some() {
return Some(Type::CheckIn);
}
if get_first_url_value(&item_mf2, "mention-of").is_some() {
return Some(Type::Mention);
}
let has_content = get_first_text_value(&item_mf2, "content").is_some();
let has_summary = get_first_text_value(&item_mf2, "summary").is_some();
if !has_content && !has_summary {
return Some(Type::Note);
}
Some(detect_note_vs_article(&item_mf2))
}
static RE_IS_ONLY_EMOJI_OR_PICTOGRAPH: OnceLock<Regex> = OnceLock::new();
fn has_emoji(text: &str) -> bool {
RE_IS_ONLY_EMOJI_OR_PICTOGRAPH
.get_or_init(|| {
Regex::new(r#"^(\p{Extended_Pictographic}|\p{Emoji_Presentation})+$"#)
.expect("Failed to compile emoji matching regex")
})
.is_match(text)
}
#[cfg(feature = "reaction")]
fn has_reaction_emoji_as_content<V>(into_item_mf2: V) -> bool
where
V: TryInto<types::Item>,
{
if let Ok(contents) = into_item_mf2
.try_into()
.map(|item: types::Item| item.content())
{
contents
.unwrap_or_default()
.into_iter()
.any(|content_value| match content_value {
types::PropertyValue::Plain(text) => has_emoji(text.as_str()),
types::PropertyValue::Fragment(types::Fragment { value: text, .. }) => {
has_emoji(text.as_str())
}
types::PropertyValue::Item(item) => has_reaction_emoji_as_content(item),
_ => false,
})
} else {
false
}
}
pub fn resolve_from_property_names(names: Vec<String>) -> Option<Type> {
let has_content = names.contains(&"content".to_owned());
let has_name = names.contains(&"name".to_string());
let mut types: Vec<Type> = vec![];
properties_from_type().iter().for_each(|(key, val)| {
if names.contains(key) {
types.push(val.clone());
}
});
if has_name && has_content {
types.push(Type::Article)
} else if !has_name && has_content {
types.push(Type::Note)
}
let first_type = types.first().cloned();
combinatory_type(types.clone())
.or(first_type)
.or(Some(Type::Note))
}
pub fn resolve_all_types(names: Vec<String>) -> Vec<Type> {
let has_content = names.contains(&"content".to_owned());
let has_name = names.contains(&"name".to_string());
let mut all_types: Vec<Type> = vec![];
properties_from_type().iter().for_each(|(key, val)| {
if names.contains(key) {
all_types.push(val.clone());
}
});
if has_name && has_content {
all_types.push(Type::Article);
} else if !has_name && has_content {
all_types.push(Type::Note);
}
let mut reactions: Vec<Type> = vec![];
let mut content_types: Vec<Type> = vec![];
let mut text_types: Vec<Type> = vec![];
let mut other_types: Vec<Type> = vec![];
for t in all_types {
if t.is_reaction() {
reactions.push(t);
} else if matches!(
t,
Type::Photo | Type::Video | Type::Audio | Type::Media | Type::Screenshot | Type::Poll
) {
content_types.push(t);
} else if matches!(t, Type::Note | Type::Article) {
text_types.push(t);
} else {
other_types.push(t);
}
}
reactions.append(&mut content_types);
reactions.append(&mut other_types);
reactions.append(&mut text_types);
if reactions.is_empty() {
reactions.push(Type::Note);
}
reactions
}
fn combinatory_type(types: Vec<Type>) -> Option<Type> {
[
(Type::RSVP, vec![Type::Reply, Type::RSVP]),
(Type::Photo, vec![Type::Photo, Type::Note]),
(Type::Video, vec![Type::Video, Type::Photo, Type::Note]),
(
Type::Media,
vec![Type::Audio, Type::Video, Type::Photo, Type::Note],
),
]
.iter()
.find_map(|(combined_type, expected_types)| {
if expected_types
.iter()
.all(|post_type| types.contains(post_type))
{
Some(combined_type.to_owned())
} else {
None
}
})
}
#[test]
fn post_type_from_json() {
assert_eq!(
serde_json::from_str::<PostType>(
r#"
{
"name": "Note",
"type": "note"
}
"#
)
.ok(),
Some(PostType::Expanded {
name: "Note".to_string(),
kind: Type::Note,
h: default_class(),
properties: Vec::default(),
required_properties: Vec::default()
})
);
assert_eq!(
serde_json::from_str::<Vec<PostType>>(
r#"
[{
"name": "Note",
"type": "note"
}, "like"]
"#
)
.ok(),
Some(vec![
PostType::Expanded {
name: "Note".to_string(),
kind: Type::Note,
h: default_class(),
properties: Vec::default(),
required_properties: Vec::default()
},
PostType::Simple(Type::Like)
])
);
assert_eq!(
serde_json::from_str::<PostType>(r#""note""#).ok(),
Some(PostType::Simple(Type::Note))
);
assert_eq!(
serde_qs::from_str::<Type>("note").map_err(|e| e.to_string()),
Ok(Type::Note)
);
#[derive(serde::Deserialize, PartialEq, Debug)]
struct V {
v: Vec<Type>,
}
assert_eq!(
serde_qs::from_str::<V>("v[0]=note&v[1]=like").map_err(|e| e.to_string()),
Ok(V {
v: vec![Type::Note, Type::Like]
})
);
}
#[test]
fn type_from_str() {
assert_eq!(Type::from_str("note"), Ok(Type::Note));
}
#[test]
fn post_type_partial_eq() {
assert_eq!(
PostType::Simple(Type::Like),
PostType::Expanded {
kind: Type::Like,
name: "Like".to_string(),
h: default_class(),
properties: Vec::default(),
required_properties: Vec::default()
}
);
}
#[test]
fn resolve_from_object_test() {
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"like-of": ["https://indieweb.org/like"]
}
})
.try_into()
.unwrap()
),
Some(Type::Like)
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"in-reply-to": ["https://indieweb.org/rsvp"],
"rsvp": ["yes"]
}
})
.try_into()
.unwrap()
),
Some(Type::RSVP)
);
#[cfg(feature = "reaction")]
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"in-reply-to": ["https://indieweb.org/rsvp"],
"content": "🚀"
}
})
.try_into()
.unwrap()
),
Some(Type::Reaction),
"detected a single emoji reaction"
);
#[cfg(feature = "reaction")]
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"in-reply-to": ["https://indieweb.org/rsvp"],
"content": ["👋🏿"]
}
})
.try_into()
.unwrap()
),
Some(Type::Reaction),
"detected a reaction with a skin tone modifier"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"in-reply-to": ["https://indieweb.org/rsvp"],
"content": ["hey there! 👋🏿"]
}
})
.try_into()
.unwrap()
),
Some(Type::Reply),
"ignores if there's text included"
);
}
#[test]
fn resolve_from_property_names_test() {
assert_eq!(
resolve_from_property_names(vec!["like-of".into()]),
Some(Type::Like)
);
}
#[test]
fn is_valid_url_test() {
assert!(is_valid_url("https://example.com"));
assert!(is_valid_url("http://example.com/path"));
assert!(is_valid_url("https://example.com/path?query=value"));
assert!(!is_valid_url("not a url"));
assert!(!is_valid_url(""));
assert!(!is_valid_url("example.com"));
}
#[test]
fn normalize_for_comparison_test() {
assert_eq!(normalize_for_comparison(" hello world "), "hello world");
assert_eq!(normalize_for_comparison("hello\t\nworld"), "hello world");
assert_eq!(normalize_for_comparison(" trimmed "), "trimmed");
assert_eq!(normalize_for_comparison(""), "");
assert_eq!(normalize_for_comparison("single"), "single");
}
#[test]
fn summary_fallback_test() {
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"summary": ["A summary without content"]
}
})
.try_into()
.unwrap()
),
Some(Type::Note),
"summary without name should be a note"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"name": ["Article Title"],
"summary": ["This is a summary"]
}
})
.try_into()
.unwrap()
),
Some(Type::Article),
"summary with non-matching name should be an article"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"name": ["This is content"],
"content": ["This is content with more text"],
"summary": ["This is a summary"]
}
})
.try_into()
.unwrap()
),
Some(Type::Note),
"content takes precedence over summary"
);
}
#[test]
fn article_vs_note_prefix_detection_test() {
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"name": ["My Title"],
"content": ["My Title and some more content"]
}
})
.try_into()
.unwrap()
),
Some(Type::Note),
"name is prefix of content -> note"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"name": ["Different Title"],
"content": ["This content doesn't match"]
}
})
.try_into()
.unwrap()
),
Some(Type::Article),
"name is not prefix of content -> article"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"content": ["Just content, no name"]
}
})
.try_into()
.unwrap()
),
Some(Type::Note),
"no name -> note"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"name": [""],
"content": ["Content with empty name"]
}
})
.try_into()
.unwrap()
),
Some(Type::Note),
"empty name -> note"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"name": [" My Title "],
"content": ["My Title with extra spaces"]
}
})
.try_into()
.unwrap()
),
Some(Type::Note),
"whitespace normalization in name/content comparison"
);
}
#[test]
fn url_validation_in_type_detection_test() {
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"like-of": ["not a valid url"]
}
})
.try_into()
.unwrap()
),
Some(Type::Note),
"invalid like-of URL should fall through to note"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"video": ["https://example.com/video.mp4"]
}
})
.try_into()
.unwrap()
),
Some(Type::Video),
"valid video URL should detect video type"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"photo": ["https://example.com/photo.jpg"]
}
})
.try_into()
.unwrap()
),
Some(Type::Photo),
"valid photo URL should detect photo type"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"photo": ["invalid-url"],
"content": ["Fallback content"]
}
})
.try_into()
.unwrap()
),
Some(Type::Note),
"invalid photo URL should fall through to content check"
);
}
#[test]
fn spec_algorithm_order_test() {
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"in-reply-to": ["https://example.com/post"],
"like-of": ["https://example.com/liked"]
}
})
.try_into()
.unwrap()
),
Some(Type::Reply),
"RSVP checked before reply, reply before like"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"like-of": ["https://example.com/liked"],
"photo": ["https://example.com/photo.jpg"]
}
})
.try_into()
.unwrap()
),
Some(Type::Like),
"like-of checked before photo"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"video": ["https://example.com/video.mp4"],
"photo": ["https://example.com/photo.jpg"]
}
})
.try_into()
.unwrap()
),
Some(Type::Video),
"video checked before photo"
);
}
#[test]
fn no_content_no_summary_test() {
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {}
})
.try_into()
.unwrap()
),
Some(Type::Note),
"empty properties should be a note"
);
}
#[test]
fn experimental_properties_test() {
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"bookmark-of": ["https://example.com/bookmarked"]
}
})
.try_into()
.unwrap()
),
Some(Type::Bookmark),
"bookmark-of should detect Bookmark type"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"quotation-of": ["https://example.com/quoted"]
}
})
.try_into()
.unwrap()
),
Some(Type::Quotation),
"quotation-of should detect Quotation type"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"follow-of": ["https://example.com/followed"]
}
})
.try_into()
.unwrap()
),
Some(Type::Follow),
"follow-of should detect Follow type"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"listen-of": ["https://example.com/listened"]
}
})
.try_into()
.unwrap()
),
Some(Type::Listen),
"listen-of should detect Listen type"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"watch-of": ["https://example.com/watched"]
}
})
.try_into()
.unwrap()
),
Some(Type::Watch),
"watch-of should detect Watch type"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"read-of": ["https://example.com/read"]
}
})
.try_into()
.unwrap()
),
Some(Type::Read),
"read-of should detect Read type"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"jam-of": ["https://example.com/jammed"]
}
})
.try_into()
.unwrap()
),
Some(Type::Jam),
"jam-of should detect Jam type"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"gameplay-of": ["https://example.com/played"]
}
})
.try_into()
.unwrap()
),
Some(Type::GamePlay),
"gameplay-of should detect GamePlay type"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"checkin": ["https://example.com/place"]
}
})
.try_into()
.unwrap()
),
Some(Type::CheckIn),
"checkin should detect CheckIn type"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"mention-of": ["https://example.com/mentioned"]
}
})
.try_into()
.unwrap()
),
Some(Type::Mention),
"mention-of should detect Mention type"
);
}
#[test]
fn experimental_properties_url_validation_test() {
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"bookmark-of": ["not a valid url"]
}
})
.try_into()
.unwrap()
),
Some(Type::Note),
"invalid bookmark-of URL should fall through to note"
);
assert_eq!(
resolve_from_object(
serde_json::json!({
"type": ["h-entry"],
"properties": {
"listen-of": ["invalid"],
"content": ["Some content"]
}
})
.try_into()
.unwrap()
),
Some(Type::Note),
"invalid listen-of URL should fall through to content check"
);
}