1use thiserror::Error;
42
43use crate::event::{Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind, Tags};
44use crate::types::Timestamp;
45
46pub const KIND_LONG_FORM_ARTICLE: Kind = Kind::LONG_FORM_TEXT_NOTE;
48
49pub const KIND_LONG_FORM_DRAFT: Kind = Kind::new(30_024);
52
53pub const TITLE_TAG: &str = "title";
55pub const IMAGE_TAG: &str = "image";
57pub const SUMMARY_TAG: &str = "summary";
59pub const PUBLISHED_AT_TAG: &str = "published_at";
61
62#[derive(Debug, Clone, PartialEq, Eq, Default)]
68pub struct Article {
69 pub identifier: String,
71 pub content: String,
75 pub title: Option<String>,
77 pub image: Option<String>,
81 pub summary: Option<String>,
83 pub published_at: Option<Timestamp>,
85 pub hashtags: Vec<String>,
87}
88
89impl Article {
90 #[must_use]
92 pub fn new(identifier: impl Into<String>, content: impl Into<String>) -> Self {
93 Self {
94 identifier: identifier.into(),
95 content: content.into(),
96 ..Self::default()
97 }
98 }
99
100 #[must_use]
102 pub fn title(mut self, title: impl Into<String>) -> Self {
103 self.title = Some(title.into());
104 self
105 }
106
107 #[must_use]
109 pub fn image(mut self, image: impl Into<String>) -> Self {
110 self.image = Some(image.into());
111 self
112 }
113
114 #[must_use]
116 pub fn summary(mut self, summary: impl Into<String>) -> Self {
117 self.summary = Some(summary.into());
118 self
119 }
120
121 #[must_use]
123 pub const fn published_at(mut self, ts: Timestamp) -> Self {
124 self.published_at = Some(ts);
125 self
126 }
127
128 #[must_use]
130 pub fn hashtag(mut self, tag: impl Into<String>) -> Self {
131 self.hashtags.push(tag.into());
132 self
133 }
134
135 #[must_use]
142 pub const fn is_draft_marker() -> Kind {
143 KIND_LONG_FORM_DRAFT
144 }
145
146 pub fn from_event(event: &Event) -> Result<Self, ArticleError> {
157 if event.kind != KIND_LONG_FORM_ARTICLE && event.kind != KIND_LONG_FORM_DRAFT {
158 return Err(ArticleError::WrongKind(event.kind));
159 }
160 let identifier = d_tag(&event.tags)
161 .ok_or(ArticleError::MissingIdentifier)?
162 .to_owned();
163
164 let mut article = Self::new(identifier, event.content.clone());
165 article.title = custom_tag(&event.tags, TITLE_TAG).map(str::to_owned);
166 article.image = custom_tag(&event.tags, IMAGE_TAG).map(str::to_owned);
167 article.summary = custom_tag(&event.tags, SUMMARY_TAG).map(str::to_owned);
168 if let Some(raw) = custom_tag(&event.tags, PUBLISHED_AT_TAG) {
169 let n: u64 = raw
170 .parse()
171 .map_err(|_| ArticleError::InvalidPublishedAt(raw.to_owned()))?;
172 article.published_at = Some(Timestamp::from_secs(n));
173 }
174 article.hashtags = event
175 .tags
176 .iter()
177 .filter_map(|tag| match tag.kind() {
178 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::T => {
179 tag.get(1).map(str::to_owned)
180 }
181 _ => None,
182 })
183 .collect();
184
185 Ok(article)
186 }
187}
188
189#[derive(Debug, Error)]
191#[non_exhaustive]
192pub enum ArticleError {
193 #[error("expected kind 30023 / 30024 (long-form), got kind {}", .0.as_u16())]
195 WrongKind(Kind),
196 #[error("NIP-23 event must carry exactly one `d` tag")]
198 MissingIdentifier,
199 #[error("`published_at` must be stringified unix seconds; got `{0}`")]
201 InvalidPublishedAt(String),
202}
203
204fn d_tag(tags: &Tags) -> Option<&str> {
205 let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
206 tags.find_first(&head).and_then(|t| t.get(1))
207}
208
209fn custom_tag<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
210 let head = TagKind::Custom(name.to_owned());
211 tags.find_first(&head).and_then(|t| t.get(1))
212}
213
214fn build_article_event(article: Article, kind: Kind) -> EventBuilder {
215 let mut builder = EventBuilder::new(kind, article.content).tag(Tag::d(article.identifier));
216 if let Some(title) = article.title {
217 builder = builder.tag(Tag::title(title));
218 }
219 if let Some(image) = article.image {
220 let head = TagKind::Custom(IMAGE_TAG.to_owned());
221 builder = builder.tag(Tag::with(&head, [image]));
222 }
223 if let Some(summary) = article.summary {
224 let head = TagKind::Custom(SUMMARY_TAG.to_owned());
225 builder = builder.tag(Tag::with(&head, [summary]));
226 }
227 if let Some(ts) = article.published_at {
228 let head = TagKind::Custom(PUBLISHED_AT_TAG.to_owned());
229 builder = builder.tag(Tag::with(&head, [ts.as_secs().to_string()]));
230 }
231 for hashtag in article.hashtags {
232 let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T));
233 builder = builder.tag(Tag::with(&head, [hashtag]));
234 }
235 builder
236}
237
238impl EventBuilder {
239 #[must_use]
241 pub fn long_form_article(article: Article) -> Self {
242 build_article_event(article, KIND_LONG_FORM_ARTICLE)
243 }
244
245 #[must_use]
247 pub fn long_form_draft(article: Article) -> Self {
248 build_article_event(article, KIND_LONG_FORM_DRAFT)
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::Keys;
256
257 fn keys() -> Keys {
258 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
259 }
260
261 #[test]
262 fn article_round_trips_all_metadata_fields() {
263 let article = Article::new("lorem-ipsum", "body")
264 .title("Lorem Ipsum")
265 .image("https://example.com/i.png")
266 .summary("a short note")
267 .published_at(Timestamp::from_secs(1_296_962_229))
268 .hashtag("placeholder")
269 .hashtag("test");
270
271 let event = EventBuilder::long_form_article(article.clone())
272 .sign_with_keys(&keys())
273 .unwrap();
274 assert_eq!(event.kind, KIND_LONG_FORM_ARTICLE);
275
276 let parsed = Article::from_event(&event).unwrap();
277 assert_eq!(parsed, article);
278 }
279
280 #[test]
281 fn drafts_use_kind_30024() {
282 let article = Article::new("draft-1", "wip");
283 let event = EventBuilder::long_form_draft(article)
284 .sign_with_keys(&keys())
285 .unwrap();
286 assert_eq!(event.kind, KIND_LONG_FORM_DRAFT);
287 }
288
289 #[test]
290 fn missing_d_tag_is_rejected_when_parsing() {
291 let event = EventBuilder::new(KIND_LONG_FORM_ARTICLE, "x")
293 .sign_with_keys(&keys())
294 .unwrap();
295 assert!(matches!(
296 Article::from_event(&event),
297 Err(ArticleError::MissingIdentifier)
298 ));
299 }
300
301 #[test]
302 fn wrong_kind_is_rejected_when_parsing() {
303 let event = EventBuilder::text_note("nope")
304 .sign_with_keys(&keys())
305 .unwrap();
306 assert!(matches!(
307 Article::from_event(&event),
308 Err(ArticleError::WrongKind(_))
309 ));
310 }
311
312 #[test]
313 fn published_at_must_parse_as_unix_seconds() {
314 let event = EventBuilder::new(KIND_LONG_FORM_ARTICLE, "x")
315 .tag(Tag::d("slug"))
316 .tag(Tag::with(
317 &TagKind::Custom(PUBLISHED_AT_TAG.to_owned()),
318 ["not-a-number"],
319 ))
320 .sign_with_keys(&keys())
321 .unwrap();
322 assert!(matches!(
323 Article::from_event(&event),
324 Err(ArticleError::InvalidPublishedAt(s)) if s == "not-a-number"
325 ));
326 }
327
328 #[test]
329 fn minimal_article_has_only_d_tag() {
330 let article = Article::new("slug", "just content");
331 let event = EventBuilder::long_form_article(article.clone())
332 .sign_with_keys(&keys())
333 .unwrap();
334 assert_eq!(event.tags.len(), 1);
336 let parsed = Article::from_event(&event).unwrap();
337 assert_eq!(parsed, article);
338 }
339}