Skip to main content

nula_core/nips/
nip84.rs

1//! [NIP-84] Highlights.
2//!
3//! `kind: 9802` events signal content the publisher found valuable.
4//! The data model is a small bundle:
5//!
6//! - `.content` carries the highlighted text. It MAY be empty when
7//!   the highlight refers to non-text media (audio/video).
8//! - One or more *sources* identify the original material. Sources
9//!   are encoded as `e`/`a` tags for native nostr events and `r`
10//!   tags for URLs.
11//! - Zero or more *attributions* (`p` tags) name the original authors
12//!   or editors. The optional 4th column ([`Attribution::role`])
13//!   carries the role keyword.
14//! - Optional surrounding `context` for short snippets.
15//! - Optional `comment` to turn the highlight into a quote-style
16//!   "quote highlight" rendering.
17//!
18//! Forward compatibility:
19//!
20//! - Unknown roles surface through [`Attribution::role`] as
21//!   [`Option<String>`] — no enum to bump.
22//! - Unknown `r` markers (`mention`, `source`, …) are surfaced
23//!   through [`HighlightSource::Url`]'s `marker` field.
24//! - Anything else round-trips through [`Highlight::extra_tags`].
25//!
26//! [NIP-84]: https://github.com/nostr-protocol/nips/blob/master/84.md
27
28use 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
37/// `kind: 9802` — highlight.
38pub const KIND_HIGHLIGHT: Kind = Kind::HIGHLIGHT;
39
40/// Spec-defined role markers for [`Attribution::role`].
41pub mod roles {
42    /// Original author.
43    pub const AUTHOR: &str = "author";
44    /// Editor.
45    pub const EDITOR: &str = "editor";
46    /// Quote-highlight mention (added by NIP-84 "Quote Highlights").
47    pub const MENTION: &str = "mention";
48}
49
50/// Spec-defined markers for `r` (URL) source tags.
51pub mod url_markers {
52    /// The source URL of the highlight (used inside quote highlights
53    /// to disambiguate from `mention`).
54    pub const SOURCE: &str = "source";
55    /// A URL mentioned inside the highlight's comment.
56    pub const MENTION: &str = "mention";
57}
58
59/// A source the highlight was extracted from.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum HighlightSource {
62    /// `e` tag — points at a specific nostr event.
63    Event {
64        /// Source event id.
65        id: EventId,
66        /// Optional relay hint.
67        relay_hint: Option<RelayUrl>,
68    },
69    /// `a` tag — points at an addressable event.
70    Address {
71        /// Source coordinate.
72        coordinate: Coordinate,
73        /// Optional relay hint.
74        relay_hint: Option<RelayUrl>,
75    },
76    /// `r` tag — external URL. The optional `marker` distinguishes
77    /// the highlight's `source` from a `mention` inside the
78    /// comment (see [`url_markers`]).
79    Url {
80        /// Source URL.
81        url: Url,
82        /// Optional marker (`source` / `mention` / custom).
83        marker: Option<String>,
84    },
85}
86
87impl HighlightSource {
88    /// Render as a [`Tag`].
89    #[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/// Attribution for the highlighted material.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Attribution {
116    /// Original author / editor / mentioned pubkey.
117    pub pubkey: PublicKey,
118    /// Optional relay hint.
119    pub relay_hint: Option<RelayUrl>,
120    /// Optional role (`author`, `editor`, `mention`, custom).
121    pub role: Option<String>,
122}
123
124impl Attribution {
125    /// Construct an attribution with no role marker.
126    #[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    /// Attach a relay hint.
136    #[must_use]
137    pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
138        self.relay_hint = Some(relay);
139        self
140    }
141
142    /// Attach a role marker. Use the constants in [`roles`] for the
143    /// spec-defined values.
144    #[must_use]
145    pub fn role(mut self, role: impl Into<String>) -> Self {
146        self.role = Some(role.into());
147        self
148    }
149
150    /// Render as a [`Tag`].
151    #[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/// Typed bundle for a `kind: 9802` highlight event.
173#[derive(Debug, Clone, PartialEq, Eq, Default)]
174pub struct Highlight {
175    /// `.content` — highlighted text. MAY be empty for non-text
176    /// highlights.
177    pub content: String,
178    /// Source material being highlighted.
179    pub sources: Vec<HighlightSource>,
180    /// Attributions (original authors, editors, mentioned pubkeys).
181    pub attributions: Vec<Attribution>,
182    /// Optional surrounding text (`context` tag).
183    pub context: Option<String>,
184    /// Optional quote-highlight comment (`comment` tag).
185    pub comment: Option<String>,
186    /// Forward-compatible passthrough for unknown tags.
187    pub extra_tags: Vec<Tag>,
188}
189
190impl Highlight {
191    /// Construct an empty highlight.
192    #[must_use]
193    pub fn new() -> Self {
194        Self::default()
195    }
196
197    /// Replace [`Self::content`].
198    #[must_use]
199    pub fn content(mut self, content: impl Into<String>) -> Self {
200        self.content = content.into();
201        self
202    }
203
204    /// Append a source.
205    #[must_use]
206    pub fn source(mut self, source: HighlightSource) -> Self {
207        self.sources.push(source);
208        self
209    }
210
211    /// Append an attribution.
212    #[must_use]
213    pub fn attribution(mut self, attribution: Attribution) -> Self {
214        self.attributions.push(attribution);
215        self
216    }
217
218    /// Set [`Self::context`].
219    #[must_use]
220    pub fn context(mut self, context: impl Into<String>) -> Self {
221        self.context = Some(context.into());
222        self
223    }
224
225    /// Set [`Self::comment`] — turns the event into a quote highlight.
226    #[must_use]
227    pub fn comment(mut self, comment: impl Into<String>) -> Self {
228        self.comment = Some(comment.into());
229        self
230    }
231
232    /// Parse a `kind: 9802` event into a typed bundle.
233    ///
234    /// # Errors
235    ///
236    /// - [`HighlightError::WrongKind`] for non-9802 events.
237    /// - Field-specific errors for malformed tag columns.
238    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/// Errors raised by [`Highlight::from_event`].
325#[derive(Debug, Error)]
326#[non_exhaustive]
327pub enum HighlightError {
328    /// The event was not `kind: 9802`.
329    #[error("expected kind 9802 (highlight), got kind {}", .0.as_u16())]
330    WrongKind(Kind),
331    /// `e` source tag is missing its event id column.
332    #[error("`e` source tag missing event id")]
333    MalformedEventSource,
334    /// `a` source tag is missing its coordinate column.
335    #[error("`a` source tag missing coordinate")]
336    MalformedAddressSource,
337    /// `r` source tag is missing its URL column.
338    #[error("`r` source tag missing URL")]
339    MalformedUrlSource,
340    /// `p` attribution tag is missing its pubkey column.
341    #[error("`p` attribution tag missing pubkey")]
342    MalformedAttribution,
343    /// Event id parser error.
344    #[error(transparent)]
345    InvalidEventId(#[from] EventIdError),
346    /// Coordinate parser error.
347    #[error(transparent)]
348    InvalidCoordinate(#[from] CoordinateError),
349    /// URL parser error.
350    #[error(transparent)]
351    InvalidUrl(#[from] UrlError),
352    /// Pubkey parser error.
353    #[error(transparent)]
354    InvalidPublicKey(#[from] PublicKeyError),
355    /// Relay URL parser error.
356    #[error(transparent)]
357    InvalidRelayUrl(#[from] RelayUrlError),
358}
359
360impl EventBuilder {
361    /// Author a NIP-84 `kind: 9802` highlight event.
362    #[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}