1use thiserror::Error;
12
13use crate::event::{Alphabet, Coordinate, Event, EventBuilder, Kind, Tag, TagKind};
14use crate::key::PublicKey;
15use crate::types::{Timestamp, TimestampError};
16
17pub const KIND_WEB_BOOKMARK: Kind = Kind::WEB_BOOKMARK;
19
20const TITLE_TAG: &str = "title";
21const PUBLISHED_AT_TAG: &str = "published_at";
22
23#[derive(Debug, Clone, PartialEq, Eq, Default)]
25pub struct WebBookmark {
26 pub identifier: String,
28 pub content: String,
30 pub title: Option<String>,
32 pub published_at: Option<Timestamp>,
34 pub hashtags: Vec<String>,
36 pub extra_tags: Vec<Tag>,
38}
39
40#[derive(Debug, Error)]
42#[non_exhaustive]
43pub enum WebBookmarkError {
44 #[error("unexpected kind for NIP-B0 web bookmark: {}", .0.as_u16())]
46 WrongKind(Kind),
47 #[error("NIP-B0 web bookmark missing `d` identifier")]
49 MissingIdentifier,
50 #[error(transparent)]
52 InvalidTimestamp(#[from] TimestampError),
53}
54
55impl WebBookmark {
56 #[must_use]
58 pub fn new(identifier: impl Into<String>) -> Self {
59 Self {
60 identifier: identifier.into(),
61 ..Self::default()
62 }
63 }
64
65 #[must_use]
67 pub fn coordinate(&self, author: PublicKey) -> Coordinate {
68 Coordinate::new(KIND_WEB_BOOKMARK, author, self.identifier.clone())
69 }
70
71 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 #[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 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 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 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 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}