diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c92585b..3958b49 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -605,6 +605,7 @@ dependencies = [ "diesel 2.1.4", "epub", "libcalibre", + "mobi", "regex", "serde", "serde_json", @@ -1084,6 +1085,70 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec" +dependencies = [ + "encoding-index-japanese", + "encoding-index-korean", + "encoding-index-simpchinese", + "encoding-index-singlebyte", + "encoding-index-tradchinese", +] + +[[package]] +name = "encoding-index-japanese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-korean" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-simpchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-singlebyte" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-tradchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding_index_tests" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" + [[package]] name = "encoding_rs" version = "0.8.33" @@ -2075,6 +2140,7 @@ dependencies = [ "diesel 2.1.4", "diesel_migrations", "epub", + "mobi", "regex", "serde", "serde_json", @@ -2287,6 +2353,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mobi" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3f8e34216126be00a189105bda27462e1743d59cd4e15d6b99ff4af051b1b4" +dependencies = [ + "encoding", + "indexmap 1.9.3", + "thiserror", +] + [[package]] name = "native-tls" version = "0.2.11" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e9935a6..57e586b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ calibre-db = "0.1.0" chrono = { version = "0.4.31", features = ["serde"] } diesel = { version = "2.1.0", features = ["sqlite", "chrono", "returning_clauses_for_sqlite_3_35"] } epub = "2.1.1" +mobi = "0.8.0" libcalibre = { path = "./libcalibre" } regex = "1.10.2" serde = { version = "1.0", features = ["derive"] } diff --git a/src-tauri/libcalibre/Cargo.toml b/src-tauri/libcalibre/Cargo.toml index 85f8905..174a7df 100644 --- a/src-tauri/libcalibre/Cargo.toml +++ b/src-tauri/libcalibre/Cargo.toml @@ -10,6 +10,7 @@ chrono = { version = "0.4.31", features = ["serde"] } diesel = { version = "2.1.0", features = ["sqlite", "chrono", "returning_clauses_for_sqlite_3_35"] } diesel_migrations = { version = "2.1.0", features = ["sqlite"] } epub = "2.1.1" +mobi = "0.8.0" regex = "1.10.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src-tauri/libcalibre/src/application/services/domain/file/service.rs b/src-tauri/libcalibre/src/application/services/domain/file/service.rs index 258faa9..7262599 100644 --- a/src-tauri/libcalibre/src/application/services/domain/file/service.rs +++ b/src-tauri/libcalibre/src/application/services/domain/file/service.rs @@ -2,6 +2,8 @@ use std::error::Error; use std::ffi::OsStr; use std::path::Path; +use mobi::Mobi; + use crate::application::services::domain::file::dto::{NewFileDto, UpdateFileDto}; use crate::domain::book_file::entity::{BookFile, NewBookFile, UpdateBookFile}; use crate::domain::book_file::repository::Repository as BookFileRepository; @@ -27,6 +29,16 @@ fn cover_data(path: &Path) -> Result>, Box> { let mut doc = epub::doc::EpubDoc::new(path)?; Ok(doc.get_cover().map(|(data, _id)| data)) } + Some(MIMETYPE::MOBI) => { + let m = Mobi::from_path(&path); + match m { + Err(_) => Err("Failed to read mobi file")?, + Ok(mobi) => { + let cover_data = mobi.image_records().last().map(|img| img.content.to_vec()); + Ok(cover_data) + } + } + } _ => Ok(None), } } diff --git a/src-tauri/libcalibre/src/mime_type.rs b/src-tauri/libcalibre/src/mime_type.rs index 851c442..6d7fa37 100644 --- a/src-tauri/libcalibre/src/mime_type.rs +++ b/src-tauri/libcalibre/src/mime_type.rs @@ -1,5 +1,6 @@ pub enum MIMETYPE { EPUB, + MOBI, UNKNOWN, } @@ -8,6 +9,7 @@ impl MIMETYPE { pub fn as_str(&self) -> &'static str { match *self { MIMETYPE::EPUB => "application/epub+zip", + MIMETYPE::MOBI => "application/x-mobipocket-ebook", MIMETYPE::UNKNOWN => "application/octet-stream", } } @@ -16,6 +18,7 @@ impl MIMETYPE { pub fn from_str(mimetype: &str) -> Option { match mimetype { "application/epub+zip" => Some(MIMETYPE::EPUB), + "application/x-mobipocket-ebook" => Some(MIMETYPE::MOBI), "application/octet-stream" => Some(MIMETYPE::UNKNOWN), _ => None, } @@ -24,6 +27,7 @@ impl MIMETYPE { pub fn to_file_extension(&self) -> &'static str { match *self { MIMETYPE::EPUB => "epub", + MIMETYPE::MOBI => "mobi", MIMETYPE::UNKNOWN => "", } } @@ -31,6 +35,7 @@ impl MIMETYPE { pub fn from_file_extension(extension: &str) -> Option { match extension.to_lowercase().as_str() { "epub" => Some(MIMETYPE::EPUB), + "mobi" => Some(MIMETYPE::MOBI), _ => None, } } diff --git a/src-tauri/src/libs/calibre/mod.rs b/src-tauri/src/libs/calibre/mod.rs index b276b10..4cd3f09 100644 --- a/src-tauri/src/libs/calibre/mod.rs +++ b/src-tauri/src/libs/calibre/mod.rs @@ -1,14 +1,11 @@ use std::io::Error; use std::path::PathBuf; -use std::str::FromStr; use std::sync::Arc; use std::sync::Mutex; use crate::book::ImportableBookMetadata; -use crate::book::ImportableBookType; use crate::book::LibraryAuthor; use crate::book::LibraryBook; -use crate::libs::file_formats::read_epub_metadata; use chrono::NaiveDate; use chrono::NaiveDateTime; @@ -41,11 +38,7 @@ use std::path::Path; #[derive(Serialize, Deserialize, specta::Type, Debug)] pub struct ImportableFile { - path: PathBuf, -} - -fn get_supported_extensions() -> Vec<&'static str> { - vec!["epub", "mobi", "pdf"] + pub(crate) path: PathBuf, } #[derive(Serialize, specta::Type)] @@ -75,49 +68,16 @@ pub fn calibre_list_all_authors(library_root: String) -> Vec { #[tauri::command] #[specta::specta] -pub fn check_file_importable(path_to_file: String) -> ImportableFile { +pub fn check_file_importable(path_to_file: String) -> Option { let file_path = Path::new(&path_to_file); - if !file_path.exists() { - panic!("File does not exist at {}", path_to_file); - } - - let file_extension = file_path.extension().and_then(|ext| ext.to_str()); - - match file_extension { - Some(extension) if get_supported_extensions().contains(&extension) => ImportableFile { - path: PathBuf::from(path_to_file), - }, - Some(extension) => { - panic!("Unsupported file extension: {}", extension); - } - None => { - panic!("File does not have an extension"); - } - } + super::file_formats::validate_file_importable(file_path) } #[tauri::command] #[specta::specta] -pub fn get_importable_file_metadata(file: ImportableFile) -> ImportableBookMetadata { - // TODO Do not assume file is an EPUB - let res = read_epub_metadata(file.path.as_path()); - - ImportableBookMetadata { - file_type: ImportableBookType::EPUB, - title: res.title.unwrap_or("".to_string()), - author_names: res.creator_list, - language: res.language, - publisher: res.publisher, - identifier: res.identifier, - path: file.path, - file_contains_cover: res.cover_image_data.is_some(), - tags: res.subjects, - publication_date: NaiveDate::from_str( - res.publication_date.unwrap_or("".to_string()).as_str(), - ) - .ok(), - } +pub fn get_importable_file_metadata(file: ImportableFile) -> Option { + super::file_formats::get_importable_file_metadata(file) } pub fn create_folder_for_author( diff --git a/src-tauri/src/libs/file_formats/epub.rs b/src-tauri/src/libs/file_formats/epub.rs new file mode 100644 index 0000000..aa0c7cb --- /dev/null +++ b/src-tauri/src/libs/file_formats/epub.rs @@ -0,0 +1,42 @@ +use std::path::Path; + +use epub::doc::EpubDoc; + +pub struct EpubMetadata { + pub title: Option, + pub creator_list: Option>, + pub identifier: Option, + pub publisher: Option, + pub publication_date: Option, + pub language: Option, + pub cover_image_data: Option>, + pub subjects: Vec, +} + +pub fn read_metadata(path: &Path) -> Option { + match EpubDoc::new(path) { + Err(_) => None, + Ok(mut doc) => { + let creators = doc + .metadata + .get("creator") + .map(|v| v.to_vec()) + .unwrap_or(Vec::new()); + + Some(EpubMetadata { + title: doc.mdata("title"), + creator_list: Some(creators), + identifier: doc.mdata("identifier"), + publisher: doc.mdata("publisher"), + language: doc.mdata("language"), + cover_image_data: doc.get_cover().map(|(data, _id)| data), + publication_date: doc.mdata("date"), + subjects: doc + .metadata + .get("subject") + .map(|v| v.to_vec()) + .unwrap_or(Vec::new()), + }) + } + } +} diff --git a/src-tauri/src/libs/file_formats/mobi.rs b/src-tauri/src/libs/file_formats/mobi.rs new file mode 100644 index 0000000..456f33b --- /dev/null +++ b/src-tauri/src/libs/file_formats/mobi.rs @@ -0,0 +1,129 @@ +use std::path::Path; + +use chrono::{NaiveDate, NaiveDateTime}; +use mobi::{headers::Language, Mobi}; + +pub struct MobiMetadata { + pub title: String, + pub author: String, + pub contributor: String, + pub isbn: String, + pub publisher: String, + pub pub_date: Option, + pub cover_image_data: Option>, + pub language: String, + pub subjects: Vec, + + pub desc: String, +} + +fn language_to_string(lang: &Language) -> String { + match lang { + Language::Neutral => "Neutral".to_string(), + Language::Afrikaans => "Afrikaans".to_string(), + Language::Albanian => "Albanian".to_string(), + Language::Arabic => "Arabic".to_string(), + Language::Armenian => "Armenian".to_string(), + Language::Assamese => "Assamese".to_string(), + Language::Azeri => "Azeri".to_string(), + Language::Basque => "Basque".to_string(), + Language::Belarusian => "Belarusian".to_string(), + Language::Bengali => "Bengali".to_string(), + Language::Bulgarian => "Bulgarian".to_string(), + Language::Catalan => "Catalan".to_string(), + Language::Chinese => "Chinese".to_string(), + Language::Czech => "Czech".to_string(), + Language::Danish => "Danish".to_string(), + Language::Dutch => "Dutch".to_string(), + Language::English => "English".to_string(), + Language::Estonian => "Estonian".to_string(), + Language::Faeroese => "Faeroese".to_string(), + Language::Farsi => "Farsi".to_string(), + Language::Finnish => "Finnish".to_string(), + Language::French => "French".to_string(), + Language::Georgian => "Georgian".to_string(), + Language::German => "German".to_string(), + Language::Greek => "Greek".to_string(), + Language::Gujarati => "Gujarati".to_string(), + Language::Hebrew => "Hebrew".to_string(), + Language::Hindi => "Hindi".to_string(), + Language::Hungarian => "Hungarian".to_string(), + Language::Icelandic => "Icelandic".to_string(), + Language::Indonesian => "Indonesian".to_string(), + Language::Italian => "Italian".to_string(), + Language::Japanese => "Japanese".to_string(), + Language::Kannada => "Kannada".to_string(), + Language::Kazak => "Kazakh".to_string(), + Language::Konkani => "Konkani".to_string(), + Language::Korean => "Korean".to_string(), + Language::Latvian => "Latvian".to_string(), + Language::Lithuanian => "Lithuanian".to_string(), + Language::Macedonian => "Macedonian".to_string(), + Language::Malay => "Malay".to_string(), + Language::Malayalam => "Malayalam".to_string(), + Language::Maltese => "Maltese".to_string(), + Language::Marathi => "Marathi".to_string(), + Language::Nepali => "Nepali".to_string(), + Language::Norwegian => "Norwegian".to_string(), + Language::Oriya => "Oriya".to_string(), + Language::Polish => "Polish".to_string(), + Language::Portuguese => "Portuguese".to_string(), + Language::Punjabi => "Punjabi".to_string(), + Language::Rhaetoromanic => "Rhaetoromanic".to_string(), + Language::Romanian => "Romanian".to_string(), + Language::Russian => "Russian".to_string(), + Language::Sami => "Sami".to_string(), + Language::Sanskrit => "Sanskrit".to_string(), + Language::Serbian => "Serbian".to_string(), + Language::Slovak => "Slovak".to_string(), + Language::Slovenian => "Slovenian".to_string(), + Language::Sorbian => "Sorbian".to_string(), + Language::Spanish => "Spanish".to_string(), + Language::Sutu => "Sutu".to_string(), + Language::Swahili => "Swahili".to_string(), + Language::Swedish => "Swedish".to_string(), + Language::Tamil => "Tamil".to_string(), + Language::Tatar => "Tatar".to_string(), + Language::Telugu => "Telugu".to_string(), + Language::Thai => "Thai".to_string(), + Language::Tsonga => "Tsonga".to_string(), + Language::Tswana => "Tswana".to_string(), + Language::Turkish => "Turkish".to_string(), + Language::Ukrainian => "Ukrainian".to_string(), + Language::Urdu => "Urdu".to_string(), + Language::Uzbek => "Uzbek".to_string(), + Language::Vietnamese => "Vietnamese".to_string(), + Language::Xhosa => "Xhosa".to_string(), + Language::Zulu => "Zulu".to_string(), + Language::Unknown => "Unknown".to_string(), + } +} + +pub fn read_metadata(path: &Path) -> Option { + let m = Mobi::from_path(path); + + match m { + Err(_) => None, + Ok(m) => { + let date: Option = NaiveDateTime::parse_from_str( + &m.metadata.publish_date().unwrap_or_default(), + "%Y-%m-%dT%H:%M:%S%z", + ).map(|dt| dt.date()).ok(); + + let cover_image = m.image_records().last().map(|i| i.content.to_vec()); + + Some(MobiMetadata { + title: m.title(), + author: m.author().unwrap_or_default(), + publisher: m.publisher().unwrap_or_default(), + desc: m.description().unwrap_or_default(), + isbn: m.isbn().unwrap_or_default(), + pub_date: date, + contributor: m.contributor().unwrap_or_default(), + language: language_to_string(&m.language()), + subjects: m.metadata.subjects().unwrap_or_default(), + cover_image_data: cover_image, + }) + } + } +} diff --git a/src-tauri/src/libs/file_formats/mod.rs b/src-tauri/src/libs/file_formats/mod.rs index 4f6389a..b2da85e 100644 --- a/src-tauri/src/libs/file_formats/mod.rs +++ b/src-tauri/src/libs/file_formats/mod.rs @@ -1,44 +1,98 @@ -use std::path::Path; - -use epub::doc::EpubDoc; - -pub struct EpubMetadata { - pub title: Option, - pub creator_list: Option>, - pub identifier: Option, - pub publisher: Option, - pub publication_date: Option, - pub language: Option, - pub cover_image_data: Option>, - pub subjects: Vec, +use chrono::NaiveDate; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use super::calibre::ImportableFile; +use crate::book::{ImportableBookMetadata, ImportableBookType}; + +mod epub; +mod mobi; + +pub enum SupportedFormats { + EPUB, + MOBI, + UNKNOWN, +} +impl SupportedFormats { + pub fn list_all() -> Vec<&'static str> { + vec!["epub", "mobi"] + } + + pub fn is_supported(ext: &str) -> bool { + Self::from_file_extension(ext).is_some() + } + + pub fn to_file_extension(&self) -> &'static str { + match *self { + Self::EPUB => "epub", + Self::MOBI => "mobi", + Self::UNKNOWN => "", + } + } + + pub fn from_file_extension(extension: &str) -> Option { + match extension.to_lowercase().as_str() { + "epub" => Some(Self::EPUB), + "mobi" => Some(Self::MOBI), + _ => None, + } + } } -pub fn cover_data(path: &Path) -> Option> { - let doc = EpubDoc::new(path); - assert!(doc.is_ok()); - let mut doc = doc.unwrap(); +/// Validate if a file at some path is importable. +/// Ensures the file exists and has a supported file extension. +pub fn validate_file_importable(path: &Path) -> Option { + if !&path.exists() { + return None; + } + let ext = &path.extension().and_then(|s| s.to_str()).unwrap_or(""); - doc.get_cover().map(|(data, _id)| data) + match SupportedFormats::from_file_extension(ext) { + Some(_) => Some(ImportableFile { + path: PathBuf::from(path), + }), + _ => None, + } } -pub fn read_epub_metadata(path: &Path) -> EpubMetadata { - let doc = EpubDoc::new(path); - assert!(doc.is_ok()); - let mut doc = doc.unwrap(); - let creators = doc.metadata.get("creator").map(|v| v.to_vec()).unwrap_or(Vec::new()); - - EpubMetadata { - title: doc.mdata("title"), - creator_list: Some(creators), - identifier: doc.mdata("identifier"), - publisher: doc.mdata("publisher"), - language: doc.mdata("language"), - cover_image_data: doc.get_cover().map(|(data, _id)| data), - publication_date: doc.mdata("date"), - subjects: doc - .metadata - .get("subject") - .map(|v| v.to_vec()) - .unwrap_or(Vec::new()), +pub fn get_importable_file_metadata(file: ImportableFile) -> Option { + let ext = &file.path.extension().and_then(|s| s.to_str()).unwrap_or(""); + let format = SupportedFormats::from_file_extension(ext); + + match format { + Some(SupportedFormats::EPUB) => match epub::read_metadata(&file.path) { + Some(metadata) => Some(ImportableBookMetadata { + file_type: ImportableBookType::EPUB, + title: metadata.title.unwrap_or("".to_string()), + author_names: metadata.creator_list, + language: metadata.language, + publisher: metadata.publisher, + identifier: metadata.identifier, + path: file.path, + file_contains_cover: metadata.cover_image_data.is_some(), + tags: metadata.subjects, + publication_date: NaiveDate::from_str( + metadata.publication_date.unwrap_or("".to_string()).as_str(), + ) + .ok(), + }), + _ => None, + }, + Some(SupportedFormats::MOBI) => match mobi::read_metadata(&file.path) { + Some(metadata) => Some(ImportableBookMetadata { + file_type: ImportableBookType::MOBI, + title: metadata.title, + author_names: Some(vec![metadata.author]), + identifier: None, + publisher: Some(metadata.publisher), + language: Some(metadata.language), + tags: metadata.subjects, + path: file.path, + publication_date: metadata.pub_date, + file_contains_cover: true, + }), + _ => None, + }, + _ => None, } } diff --git a/src/bindings.ts b/src/bindings.ts index 1cda23f..e62be52 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -13,10 +13,10 @@ return await TAURI_INVOKE("plugin:tauri-specta|calibre_send_to_device", { librar async initClient(libraryPath: string) : Promise { return await TAURI_INVOKE("plugin:tauri-specta|init_client", { libraryPath }); }, -async getImportableFileMetadata(file: ImportableFile) : Promise { +async getImportableFileMetadata(file: ImportableFile) : Promise<{ file_type: ImportableBookType; title: string; author_names: string[] | null; identifier: string | null; publisher: string | null; language: string | null; tags: string[]; path: string; publication_date: string | null; file_contains_cover: boolean } | null> { return await TAURI_INVOKE("plugin:tauri-specta|get_importable_file_metadata", { file }); }, -async checkFileImportable(pathToFile: string) : Promise { +async checkFileImportable(pathToFile: string) : Promise<{ path: string } | null> { return await TAURI_INVOKE("plugin:tauri-specta|check_file_importable", { pathToFile }); }, async addBookToDbByMetadata(libraryPath: string, md: ImportableBookMetadata) : Promise { diff --git a/src/lib/library/addBook.ts b/src/lib/library/addBook.ts index 7efe5da..0c7add6 100644 --- a/src/lib/library/addBook.ts +++ b/src/lib/library/addBook.ts @@ -11,6 +11,10 @@ export const promptToAddBook = async (library: Library): Promise