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}