Skip to content

Commit

Permalink
feat: support Mobi files
Browse files Browse the repository at this point in the history
Adds support to Citadel (and libcalibre) for importing Mobi files.

Includes extracting cover images.
  • Loading branch information
phildenhoff committed Feb 1, 2024
1 parent 0d4d7c3 commit 2abfd05
Show file tree
Hide file tree
Showing 9 changed files with 247 additions and 2 deletions.
77 changes: 77 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
1 change: 1 addition & 0 deletions src-tauri/libcalibre/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,6 +29,16 @@ fn cover_data(path: &Path) -> Result<Option<Vec<u8>>, Box<dyn Error>> {
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),
}
}
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/libcalibre/src/mime_type.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub enum MIMETYPE {
EPUB,
MOBI,
UNKNOWN,
}

Expand All @@ -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",
}
}
Expand All @@ -16,6 +18,7 @@ impl MIMETYPE {
pub fn from_str(mimetype: &str) -> Option<Self> {
match mimetype {
"application/epub+zip" => Some(MIMETYPE::EPUB),
"application/x-mobipocket-ebook" => Some(MIMETYPE::MOBI),
"application/octet-stream" => Some(MIMETYPE::UNKNOWN),
_ => None,
}
Expand All @@ -24,13 +27,15 @@ impl MIMETYPE {
pub fn to_file_extension(&self) -> &'static str {
match *self {
MIMETYPE::EPUB => "epub",
MIMETYPE::MOBI => "mobi",
MIMETYPE::UNKNOWN => "",
}
}

pub fn from_file_extension(extension: &str) -> Option<Self> {
match extension.to_lowercase().as_str() {
"epub" => Some(MIMETYPE::EPUB),
"mobi" => Some(MIMETYPE::MOBI),
_ => None,
}
}
Expand Down
129 changes: 129 additions & 0 deletions src-tauri/src/libs/file_formats/mobi.rs
Original file line number Diff line number Diff line change
@@ -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<NaiveDate>,
pub cover_image_data: Option<Vec<u8>>,
pub language: String,
pub subjects: Vec<String>,

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<MobiMetadata> {
let m = Mobi::from_path(path);

match m {
Err(_) => None,
Ok(m) => {
let date: Option<NaiveDate> = 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,
})
}
}
}
16 changes: 16 additions & 0 deletions src-tauri/src/libs/file_formats/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use super::calibre::ImportableFile;
use crate::book::{ImportableBookMetadata, ImportableBookType};

mod epub;
mod mobi;

pub enum SupportedFormats {
EPUB,
Expand Down Expand Up @@ -77,6 +78,21 @@ pub fn get_importable_file_metadata(file: ImportableFile) -> Option<ImportableBo
}),
_ => 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,
}
}
4 changes: 2 additions & 2 deletions src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ return await TAURI_INVOKE("plugin:tauri-specta|calibre_send_to_device", { librar
async initClient(libraryPath: string) : Promise<CalibreClientConfig> {
return await TAURI_INVOKE("plugin:tauri-specta|init_client", { libraryPath });
},
async getImportableFileMetadata(file: ImportableFile) : Promise<ImportableBookMetadata> {
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<ImportableFile> {
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<null> {
Expand Down
4 changes: 4 additions & 0 deletions src/lib/library/addBook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export const promptToAddBook = async (library: Library): Promise<ImportableBookM
name: "EPUB",
extensions: ["epub"],
},
{
name: "MOBI",
extensions: ["mobi"],
}
],
});
if (!filePath) {
Expand Down

0 comments on commit 2abfd05

Please sign in to comment.