Skip to main content

nula_core/nips/
nip54.rs

1//! [NIP-54] Wiki.
2//!
3//! Three kinds make up the wiki primitives:
4//!
5//! - **`kind: 30818` Wiki article** — addressable encyclopedia entry
6//!   identified by a normalised `d` tag (lowercase, hyphenated). The
7//!   `.content` is Djot per spec, with optional NIP-21 `nostr:`
8//!   references; we keep it as opaque `String` and let downstream
9//!   renderers handle the format.
10//! - **`kind: 30819` Wiki redirect** — addressable redirect from one
11//!   `d` slug to another article coordinate.
12//! - **`kind: 818` Wiki merge request** — non-addressable request to
13//!   merge a forked article version back into the source author's
14//!   entry. Carries the destination's `a`/`p` tags plus two `e` tags
15//!   (base version + source revision with a `source` marker).
16//!
17//! # `d`-tag normalisation
18//!
19//! The spec pins a strict normalisation: lowercase, whitespace →
20//! `-`, drop ASCII punctuation, collapse runs of `-`, trim
21//! leading/trailing `-`, preserve non-ASCII letters. We expose
22//! [`normalize_d_tag`] (and its `Cow`-returning sibling
23//! [`normalize_d_tag_cow`]) so producers and consumers agree on the
24//! canonical form.
25//!
26//! `fork` and `defer` markers from spec §"Forks" / §"Deference" are
27//! modelled as typed [`Relation`] tags carried on
28//! [`WikiArticle::relations`].
29//!
30//! [NIP-54]: https://github.com/nostr-protocol/nips/blob/master/54.md
31
32use std::borrow::Cow;
33use std::fmt;
34
35use thiserror::Error;
36
37use crate::event::{
38    Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
39    SingleLetterTag, Tag, TagKind, Tags,
40};
41use crate::key::{PublicKey, PublicKeyError};
42use crate::types::{RelayUrl, RelayUrlError};
43
44/// `kind: 30818` — wiki article.
45pub const KIND_WIKI_ARTICLE: Kind = Kind::WIKI_ARTICLE;
46
47/// `kind: 30819` — wiki redirect.
48pub const KIND_WIKI_REDIRECT: Kind = Kind::WIKI_REDIRECT;
49
50/// `kind: 818` — wiki merge request.
51pub const KIND_WIKI_MERGE_REQUEST: Kind = Kind::WIKI_MERGE_REQUEST;
52
53const TITLE_TAG: &str = "title";
54const SUMMARY_TAG: &str = "summary";
55const SOURCE_MARKER: &str = "source";
56const FORK_MARKER: &str = "fork";
57const DEFER_MARKER: &str = "defer";
58
59/// Marker for a tagged reference (`fork`, `defer`).
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum Relation {
62    /// `fork` marker (spec §"Forks").
63    Fork,
64    /// `defer` marker (spec §"Deference").
65    Defer,
66}
67
68impl Relation {
69    /// Wire token.
70    #[must_use]
71    pub const fn as_str(self) -> &'static str {
72        match self {
73            Self::Fork => FORK_MARKER,
74            Self::Defer => DEFER_MARKER,
75        }
76    }
77
78    /// Parse a wire token. Returns `None` for unrecognised tokens.
79    #[must_use]
80    pub fn parse(token: &str) -> Option<Self> {
81        match token {
82            FORK_MARKER => Some(Self::Fork),
83            DEFER_MARKER => Some(Self::Defer),
84            _ => None,
85        }
86    }
87}
88
89impl fmt::Display for Relation {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        f.write_str(self.as_str())
92    }
93}
94
95/// One `fork`/`defer` reference on a wiki article.
96///
97/// Both `a` (addressable coordinate) and `e` (specific revision)
98/// are spec-recommended; either may be `None` for tolerant
99/// round-trips.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct RelationRef {
102    /// Marker (`fork` or `defer`).
103    pub relation: Relation,
104    /// Optional source article coordinate.
105    pub coordinate: Option<Coordinate>,
106    /// Optional source article relay hint.
107    pub coordinate_relay_hint: Option<RelayUrl>,
108    /// Optional specific source revision id.
109    pub event_id: Option<EventId>,
110    /// Optional source revision relay hint.
111    pub event_relay_hint: Option<RelayUrl>,
112}
113
114/// Normalise an article title into its canonical `d` tag value.
115///
116/// Drops ASCII punctuation, collapses runs of `-`, trims leading
117/// and trailing `-`, lowercases every letter that has a case, and
118/// preserves non-ASCII codepoints intact (see spec §"`d` tag
119/// normalization rules" for the rationale).
120///
121/// Returns a borrowed slice if the input is already canonical.
122#[must_use]
123pub fn normalize_d_tag(input: &str) -> String {
124    normalize_d_tag_cow(input).into_owned()
125}
126
127/// Borrowing variant of [`normalize_d_tag`].
128///
129/// Returns [`Cow::Borrowed`] when the input is already canonical
130/// (`is_canonical_d_tag(input) == true`); otherwise allocates a
131/// fresh `String` with the normalised form.
132#[must_use]
133pub fn normalize_d_tag_cow(input: &str) -> Cow<'_, str> {
134    if is_canonical_d_tag(input) {
135        return Cow::Borrowed(input);
136    }
137    let mut out = String::with_capacity(input.len());
138    let mut prev_dash = true;
139    for ch in input.chars() {
140        if ch.is_alphabetic() || ch.is_numeric() {
141            for low in ch.to_lowercase() {
142                out.push(low);
143            }
144            prev_dash = false;
145        } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !prev_dash {
146            out.push('-');
147            prev_dash = true;
148        }
149        // Anything else (ASCII punctuation, control, symbols) is
150        // dropped per spec.
151    }
152    while out.ends_with('-') {
153        out.pop();
154    }
155    Cow::Owned(out)
156}
157
158/// Heuristic check: returns true when `input` already obeys the
159/// `d`-tag rules and [`normalize_d_tag`] would be the identity.
160#[must_use]
161pub fn is_canonical_d_tag(input: &str) -> bool {
162    if input.starts_with('-') || input.ends_with('-') {
163        return false;
164    }
165    let mut prev_dash = false;
166    for ch in input.chars() {
167        if ch.is_uppercase() {
168            return false;
169        }
170        if ch == '-' {
171            if prev_dash {
172                return false;
173            }
174            prev_dash = true;
175        } else if ch.is_alphabetic() || ch.is_numeric() {
176            prev_dash = false;
177        } else {
178            // Anything outside `-` / alphanumeric is non-canonical.
179            return false;
180        }
181    }
182    true
183}
184
185/// Typed bundle for a `kind: 30818` wiki article event.
186#[derive(Debug, Clone, PartialEq, Eq, Default)]
187pub struct WikiArticle {
188    /// Normalised `d` slug.
189    pub identifier: String,
190    /// Djot body (`.content`).
191    pub content: String,
192    /// Optional display title (`title` tag).
193    pub title: Option<String>,
194    /// Optional summary (`summary` tag).
195    pub summary: Option<String>,
196    /// `fork`/`defer` relations carried on the article.
197    pub relations: Vec<RelationRef>,
198    /// Forward-compatible passthrough for unknown tags.
199    pub extra_tags: Vec<Tag>,
200}
201
202impl WikiArticle {
203    /// Construct an article seeded with `identifier`.
204    ///
205    /// `identifier` is normalised through [`normalize_d_tag`] so
206    /// callers don't have to.
207    #[must_use]
208    pub fn new(identifier: impl AsRef<str>) -> Self {
209        Self {
210            identifier: normalize_d_tag(identifier.as_ref()),
211            ..Self::default()
212        }
213    }
214
215    /// Set the Djot body.
216    #[must_use]
217    pub fn content(mut self, content: impl Into<String>) -> Self {
218        self.content = content.into();
219        self
220    }
221
222    /// Set [`Self::title`].
223    #[must_use]
224    pub fn title(mut self, title: impl Into<String>) -> Self {
225        self.title = Some(title.into());
226        self
227    }
228
229    /// Set [`Self::summary`].
230    #[must_use]
231    pub fn summary(mut self, summary: impl Into<String>) -> Self {
232        self.summary = Some(summary.into());
233        self
234    }
235
236    /// Append a `fork`/`defer` relation.
237    #[must_use]
238    pub fn relation(mut self, relation: RelationRef) -> Self {
239        self.relations.push(relation);
240        self
241    }
242
243    /// Build the article's addressable coordinate.
244    #[must_use]
245    pub fn coordinate(&self, author: PublicKey) -> Coordinate {
246        Coordinate::new(KIND_WIKI_ARTICLE, author, self.identifier.clone())
247    }
248
249    /// Parse a `kind: 30818` event into a typed bundle.
250    ///
251    /// # Errors
252    ///
253    /// - [`WikiError::WrongKind`] for any other kind.
254    /// - [`WikiError::MissingIdentifier`] when the `d` tag is
255    ///   absent.
256    pub fn from_event(event: &Event) -> Result<Self, WikiError> {
257        if event.kind != KIND_WIKI_ARTICLE {
258            return Err(WikiError::WrongKind(event.kind));
259        }
260        let identifier = d_value(&event.tags)
261            .ok_or(WikiError::MissingIdentifier)?
262            .to_owned();
263        let mut article = Self {
264            identifier,
265            content: event.content.clone(),
266            ..Self::default()
267        };
268        let mut pending_a: Vec<(Coordinate, Option<RelayUrl>, Option<Relation>)> = Vec::new();
269        let mut pending_e: Vec<(EventId, Option<RelayUrl>, Option<Relation>)> = Vec::new();
270        for tag in &event.tags {
271            match tag.kind() {
272                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
273                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::A => {
274                    absorb_marker_tag_a(tag, &mut pending_a, &mut article)?;
275                }
276                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::E => {
277                    absorb_marker_tag_e(tag, &mut pending_e, &mut article)?;
278                }
279                _ if tag.name() == TITLE_TAG => article.title = tag.get(1).map(str::to_owned),
280                _ if tag.name() == SUMMARY_TAG => article.summary = tag.get(1).map(str::to_owned),
281                _ => article.extra_tags.push(tag.clone()),
282            }
283        }
284        article.relations = pair_relations(pending_a, pending_e);
285        Ok(article)
286    }
287}
288
289fn absorb_marker_tag_a(
290    tag: &Tag,
291    pending: &mut Vec<(Coordinate, Option<RelayUrl>, Option<Relation>)>,
292    article: &mut WikiArticle,
293) -> Result<(), WikiError> {
294    let (coord, relay, marker) = parse_a_tag_with_marker(tag)?;
295    if let Some(rel) = marker.as_ref().and_then(|m| Relation::parse(m)) {
296        pending.push((coord, relay, Some(rel)));
297    } else {
298        article.extra_tags.push(tag.clone());
299    }
300    Ok(())
301}
302
303fn absorb_marker_tag_e(
304    tag: &Tag,
305    pending: &mut Vec<(EventId, Option<RelayUrl>, Option<Relation>)>,
306    article: &mut WikiArticle,
307) -> Result<(), WikiError> {
308    let (id, relay, marker) = parse_e_tag_with_marker(tag)?;
309    if let Some(rel) = marker.as_ref().and_then(|m| Relation::parse(m)) {
310        pending.push((id, relay, Some(rel)));
311    } else {
312        article.extra_tags.push(tag.clone());
313    }
314    Ok(())
315}
316
317fn absorb_merge_e_tag(
318    tag: &Tag,
319    source: &mut Option<(EventId, Option<RelayUrl>)>,
320    base: &mut Option<(EventId, Option<RelayUrl>)>,
321    extra_tags: &mut Vec<Tag>,
322) -> Result<(), WikiError> {
323    let (id, relay, marker) = parse_e_tag_with_marker(tag)?;
324    if marker.as_deref() == Some(SOURCE_MARKER) {
325        *source = Some((id, relay));
326    } else if base.is_none() {
327        *base = Some((id, relay));
328    } else {
329        extra_tags.push(tag.clone());
330    }
331    Ok(())
332}
333
334fn pair_relations(
335    a_tags: Vec<(Coordinate, Option<RelayUrl>, Option<Relation>)>,
336    e_tags: Vec<(EventId, Option<RelayUrl>, Option<Relation>)>,
337) -> Vec<RelationRef> {
338    let mut out: Vec<RelationRef> = Vec::new();
339    let mut e_iter = e_tags.into_iter();
340    for (coord, coord_relay, rel) in a_tags {
341        let relation = rel.unwrap_or(Relation::Fork);
342        let companion = e_iter.next();
343        out.push(RelationRef {
344            relation,
345            coordinate: Some(coord),
346            coordinate_relay_hint: coord_relay,
347            event_id: companion.as_ref().map(|(id, _, _)| *id),
348            event_relay_hint: companion.and_then(|(_, relay, _)| relay),
349        });
350    }
351    // Any remaining `e` tags with markers but no `a` partner.
352    for (id, relay, rel) in e_iter {
353        let relation = rel.unwrap_or(Relation::Fork);
354        out.push(RelationRef {
355            relation,
356            coordinate: None,
357            coordinate_relay_hint: None,
358            event_id: Some(id),
359            event_relay_hint: relay,
360        });
361    }
362    out
363}
364
365/// Typed bundle for a `kind: 30819` wiki redirect event.
366#[derive(Debug, Clone, PartialEq, Eq)]
367pub struct WikiRedirect {
368    /// Normalised source `d` slug.
369    pub identifier: String,
370    /// Target article coordinate (`a` tag).
371    pub target: Coordinate,
372    /// Optional relay hint for the target.
373    pub target_relay_hint: Option<RelayUrl>,
374    /// Forward-compatible passthrough for unknown tags.
375    pub extra_tags: Vec<Tag>,
376}
377
378impl WikiRedirect {
379    /// Construct a redirect from `identifier` to `target`. The
380    /// identifier is normalised automatically.
381    #[must_use]
382    pub fn new(identifier: impl AsRef<str>, target: Coordinate) -> Self {
383        Self {
384            identifier: normalize_d_tag(identifier.as_ref()),
385            target,
386            target_relay_hint: None,
387            extra_tags: Vec::new(),
388        }
389    }
390
391    /// Attach a relay hint for the target.
392    #[must_use]
393    pub fn target_relay_hint(mut self, relay: RelayUrl) -> Self {
394        self.target_relay_hint = Some(relay);
395        self
396    }
397
398    /// Build the redirect's addressable coordinate.
399    #[must_use]
400    pub fn coordinate(&self, author: PublicKey) -> Coordinate {
401        Coordinate::new(KIND_WIKI_REDIRECT, author, self.identifier.clone())
402    }
403
404    /// Parse a `kind: 30819` event into a typed bundle.
405    ///
406    /// # Errors
407    ///
408    /// - [`WikiError::WrongKind`] for any other kind.
409    /// - [`WikiError::MissingIdentifier`] when the `d` tag is
410    ///   absent.
411    /// - [`WikiError::MissingTarget`] when no `a` tag is present.
412    pub fn from_event(event: &Event) -> Result<Self, WikiError> {
413        if event.kind != KIND_WIKI_REDIRECT {
414            return Err(WikiError::WrongKind(event.kind));
415        }
416        let identifier = d_value(&event.tags)
417            .ok_or(WikiError::MissingIdentifier)?
418            .to_owned();
419        let mut target: Option<(Coordinate, Option<RelayUrl>)> = None;
420        let mut extra_tags: Vec<Tag> = Vec::new();
421        for tag in &event.tags {
422            match tag.kind() {
423                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
424                TagKind::SingleLetter(s)
425                    if !s.uppercase && s.character == Alphabet::A && target.is_none() =>
426                {
427                    let (coord, relay, _) = parse_a_tag_with_marker(tag)?;
428                    target = Some((coord, relay));
429                }
430                _ => extra_tags.push(tag.clone()),
431            }
432        }
433        let (target, target_relay_hint) = target.ok_or(WikiError::MissingTarget)?;
434        Ok(Self {
435            identifier,
436            target,
437            target_relay_hint,
438            extra_tags,
439        })
440    }
441}
442
443/// Typed bundle for a `kind: 818` merge-request event.
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct MergeRequest {
446    /// Destination article coordinate (target of the merge).
447    pub destination: Coordinate,
448    /// Optional relay hint for the destination.
449    pub destination_relay_hint: Option<RelayUrl>,
450    /// Destination author pubkey (`p` tag).
451    pub destination_pubkey: PublicKey,
452    /// Optional base revision the modification was made against.
453    pub base_revision: Option<EventId>,
454    /// Optional relay hint for the base revision.
455    pub base_revision_relay_hint: Option<RelayUrl>,
456    /// Source revision (`e` tag with `source` marker — required by
457    /// spec, but tolerated as `Option` to round-trip malformed
458    /// events).
459    pub source_revision: EventId,
460    /// Optional relay hint for the source revision.
461    pub source_revision_relay_hint: Option<RelayUrl>,
462    /// `.content` — explanation of the merge.
463    pub content: String,
464    /// Forward-compatible passthrough for unknown tags.
465    pub extra_tags: Vec<Tag>,
466}
467
468impl MergeRequest {
469    /// Construct a merge request with the required spec columns.
470    #[must_use]
471    pub const fn new(
472        destination: Coordinate,
473        destination_pubkey: PublicKey,
474        source_revision: EventId,
475    ) -> Self {
476        Self {
477            destination,
478            destination_relay_hint: None,
479            destination_pubkey,
480            base_revision: None,
481            base_revision_relay_hint: None,
482            source_revision,
483            source_revision_relay_hint: None,
484            content: String::new(),
485            extra_tags: Vec::new(),
486        }
487    }
488
489    /// Attach a relay hint for the destination coordinate.
490    #[must_use]
491    pub fn destination_relay_hint(mut self, relay: RelayUrl) -> Self {
492        self.destination_relay_hint = Some(relay);
493        self
494    }
495
496    /// Set the base revision.
497    #[must_use]
498    pub const fn base_revision(mut self, id: EventId) -> Self {
499        self.base_revision = Some(id);
500        self
501    }
502
503    /// Attach a relay hint for the base revision.
504    #[must_use]
505    pub fn base_revision_relay_hint(mut self, relay: RelayUrl) -> Self {
506        self.base_revision_relay_hint = Some(relay);
507        self
508    }
509
510    /// Attach a relay hint for the source revision.
511    #[must_use]
512    pub fn source_revision_relay_hint(mut self, relay: RelayUrl) -> Self {
513        self.source_revision_relay_hint = Some(relay);
514        self
515    }
516
517    /// Set the explanation body.
518    #[must_use]
519    pub fn content(mut self, content: impl Into<String>) -> Self {
520        self.content = content.into();
521        self
522    }
523
524    /// Parse a `kind: 818` event into a typed bundle.
525    ///
526    /// # Errors
527    ///
528    /// - [`WikiError::WrongKind`] for any other kind.
529    /// - [`WikiError::MissingTarget`] when no `a` tag is present.
530    /// - [`WikiError::MissingMergePubkey`] when no `p` tag is
531    ///   present.
532    /// - [`WikiError::MissingMergeSource`] when no `e` tag with the
533    ///   `source` marker is present.
534    pub fn from_event(event: &Event) -> Result<Self, WikiError> {
535        if event.kind != KIND_WIKI_MERGE_REQUEST {
536            return Err(WikiError::WrongKind(event.kind));
537        }
538        let mut destination: Option<(Coordinate, Option<RelayUrl>)> = None;
539        let mut destination_pubkey: Option<PublicKey> = None;
540        let mut base_revision: Option<(EventId, Option<RelayUrl>)> = None;
541        let mut source_revision: Option<(EventId, Option<RelayUrl>)> = None;
542        let mut extra_tags: Vec<Tag> = Vec::new();
543        for tag in &event.tags {
544            match tag.kind() {
545                TagKind::SingleLetter(s)
546                    if !s.uppercase && s.character == Alphabet::A && destination.is_none() =>
547                {
548                    let (coord, relay, _) = parse_a_tag_with_marker(tag)?;
549                    destination = Some((coord, relay));
550                }
551                TagKind::SingleLetter(s)
552                    if !s.uppercase
553                        && s.character == Alphabet::P
554                        && destination_pubkey.is_none() =>
555                {
556                    let pk_hex = tag.get(1).ok_or(WikiError::MissingMergePubkey)?;
557                    destination_pubkey = Some(PublicKey::parse(pk_hex)?);
558                }
559                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::E => {
560                    absorb_merge_e_tag(
561                        tag,
562                        &mut source_revision,
563                        &mut base_revision,
564                        &mut extra_tags,
565                    )?;
566                }
567                _ => extra_tags.push(tag.clone()),
568            }
569        }
570        let (destination, destination_relay_hint) = destination.ok_or(WikiError::MissingTarget)?;
571        let destination_pubkey = destination_pubkey.ok_or(WikiError::MissingMergePubkey)?;
572        let (source_revision, source_revision_relay_hint) =
573            source_revision.ok_or(WikiError::MissingMergeSource)?;
574        let (base_revision, base_revision_relay_hint) =
575            base_revision.map_or((None, None), |(id, relay)| (Some(id), relay));
576        Ok(Self {
577            destination,
578            destination_relay_hint,
579            destination_pubkey,
580            base_revision,
581            base_revision_relay_hint,
582            source_revision,
583            source_revision_relay_hint,
584            content: event.content.clone(),
585            extra_tags,
586        })
587    }
588}
589
590fn parse_a_tag_with_marker(
591    tag: &Tag,
592) -> Result<(Coordinate, Option<RelayUrl>, Option<String>), WikiError> {
593    let coord_str = tag.get(1).ok_or(WikiError::MalformedAddressTag)?;
594    let coord = Coordinate::parse(coord_str)?;
595    let relay = match tag.get(2) {
596        Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
597        _ => None,
598    };
599    let marker = tag.get(3).filter(|s| !s.is_empty()).map(str::to_owned);
600    Ok((coord, relay, marker))
601}
602
603fn parse_e_tag_with_marker(
604    tag: &Tag,
605) -> Result<(EventId, Option<RelayUrl>, Option<String>), WikiError> {
606    let id_hex = tag.get(1).ok_or(WikiError::MalformedEventTag)?;
607    let id = EventId::parse(id_hex)?;
608    let relay = match tag.get(2) {
609        Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
610        _ => None,
611    };
612    let marker = tag.get(3).filter(|s| !s.is_empty()).map(str::to_owned);
613    Ok((id, relay, marker))
614}
615
616fn d_value(tags: &Tags) -> Option<&str> {
617    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
618    tags.find_first(&head).and_then(|tag| tag.get(1))
619}
620
621/// Errors raised by NIP-54 parsers.
622#[derive(Debug, Error)]
623#[non_exhaustive]
624pub enum WikiError {
625    /// The event was not a NIP-54 kind.
626    #[error("unexpected kind for NIP-54 event: {}", .0.as_u16())]
627    WrongKind(Kind),
628    /// `d` tag is absent.
629    #[error("NIP-54 event missing `d` tag")]
630    MissingIdentifier,
631    /// `a` target tag is absent.
632    #[error("NIP-54 event missing target `a` tag")]
633    MissingTarget,
634    /// Merge request `p` tag is absent.
635    #[error("NIP-54 merge request missing `p` tag")]
636    MissingMergePubkey,
637    /// Merge request `e` tag with the `source` marker is absent.
638    #[error("NIP-54 merge request missing source revision `e` tag")]
639    MissingMergeSource,
640    /// `a` tag column 1 is absent.
641    #[error("`a` tag missing coordinate")]
642    MalformedAddressTag,
643    /// `e` tag column 1 is absent.
644    #[error("`e` tag missing event id")]
645    MalformedEventTag,
646    /// Wrapped coordinate parser error.
647    #[error(transparent)]
648    InvalidCoordinate(#[from] CoordinateError),
649    /// Wrapped event-id parser error.
650    #[error(transparent)]
651    InvalidEventId(#[from] EventIdError),
652    /// Wrapped relay-url parser error.
653    #[error(transparent)]
654    InvalidRelayUrl(#[from] RelayUrlError),
655    /// Wrapped pubkey parser error.
656    #[error(transparent)]
657    InvalidPublicKey(#[from] PublicKeyError),
658}
659
660impl EventBuilder {
661    /// Author a NIP-54 `kind: 30818` wiki article event.
662    #[must_use]
663    pub fn wiki_article(article: &WikiArticle) -> Self {
664        let mut builder = Self::new(KIND_WIKI_ARTICLE, article.content.clone());
665        builder = builder.tag(Tag::d(&article.identifier));
666        if let Some(title) = &article.title {
667            builder = builder.tag(Tag::with(&TagKind::from_wire(TITLE_TAG), [title.clone()]));
668        }
669        if let Some(summary) = &article.summary {
670            builder = builder.tag(Tag::with(
671                &TagKind::from_wire(SUMMARY_TAG),
672                [summary.clone()],
673            ));
674        }
675        for rel in &article.relations {
676            builder = push_relation_tags(rel, builder);
677        }
678        for tag in &article.extra_tags {
679            builder = builder.tag(tag.clone());
680        }
681        builder
682    }
683
684    /// Author a NIP-54 `kind: 30819` wiki redirect event.
685    #[must_use]
686    pub fn wiki_redirect(redirect: &WikiRedirect) -> Self {
687        let mut builder = Self::new(KIND_WIKI_REDIRECT, "");
688        builder = builder.tag(Tag::d(&redirect.identifier));
689        let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::A));
690        builder = builder.tag(redirect.target_relay_hint.as_ref().map_or_else(
691            || Tag::with(&head, [redirect.target.to_wire()]),
692            |relay| {
693                Tag::with(
694                    &head,
695                    [redirect.target.to_wire(), relay.as_str().to_owned()],
696                )
697            },
698        ));
699        for tag in &redirect.extra_tags {
700            builder = builder.tag(tag.clone());
701        }
702        builder
703    }
704
705    /// Author a NIP-54 `kind: 818` merge request event.
706    ///
707    /// Tag order matches spec example: destination `a`, base `e`,
708    /// destination `p`, then source `e` with the `source` marker.
709    #[must_use]
710    pub fn wiki_merge_request(merge: &MergeRequest) -> Self {
711        let mut builder = Self::new(KIND_WIKI_MERGE_REQUEST, merge.content.clone());
712        let head_a = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::A));
713        builder = builder.tag(merge.destination_relay_hint.as_ref().map_or_else(
714            || Tag::with(&head_a, [merge.destination.to_wire()]),
715            |relay| {
716                Tag::with(
717                    &head_a,
718                    [merge.destination.to_wire(), relay.as_str().to_owned()],
719                )
720            },
721        ));
722        if let Some(base) = merge.base_revision {
723            let head_e = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
724            builder = builder.tag(merge.base_revision_relay_hint.as_ref().map_or_else(
725                || Tag::with(&head_e, [base.to_hex()]),
726                |relay| Tag::with(&head_e, [base.to_hex(), relay.as_str().to_owned()]),
727            ));
728        }
729        let head_p = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
730        builder = builder.tag(Tag::with(&head_p, [merge.destination_pubkey.to_hex()]));
731        let head_e = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
732        let source_relay_str = merge
733            .source_revision_relay_hint
734            .as_ref()
735            .map_or_else(String::new, |r| r.as_str().to_owned());
736        builder = builder.tag(Tag::with(
737            &head_e,
738            [
739                merge.source_revision.to_hex(),
740                source_relay_str,
741                SOURCE_MARKER.to_owned(),
742            ],
743        ));
744        for tag in &merge.extra_tags {
745            builder = builder.tag(tag.clone());
746        }
747        builder
748    }
749}
750
751fn push_relation_tags(rel: &RelationRef, mut builder: EventBuilder) -> EventBuilder {
752    if let Some(coord) = &rel.coordinate {
753        let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::A));
754        let relay_str = rel
755            .coordinate_relay_hint
756            .as_ref()
757            .map_or_else(String::new, |r| r.as_str().to_owned());
758        builder = builder.tag(Tag::with(
759            &head,
760            [coord.to_wire(), relay_str, rel.relation.as_str().to_owned()],
761        ));
762    }
763    if let Some(id) = rel.event_id {
764        let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
765        let relay_str = rel
766            .event_relay_hint
767            .as_ref()
768            .map_or_else(String::new, |r| r.as_str().to_owned());
769        builder = builder.tag(Tag::with(
770            &head,
771            [id.to_hex(), relay_str, rel.relation.as_str().to_owned()],
772        ));
773    }
774    builder
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780    use crate::Keys;
781
782    fn keys() -> Keys {
783        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
784    }
785
786    #[test]
787    fn d_tag_normalisation_examples() {
788        assert_eq!(normalize_d_tag("Wiki Article"), "wiki-article");
789        assert_eq!(normalize_d_tag("What's Up?"), "whats-up");
790        assert_eq!(normalize_d_tag("  Hello  World  "), "hello-world");
791        assert_eq!(normalize_d_tag("Article 1"), "article-1");
792        assert_eq!(normalize_d_tag("ウィキペディア"), "ウィキペディア");
793        assert_eq!(normalize_d_tag("日本語 Article"), "日本語-article");
794    }
795
796    #[test]
797    fn is_canonical_d_tag_basic_cases() {
798        assert!(is_canonical_d_tag("wiki-article"));
799        assert!(is_canonical_d_tag("article-1"));
800        assert!(!is_canonical_d_tag("Wiki Article"));
801        assert!(!is_canonical_d_tag("-leading"));
802        assert!(!is_canonical_d_tag("trailing-"));
803        assert!(!is_canonical_d_tag("double--dash"));
804        assert!(!is_canonical_d_tag("with.punct"));
805    }
806
807    #[test]
808    fn wiki_article_round_trip() {
809        let article = WikiArticle::new("Wiki Article")
810            .content("Wiki body")
811            .title("Wiki Article")
812            .summary("Short");
813        let event = EventBuilder::wiki_article(&article)
814            .sign_with_keys(&keys())
815            .unwrap();
816        let parsed = WikiArticle::from_event(&event).unwrap();
817        assert_eq!(parsed, article);
818        assert_eq!(parsed.identifier, "wiki-article");
819    }
820
821    #[test]
822    fn wiki_redirect_round_trip() {
823        let target = Coordinate::new(
824            KIND_WIKI_ARTICLE,
825            *keys().public_key(),
826            "bitcoin".to_owned(),
827        );
828        let redirect = WikiRedirect::new("BTC", target)
829            .target_relay_hint(RelayUrl::parse("wss://relay.example/").unwrap());
830        let event = EventBuilder::wiki_redirect(&redirect)
831            .sign_with_keys(&keys())
832            .unwrap();
833        let parsed = WikiRedirect::from_event(&event).unwrap();
834        assert_eq!(parsed, redirect);
835        assert_eq!(parsed.identifier, "btc");
836    }
837
838    #[test]
839    fn merge_request_round_trip() {
840        let destination = Coordinate::new(
841            KIND_WIKI_ARTICLE,
842            *keys().public_key(),
843            "bitcoin".to_owned(),
844        );
845        let base = EventId::from_byte_array([0xaa; 32]);
846        let src = EventId::from_byte_array([0xbb; 32]);
847        let merge = MergeRequest::new(destination, *keys().public_key(), src)
848            .base_revision(base)
849            .content("Added section about block size");
850        let event = EventBuilder::wiki_merge_request(&merge)
851            .sign_with_keys(&keys())
852            .unwrap();
853        let parsed = MergeRequest::from_event(&event).unwrap();
854        assert_eq!(parsed, merge);
855    }
856
857    #[test]
858    fn wiki_article_wrong_kind_is_rejected() {
859        let event = EventBuilder::text_note("nope")
860            .sign_with_keys(&keys())
861            .unwrap();
862        assert!(matches!(
863            WikiArticle::from_event(&event),
864            Err(WikiError::WrongKind(_))
865        ));
866    }
867
868    #[test]
869    fn wiki_redirect_missing_target_is_rejected() {
870        let event = EventBuilder::new(KIND_WIKI_REDIRECT, "")
871            .tag(Tag::d("btc"))
872            .sign_with_keys(&keys())
873            .unwrap();
874        assert!(matches!(
875            WikiRedirect::from_event(&event),
876            Err(WikiError::MissingTarget)
877        ));
878    }
879
880    #[test]
881    fn merge_request_missing_source_is_rejected() {
882        let destination = Coordinate::new(
883            KIND_WIKI_ARTICLE,
884            *keys().public_key(),
885            "bitcoin".to_owned(),
886        );
887        let event = EventBuilder::new(KIND_WIKI_MERGE_REQUEST, "")
888            .tag(Tag::a(&destination))
889            .tag(Tag::p(*keys().public_key()))
890            .sign_with_keys(&keys())
891            .unwrap();
892        assert!(matches!(
893            MergeRequest::from_event(&event),
894            Err(WikiError::MissingMergeSource)
895        ));
896    }
897}