Skip to main content

feedo/sync/
types.rs

1//! Google Reader API type definitions.
2
3#![allow(clippy::must_use_candidate)]
4#![allow(clippy::doc_markdown)]
5#![allow(clippy::option_if_let_else)]
6#![allow(clippy::cast_sign_loss)]
7#![allow(clippy::derivable_impls)]
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12/// Authentication token from login.
13#[derive(Debug, Clone)]
14pub struct AuthToken {
15    /// The auth token string.
16    pub token: String,
17}
18
19/// User information.
20#[derive(Debug, Clone, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct UserInfo {
23    /// User ID.
24    pub user_id: String,
25    /// Username.
26    pub user_name: String,
27    /// User email.
28    pub user_email: Option<String>,
29    /// Profile ID.
30    pub user_profile_id: Option<String>,
31}
32
33/// A subscription (feed).
34#[derive(Debug, Clone, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct Subscription {
37    /// Feed ID (format: "feed/{id}").
38    pub id: String,
39    /// Feed title.
40    pub title: String,
41    /// Feed URL.
42    pub url: String,
43    /// Website URL.
44    #[serde(default)]
45    pub html_url: Option<String>,
46    /// Favicon URL.
47    #[serde(default)]
48    pub icon_url: Option<String>,
49    /// Categories/folders.
50    #[serde(default)]
51    pub categories: Vec<Category>,
52}
53
54/// A category/folder/tag.
55#[derive(Debug, Clone, Deserialize, Serialize)]
56pub struct Category {
57    /// Category ID (format: "user/-/label/{name}").
58    pub id: String,
59    /// Display label.
60    pub label: String,
61    /// Type (e.g., "folder").
62    #[serde(rename = "type", default)]
63    pub category_type: Option<String>,
64}
65
66/// Response from subscription/list endpoint.
67#[derive(Debug, Deserialize)]
68pub struct SubscriptionList {
69    /// List of subscriptions.
70    pub subscriptions: Vec<Subscription>,
71}
72
73/// Response from tag/list endpoint.
74#[derive(Debug, Deserialize)]
75pub struct TagList {
76    /// List of tags.
77    pub tags: Vec<Tag>,
78}
79
80/// A tag (label, state, or folder).
81#[derive(Debug, Clone, Deserialize)]
82pub struct Tag {
83    /// Tag ID.
84    pub id: String,
85    /// Sort ID (optional).
86    #[serde(rename = "sortid", default)]
87    pub sort_id: Option<String>,
88}
89
90/// Response from unread-count endpoint.
91#[derive(Debug, Deserialize)]
92pub struct UnreadCount {
93    /// Maximum count.
94    pub max: i64,
95    /// Unread counts per feed/category.
96    #[serde(default)]
97    pub unreadcounts: Vec<UnreadCountItem>,
98}
99
100/// Unread count for a single feed or category.
101#[derive(Debug, Deserialize)]
102pub struct UnreadCountItem {
103    /// Feed or category ID.
104    pub id: String,
105    /// Number of unread items.
106    pub count: i64,
107    /// Newest item timestamp (microseconds).
108    #[serde(rename = "newestItemTimestampUsec", default)]
109    pub newest_item_timestamp_usec: Option<String>,
110}
111
112/// Response from stream/contents endpoint.
113#[derive(Debug, Deserialize)]
114pub struct StreamContents {
115    /// Stream ID.
116    pub id: String,
117    /// Stream title.
118    #[serde(default)]
119    pub title: Option<String>,
120    /// Last updated timestamp.
121    #[serde(default)]
122    pub updated: Option<i64>,
123    /// Continuation token for pagination.
124    #[serde(default)]
125    pub continuation: Option<String>,
126    /// Items in the stream.
127    #[serde(default)]
128    pub items: Vec<StreamItem>,
129}
130
131/// A single item from a stream.
132#[derive(Debug, Clone, Deserialize)]
133pub struct StreamItem {
134    /// Item ID (long form: "tag:google.com,2005:reader/item/{hex}").
135    pub id: String,
136    /// Feed ID.
137    #[serde(default)]
138    pub origin: Option<StreamItemOrigin>,
139    /// Item title.
140    #[serde(default)]
141    pub title: Option<String>,
142    /// Author name.
143    #[serde(default)]
144    pub author: Option<String>,
145    /// Published timestamp (seconds).
146    #[serde(default)]
147    pub published: Option<i64>,
148    /// Updated timestamp (seconds).
149    #[serde(default)]
150    pub updated: Option<i64>,
151    /// Crawled timestamp (microseconds).
152    #[serde(rename = "crawlTimeMsec", default)]
153    pub crawl_time_msec: Option<String>,
154    /// Timestamp in microseconds.
155    #[serde(rename = "timestampUsec", default)]
156    pub timestamp_usec: Option<String>,
157    /// Categories/tags applied to this item.
158    #[serde(default)]
159    pub categories: Vec<String>,
160    /// Canonical URL.
161    #[serde(default)]
162    pub canonical: Option<Vec<StreamItemLink>>,
163    /// Alternate URLs.
164    #[serde(default)]
165    pub alternate: Option<Vec<StreamItemLink>>,
166    /// Summary content.
167    #[serde(default)]
168    pub summary: Option<StreamItemContent>,
169    /// Full content.
170    #[serde(default)]
171    pub content: Option<StreamItemContent>,
172}
173
174impl StreamItem {
175    /// Check if item is read.
176    pub fn is_read(&self) -> bool {
177        self.categories
178            .iter()
179            .any(|c| c.ends_with("/state/com.google/read"))
180    }
181
182    /// Check if item is starred.
183    pub fn is_starred(&self) -> bool {
184        self.categories
185            .iter()
186            .any(|c| c.ends_with("/state/com.google/starred"))
187    }
188
189    /// Get the item's link URL.
190    pub fn link(&self) -> Option<&str> {
191        self.canonical
192            .as_ref()
193            .and_then(|links| links.first())
194            .or_else(|| self.alternate.as_ref().and_then(|links| links.first()))
195            .map(|l| l.href.as_str())
196    }
197
198    /// Get content (prefers full content over summary).
199    pub fn get_content(&self) -> Option<&str> {
200        self.content
201            .as_ref()
202            .or(self.summary.as_ref())
203            .map(|c| c.content.as_str())
204    }
205
206    /// Get published datetime.
207    pub fn published_at(&self) -> Option<DateTime<Utc>> {
208        self.published
209            .and_then(|ts| DateTime::from_timestamp(ts, 0))
210    }
211
212    /// Parse item ID to decimal.
213    pub fn id_decimal(&self) -> Option<i64> {
214        parse_item_id(&self.id)
215    }
216}
217
218/// Origin (feed) of a stream item.
219#[derive(Debug, Clone, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct StreamItemOrigin {
222    /// Feed ID.
223    pub stream_id: String,
224    /// Feed title.
225    #[serde(default)]
226    pub title: Option<String>,
227    /// Feed HTML URL.
228    #[serde(default)]
229    pub html_url: Option<String>,
230}
231
232/// A link in a stream item.
233#[derive(Debug, Clone, Deserialize)]
234pub struct StreamItemLink {
235    /// URL.
236    pub href: String,
237    /// MIME type.
238    #[serde(rename = "type", default)]
239    pub link_type: Option<String>,
240}
241
242/// Content of a stream item.
243#[derive(Debug, Clone, Deserialize)]
244pub struct StreamItemContent {
245    /// Direction (ltr/rtl).
246    #[serde(default)]
247    pub direction: Option<String>,
248    /// Content HTML.
249    pub content: String,
250}
251
252/// Response from stream/items/ids endpoint.
253#[derive(Debug, Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct StreamItemIds {
256    /// Item references.
257    pub item_refs: Vec<ItemRef>,
258    /// Continuation token.
259    #[serde(default)]
260    pub continuation: Option<String>,
261}
262
263/// A reference to an item (ID only).
264#[derive(Debug, Clone, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct ItemRef {
267    /// Item ID (decimal string).
268    pub id: String,
269    /// Timestamp in microseconds.
270    #[serde(default)]
271    pub timestamp_usec: Option<String>,
272    /// Direct stream IDs.
273    #[serde(default)]
274    pub direct_stream_ids: Option<Vec<String>>,
275}
276
277/// Sync configuration.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct SyncConfig {
280    /// Sync provider type.
281    pub provider: SyncProvider,
282    /// Server URL (e.g., "https://freshrss.example.com/api/greader.php").
283    pub server: String,
284    /// Username (fallback if encrypted storage unavailable).
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub username: Option<String>,
287    /// Password or API key (fallback if encrypted storage unavailable).
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub password: Option<String>,
290}
291
292impl SyncConfig {
293    /// Get credentials (username, password) from encrypted storage or config fallback.
294    #[must_use]
295    pub fn get_credentials(&self) -> Option<(String, String)> {
296        // Try encrypted storage first
297        let credential_key = format!("sync@{}", self.server);
298        if let Some(creds) = crate::credentials::get_credentials(&credential_key) {
299            return Some(creds);
300        }
301        // Fall back to config file
302        match (&self.username, &self.password) {
303            (Some(u), Some(p)) => Some((u.clone(), p.clone())),
304            _ => None,
305        }
306    }
307}
308
309/// Supported sync providers.
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "lowercase")]
312pub enum SyncProvider {
313    /// FreshRSS (Google Reader API).
314    FreshRSS,
315    /// Miniflux (Google Reader API).
316    Miniflux,
317    /// Generic Google Reader API.
318    GReader,
319}
320
321impl Default for SyncProvider {
322    fn default() -> Self {
323        Self::GReader
324    }
325}
326
327// --- ID Parsing Utilities ---
328
329/// Parse an item ID from any format to decimal.
330///
331/// Supports:
332/// - Long form: `tag:google.com,2005:reader/item/000000000000001F`
333/// - Short hex: `000000000000001F` (16 chars)
334/// - Decimal: `31`
335pub fn parse_item_id(id: &str) -> Option<i64> {
336    const PREFIX: &str = "tag:google.com,2005:reader/item/";
337
338    if let Some(hex) = id.strip_prefix(PREFIX) {
339        // Long form hex
340        i64::from_str_radix(hex, 16).ok()
341    } else if id.len() == 16 && id.chars().all(|c| c.is_ascii_hexdigit()) {
342        // Short form hex (16 chars, zero-padded)
343        i64::from_str_radix(id, 16).ok()
344    } else {
345        // Decimal
346        id.parse().ok()
347    }
348}
349
350/// Format an item ID as long form hex.
351pub fn format_item_id_long(id: i64) -> String {
352    format!("tag:google.com,2005:reader/item/{:016x}", id as u64)
353}
354
355/// Format an item ID as short form hex.
356pub fn format_item_id_short(id: i64) -> String {
357    format!("{:016x}", id as u64)
358}
359
360// --- Stream IDs ---
361
362/// Well-known stream IDs.
363pub mod streams {
364    /// All items (reading list).
365    pub const READING_LIST: &str = "user/-/state/com.google/reading-list";
366    /// Read items.
367    pub const READ: &str = "user/-/state/com.google/read";
368    /// Starred items.
369    pub const STARRED: &str = "user/-/state/com.google/starred";
370    /// Kept unread items.
371    pub const KEPT_UNREAD: &str = "user/-/state/com.google/kept-unread";
372    /// Broadcast items.
373    pub const BROADCAST: &str = "user/-/state/com.google/broadcast";
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_parse_item_id_long() {
382        let id = "tag:google.com,2005:reader/item/000000000000001f";
383        assert_eq!(parse_item_id(id), Some(31));
384    }
385
386    #[test]
387    fn test_parse_item_id_short_hex() {
388        let id = "000000000000001f";
389        assert_eq!(parse_item_id(id), Some(31));
390    }
391
392    #[test]
393    fn test_parse_item_id_decimal() {
394        let id = "31";
395        assert_eq!(parse_item_id(id), Some(31));
396    }
397
398    #[test]
399    fn test_format_item_id_long() {
400        assert_eq!(
401            format_item_id_long(31),
402            "tag:google.com,2005:reader/item/000000000000001f"
403        );
404    }
405}