1use anyhow::Result;
3use base64::encode;
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6use std::process::Command;
7use url::Url;
8
9#[derive(Debug, Deserialize, Serialize)]
10struct BookmarksResponse {
11 data: Vec<Option<Bookmark>>,
12 status: String,
13}
14
15#[derive(Debug, Deserialize, Serialize)]
16struct TagsResponse {
17 data: Vec<String>,
18 status: String,
19}
20
21#[derive(Debug, Default, Deserialize, Serialize)]
22pub struct Bookmark {
23 added: u64,
24 clickcount: u64,
25 description: String,
26 folders: Vec<i32>,
27 id: u64,
28 lastmodified: u64,
29 public: Option<u64>,
30 tags: Vec<String>,
31 title: String,
32 url: String,
33 user_id: Option<String>,
34}
35
36#[derive(Debug)]
37pub struct BookmarkAPIClient {
38 auth_id: String,
39 auth_secret: String,
40 root_url: Url,
41 bookmarks_url: Url,
42 tags_url: Url,
43 client: reqwest::Client,
44}
45
46impl BookmarkAPIClient {
47 pub fn new(auth_id: String, auth_secret: String, root_url: Url) -> Result<BookmarkAPIClient> {
48 let base_url = root_url.join("/index.php/apps/bookmarks/public/rest/v2")?;
49 let bookmarks_url = base_url.join("/bookmark")?;
50 let tags_url = base_url.join("/tag")?;
51
52 Ok(BookmarkAPIClient {
53 auth_id,
54 auth_secret,
55 root_url,
56 bookmarks_url,
57 tags_url,
58 client: reqwest::Client::new(),
59 })
60 }
61
62 pub async fn read_tags(&self) -> Result<Vec<String>> {
63 let request_url = format!("{}", &self.tags_url);
64
65 log::debug!("tag api url: {}", request_url);
66 let encoded_basic_auth = encode(format!("{}:{}", self.auth_id, self.auth_secret));
67
68 let client = reqwest::Client::new();
69 log::debug!("calling get");
70 let response = client
71 .get(request_url)
72 .header("AUTHORIZATION", format!("Basic {}", encoded_basic_auth))
73 .send()
74 .await?;
75
76 let response_text = response.text().await?;
77 log::debug!("Response Text: {}", &response_text);
78
79 let mut tags_response: Vec<String> = serde_json::from_str(&response_text)?;
80 tags_response.sort();
81
82 if tags_response.is_empty() {
83 log::debug!("No tags exist.")
84 }
85
86 Ok(tags_response)
87 }
88
89 pub async fn read_bookmarks(
90 &self,
91 query_tags: Vec<String>,
92 filters: Vec<String>,
93 unavailable: bool,
94 ) -> Result<Vec<Bookmark>> {
95 let tags: String = query_tags
96 .clone()
97 .into_iter()
98 .map(|tag| format!("tags[]={}", tag))
99 .collect::<Vec<String>>()
100 .join("&");
101
102 let filter: String = filters
103 .clone()
104 .into_iter()
105 .map(|x| format!("search[]={}", x))
106 .collect::<Vec<String>>()
107 .join("&");
108
109 let page: String = "page=-1".to_string();
110 let conjunction: String = "conjunction=or".to_string();
111 let unavailable: String = format!("unavailable={}", unavailable);
112
113 let request_url = format!(
115 "{bookmarks_url}?{parameters}",
116 bookmarks_url = self.bookmarks_url,
117 parameters = vec![tags, filter, page, conjunction, unavailable].join("&"),
118 );
119
120 log::info!("bookmark api url: {}", request_url);
121 let encoded_basic_auth = encode(format!("{}:{}", self.auth_id, self.auth_secret));
122
123 let client = reqwest::Client::new();
124 log::debug!("calling get");
125 let response = client
127 .get(&request_url)
128 .header("AUTHORIZATION", format!("Basic {}", encoded_basic_auth))
129 .send()
130 .await?;
131
132 let response_text = response.text().await?;
133 log::debug!("Response Text: {}", &response_text);
134
135 let bookmarks_response: BookmarksResponse = serde_json::from_str(&response_text)?;
136
137 if bookmarks_response.data.is_empty() {
138 log::info!("No bookmarks matched the query selector(s).")
139 }
140
141 let bookmarks: Vec<Bookmark> = bookmarks_response
143 .data
144 .into_iter()
145 .map(|b| b.unwrap_or_default())
146 .collect();
147
148 Ok(bookmarks)
149 }
150
151 pub fn download_url(
153 &self,
154 url: &str,
155 path: Option<&PathBuf>,
156 command: &String,
157 ) -> Result<bool> {
158 std::fs::create_dir_all(path.unwrap_or(&PathBuf::from("./"))).unwrap();
159 let mut child = Command::new(command)
160 .current_dir(path.unwrap_or(&PathBuf::from("./")))
161 .arg("-i")
162 .arg(url)
163 .spawn()
164 .expect("Failed to execute command");
165
166 let ecode = child.wait().expect("Failed to wait on child");
167
168 log::debug!("output: {:?}", ecode);
169 Ok(ecode.success())
170 }
171
172 pub async fn delete_bookmark(&self, id: u64) -> Result<bool, reqwest::Error> {
173 let request_url = format!(
174 "{bookmarks_url}/{id}",
175 bookmarks_url = self.bookmarks_url,
176 id = id,
177 );
178
179 let encoded_basic_auth = encode(format!("{}:{}", self.auth_id, self.auth_secret));
180 let response = self
181 .client
182 .delete(&request_url)
183 .header("AUTHORIZATION", "Basic {}".to_owned() + &encoded_basic_auth)
184 .send()
185 .await?;
186
187 let status = response.status().is_success();
188 let response_text = response.text().await?;
189 log::info!("delete api response: {}", response_text);
190 Ok(status)
191 }
192
193 pub async fn run(
194 &self,
195 command: String,
196 query_tags: Vec<String>,
197 filters: Vec<String>,
198 unavailable: bool,
199 do_download: bool,
200 do_remove_bookmark: bool,
201 output_dir: Option<PathBuf>,
202 ) -> Result<()> {
203 let bookmarks = self
204 .read_bookmarks(query_tags, filters, unavailable)
205 .await?;
206
207 for bookmark in bookmarks {
208 log::debug!("Bookmark: {:?}", bookmark);
209 let url: String = bookmark.url.to_string();
210 let url = url
212 .trim_start_matches('"')
213 .to_owned()
214 .trim_end_matches('"')
215 .to_owned();
216 log::info!("bookmark url: {}", url);
217
218 let download_success = if do_download {
219 self.download_url(&url, output_dir.as_ref(), &command)
220 .unwrap()
221 } else {
222 true
223 };
224
225 if download_success && do_remove_bookmark {
226 self.delete_bookmark(bookmark.id).await?;
227 log::info!("Removed Bookmark: {}\n{}", bookmark.title, url);
228 } else {
229 log::info!("Would have deleted url: {}", url);
230 }
231 }
232
233 Ok(())
234 }
235}
236
237#[cfg(test)]
239mod tests {
240 use super::*;
241 use httpmock::prelude::*;
242 use httpmock::Mock;
243
244 fn base_url(server: &MockServer) -> String {
245 return server
246 .url("/index.php/apps/bookmarks/public/rest/v2")
247 .to_string();
248 }
249
250 fn get_api_client(mock_server: &MockServer) -> Result<BookmarkAPIClient> {
251 BookmarkAPIClient::new(
252 String::from("auth_id"),
253 String::from("auth_Secret"),
254 Url::parse(&base_url(&mock_server))?,
255 )
256 }
257
258 #[test]
259 fn bookmark_api_client_should_have_expected_urls() -> Result<()> {
260 let server: MockServer = MockServer::start();
261 let base_url = &base_url(&server);
262
263 let client = get_api_client(&server)?;
264 let expected_bookmarks_url = Url::parse(base_url)?.join("/bookmark")?;
265 let expected_tags_url = Url::parse(base_url)?.join("/tag")?;
266 assert!(client.bookmarks_url == expected_bookmarks_url);
267 assert!(client.tags_url == expected_tags_url);
268 Ok(())
269 }
270
271 #[tokio::test]
272 async fn bookmark_api_client_reads_bookmarks() -> Result<()> {
273 let server: MockServer = MockServer::start();
274 let bookmarks_path = "/bookmark";
275
276 let hello_mock: Mock = server.mock(|when, then| {
277 when.method(GET)
278 .path(bookmarks_path);
279 then.status(200)
280 .header("content-type", "application/json")
281 .body(r#"{"data":[{"id":836,"url":"https://great.example/","title":"Example title","description":"Website Description","lastmodified":1662500203,"added":1662500203,"clickcount":0,"lastPreview":0,"available":true,"archivedFile":null,"userId":"dan","tags":["go"],"folders":[-1],"textContent":null,"htmlContent":null}],"status":"success"}"#);
282 });
283
284 let client = get_api_client(&server)?;
285 let query_tags = vec![];
286 let filters = vec![];
287 let unavailable = false;
288 let bookmarks = client
289 .read_bookmarks(query_tags, filters, unavailable)
290 .await?;
291 hello_mock.assert();
292 assert!(bookmarks.len() == 1);
293 assert!(bookmarks[0].id == 836);
294 assert!(bookmarks[0].url == "https://great.example/");
295 assert!(bookmarks[0].description == "Website Description");
296 assert!(bookmarks[0].tags.len() == 1);
297 assert!(bookmarks[0].tags[0] == "go");
298 Ok(())
299 }
300}