feedparser_rs/types/entry.rs
1use super::{
2 common::{
3 Content, Enclosure, Link, MediaContent, MediaCopyright, MediaCredit, MediaRating,
4 MediaThumbnail, Person, Source, Tag, TextConstruct,
5 },
6 generics::LimitedCollectionExt,
7 podcast::{ItunesEntryMeta, PodcastEntryMeta, PodcastPerson, PodcastTranscript},
8 thread::InReplyTo,
9};
10use chrono::{DateTime, Utc};
11use std::collections::HashMap;
12
13/// Feed entry/item
14#[derive(Debug, Clone, Default)]
15pub struct Entry {
16 /// Unique entry identifier (stored inline for IDs ≤24 bytes)
17 pub id: Option<super::common::SmallString>,
18 /// Entry title
19 pub title: Option<String>,
20 /// Detailed title with metadata
21 pub title_detail: Option<TextConstruct>,
22 /// Primary link
23 pub link: Option<String>,
24 /// All links associated with this entry
25 pub links: Vec<Link>,
26 /// Entry subtitle (Atom §4.2.12 at entry level)
27 pub subtitle: Option<String>,
28 /// Detailed subtitle with metadata
29 pub subtitle_detail: Option<TextConstruct>,
30 /// Rights/copyright statement
31 pub rights: Option<String>,
32 /// Detailed rights with metadata
33 pub rights_detail: Option<TextConstruct>,
34 /// Short description/summary
35 pub summary: Option<String>,
36 /// Detailed summary with metadata
37 pub summary_detail: Option<TextConstruct>,
38 /// Full content blocks
39 pub content: Vec<Content>,
40 /// Publication date
41 pub published: Option<DateTime<Utc>>,
42 /// Original publication date string as found in the feed (timezone preserved)
43 pub published_str: Option<String>,
44 /// Last update date
45 pub updated: Option<DateTime<Utc>>,
46 /// Original update date string as found in the feed (timezone preserved)
47 pub updated_str: Option<String>,
48 /// Creation date
49 pub created: Option<DateTime<Utc>>,
50 /// Original creation date string as found in the feed (timezone preserved)
51 pub created_str: Option<String>,
52 /// Expiration date
53 pub expired: Option<DateTime<Utc>>,
54 /// Primary author name (stored inline for names ≤24 bytes)
55 pub author: Option<super::common::SmallString>,
56 /// Detailed author information
57 pub author_detail: Option<Person>,
58 /// All authors
59 pub authors: Vec<Person>,
60 /// Contributors
61 pub contributors: Vec<Person>,
62 /// Publisher name (stored inline for names ≤24 bytes)
63 pub publisher: Option<super::common::SmallString>,
64 /// Detailed publisher information
65 pub publisher_detail: Option<Person>,
66 /// Tags/categories
67 pub tags: Vec<Tag>,
68 /// Media enclosures (audio, video, etc.)
69 pub enclosures: Vec<Enclosure>,
70 /// Comments URL or text
71 pub comments: Option<String>,
72 /// Source feed reference
73 pub source: Option<Source>,
74 /// iTunes episode metadata (if present)
75 pub itunes: Option<Box<ItunesEntryMeta>>,
76 /// Dublin Core creator (author fallback) - stored inline for names ≤24 bytes
77 pub dc_creator: Option<super::common::SmallString>,
78 /// Dublin Core date (publication date fallback)
79 pub dc_date: Option<DateTime<Utc>>,
80 /// Dublin Core subjects (tags)
81 pub dc_subject: Vec<String>,
82 /// Dublin Core rights (copyright)
83 pub dc_rights: Option<String>,
84 /// Media RSS thumbnails
85 pub media_thumbnail: Vec<MediaThumbnail>,
86 /// Media RSS content items
87 pub media_content: Vec<MediaContent>,
88 /// Media RSS credits (media:credit elements)
89 pub media_credit: Vec<MediaCredit>,
90 /// Media RSS copyright (media:copyright element)
91 pub media_copyright: Option<MediaCopyright>,
92 /// Media RSS rating (media:rating element)
93 pub media_rating: Option<MediaRating>,
94 /// Media RSS keywords (raw comma-separated string from media:keywords)
95 pub media_keywords: Option<String>,
96 /// Media RSS description (plain text only; None if type != "plain")
97 pub media_description: Option<String>,
98 /// Media RSS title (`media:title` element, plain text only; `None` if `type != "plain"`)
99 pub media_title: Option<String>,
100 /// Podcast 2.0 transcripts for this episode
101 pub podcast_transcripts: Vec<PodcastTranscript>,
102 /// Podcast 2.0 persons for this episode (hosts, guests, etc.)
103 pub podcast_persons: Vec<PodcastPerson>,
104 /// Podcast 2.0 episode metadata
105 pub podcast: Option<Box<PodcastEntryMeta>>,
106 /// `GeoRSS` location data (exposed as `where` per Python feedparser API)
107 pub r#where: Option<Box<crate::namespace::georss::GeoLocation>>,
108 /// W3C Basic Geo latitude (`geo:lat`)
109 pub geo_lat: Option<String>,
110 /// W3C Basic Geo longitude (`geo:long`)
111 pub geo_long: Option<String>,
112 /// License URL (Creative Commons, etc.)
113 pub license: Option<String>,
114 /// Atom Threading Extensions: entries this is a reply to (thr:in-reply-to)
115 pub in_reply_to: Vec<InReplyTo>,
116 /// Atom Threading Extensions: total response count (thr:total)
117 ///
118 /// Stored as u32 in Rust for type safety. Python binding converts
119 /// to string to match Python feedparser's API.
120 pub thr_total: Option<u32>,
121 /// Slash namespace: comment count (`slash:comments`)
122 pub slash_comments: Option<u32>,
123 /// Slash namespace: hit parade (`slash:hit_parade`)
124 pub slash_hit_parade: Option<String>,
125 /// WFW namespace: comment RSS feed URL (`wfw:commentRss`)
126 pub wfw_comment_rss: Option<String>,
127 /// Whether the RSS `<guid>` is a permalink (`isPermaLink` attribute).
128 ///
129 /// `true` when `isPermaLink="true"` or the attribute is absent (RSS 2.0 default).
130 /// `false` when `isPermaLink="false"`. `None` when no `<guid>` element is present.
131 pub guidislink: Option<bool>,
132 /// Entry language (JSON Feed `language` field)
133 pub language: Option<super::common::SmallString>,
134 /// External URL where the full content lives (JSON Feed `external_url`)
135 pub external_url: Option<String>,
136 /// Custom JSON Feed extension objects captured from this item.
137 ///
138 /// Same capture mechanism as [`super::feed::FeedMeta::json_extensions`],
139 /// scoped to this entry independently — a key present at both feed and
140 /// item level is captured separately in each map, with no merging.
141 ///
142 /// # Examples
143 ///
144 /// ```
145 /// use feedparser_rs::parse;
146 ///
147 /// let json = br#"{
148 /// "version": "https://jsonfeed.org/version/1.1",
149 /// "title": "Feed",
150 /// "items": [{"id": "1", "_explicit": true}]
151 /// }"#;
152 /// let feed = parse(json).unwrap();
153 /// assert_eq!(feed.entries[0].json_extensions["_explicit"], true);
154 /// ```
155 pub json_extensions: HashMap<String, serde_json::Value>,
156}
157
158impl Entry {
159 /// Creates `Entry` with pre-allocated capacity for collections
160 ///
161 /// Pre-allocates space for typical entry fields:
162 /// - 1-2 links (alternate, related)
163 /// - 1 content block
164 /// - 1 author
165 /// - 2-3 tags
166 /// - 0-1 enclosures
167 /// - 2 podcast transcripts (typical for podcasts with multiple languages)
168 /// - 4 podcast persons (host, co-hosts, guests)
169 ///
170 /// # Examples
171 ///
172 /// ```
173 /// use feedparser_rs::Entry;
174 ///
175 /// let entry = Entry::with_capacity();
176 /// ```
177 #[must_use]
178 pub fn with_capacity() -> Self {
179 Self {
180 links: Vec::with_capacity(2),
181 content: Vec::with_capacity(1),
182 authors: Vec::with_capacity(1),
183 contributors: Vec::new(),
184 tags: Vec::with_capacity(3),
185 enclosures: Vec::with_capacity(1),
186 dc_subject: Vec::with_capacity(2),
187 media_thumbnail: Vec::with_capacity(1),
188 media_content: Vec::with_capacity(1),
189 media_credit: Vec::with_capacity(1),
190 podcast_transcripts: Vec::with_capacity(2),
191 podcast_persons: Vec::with_capacity(4),
192 // Most entries reply to at most one parent
193 in_reply_to: Vec::with_capacity(1),
194 ..Default::default()
195 }
196 }
197
198 /// Sets title field with `TextConstruct`, storing both simple and detailed versions
199 ///
200 /// # Examples
201 ///
202 /// ```
203 /// use feedparser_rs::{Entry, TextConstruct};
204 ///
205 /// let mut entry = Entry::default();
206 /// entry.set_title(TextConstruct::text("Great Article"));
207 /// assert_eq!(entry.title.as_deref(), Some("Great Article"));
208 /// ```
209 #[inline]
210 pub fn set_title(&mut self, text: TextConstruct) {
211 self.title = Some(text.value.clone());
212 self.title_detail = Some(text);
213 }
214
215 /// Sets subtitle field with `TextConstruct`, storing both simple and detailed versions
216 ///
217 /// # Examples
218 ///
219 /// ```
220 /// use feedparser_rs::{Entry, TextConstruct};
221 ///
222 /// let mut entry = Entry::default();
223 /// entry.set_subtitle(TextConstruct::text("A teaser"));
224 /// assert_eq!(entry.subtitle.as_deref(), Some("A teaser"));
225 /// ```
226 #[inline]
227 pub fn set_subtitle(&mut self, text: TextConstruct) {
228 self.subtitle = Some(text.value.clone());
229 self.subtitle_detail = Some(text);
230 }
231
232 /// Sets rights field with `TextConstruct`, storing both simple and detailed versions
233 #[inline]
234 pub fn set_rights(&mut self, text: TextConstruct) {
235 self.rights = Some(text.value.clone());
236 self.rights_detail = Some(text);
237 }
238
239 /// Sets summary field with `TextConstruct`, storing both simple and detailed versions
240 ///
241 /// # Examples
242 ///
243 /// ```
244 /// use feedparser_rs::{Entry, TextConstruct};
245 ///
246 /// let mut entry = Entry::default();
247 /// entry.set_summary(TextConstruct::text("A summary"));
248 /// assert_eq!(entry.summary.as_deref(), Some("A summary"));
249 /// ```
250 #[inline]
251 pub fn set_summary(&mut self, text: TextConstruct) {
252 self.summary = Some(text.value.clone());
253 self.summary_detail = Some(text);
254 }
255
256 /// Sets author field with `Person`, storing both simple and detailed versions
257 ///
258 /// # Examples
259 ///
260 /// ```
261 /// use feedparser_rs::{Entry, Person};
262 ///
263 /// let mut entry = Entry::default();
264 /// entry.set_author(Person::from_name("Jane Doe"));
265 /// assert_eq!(entry.author.as_deref(), Some("Jane Doe"));
266 /// ```
267 #[inline]
268 pub fn set_author(&mut self, person: Person) {
269 self.author = person.flat_string();
270 self.author_detail = Some(person);
271 }
272
273 /// Sets publisher field with `Person`, storing both simple and detailed versions
274 ///
275 /// # Examples
276 ///
277 /// ```
278 /// use feedparser_rs::{Entry, Person};
279 ///
280 /// let mut entry = Entry::default();
281 /// entry.set_publisher(Person::from_name("ACME Corp"));
282 /// assert_eq!(entry.publisher.as_deref(), Some("ACME Corp"));
283 /// ```
284 #[inline]
285 pub fn set_publisher(&mut self, person: Person) {
286 self.publisher.clone_from(&person.name);
287 self.publisher_detail = Some(person);
288 }
289
290 /// Sets the primary link and adds it to the links collection
291 ///
292 /// This is a convenience method that:
293 /// 1. Sets the `link` field (if not already set)
294 /// 2. Adds an "alternate" link to the `links` collection
295 ///
296 /// # Examples
297 ///
298 /// ```
299 /// use feedparser_rs::Entry;
300 ///
301 /// let mut entry = Entry::default();
302 /// entry.set_alternate_link("https://example.com/post/1".to_string(), 10);
303 /// assert_eq!(entry.link.as_deref(), Some("https://example.com/post/1"));
304 /// assert_eq!(entry.links.len(), 1);
305 /// assert_eq!(entry.links[0].rel.as_deref(), Some("alternate"));
306 /// ```
307 #[inline]
308 pub fn set_alternate_link(&mut self, href: String, max_links: usize) {
309 if self.link.is_none() {
310 self.link = Some(href.clone());
311 }
312 self.links.try_push_limited(
313 Link {
314 href: href.into(),
315 rel: Some("alternate".into()),
316 ..Default::default()
317 },
318 max_links,
319 );
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn test_entry_default() {
329 let entry = Entry::default();
330 assert!(entry.id.is_none());
331 assert!(entry.title.is_none());
332 assert!(entry.links.is_empty());
333 assert!(entry.content.is_empty());
334 assert!(entry.authors.is_empty());
335 }
336
337 #[test]
338 #[allow(clippy::redundant_clone)]
339 fn test_entry_clone() {
340 fn create_entry() -> Entry {
341 Entry {
342 title: Some("Test".to_string()),
343 links: vec![Link::default()],
344 ..Default::default()
345 }
346 }
347 let entry = create_entry();
348 let cloned = entry.clone();
349 assert_eq!(cloned.title.as_deref(), Some("Test"));
350 assert_eq!(cloned.links.len(), 1);
351 }
352}