Skip to main content

nula_core/nips/
nipb0.rs

1//! [NIP-B0] Web Bookmarks.
2//!
3//! `kind: 39701` is an addressable web-bookmark event. The `d` tag
4//! holds the bookmarked URL **without** the scheme (always assumed to
5//! be `https://` or `http://`), enabling clients to query bookmarks by
6//! `d` value. Optional metadata mirrors common bookmarking apps:
7//! `title`, `published_at` (unix-seconds string), and `t` hashtags.
8//!
9//! [NIP-B0]: https://github.com/nostr-protocol/nips/blob/master/B0.md
10
11use thiserror::Error;
12
13use crate::event::{Alphabet, Coordinate, Event, EventBuilder, Kind, Tag, TagKind};
14use crate::key::PublicKey;
15use crate::types::{Timestamp, TimestampError};
16
17/// `kind: 39701` — web bookmark.
18pub const KIND_WEB_BOOKMARK: Kind = Kind::WEB_BOOKMARK;
19
20const TITLE_TAG: &str = "title";
21const PUBLISHED_AT_TAG: &str = "published_at";
22
23/// Typed bundle for a `kind: 39701` web-bookmark event.
24#[derive(Debug, Clone, PartialEq, Eq, Default)]
25pub struct WebBookmark {
26    /// `d`-identifier — the URL without the scheme.
27    pub identifier: String,
28    /// Free-form description body.
29    pub content: String,
30    /// Optional `title` (HTML link title attribute).
31    pub title: Option<String>,
32    /// Optional `published_at` Unix timestamp (first publication).
33    pub published_at: Option<Timestamp>,
34    /// `t` hashtags (lower-cased).
35    pub hashtags: Vec<String>,
36    /// Forward-compatible passthrough for unknown tags.
37    pub extra_tags: Vec<Tag>,
38}
39
40/// Errors raised while parsing a NIP-B0 event.
41#[derive(Debug, Error)]
42#[non_exhaustive]
43pub enum WebBookmarkError {
44    /// Event kind is not `39701`.
45    #[error("unexpected kind for NIP-B0 web bookmark: {}", .0.as_u16())]
46    WrongKind(Kind),
47    /// `d` tag missing.
48    #[error("NIP-B0 web bookmark missing `d` identifier")]
49    MissingIdentifier,
50    /// Wrapped timestamp parser error.
51    #[error(transparent)]
52    InvalidTimestamp(#[from] TimestampError),
53}
54
55impl WebBookmark {
56    /// Construct a bookmark with the URL identifier seeded.
57    #[must_use]
58    pub fn new(identifier: impl Into<String>) -> Self {
59        Self {
60            identifier: identifier.into(),
61            ..Self::default()
62        }
63    }
64
65    /// Build the bookmark's addressable coordinate.
66    #[must_use]
67    pub fn coordinate(&self, author: PublicKey) -> Coordinate {
68        Coordinate::new(KIND_WEB_BOOKMARK, author, self.identifier.clone())
69    }
70
71    /// Parse a `kind: 39701` web-bookmark event.
72    ///
73    /// # Errors
74    ///
75    /// See [`WebBookmarkError`] for the failure modes.
76    pub fn from_event(event: &Event) -> Result<Self, WebBookmarkError> {
77        if event.kind != KIND_WEB_BOOKMARK {
78            return Err(WebBookmarkError::WrongKind(event.kind));
79        }
80        let mut identifier: Option<String> = None;
81        let mut title: Option<String> = None;
82        let mut published_at: Option<Timestamp> = None;
83        let mut hashtags: Vec<String> = Vec::new();
84        let mut extra_tags: Vec<Tag> = Vec::new();
85        for tag in &event.tags {
86            absorb_tag(
87                tag,
88                &mut identifier,
89                &mut title,
90                &mut published_at,
91                &mut hashtags,
92                &mut extra_tags,
93            )?;
94        }
95        Ok(Self {
96            identifier: identifier.ok_or(WebBookmarkError::MissingIdentifier)?,
97            content: event.content.clone(),
98            title,
99            published_at,
100            hashtags,
101            extra_tags,
102        })
103    }
104}
105
106fn absorb_tag(
107    tag: &Tag,
108    identifier: &mut Option<String>,
109    title: &mut Option<String>,
110    published_at: &mut Option<Timestamp>,
111    hashtags: &mut Vec<String>,
112    extra_tags: &mut Vec<Tag>,
113) -> Result<(), WebBookmarkError> {
114    match tag.kind() {
115        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {
116            *identifier = tag.get(1).map(str::to_owned);
117        }
118        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::T => {
119            if let Some(raw) = tag.get(1) {
120                hashtags.push(raw.to_ascii_lowercase());
121            }
122        }
123        _ if tag.name() == TITLE_TAG => *title = tag.get(1).map(str::to_owned),
124        _ if tag.name() == PUBLISHED_AT_TAG => {
125            if let Some(raw) = tag.get(1) {
126                *published_at = Some(raw.parse::<Timestamp>()?);
127            }
128        }
129        _ => extra_tags.push(tag.clone()),
130    }
131    Ok(())
132}
133
134impl EventBuilder {
135    /// Author a NIP-B0 `kind: 39701` web-bookmark event.
136    #[must_use]
137    pub fn web_bookmark(bookmark: &WebBookmark) -> Self {
138        let mut builder = Self::new(KIND_WEB_BOOKMARK, bookmark.content.clone());
139        builder = builder.tag(Tag::d(&bookmark.identifier));
140        if let Some(title) = &bookmark.title {
141            builder = builder.tag(Tag::with(&TagKind::from_wire(TITLE_TAG), [title.clone()]));
142        }
143        if let Some(ts) = bookmark.published_at {
144            builder = builder.tag(Tag::with(
145                &TagKind::from_wire(PUBLISHED_AT_TAG),
146                [ts.as_secs().to_string()],
147            ));
148        }
149        for hashtag in &bookmark.hashtags {
150            builder = builder.tag(Tag::t(hashtag));
151        }
152        for tag in &bookmark.extra_tags {
153            builder = builder.tag(tag.clone());
154        }
155        builder
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::Keys;
163
164    fn keys() -> Keys {
165        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
166    }
167
168    #[test]
169    fn web_bookmark_round_trip() {
170        let bookmark = WebBookmark {
171            identifier: "alice.blog/post".into(),
172            content: "A marvelous insight".into(),
173            title: Some("Blog insights by Alice".into()),
174            published_at: Some(Timestamp::from_secs(1_738_863_000)),
175            hashtags: vec!["post".into(), "insight".into()],
176            extra_tags: Vec::new(),
177        };
178        let event = EventBuilder::web_bookmark(&bookmark)
179            .sign_with_keys(&keys())
180            .unwrap();
181        let parsed = WebBookmark::from_event(&event).unwrap();
182        assert_eq!(parsed, bookmark);
183    }
184
185    #[test]
186    fn missing_d_is_rejected() {
187        let event = EventBuilder::new(KIND_WEB_BOOKMARK, "content")
188            .sign_with_keys(&keys())
189            .unwrap();
190        assert!(matches!(
191            WebBookmark::from_event(&event),
192            Err(WebBookmarkError::MissingIdentifier)
193        ));
194    }
195
196    #[test]
197    fn wrong_kind_is_rejected() {
198        // Bookmarks are addressable kind 39701 \u2014 anything else fails.
199        let event = EventBuilder::text_note("nope")
200            .sign_with_keys(&keys())
201            .unwrap();
202        assert!(matches!(
203            WebBookmark::from_event(&event),
204            Err(WebBookmarkError::WrongKind(_))
205        ));
206    }
207
208    #[test]
209    fn hashtags_are_lowercased_on_round_trip() {
210        // Spec recommends lowercase hashtags; the parser normalises.
211        let event = EventBuilder::new(KIND_WEB_BOOKMARK, "body")
212            .tag(Tag::d("site.example/page"))
213            .tag(Tag::t("MixedCase"))
214            .tag(Tag::t("CAPS"))
215            .sign_with_keys(&keys())
216            .unwrap();
217        let parsed = WebBookmark::from_event(&event).unwrap();
218        assert_eq!(parsed.hashtags, vec!["mixedcase", "caps"]);
219    }
220
221    #[test]
222    fn coordinate_matches_addressable_triple() {
223        // The bookmark's coordinate accessor MUST yield `(kind, author,
224        // d)` so callers can compose NIP-19 `naddr` entities.
225        let bookmark = WebBookmark::new("alice.blog/post-1");
226        let coord = bookmark.coordinate(*keys().public_key());
227        assert_eq!(coord.kind, KIND_WEB_BOOKMARK);
228        assert_eq!(coord.author, *keys().public_key());
229        assert_eq!(coord.identifier, "alice.blog/post-1");
230    }
231
232    #[test]
233    fn malformed_published_at_is_rejected() {
234        // `published_at` MUST be a parseable Unix timestamp; non-numeric
235        // values bubble up as `InvalidTimestamp`.
236        let event = EventBuilder::new(KIND_WEB_BOOKMARK, "body")
237            .tag(Tag::d("site.example/page"))
238            .tag(Tag::with(
239                &TagKind::from_wire(PUBLISHED_AT_TAG),
240                ["not-a-timestamp".to_owned()],
241            ))
242            .sign_with_keys(&keys())
243            .unwrap();
244        assert!(matches!(
245            WebBookmark::from_event(&event),
246            Err(WebBookmarkError::InvalidTimestamp(_))
247        ));
248    }
249}