asimov_readwise_module/api/
readwise.rs

1// This is free and unencumbered software released into the public domain.
2
3use crate::api::types::{BookListResponse, HighlightsResponse};
4use anyhow::Result;
5
6use ureq;
7
8#[derive(Debug, Clone)]
9pub struct ReadwiseConfig {
10    pub base_url: String,
11    pub access_token: String,
12}
13
14impl ReadwiseConfig {
15    pub fn new(access_token: String) -> Self {
16        Self {
17            base_url: "https://readwise.io/api/v2".to_string(),
18            access_token,
19        }
20    }
21
22    pub fn endpoint_url(&self, path: &str) -> String {
23        format!("{}{}", self.base_url, path)
24    }
25}
26
27pub struct ReadwiseClient {
28    config: ReadwiseConfig,
29}
30
31impl ReadwiseClient {
32    pub fn new(config: ReadwiseConfig) -> Result<Self> {
33        Ok(Self { config })
34    }
35
36    fn auth_header(&self) -> String {
37        format!("Token {}", self.config.access_token)
38    }
39
40    pub fn endpoint_url(&self, path: &str) -> String {
41        self.config.endpoint_url(path)
42    }
43
44    fn build_url_with_params(
45        &self,
46        path: &str,
47        page_size: Option<usize>,
48        page: Option<usize>,
49    ) -> String {
50        let mut url = self.endpoint_url(path);
51        let mut params = vec![];
52
53        if let Some(size) = page_size {
54            params.push(format!("page_size={}", size));
55        }
56        if let Some(p) = page {
57            params.push(format!("page={}", p));
58        }
59
60        if !params.is_empty() {
61            url.push('?');
62            url.push_str(&params.join("&"));
63        }
64
65        url
66    }
67
68    pub fn fetch_highlights(
69        &mut self,
70        page_size: Option<usize>,
71        page: Option<usize>,
72    ) -> Result<HighlightsResponse> {
73        let url = self.build_url_with_params("/highlights/", page_size, page);
74
75        let mut response = ureq::get(&url)
76            .header("Authorization", &self.auth_header())
77            .call()
78            .map_err(|e| {
79                if e.to_string().contains("429") {
80                    anyhow::anyhow!("Rate limit exceeded (429). Please wait a minute before trying again. Consider using smaller page sizes to avoid hitting limits.")
81                } else {
82                    e.into()
83                }
84            })?;
85        let response_body: HighlightsResponse =
86            serde_json::from_str(&response.body_mut().read_to_string()?)?;
87        Ok(response_body)
88    }
89
90    pub fn fetch_booklist(
91        &mut self,
92        page_size: Option<usize>,
93        page: Option<usize>,
94    ) -> Result<BookListResponse> {
95        let url = self.build_url_with_params("/books/", page_size, page);
96
97        let mut response = ureq::get(&url)
98            .header("Authorization", &self.auth_header())
99            .call()
100            .map_err(|e| {
101                if e.to_string().contains("429") {
102                    anyhow::anyhow!("Rate limit exceeded (429). Please wait a minute before trying again. Consider using smaller page sizes to avoid hitting limits.")
103                } else {
104                    e.into()
105                }
106            })?;
107        let response_body: BookListResponse =
108            serde_json::from_str(&response.body_mut().read_to_string()?)?;
109        Ok(response_body)
110    }
111
112    pub fn fetch_highlight_tags(&mut self) -> Result<Vec<serde_json::Value>> {
113        let mut all_tags = std::collections::HashMap::new();
114        let mut page = 1;
115        let page_size = 100;
116
117        loop {
118            let highlights = self.fetch_highlights(Some(page_size), Some(page))
119            .map_err(|e| {
120                if e.to_string().contains("429") {
121                    anyhow::anyhow!("Rate limit exceeded while fetching highlights (429). Please wait a minute before trying again.")
122                } else {
123                    e
124                }
125            })?;
126
127            if let Some(results) = highlights.results {
128                if results.is_empty() {
129                    break;
130                }
131
132                let has_next = highlights.next.is_some();
133
134                for highlight in results {
135                    if let Some(highlight_id) = highlight.id {
136                        let tags_url =
137                            self.endpoint_url(&format!("/highlights/{}/tags", highlight_id));
138
139                        let mut response = ureq::get(&tags_url)
140                            .header("Authorization", &self.auth_header())
141                            .call()
142                            .map_err(|e| {
143                                if e.to_string().contains("429") {
144                                    anyhow::anyhow!("Rate limit exceeded while fetching tags (429). Please wait a minute before trying again.")
145                                } else {
146                                    e.into()
147                                }
148                            })?;
149
150                        let response_body = response.body_mut().read_to_string()?;
151
152                        let tags_data: serde_json::Value = serde_json::from_str(&response_body)?;
153
154                        if let Some(tag_results) = tags_data["results"].as_array() {
155                            for tag in tag_results {
156                                if let (Some(name), Some(id)) =
157                                    (tag["name"].as_str(), tag["id"].as_u64())
158                                {
159                                    all_tags.insert(
160                                        name.to_string(),
161                                        serde_json::json!({
162                                            "name": name,
163                                            "id": id
164                                        }),
165                                    );
166                                }
167                            }
168                        }
169                    }
170                }
171
172                if has_next {
173                    page += 1;
174                } else {
175                    break;
176                }
177            } else {
178                break;
179            }
180        }
181
182        Ok(all_tags.values().cloned().collect())
183    }
184}