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};
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}