bookstack_exporter/
lib.rs

1use clap::ValueEnum;
2use reqwest::header::{self, CONTENT_DISPOSITION};
3use serde::Deserialize;
4use std::fmt::Display;
5use std::{error::Error, fs, path::PathBuf};
6
7// TODO(evert): Remove reference from clap so library can be used on own?
8#[derive(ValueEnum, Clone, Deserialize, Debug)]
9pub enum ExportType {
10    #[serde(rename = "html")]
11    HTML,
12    #[serde(rename = "pdf")]
13    PDF,
14    #[serde(rename = "markdown")]
15    Markdown,
16}
17
18impl Display for ExportType {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        let str = match self {
21            ExportType::HTML => String::from("html"),
22            ExportType::PDF => String::from("pdf"),
23            ExportType::Markdown => String::from("markdown"),
24        };
25        write!(f, "{}", str)
26    }
27}
28
29#[derive(Debug)]
30pub struct BookstackClient {
31    bookstack_url: String,
32    client: reqwest::blocking::Client,
33}
34
35#[derive(Deserialize, Debug)]
36pub struct ShelveListItem {
37    id: u32,
38    slug: String,
39}
40
41#[derive(Deserialize, Debug)]
42pub struct ShelveListResponse {
43    data: Vec<ShelveListItem>,
44}
45
46#[derive(Deserialize, Debug)]
47pub struct ShelveBookItem {
48    id: u32,
49    slug: String,
50}
51
52#[derive(Deserialize, Debug)]
53pub struct Shelve {
54    books: Vec<ShelveBookItem>,
55}
56
57#[derive(Deserialize, Debug)]
58pub struct Page {
59    id: u32,
60}
61
62#[derive(Deserialize, Debug)]
63pub struct BookContent {
64    id: u32,
65    slug: String,
66    r#type: Option<String>,
67}
68
69#[derive(Deserialize, Debug)]
70pub struct Book {
71    contents: Vec<BookContent>,
72}
73
74#[derive(Deserialize, Debug)]
75pub struct Chapter {
76    pages: Vec<Page>,
77}
78
79// TODO(evert): This feels like a hack
80pub fn get_filename(content_disposition: &str) -> String {
81    match content_disposition.split_once("filename=") {
82        Some((_, after)) => after.replace(['"', ';', '/', '\\'], "").to_string(),
83        None => {
84            panic!(
85                "Could not get file name from content-disposition: {}",
86                content_disposition
87            );
88        }
89    }
90}
91
92impl BookstackClient {
93    pub fn new(bookstack_url: &str, token_id: &str, token_secret: &str) -> Self {
94        let mut headers = header::HeaderMap::new();
95        let mut auth_token = "Token ".to_string();
96        auth_token.push_str(token_id);
97        auth_token.push(':');
98        auth_token.push_str(token_secret);
99
100        headers.insert(
101            "Authorization",
102            header::HeaderValue::from_str(&auth_token)
103                .expect("Failed to set Authorization header from API ID and Secret"),
104        );
105
106        let reqwest_client = reqwest::blocking::Client::builder()
107            .default_headers(headers)
108            .build()
109            .expect("Failed to create Reqwest client");
110
111        BookstackClient {
112            bookstack_url: bookstack_url.to_string(),
113            client: reqwest_client,
114        }
115    }
116
117    pub fn get_shelves(&self) -> Result<Vec<ShelveListItem>, Box<dyn Error>> {
118        println!("get_shelves");
119
120        let res = self
121            .client
122            .get(self.bookstack_url.to_owned() + "/api/shelves")
123            .send()?;
124        let data: ShelveListResponse = res.json()?;
125
126        Ok(data.data)
127    }
128
129    pub fn get_shelve(&self, id: u32) -> Result<Shelve, Box<dyn Error>> {
130        println!("get_shelve");
131        dbg!(id);
132        let res = self
133            .client
134            .get(self.bookstack_url.to_owned() + "/api/shelves/" + &id.to_string())
135            .send()?;
136
137        Ok(res.json::<Shelve>()?)
138    }
139
140    pub fn get_book(&self, id: u32) -> Result<Book, Box<dyn Error>> {
141        println!("get_book");
142        dbg!(id);
143        let res = self
144            .client
145            .get(self.bookstack_url.to_owned() + "/api/books/" + &id.to_string())
146            .send()?;
147
148        Ok(res.json::<Book>()?)
149    }
150
151    pub fn get_chapter(&self, id: u32) -> Result<Chapter, Box<dyn Error>> {
152        println!("get_chapter");
153        dbg!(id);
154
155        let res = self
156            .client
157            .get(self.bookstack_url.to_owned() + "/api/chapters/" + &id.to_string())
158            .send()?;
159
160        Ok(res.json::<Chapter>()?)
161    }
162
163    pub fn clone_page(
164        &self,
165        export_type: &ExportType,
166        parent_path: &PathBuf,
167        page_id: u32,
168    ) -> Result<(), Box<dyn Error>> {
169        println!("clone_page");
170        dbg!(parent_path, page_id);
171
172        let mut res = self
173            .client
174            .get(
175                self.bookstack_url.to_owned()
176                    + "/api/pages/"
177                    + &page_id.to_string()
178                    + "/export/"
179                    + &export_type.to_string(),
180            )
181            .send()?;
182
183        dbg!(&res);
184
185        let filename: String;
186
187        let content_disposition_result = res.headers().get(CONTENT_DISPOSITION);
188        if let Some(content_disposition) = content_disposition_result {
189            dbg!(&content_disposition);
190            filename = get_filename(content_disposition.to_str()?);
191            dbg!(&filename);
192        } else {
193            panic!(
194                "Failed to get filename from content-disposition for exporting page ID {}, as {}",
195                page_id, export_type
196            );
197        }
198
199        println!("Exporting to {}", filename);
200        let mut file = fs::File::create(parent_path.join(filename))?;
201        std::io::copy(&mut res, &mut file)?;
202
203        Ok(())
204    }
205
206    pub fn clone_chapter(
207        &self,
208        export_type: &ExportType,
209        parent_path: &PathBuf,
210        chapter_id: u32,
211    ) -> Result<(), Box<dyn Error>> {
212        println!("clone_chapter");
213        dbg!(parent_path, chapter_id);
214
215        let chapter = self.get_chapter(chapter_id)?;
216
217        for page in chapter.pages {
218            self.clone_page(export_type, parent_path, page.id)?;
219        }
220
221        Ok(())
222    }
223
224    pub fn clone_book(
225        &self,
226        export_type: &ExportType,
227        parent_path: &PathBuf,
228        book_id: u32,
229    ) -> Result<(), Box<dyn Error>> {
230        println!("clone_book");
231        dbg!(parent_path, book_id);
232        let book = self.get_book(book_id)?;
233
234        for book_content in book.contents {
235            if let Some(content_type) = book_content.r#type {
236                if content_type == "chapter" {
237                    let child_path = parent_path.join(book_content.slug);
238                    fs::create_dir_all(&child_path)?;
239
240                    self.clone_chapter(export_type, &child_path, book_content.id)?;
241                } else if content_type == "page" {
242                    self.clone_page(export_type, parent_path, book_content.id)?;
243                }
244            } else {
245                self.clone_page(export_type, parent_path, book_content.id)?;
246            }
247        }
248
249        Ok(())
250    }
251
252    pub fn clone_shelve(
253        &self,
254        export_type: &ExportType,
255        parent_path: &PathBuf,
256        shelve_id: u32,
257    ) -> Result<(), Box<dyn Error>> {
258        println!("clone_shelve");
259        dbg!(parent_path, shelve_id);
260
261        let shelve = self.get_shelve(shelve_id)?;
262
263        for book_stub in shelve.books {
264            let child_path = parent_path.join(&book_stub.slug);
265            fs::create_dir_all(&child_path)?;
266
267            self.clone_book(export_type, &child_path, book_stub.id)?;
268        }
269
270        Ok(())
271    }
272
273    pub fn clone_bookstack(
274        &self,
275        export_type: &ExportType,
276        output_dir: &str,
277    ) -> Result<(), Box<dyn Error>> {
278        println!("clone_bookstack");
279        dbg!(export_type, output_dir);
280
281        let output_path = PathBuf::from(output_dir);
282        fs::create_dir_all(&output_path)?;
283
284        let shelves = self.get_shelves()?;
285
286        for shelve_stub in shelves {
287            let child_path = output_path.join(shelve_stub.slug);
288
289            fs::create_dir_all(&child_path)?;
290            self.clone_shelve(export_type, &child_path, shelve_stub.id)?;
291        }
292
293        Ok(())
294    }
295}