asimov_readwise_module/api/
readwise.rs1use 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(¶ms.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}