From 032409ba71e66aa7137e6b285e719a9fb9ad0b43 Mon Sep 17 00:00:00 2001 From: Morgan Creekmore Date: Mon, 25 Nov 2024 19:00:39 -0600 Subject: [PATCH] Add OPDS app --- Cargo.lock | 41 +- crates/core/Cargo.toml | 2 + crates/core/src/lib.rs | 1 + crates/core/src/opds.rs | 224 +++++++ crates/core/src/settings/mod.rs | 32 + crates/core/src/view/common.rs | 2 + crates/core/src/view/mod.rs | 8 + crates/core/src/view/opds/bottom_bar.rs | 198 ++++++ crates/core/src/view/opds/feed_browser.rs | 127 ++++ crates/core/src/view/opds/feed_entry.rs | 158 +++++ crates/core/src/view/opds/mod.rs | 630 ++++++++++++++++++++ crates/core/src/view/opds/navigation_bar.rs | 127 ++++ crates/emulator/src/main.rs | 4 + crates/plato/src/app.rs | 4 + 14 files changed, 1553 insertions(+), 5 deletions(-) create mode 100644 crates/core/src/opds.rs create mode 100644 crates/core/src/view/opds/bottom_bar.rs create mode 100644 crates/core/src/view/opds/feed_browser.rs create mode 100644 crates/core/src/view/opds/feed_entry.rs create mode 100644 crates/core/src/view/opds/mod.rs create mode 100644 crates/core/src/view/opds/navigation_bar.rs diff --git a/Cargo.lock b/Cargo.lock index 19c65089..919436e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,21 @@ dependencies = [ "serde", ] +[[package]] +name = "attohttpc" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a13149d0cf3f7f9b9261fad4ec63b2efbf9a80665f52def86282d26255e6331" +dependencies = [ + "base64", + "flate2", + "http", + "log", + "rustls 0.22.4", + "url", + "webpki-roots", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -608,7 +623,7 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls", + "rustls 0.23.18", "rustls-pki-types", "tokio", "tokio-rustls", @@ -1051,6 +1066,7 @@ name = "plato-core" version = "0.9.44" dependencies = [ "anyhow", + "attohttpc", "bitflags 2.6.0", "byteorder", "chrono", @@ -1078,6 +1094,7 @@ dependencies = [ "titlecase", "toml", "unicode-normalization", + "url", "walkdir", "xi-unicode", "zip", @@ -1131,7 +1148,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", + "rustls 0.23.18", "socket2", "thiserror", "tokio", @@ -1149,7 +1166,7 @@ dependencies = [ "rand", "ring", "rustc-hash", - "rustls", + "rustls 0.23.18", "rustls-pki-types", "slab", "thiserror", @@ -1274,7 +1291,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.18", "rustls-pemfile", "rustls-pki-types", "serde", @@ -1319,6 +1336,20 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.18" @@ -1669,7 +1700,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls", + "rustls 0.23.18", "rustls-pki-types", "tokio", ] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 25a48e61..77dfdc3c 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -39,3 +39,5 @@ rand_core = "0.6.4" rand_xoshiro = "0.6.0" percent-encoding = "2.3.1" chrono = { version = "0.4.38", features = ["serde", "clock"], default-features = false } +attohttpc = { version = "0.28.0", features = ["basic-auth", "compress", "tls-rustls-webpki-roots"], default-features = false} +url = "2.5.4" \ No newline at end of file diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 88896dd7..e41b786e 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -19,6 +19,7 @@ pub mod settings; pub mod font; pub mod context; pub mod gesture; +pub mod opds; pub use anyhow; pub use fxhash; diff --git a/crates/core/src/opds.rs b/crates/core/src/opds.rs new file mode 100644 index 00000000..cf3deafa --- /dev/null +++ b/crates/core/src/opds.rs @@ -0,0 +1,224 @@ +use std::fmt::Display; +use std::{fs::File, io::Write, path::PathBuf, str::FromStr}; + +use anyhow::{format_err, Error}; +use attohttpc::Response; +use url::{Position, Url}; + +use crate::document::html::xml::XmlParser; +use crate::helpers::decode_entities; +use crate::settings::OpdsSettings; + +#[derive(PartialEq, Debug, Clone)] +pub enum MimeType { + Epub, + Cbz, + Pdf, + OpdsCatalog, + OpdsEntry, + Other(String), +} + +impl Display for MimeType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = match *self { + MimeType::Epub => "epub".to_string(), + MimeType::Cbz => "cbz".to_string(), + MimeType::Pdf => "pdf".to_string(), + MimeType::OpdsCatalog => "xml".to_string(), + MimeType::OpdsEntry => "xml".to_string(), + MimeType::Other(ref s) => s.to_string(), + }; + write!(f, "{}", str) + } +} + +impl FromStr for MimeType { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "application/epub+zip" => Ok(MimeType::Epub), + "application/x-cbz" => Ok(MimeType::Cbz), + "application/pdf" => Ok(MimeType::Pdf), + "application/atom+xml;profile=opds-catalog" => Ok(MimeType::OpdsCatalog), + "application/atom+xml;type=entry;profile=opds-catalog" => Ok(MimeType::OpdsEntry), + _ => Ok(MimeType::Other(s.to_string())), + } + } +} + +#[derive(PartialEq, Debug, Clone)] +pub enum LinkType { + Acquisition, + Cover, + Thumbnail, + Sample, + OpenAccess, + Borrow, + Buy, + Subscribe, + Subsection, + Other(String), +} + +impl FromStr for LinkType { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "http://opds-spec.org/acquisition" => Ok(LinkType::Acquisition), + "http://opds-spec.org/image" => Ok(LinkType::Cover), + "http://opds-spec.org/image/thumbnail" => Ok(LinkType::Thumbnail), + "http://opds-spec.org/acquisition/sample" => Ok(LinkType::Sample), + "http://opds-spec.org/acquisition/preview" => Ok(LinkType::Sample), + "http://opds-spec.org/acquisition/open-access" => Ok(LinkType::OpenAccess), + "http://opds-spec.org/acquisition/borrow" => Ok(LinkType::Borrow), + "http://opds-spec.org/acquisition/buy" => Ok(LinkType::Buy), + "http://opds-spec.org/acquisition/subscribe" => Ok(LinkType::Subscribe), + "subsection" => Ok(LinkType::Subsection), + _ => Ok(LinkType::Other(s.to_string())), + } + } +} + +#[derive(Default, Debug, Clone)] +pub struct Feed { + pub title: String, + pub entries: Vec, + // pub links: Vec, +} + +#[derive(Default, Debug, Clone)] +pub struct Entry { + pub id: String, + pub title: String, + pub author: Option, + pub links: Vec, +} + +#[derive(Default, Debug, Clone)] +pub struct Link { + pub rel: Option, + pub href: Option, + pub mime_type: Option, +} + +#[derive(Debug, Clone)] +pub struct OpdsFetcher { + pub settings: OpdsSettings, + pub root_url: Url, + pub base_url: Url, +} + +impl OpdsFetcher { + pub fn new(settings: OpdsSettings) -> Result { + let root_url = Url::parse(&settings.url)?; + let base_url = Url::parse(&root_url[..Position::BeforePath])?; + + Ok(OpdsFetcher { + settings: settings, + root_url: root_url, + base_url: base_url, + }) + } + + pub fn download_relative(&self, path: &str, file_path: &PathBuf) -> Result { + let full_url = Url::join(&self.base_url, path)?; + let mut file = File::create(&file_path)?; + let response: Response = self.request(&full_url)?; + //TODO check success + let bytes = response.bytes()?; + let _ = file.write(&bytes); + return Ok(file); + } + + pub fn home(&self) -> Result { + let response = self.request(&self.root_url)?; + //TODO check success + return OpdsFetcher::parse_feed(response); + } + + pub fn pull_relative(&self, path: &str) -> Result { + let full_url = Url::join(&self.base_url, path)?; + let response = self.request(&full_url)?; + //TODO check success + return OpdsFetcher::parse_feed(response); + } + + fn parse_feed(response: Response) -> Result { + let body = response.text()?; + let root = XmlParser::new(&body).parse(); + //println!("{:?}", body); + let feed = root + .root() + .find("feed") + .ok_or_else(|| format_err!("feed is missing"))?; + let mut entries = Vec::new(); + let mut title = String::new(); + for child in feed.children() { + if child.tag_name() == Some("title") { + title = decode_entities(&child.text()).into_owned(); + } + if child.tag_name() == Some("entry") { + let mut find_id = None; + let mut find_title = None; + let mut author = None; + let mut links = Vec::new(); + for entry_child in child.children() { + if entry_child.tag_name() == Some("id") { + find_id = Some(entry_child.text()); + } + if entry_child.tag_name() == Some("title") { + find_title = Some(decode_entities(&entry_child.text()).into_owned()); + } + if entry_child.tag_name() == Some("author") { + if let Some(name) = entry_child.find("name") { + author = Some(decode_entities(&name.text()).into_owned()); + } + } + if entry_child.tag_name() == Some("link") { + let rel = entry_child + .attribute("rel") + .map(|s| LinkType::from_str(s).ok()) + .flatten(); + let href = entry_child.attribute("href").map(String::from); + let mime_type = entry_child + .attribute("type") + .map(|s| MimeType::from_str(s).ok()) + .flatten(); + links.push(Link { + rel: rel, + href: href, + mime_type: mime_type, + }); + } + } + //TODO error + if let (Some(id), Some(title)) = (find_id, find_title) { + let entry = Entry { + id: id, + title: title, + author: author, + links: links, + }; + //println!("{:#?}", entry); + entries.push(entry); + } + } + } + Ok(Feed { + title: title, + entries: entries, + }) + } + + fn request(&self, url: &Url) -> Result { + let mut request_builder = attohttpc::get(url); + if let Some(username) = self.settings.username.clone() { + request_builder = request_builder.basic_auth(username, self.settings.password.clone()); + } + let response = request_builder.send()?; + return Ok(response); + } +} diff --git a/crates/core/src/settings/mod.rs b/crates/core/src/settings/mod.rs index 2cffab5e..3eef7bbf 100644 --- a/crates/core/src/settings/mod.rs +++ b/crates/core/src/settings/mod.rs @@ -125,6 +125,7 @@ pub struct Settings { pub calculator: CalculatorSettings, pub battery: BatterySettings, pub frontlight_levels: LightLevels, + pub opds: Vec } #[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)] @@ -489,6 +490,28 @@ impl Default for BatterySettings { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct OpdsSettings { + pub name: String, + pub url: String, + pub username: Option, + pub password: Option, + pub download_location: PathBuf, +} + +impl Default for OpdsSettings { + fn default() -> Self { + OpdsSettings { + name: "Unnamed".to_string(), + url: String::default(), + username: None, + password: None, + download_location: PathBuf::default() + } + } +} + impl Default for Settings { fn default() -> Self { Settings { @@ -551,6 +574,15 @@ impl Default for Settings { battery: BatterySettings::default(), frontlight_levels: LightLevels::default(), frontlight_presets: Vec::new(), + opds: vec![ + OpdsSettings { + name: "Project Gutenberg".to_string(), + url: "https://m.gutenberg.org/ebooks.opds/".to_string(), + username: None, + password: None, + download_location: PathBuf::from(INTERNAL_CARD_ROOT) + } + ], } } } diff --git a/crates/core/src/view/common.rs b/crates/core/src/view/common.rs index e4a5a696..c7e6b869 100644 --- a/crates/core/src/view/common.rs +++ b/crates/core/src/view/common.rs @@ -85,6 +85,8 @@ pub fn toggle_main_menu(view: &mut dyn View, rect: Rectangle, enable: Option>, + is_prev_disabled: bool, + is_next_disabled: bool, +} + +impl BottomBar { + pub fn new(rect: Rectangle, current_page: usize, pages_count: usize, name: &str) -> BottomBar { + let id = ID_FEEDER.next(); + let mut children = Vec::new(); + let side = rect.height() as i32; + let is_prev_disabled = pages_count < 2 || current_page == 0; + let is_next_disabled = pages_count < 2 || current_page == pages_count - 1; + + let prev_rect = rect![rect.min, rect.min + side]; + + if is_prev_disabled { + let prev_filler = Filler::new(prev_rect, WHITE); + children.push(Box::new(prev_filler) as Box); + } else { + let prev_icon = Icon::new("arrow-left", prev_rect, Event::Page(CycleDir::Previous)); + children.push(Box::new(prev_icon) as Box); + } + + let (small_half_width, big_half_width) = halves(rect.width() as i32 - 2 * side); + let server_rect = rect![ + rect.min.x + side, + rect.min.y, + rect.min.x + side + small_half_width, + rect.max.y + ]; + let server_label = Label::new(server_rect, name.to_string(), Align::Center) + .event(Some(Event::ToggleNear(ViewId::OpdsServerMenu, server_rect))); + + children.push(Box::new(server_label) as Box); + + let page_label = PageLabel::new( + rect![ + rect.max.x - side - big_half_width, + rect.min.y, + rect.max.x - side, + rect.max.y + ], + current_page, + pages_count, + false, + ); + children.push(Box::new(page_label) as Box); + + let next_rect = rect![rect.max - side, rect.max]; + + if is_next_disabled { + let next_filler = Filler::new(next_rect, WHITE); + children.push(Box::new(next_filler) as Box); + } else { + let next_icon = Icon::new( + "arrow-right", + rect![rect.max - side, rect.max], + Event::Page(CycleDir::Next), + ); + children.push(Box::new(next_icon) as Box); + } + + BottomBar { + id, + rect, + children, + is_prev_disabled, + is_next_disabled, + } + } + + pub fn update_server_label(&mut self, name: &str, rq: &mut RenderQueue) { + let server_label = self.children[1].as_mut().downcast_mut::