mod epub; mod http_client; mod models; mod xml; use std::collections::HashMap; use crate::epub::{create_epub_archive, download_all_files}; use crate::http_client::build_authenticated_client; use crate::models::{Chapter, EpubResponse, FileEntry, Paginated, SpineItem, TocNode}; use anyhow::{Context, Result}; use clap::Parser; use directories::{BaseDirs, UserDirs}; use reqwest::Client; /// Download and generate an EPUB from Safari Books Online. #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] struct Args { /// The Book digits ID that you want to download. #[arg(required = true)] bookid: String, /// Path to the cookies.json file. #[arg(long, default_value = "cookies.json")] cookies: String, /// Do not download files. Use if they were already downloaded in a previous run. #[arg(long = "skip-download")] skip_download: bool, } /// Fetches EPUB structural data (like the chapters URL). async fn fetch_epub_data(client: &Client, bookid: &str) -> Result { let url = format!("https://learning.oreilly.com/api/v2/epubs/urn:orm:book:{bookid}/"); let response = client .get(&url) .send() .await? .error_for_status()? .json::() .await .context("Failed to deserialize EPUB API response")?; Ok(response) } /// Fetches a direct array endpoint (no pagination, simple list). async fn fetch_direct_array(client: &Client, url: &str) -> Result> where T: serde::de::DeserializeOwned, { let response = client .get(url) .send() .await? .error_for_status()? .json::>() .await .context("Failed to deserialize API response")?; Ok(response) } /// Fetch a paginated API. async fn fetch_all_pages(client: &reqwest::Client, mut url: String) -> Result> where T: serde::de::DeserializeOwned, { let mut items = Vec::new(); loop { // GET current URL and deserialize into Paginated. let response = client .get(&url) .send() .await? .error_for_status()? .json::>() .await .context("Failed to deserialize API response.")?; // Extend items with the page's results. items.extend(response.results); // Set url to next page if available, else break. if let Some(next) = response.next { url = next; } else { break; } } Ok(items) } #[tokio::main] async fn main() -> Result<()> { // Obtain relevant XDG base directories. let base_dirs = BaseDirs::new().context("Could not get XDG base directories.")?; let data_root = base_dirs.data_dir().join("oreilly-epub"); // Parse the command line arguments let args = Args::parse(); // Obtain the path to the destination EPUB file. let user_dirs = UserDirs::new().context("Could not get XDG user directories.")?; let epub_path = user_dirs .download_dir() .unwrap_or(&user_dirs.home_dir().join("Downloads")) .join("oreilly-epub") .join(format!("{}.epub", args.bookid)); println!("Welcome to SafariBooks Rust Port!"); println!("Target Book ID: {}", args.bookid); // Initialise the HTTP client. println!("Loading cookies and initialising the HTTP client..."); let client = build_authenticated_client(&args.cookies)?; println!("Fetching book metadata..."); // Fetch from the EPUB API. let epub_data = fetch_epub_data(&client, &args.bookid).await?; println!("Publication date: {}", epub_data.publication_date); println!("Title: {}", epub_data.title); println!("Chapters URL: {}", epub_data.chapters); println!("Resources URL: {}", epub_data.files); println!("------------------\n"); println!("Fetching book structure..."); let chapters: Vec = fetch_all_pages(&client, epub_data.chapters.clone()).await?; let chapters: HashMap = chapters.into_iter().map(|c| (c.ourn.clone(), c)).collect(); let file_entries: Vec = fetch_all_pages(&client, epub_data.files.clone()).await?; let spine_items: Vec = fetch_all_pages(&client, epub_data.spine.clone()).await?; let toc_vec: Vec = fetch_direct_array(&client, &epub_data.table_of_contents).await?; let epub_root = data_root.join("files").join(&args.bookid); if !args.skip_download { println!("Downloading files from the server..."); download_all_files(&client, &file_entries, &epub_root).await?; } println!("Generating the EPUB file..."); create_epub_archive(&epub_data, &epub_root, &epub_path, &file_entries, &chapters)?; Ok(()) }