Skip to main content

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