1use thiserror::Error;
29
30use crate::event::{
31 Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
32 SingleLetterTag, Tag, TagKind,
33};
34use crate::key::{PublicKey, PublicKeyError};
35use crate::types::{RelayUrl, RelayUrlError, Url, UrlError};
36
37pub const KIND_HIGHLIGHT: Kind = Kind::HIGHLIGHT;
39
40pub mod roles {
42 pub const AUTHOR: &str = "author";
44 pub const EDITOR: &str = "editor";
46 pub const MENTION: &str = "mention";
48}
49
50pub mod url_markers {
52 pub const SOURCE: &str = "source";
55 pub const MENTION: &str = "mention";
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum HighlightSource {
62 Event {
64 id: EventId,
66 relay_hint: Option<RelayUrl>,
68 },
69 Address {
71 coordinate: Coordinate,
73 relay_hint: Option<RelayUrl>,
75 },
76 Url {
80 url: Url,
82 marker: Option<String>,
84 },
85}
86
87impl HighlightSource {
88 #[must_use]
90 pub fn to_tag(&self) -> Tag {
91 match self {
92 Self::Event { id, relay_hint } => relay_hint
93 .as_ref()
94 .map_or_else(|| Tag::e(*id), |url| Tag::e_with_relay(*id, url)),
95 Self::Address {
96 coordinate,
97 relay_hint,
98 } => relay_hint.as_ref().map_or_else(
99 || Tag::a(coordinate),
100 |url| Tag::a_with_relay(coordinate, url),
101 ),
102 Self::Url { url, marker } => {
103 let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::R));
104 marker.as_ref().map_or_else(
105 || Tag::with(&head, [url.as_str().to_owned()]),
106 |m| Tag::with(&head, [url.as_str().to_owned(), m.clone()]),
107 )
108 }
109 }
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Attribution {
116 pub pubkey: PublicKey,
118 pub relay_hint: Option<RelayUrl>,
120 pub role: Option<String>,
122}
123
124impl Attribution {
125 #[must_use]
127 pub const fn new(pubkey: PublicKey) -> Self {
128 Self {
129 pubkey,
130 relay_hint: None,
131 role: None,
132 }
133 }
134
135 #[must_use]
137 pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
138 self.relay_hint = Some(relay);
139 self
140 }
141
142 #[must_use]
145 pub fn role(mut self, role: impl Into<String>) -> Self {
146 self.role = Some(role.into());
147 self
148 }
149
150 #[must_use]
152 pub fn to_tag(&self) -> Tag {
153 let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
154 let mut values: Vec<String> = Vec::with_capacity(4);
155 values.push(self.pubkey.to_hex());
156 match (&self.relay_hint, &self.role) {
157 (Some(relay), Some(role)) => {
158 values.push(relay.as_str().to_owned());
159 values.push(role.clone());
160 }
161 (Some(relay), None) => values.push(relay.as_str().to_owned()),
162 (None, Some(role)) => {
163 values.push(String::new());
164 values.push(role.clone());
165 }
166 (None, None) => {}
167 }
168 Tag::with(&head, values)
169 }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Default)]
174pub struct Highlight {
175 pub content: String,
178 pub sources: Vec<HighlightSource>,
180 pub attributions: Vec<Attribution>,
182 pub context: Option<String>,
184 pub comment: Option<String>,
186 pub extra_tags: Vec<Tag>,
188}
189
190impl Highlight {
191 #[must_use]
193 pub fn new() -> Self {
194 Self::default()
195 }
196
197 #[must_use]
199 pub fn content(mut self, content: impl Into<String>) -> Self {
200 self.content = content.into();
201 self
202 }
203
204 #[must_use]
206 pub fn source(mut self, source: HighlightSource) -> Self {
207 self.sources.push(source);
208 self
209 }
210
211 #[must_use]
213 pub fn attribution(mut self, attribution: Attribution) -> Self {
214 self.attributions.push(attribution);
215 self
216 }
217
218 #[must_use]
220 pub fn context(mut self, context: impl Into<String>) -> Self {
221 self.context = Some(context.into());
222 self
223 }
224
225 #[must_use]
227 pub fn comment(mut self, comment: impl Into<String>) -> Self {
228 self.comment = Some(comment.into());
229 self
230 }
231
232 pub fn from_event(event: &Event) -> Result<Self, HighlightError> {
239 if event.kind != KIND_HIGHLIGHT {
240 return Err(HighlightError::WrongKind(event.kind));
241 }
242 let mut sources: Vec<HighlightSource> = Vec::new();
243 let mut attributions: Vec<Attribution> = Vec::new();
244 let mut context: Option<String> = None;
245 let mut comment: Option<String> = None;
246 let mut extra_tags: Vec<Tag> = Vec::new();
247 for tag in &event.tags {
248 match tag.kind() {
249 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::E => {
250 sources.push(parse_event_source(tag)?);
251 }
252 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::A => {
253 sources.push(parse_address_source(tag)?);
254 }
255 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::R => {
256 sources.push(parse_url_source(tag)?);
257 }
258 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
259 attributions.push(parse_attribution(tag)?);
260 }
261 _ if tag.name() == "context" => {
262 context = tag.get(1).map(str::to_owned);
263 }
264 _ if tag.name() == "comment" => {
265 comment = tag.get(1).map(str::to_owned);
266 }
267 _ => extra_tags.push(tag.clone()),
268 }
269 }
270 Ok(Self {
271 content: event.content.clone(),
272 sources,
273 attributions,
274 context,
275 comment,
276 extra_tags,
277 })
278 }
279}
280
281fn parse_event_source(tag: &Tag) -> Result<HighlightSource, HighlightError> {
282 let id_hex = tag.get(1).ok_or(HighlightError::MalformedEventSource)?;
283 let id = EventId::parse(id_hex)?;
284 let relay_hint = parse_optional_relay(tag.get(2))?;
285 Ok(HighlightSource::Event { id, relay_hint })
286}
287
288fn parse_address_source(tag: &Tag) -> Result<HighlightSource, HighlightError> {
289 let coord_str = tag.get(1).ok_or(HighlightError::MalformedAddressSource)?;
290 let coordinate = Coordinate::parse(coord_str)?;
291 let relay_hint = parse_optional_relay(tag.get(2))?;
292 Ok(HighlightSource::Address {
293 coordinate,
294 relay_hint,
295 })
296}
297
298fn parse_url_source(tag: &Tag) -> Result<HighlightSource, HighlightError> {
299 let url_str = tag.get(1).ok_or(HighlightError::MalformedUrlSource)?;
300 let url = Url::parse(url_str)?;
301 let marker = tag.get(2).filter(|s| !s.is_empty()).map(str::to_owned);
302 Ok(HighlightSource::Url { url, marker })
303}
304
305fn parse_attribution(tag: &Tag) -> Result<Attribution, HighlightError> {
306 let pk_hex = tag.get(1).ok_or(HighlightError::MalformedAttribution)?;
307 let pubkey = PublicKey::parse(pk_hex)?;
308 let relay_hint = parse_optional_relay(tag.get(2))?;
309 let role = tag.get(3).filter(|s| !s.is_empty()).map(str::to_owned);
310 Ok(Attribution {
311 pubkey,
312 relay_hint,
313 role,
314 })
315}
316
317fn parse_optional_relay(value: Option<&str>) -> Result<Option<RelayUrl>, HighlightError> {
318 match value {
319 Some(s) if !s.is_empty() => Ok(Some(RelayUrl::parse(s)?)),
320 _ => Ok(None),
321 }
322}
323
324#[derive(Debug, Error)]
326#[non_exhaustive]
327pub enum HighlightError {
328 #[error("expected kind 9802 (highlight), got kind {}", .0.as_u16())]
330 WrongKind(Kind),
331 #[error("`e` source tag missing event id")]
333 MalformedEventSource,
334 #[error("`a` source tag missing coordinate")]
336 MalformedAddressSource,
337 #[error("`r` source tag missing URL")]
339 MalformedUrlSource,
340 #[error("`p` attribution tag missing pubkey")]
342 MalformedAttribution,
343 #[error(transparent)]
345 InvalidEventId(#[from] EventIdError),
346 #[error(transparent)]
348 InvalidCoordinate(#[from] CoordinateError),
349 #[error(transparent)]
351 InvalidUrl(#[from] UrlError),
352 #[error(transparent)]
354 InvalidPublicKey(#[from] PublicKeyError),
355 #[error(transparent)]
357 InvalidRelayUrl(#[from] RelayUrlError),
358}
359
360impl EventBuilder {
361 #[must_use]
363 pub fn highlight(highlight: &Highlight) -> Self {
364 let mut builder = Self::new(KIND_HIGHLIGHT, highlight.content.clone());
365 for source in &highlight.sources {
366 builder = builder.tag(source.to_tag());
367 }
368 for attribution in &highlight.attributions {
369 builder = builder.tag(attribution.to_tag());
370 }
371 if let Some(context) = &highlight.context {
372 builder = builder.tag(Tag::with(&TagKind::from_wire("context"), [context.clone()]));
373 }
374 if let Some(comment) = &highlight.comment {
375 builder = builder.tag(Tag::with(&TagKind::from_wire("comment"), [comment.clone()]));
376 }
377 for tag in &highlight.extra_tags {
378 builder = builder.tag(tag.clone());
379 }
380 builder
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::Keys;
388
389 fn keys() -> Keys {
390 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
391 }
392
393 fn relay() -> RelayUrl {
394 RelayUrl::parse("wss://relay.example/").unwrap()
395 }
396
397 fn url(input: &str) -> Url {
398 Url::parse(input).unwrap()
399 }
400
401 #[test]
402 fn round_trip_text_highlight() {
403 let id = EventId::from_byte_array([0x07; 32]);
404 let highlight = Highlight::new()
405 .content("Important sentence")
406 .source(HighlightSource::Event {
407 id,
408 relay_hint: Some(relay()),
409 })
410 .attribution(
411 Attribution::new(*keys().public_key())
412 .relay_hint(relay())
413 .role(roles::AUTHOR),
414 );
415 let event = EventBuilder::highlight(&highlight)
416 .sign_with_keys(&keys())
417 .unwrap();
418 assert_eq!(event.kind, KIND_HIGHLIGHT);
419 let parsed = Highlight::from_event(&event).unwrap();
420 assert_eq!(parsed, highlight);
421 }
422
423 #[test]
424 fn round_trip_url_highlight_with_context() {
425 let highlight = Highlight::new()
426 .content("Excerpt")
427 .source(HighlightSource::Url {
428 url: url("https://example.com/article"),
429 marker: Some(url_markers::SOURCE.to_owned()),
430 })
431 .context("Surrounding paragraph for context.");
432 let event = EventBuilder::highlight(&highlight)
433 .sign_with_keys(&keys())
434 .unwrap();
435 let parsed = Highlight::from_event(&event).unwrap();
436 assert_eq!(parsed, highlight);
437 }
438
439 #[test]
440 fn round_trip_quote_highlight() {
441 let highlight = Highlight::new()
442 .content("the quoted text")
443 .source(HighlightSource::Url {
444 url: url("https://example.com/article"),
445 marker: Some(url_markers::SOURCE.to_owned()),
446 })
447 .attribution(Attribution::new(*keys().public_key()).role(roles::AUTHOR))
448 .attribution(Attribution::new(*keys().public_key()).role(roles::MENTION))
449 .comment("My take on this");
450 let event = EventBuilder::highlight(&highlight)
451 .sign_with_keys(&keys())
452 .unwrap();
453 let parsed = Highlight::from_event(&event).unwrap();
454 assert_eq!(parsed, highlight);
455 }
456
457 #[test]
458 fn round_trip_address_source() {
459 let coord = Coordinate::new(Kind::new(30_023), *keys().public_key(), "post-1".to_owned());
460 let highlight = Highlight::new()
461 .content("Highlight from long-form post")
462 .source(HighlightSource::Address {
463 coordinate: coord,
464 relay_hint: Some(relay()),
465 });
466 let event = EventBuilder::highlight(&highlight)
467 .sign_with_keys(&keys())
468 .unwrap();
469 let parsed = Highlight::from_event(&event).unwrap();
470 assert_eq!(parsed, highlight);
471 }
472
473 #[test]
474 fn empty_content_is_allowed_for_audio_video() {
475 let highlight = Highlight::new().source(HighlightSource::Url {
476 url: url("https://example.com/podcast.mp3"),
477 marker: Some(url_markers::SOURCE.to_owned()),
478 });
479 let event = EventBuilder::highlight(&highlight)
480 .sign_with_keys(&keys())
481 .unwrap();
482 let parsed = Highlight::from_event(&event).unwrap();
483 assert_eq!(parsed, highlight);
484 assert!(parsed.content.is_empty());
485 }
486
487 #[test]
488 fn wrong_kind_is_rejected() {
489 let event = EventBuilder::text_note("nope")
490 .sign_with_keys(&keys())
491 .unwrap();
492 assert!(matches!(
493 Highlight::from_event(&event),
494 Err(HighlightError::WrongKind(_))
495 ));
496 }
497
498 #[test]
499 fn malformed_event_source_propagates() {
500 let event = EventBuilder::new(KIND_HIGHLIGHT, "")
501 .tag(Tag::with(
502 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E)),
503 ["not-a-hex"],
504 ))
505 .sign_with_keys(&keys())
506 .unwrap();
507 assert!(matches!(
508 Highlight::from_event(&event),
509 Err(HighlightError::InvalidEventId(_))
510 ));
511 }
512}