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    /// Media RSS title (`media:title` element, plain text only; `None` if `type != "plain"`)
98    pub media_title: Option<String>,
99    /// Podcast 2.0 transcripts for this episode
100    pub podcast_transcripts: Vec<PodcastTranscript>,
101    /// Podcast 2.0 persons for this episode (hosts, guests, etc.)
102    pub podcast_persons: Vec<PodcastPerson>,
103    /// Podcast 2.0 episode metadata
104    pub podcast: Option<Box<PodcastEntryMeta>>,
105    /// `GeoRSS` location data (exposed as `where` per Python feedparser API)
106    pub r#where: Option<Box<crate::namespace::georss::GeoLocation>>,
107    /// W3C Basic Geo latitude (`geo:lat`)
108    pub geo_lat: Option<String>,
109    /// W3C Basic Geo longitude (`geo:long`)
110    pub geo_long: Option<String>,
111    /// License URL (Creative Commons, etc.)
112    pub license: Option<String>,
113    /// Atom Threading Extensions: entries this is a reply to (thr:in-reply-to)
114    pub in_reply_to: Vec<InReplyTo>,
115    /// Atom Threading Extensions: total response count (thr:total)
116    ///
117    /// Stored as u32 in Rust for type safety. Python binding converts
118    /// to string to match Python feedparser's API.
119    pub thr_total: Option<u32>,
120    /// Slash namespace: comment count (`slash:comments`)
121    pub slash_comments: Option<u32>,
122    /// Slash namespace: hit parade (`slash:hit_parade`)
123    pub slash_hit_parade: Option<String>,
124    /// WFW namespace: comment RSS feed URL (`wfw:commentRss`)
125    pub wfw_comment_rss: Option<String>,
126    /// Whether the RSS `<guid>` is a permalink (`isPermaLink` attribute).
127    ///
128    /// `true` when `isPermaLink="true"` or the attribute is absent (RSS 2.0 default).
129    /// `false` when `isPermaLink="false"`. `None` when no `<guid>` element is present.
130    pub guidislink: Option<bool>,
131    /// Entry language (JSON Feed `language` field)
132    pub language: Option<super::common::SmallString>,
133    /// External URL where the full content lives (JSON Feed `external_url`)
134    pub external_url: Option<String>,
135}
136
137impl Entry {
138    /// Creates `Entry` with pre-allocated capacity for collections
139    ///
140    /// Pre-allocates space for typical entry fields:
141    /// - 1-2 links (alternate, related)
142    /// - 1 content block
143    /// - 1 author
144    /// - 2-3 tags
145    /// - 0-1 enclosures
146    /// - 2 podcast transcripts (typical for podcasts with multiple languages)
147    /// - 4 podcast persons (host, co-hosts, guests)
148    ///
149    /// # Examples
150    ///
151    /// ```
152    /// use feedparser_rs::Entry;
153    ///
154    /// let entry = Entry::with_capacity();
155    /// ```
156    #[must_use]
157    pub fn with_capacity() -> Self {
158        Self {
159            links: Vec::with_capacity(2),
160            content: Vec::with_capacity(1),
161            authors: Vec::with_capacity(1),
162            contributors: Vec::with_capacity(0),
163            tags: Vec::with_capacity(3),
164            enclosures: Vec::with_capacity(1),
165            dc_subject: Vec::with_capacity(2),
166            media_thumbnail: Vec::with_capacity(1),
167            media_content: Vec::with_capacity(1),
168            media_credit: Vec::with_capacity(1),
169            podcast_transcripts: Vec::with_capacity(2),
170            podcast_persons: Vec::with_capacity(4),
171            // Most entries reply to at most one parent
172            in_reply_to: Vec::with_capacity(1),
173            ..Default::default()
174        }
175    }
176
177    /// Sets title field with `TextConstruct`, storing both simple and detailed versions
178    ///
179    /// # Examples
180    ///
181    /// ```
182    /// use feedparser_rs::{Entry, TextConstruct};
183    ///
184    /// let mut entry = Entry::default();
185    /// entry.set_title(TextConstruct::text("Great Article"));
186    /// assert_eq!(entry.title.as_deref(), Some("Great Article"));
187    /// ```
188    #[inline]
189    pub fn set_title(&mut self, text: TextConstruct) {
190        self.title = Some(text.value.clone());
191        self.title_detail = Some(text);
192    }
193
194    /// Sets subtitle field with `TextConstruct`, storing both simple and detailed versions
195    ///
196    /// # Examples
197    ///
198    /// ```
199    /// use feedparser_rs::{Entry, TextConstruct};
200    ///
201    /// let mut entry = Entry::default();
202    /// entry.set_subtitle(TextConstruct::text("A teaser"));
203    /// assert_eq!(entry.subtitle.as_deref(), Some("A teaser"));
204    /// ```
205    #[inline]
206    pub fn set_subtitle(&mut self, text: TextConstruct) {
207        self.subtitle = Some(text.value.clone());
208        self.subtitle_detail = Some(text);
209    }
210
211    /// Sets rights field with `TextConstruct`, storing both simple and detailed versions
212    #[inline]
213    pub fn set_rights(&mut self, text: TextConstruct) {
214        self.rights = Some(text.value.clone());
215        self.rights_detail = Some(text);
216    }
217
218    /// Sets summary field with `TextConstruct`, storing both simple and detailed versions
219    ///
220    /// # Examples
221    ///
222    /// ```
223    /// use feedparser_rs::{Entry, TextConstruct};
224    ///
225    /// let mut entry = Entry::default();
226    /// entry.set_summary(TextConstruct::text("A summary"));
227    /// assert_eq!(entry.summary.as_deref(), Some("A summary"));
228    /// ```
229    #[inline]
230    pub fn set_summary(&mut self, text: TextConstruct) {
231        self.summary = Some(text.value.clone());
232        self.summary_detail = Some(text);
233    }
234
235    /// Sets author field with `Person`, storing both simple and detailed versions
236    ///
237    /// # Examples
238    ///
239    /// ```
240    /// use feedparser_rs::{Entry, Person};
241    ///
242    /// let mut entry = Entry::default();
243    /// entry.set_author(Person::from_name("Jane Doe"));
244    /// assert_eq!(entry.author.as_deref(), Some("Jane Doe"));
245    /// ```
246    #[inline]
247    pub fn set_author(&mut self, person: Person) {
248        self.author = person.flat_string();
249        self.author_detail = Some(person);
250    }
251
252    /// Sets publisher field with `Person`, storing both simple and detailed versions
253    ///
254    /// # Examples
255    ///
256    /// ```
257    /// use feedparser_rs::{Entry, Person};
258    ///
259    /// let mut entry = Entry::default();
260    /// entry.set_publisher(Person::from_name("ACME Corp"));
261    /// assert_eq!(entry.publisher.as_deref(), Some("ACME Corp"));
262    /// ```
263    #[inline]
264    pub fn set_publisher(&mut self, person: Person) {
265        self.publisher.clone_from(&person.name);
266        self.publisher_detail = Some(person);
267    }
268
269    /// Sets the primary link and adds it to the links collection
270    ///
271    /// This is a convenience method that:
272    /// 1. Sets the `link` field (if not already set)
273    /// 2. Adds an "alternate" link to the `links` collection
274    ///
275    /// # Examples
276    ///
277    /// ```
278    /// use feedparser_rs::Entry;
279    ///
280    /// let mut entry = Entry::default();
281    /// entry.set_alternate_link("https://example.com/post/1".to_string(), 10);
282    /// assert_eq!(entry.link.as_deref(), Some("https://example.com/post/1"));
283    /// assert_eq!(entry.links.len(), 1);
284    /// assert_eq!(entry.links[0].rel.as_deref(), Some("alternate"));
285    /// ```
286    #[inline]
287    pub fn set_alternate_link(&mut self, href: String, max_links: usize) {
288        if self.link.is_none() {
289            self.link = Some(href.clone());
290        }
291        self.links.try_push_limited(
292            Link {
293                href: href.into(),
294                rel: Some("alternate".into()),
295                ..Default::default()
296            },
297            max_links,
298        );
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_entry_default() {
308        let entry = Entry::default();
309        assert!(entry.id.is_none());
310        assert!(entry.title.is_none());
311        assert!(entry.links.is_empty());
312        assert!(entry.content.is_empty());
313        assert!(entry.authors.is_empty());
314    }
315
316    #[test]
317    #[allow(clippy::redundant_clone)]
318    fn test_entry_clone() {
319        fn create_entry() -> Entry {
320            Entry {
321                title: Some("Test".to_string()),
322                links: vec![Link::default()],
323                ..Default::default()
324            }
325        }
326        let entry = create_entry();
327        let cloned = entry.clone();
328        assert_eq!(cloned.title.as_deref(), Some("Test"));
329        assert_eq!(cloned.links.len(), 1);
330    }
331}