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#[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
79pub 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}