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}