use thiserror::Error;
use crate::event::{Alphabet, Coordinate, Event, EventBuilder, Kind, Tag, TagKind};
use crate::key::PublicKey;
use crate::types::{Timestamp, TimestampError};
pub const KIND_WEB_BOOKMARK: Kind = Kind::WEB_BOOKMARK;
const TITLE_TAG: &str = "title";
const PUBLISHED_AT_TAG: &str = "published_at";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct WebBookmark {
pub identifier: String,
pub content: String,
pub title: Option<String>,
pub published_at: Option<Timestamp>,
pub hashtags: Vec<String>,
pub extra_tags: Vec<Tag>,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum WebBookmarkError {
#[error("unexpected kind for NIP-B0 web bookmark: {}", .0.as_u16())]
WrongKind(Kind),
#[error("NIP-B0 web bookmark missing `d` identifier")]
MissingIdentifier,
#[error(transparent)]
InvalidTimestamp(#[from] TimestampError),
}
impl WebBookmark {
#[must_use]
pub fn new(identifier: impl Into<String>) -> Self {
Self {
identifier: identifier.into(),
..Self::default()
}
}
#[must_use]
pub fn coordinate(&self, author: PublicKey) -> Coordinate {
Coordinate::new(KIND_WEB_BOOKMARK, author, self.identifier.clone())
}
pub fn from_event(event: &Event) -> Result<Self, WebBookmarkError> {
if event.kind != KIND_WEB_BOOKMARK {
return Err(WebBookmarkError::WrongKind(event.kind));
}
let mut identifier: Option<String> = None;
let mut title: Option<String> = None;
let mut published_at: Option<Timestamp> = None;
let mut hashtags: Vec<String> = Vec::new();
let mut extra_tags: Vec<Tag> = Vec::new();
for tag in &event.tags {
absorb_tag(
tag,
&mut identifier,
&mut title,
&mut published_at,
&mut hashtags,
&mut extra_tags,
)?;
}
Ok(Self {
identifier: identifier.ok_or(WebBookmarkError::MissingIdentifier)?,
content: event.content.clone(),
title,
published_at,
hashtags,
extra_tags,
})
}
}
fn absorb_tag(
tag: &Tag,
identifier: &mut Option<String>,
title: &mut Option<String>,
published_at: &mut Option<Timestamp>,
hashtags: &mut Vec<String>,
extra_tags: &mut Vec<Tag>,
) -> Result<(), WebBookmarkError> {
match tag.kind() {
TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {
*identifier = tag.get(1).map(str::to_owned);
}
TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::T => {
if let Some(raw) = tag.get(1) {
hashtags.push(raw.to_ascii_lowercase());
}
}
_ if tag.name() == TITLE_TAG => *title = tag.get(1).map(str::to_owned),
_ if tag.name() == PUBLISHED_AT_TAG => {
if let Some(raw) = tag.get(1) {
*published_at = Some(raw.parse::<Timestamp>()?);
}
}
_ => extra_tags.push(tag.clone()),
}
Ok(())
}
impl EventBuilder {
#[must_use]
pub fn web_bookmark(bookmark: &WebBookmark) -> Self {
let mut builder = Self::new(KIND_WEB_BOOKMARK, bookmark.content.clone());
builder = builder.tag(Tag::d(&bookmark.identifier));
if let Some(title) = &bookmark.title {
builder = builder.tag(Tag::with(&TagKind::from_wire(TITLE_TAG), [title.clone()]));
}
if let Some(ts) = bookmark.published_at {
builder = builder.tag(Tag::with(
&TagKind::from_wire(PUBLISHED_AT_TAG),
[ts.as_secs().to_string()],
));
}
for hashtag in &bookmark.hashtags {
builder = builder.tag(Tag::t(hashtag));
}
for tag in &bookmark.extra_tags {
builder = builder.tag(tag.clone());
}
builder
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
fn keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
}
#[test]
fn web_bookmark_round_trip() {
let bookmark = WebBookmark {
identifier: "alice.blog/post".into(),
content: "A marvelous insight".into(),
title: Some("Blog insights by Alice".into()),
published_at: Some(Timestamp::from_secs(1_738_863_000)),
hashtags: vec!["post".into(), "insight".into()],
extra_tags: Vec::new(),
};
let event = EventBuilder::web_bookmark(&bookmark)
.sign_with_keys(&keys())
.unwrap();
let parsed = WebBookmark::from_event(&event).unwrap();
assert_eq!(parsed, bookmark);
}
#[test]
fn missing_d_is_rejected() {
let event = EventBuilder::new(KIND_WEB_BOOKMARK, "content")
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
WebBookmark::from_event(&event),
Err(WebBookmarkError::MissingIdentifier)
));
}
#[test]
fn wrong_kind_is_rejected() {
let event = EventBuilder::text_note("nope")
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
WebBookmark::from_event(&event),
Err(WebBookmarkError::WrongKind(_))
));
}
#[test]
fn hashtags_are_lowercased_on_round_trip() {
let event = EventBuilder::new(KIND_WEB_BOOKMARK, "body")
.tag(Tag::d("site.example/page"))
.tag(Tag::t("MixedCase"))
.tag(Tag::t("CAPS"))
.sign_with_keys(&keys())
.unwrap();
let parsed = WebBookmark::from_event(&event).unwrap();
assert_eq!(parsed.hashtags, vec!["mixedcase", "caps"]);
}
#[test]
fn coordinate_matches_addressable_triple() {
let bookmark = WebBookmark::new("alice.blog/post-1");
let coord = bookmark.coordinate(*keys().public_key());
assert_eq!(coord.kind, KIND_WEB_BOOKMARK);
assert_eq!(coord.author, *keys().public_key());
assert_eq!(coord.identifier, "alice.blog/post-1");
}
#[test]
fn malformed_published_at_is_rejected() {
let event = EventBuilder::new(KIND_WEB_BOOKMARK, "body")
.tag(Tag::d("site.example/page"))
.tag(Tag::with(
&TagKind::from_wire(PUBLISHED_AT_TAG),
["not-a-timestamp".to_owned()],
))
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
WebBookmark::from_event(&event),
Err(WebBookmarkError::InvalidTimestamp(_))
));
}
}