libnclbk/
libnclbk.rs

1//Consider using https://crates.io/crates/thiserror
2use 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        //https://github.com/nextcloud/bookmarks/blob/4711d6507fd3e736fe15b104c7bbe54d276fac5b/lib/QueryParameters.php
114        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        // Failing here
126        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        //TODO find a bettery approach than using `unwrap()` here to avoid panics
142        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    // TODO: Consider better approaches here: subprocess, show some progress, handle errors better,
152    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            // FIXME: Find a more elegant way to unquote the URL
211            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//https://doc.rust-lang.org/book/ch11-00-testing.html
238#[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}