#![deny(
rustdoc::broken_intra_doc_links,
rustdoc::private_intra_doc_links,
rustdoc::bare_urls,
missing_docs
)]
#![warn(rustdoc::unescaped_backticks)]
use std::fmt::{format, Debug, Formatter};
use std::fs;
use std::fs::{create_dir_all, File};
use std::io::{Cursor, Read, Write};
use std::path::{Path, PathBuf};
use directories::BaseDirs;
#[cfg(feature = "epub")]
use epub_builder::{EpubBuilder, Error, ZipLibrary};
use fs_extra::dir::{move_dir, CopyOptions};
#[cfg(feature = "epub")]
use image::EncodableLayout;
use image::{ImageError, ImageFormat};
use libwebnovel::backends::BackendError;
use libwebnovel::{Backend, Backends, Chapter, ChapterParseError};
use log::{debug, error, info, trace, warn};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use url::Url;
#[derive(Error, Debug)]
pub enum LibraryError {
#[error(transparent)]
IoError(#[from] std::io::Error),
#[error(transparent)]
TomlDeserializationError(#[from] toml::de::Error),
#[error(transparent)]
TomlSerializationError(#[from] toml::ser::Error),
#[error(transparent)]
UrlParseError(#[from] url::ParseError),
#[error(transparent)]
ChapterParseError(#[from] ChapterParseError),
#[error(transparent)]
BackendError(#[from] BackendError),
#[error(transparent)]
ImageError(#[from] ImageError),
#[error(transparent)]
Infallible(#[from] std::convert::Infallible),
#[error(transparent)]
FsError(#[from] fs_extra::error::Error),
#[error("No novel with URL \"{0}\"was found.")]
NoSuchNovel(Url),
#[error("{0}")]
ParseError(String),
#[error("Novel at url {0} is already in our watchlist")]
NovelAlreadyPresent(Url),
}
#[derive(Serialize, Deserialize, Debug)]
pub struct LocalLibrary {
#[serde(skip)]
config_path: PathBuf,
library_base_path: PathBuf,
novels: Vec<Novel>,
}
impl Default for LocalLibrary {
fn default() -> Self {
let dirs = BaseDirs::new().unwrap();
Self {
config_path: dirs.config_dir().join(env!("CARGO_PKG_NAME")).to_path_buf(),
library_base_path: dirs.data_dir().join(env!("CARGO_PKG_NAME")),
novels: Vec::new(),
}
}
}
impl LocalLibrary {
pub fn load(config_path: impl Into<PathBuf>) -> Result<Self, LibraryError> {
let config_path = config_path.into();
if !config_path.exists() {
info!("Could not find a configuration file, creating one with default values.");
return Ok(Self {
config_path,
..Default::default()
});
}
let mut config_file = File::open(&config_path)?;
let mut config_str = String::new();
config_file.read_to_string(&mut config_str)?;
let mut config: Self = toml::from_str(&config_str)?;
config.config_path = config_path;
Ok(config)
}
pub fn set_base_path(
&mut self,
base_path: impl Into<PathBuf> + AsRef<Path>,
) -> Result<(), LibraryError> {
if self.library_base_path.is_dir() {
create_dir_all(&base_path)?;
move_dir(
&self.library_base_path,
&base_path,
&CopyOptions {
overwrite: true,
..CopyOptions::default()
},
)?;
}
self.library_base_path = base_path.into();
for novel in self.novels.iter_mut() {
novel.path = self
.library_base_path
.join(novel.backend.immutable_identifier()?);
}
self.persist()?;
Ok(())
}
pub fn persist(&self) -> Result<(), LibraryError> {
info!("Saving file to disk at path {}", self.config_path.display());
let toml = toml::to_string(self)?;
create_dir_all(self.config_path.parent().unwrap())?;
let mut file = File::create(&self.config_path)?;
file.write_all(toml.as_bytes())?;
Ok(())
}
pub fn base_path(&self) -> &Path {
self.library_base_path.as_path()
}
pub fn add(&mut self, url: &str) -> Result<String, LibraryError> {
let url = url.parse::<Url>()?;
if self.novels.iter().any(|n| n.url == url) {
return Err(LibraryError::NovelAlreadyPresent(url));
}
let novel = Novel::new(url, self.base_path())?;
let novel_title = novel.title()?;
self.novels.push(novel);
self.persist()?;
Ok(novel_title)
}
pub fn list(&self) -> Vec<Url> {
self.novels.iter().map(|novel| novel.url.clone()).collect()
}
pub fn update(&mut self) -> Result<(), Vec<LibraryError>> {
let mut errors = Vec::new();
for novel in self.novels.iter_mut() {
match novel.update() {
Ok(()) => {}
Err(e) => {
errors.push(e);
}
}
}
if !errors.is_empty() {
return Err(errors);
}
Ok(())
}
pub fn remove(&mut self, url: &str) -> Result<(), LibraryError> {
let url = Url::parse(url)?;
self.novels.retain(|novel| {
if novel.url == url {
let path = novel.novel_path();
if let Err(e) = fs::remove_dir_all(path) {
warn!("Failed to remove directory {}: {}", path.display(), e);
}
return false;
}
true
});
Ok(())
}
pub fn novels(&self) -> &Vec<Novel> {
&self.novels
}
pub fn novels_mut(&mut self) -> &mut Vec<Novel> {
&mut self.novels
}
}
#[derive(Serialize, Deserialize)]
#[serde(try_from = "NovelConfig", into = "NovelConfig")]
pub struct Novel {
url: Url,
path: PathBuf,
backend: Backends,
chapters: Vec<Chapter>,
}
impl Debug for Novel {
#[allow(dead_code)]
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
#[derive(Debug)]
struct Novel<'a> {
url: String,
path: &'a PathBuf,
backend: &'a Backends,
chapters: &'a Vec<Chapter>,
}
let Self {
url,
path,
backend,
chapters,
} = self;
Debug::fmt(
&Novel {
url: url.to_string(),
path,
backend,
chapters,
},
f,
)
}
}
impl Clone for Novel {
fn clone(&self) -> Self {
Self {
url: self.url.clone(),
path: self.path.clone(),
backend: Backends::new(self.url.as_ref()).unwrap(),
chapters: self.chapters.clone(),
}
}
}
pub struct NovelChapterUpdateIter<'a> {
novel: &'a mut Novel,
missing_chapters: Vec<usize>,
current_chapter_index: usize,
}
impl Debug for NovelChapterUpdateIter<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"NovelChapterUpdateIter {}/{}:\n Chapter_Index={},\n Novel={},\n base_dir={}",
self.current_chapter_index,
self.missing_chapters.len(),
if self.current_chapter_index >= self.missing_chapters.len() {
"Out of bounds (may be normal if end of iter)".to_string()
} else {
self.missing_chapters[self.current_chapter_index].to_string()
},
self.novel.url,
self.novel.path.display(),
)
}
}
impl<'a> Iterator for NovelChapterUpdateIter<'a> {
type Item = Result<String, LibraryError>;
fn next(&mut self) -> Option<Self::Item> {
trace!("{:?}", self);
if self.current_chapter_index >= self.missing_chapters.len() {
return None;
}
let r = match self
.novel
.backend
.get_chapter(self.missing_chapters[self.current_chapter_index])
{
Ok(v) => match Novel::persist_chapter(&self.novel.path, &v) {
Ok(_) => {
let title = v.title().clone().unwrap();
self.novel.chapters.push(v);
Some(Ok(title))
}
Err(e) => Some(Err(e)),
},
Err(e) => Some(Err(LibraryError::from(e))),
};
self.current_chapter_index += 1;
r
}
}
impl<'a> ExactSizeIterator for NovelChapterUpdateIter<'a> {
fn len(&self) -> usize {
self.missing_chapters.len()
}
}
impl Novel {
pub fn new(url: impl Into<Url>, library_path: &Path) -> Result<Self, LibraryError> {
let url = url.into();
let mut novel = Self {
url: url.clone(),
path: Default::default(),
backend: Backends::new(url.as_ref())?,
chapters: vec![],
};
novel.path = library_path.join(novel.backend.immutable_identifier()?);
Ok(novel)
}
pub fn url(&self) -> &Url {
&self.url
}
pub fn title(&self) -> Result<String, LibraryError> {
Ok(self.backend.title()?)
}
pub fn get_chapter_storage_path(novel_path: &Path) -> PathBuf {
novel_path.join("chapters")
}
pub fn get_cover_storage_path(novel_path: &Path) -> PathBuf {
novel_path.join("cover.png")
}
pub fn chapter_storage_path(&self) -> PathBuf {
Self::get_chapter_storage_path(&self.path)
}
pub fn cover_storage_path(&self) -> PathBuf {
Self::get_cover_storage_path(&self.path)
}
fn persist_chapter(novel_path: &Path, chapter: &Chapter) -> Result<(), LibraryError> {
let path = Self::get_chapter_storage_path(novel_path);
if !path.exists() {
create_dir_all(&path)?;
}
let chapter_file_name = match chapter.title() {
None => {
format!("{}.html", chapter.index())
}
Some(title) => {
format!("{}-{}.html", chapter.index(), title)
}
};
let chapter_path = path.join(chapter_file_name);
let mut file = File::create(chapter_path)?;
file.write_all(chapter.to_string().as_bytes())?;
Ok(())
}
pub fn download_cover(&self) -> Result<(), LibraryError> {
let cover_path = self.path.join("cover.png");
if !cover_path.is_file() {
if !self.path.exists() {
create_dir_all(&self.path)?;
} else if cover_path.is_dir() {
warn!(
"{} is a directory! we will delete it in order to create the cover file",
cover_path.display()
);
fs::remove_dir_all(&cover_path)?;
}
let cover_data = self.backend.cover()?;
let data = match image::guess_format(&cover_data) {
Ok(format) => match format {
ImageFormat::Png => cover_data,
iformat => {
info!(
"Cover image is in {}. Converting to png.",
iformat.extensions_str()[0]
);
let cursor = Cursor::new(cover_data);
let img = image::load(cursor, iformat)?;
let mut buffer = Vec::new();
img.write_to(&mut Cursor::new(&mut buffer), ImageFormat::Png)?;
buffer
}
},
Err(e) => {
warn!("Error trying to guess image: {}", e);
cover_data
}
};
let mut f = File::create(cover_path)?;
f.write_all(&data)?;
}
Ok(())
}
pub fn update(&mut self) -> Result<(), LibraryError> {
self.load_local_chapters()?;
let _errors = self
.update_iter()
.filter(Result::is_err)
.map(|r| {
let err = r.unwrap_err();
warn!("{}", err);
err
})
.collect::<Vec<_>>();
self.consolidate_chapter_collection();
for chapter in &self.chapters {
Self::persist_chapter(&self.path, chapter)?;
}
Ok(())
}
pub fn consolidate_chapter_collection(&mut self) {
self.chapters.sort_by(self.backend.get_ordering_function());
self.chapters.dedup();
for (i, chapter) in self.chapters.iter_mut().enumerate() {
let chapter_index = i + 1;
if *chapter.index() != chapter_index {
warn!("There could be a conflict in chapter {}: index was expected to be {} but was {}. Setting chapter index to expectation",
chapter.title().clone().unwrap_or("<title_not_found>".to_string()),
chapter_index,
chapter.index()
);
chapter.set_index(chapter_index);
}
}
}
pub fn update_iter(&mut self) -> NovelChapterUpdateIter {
let missing_chapters = self.get_missing_chapters_indexes().unwrap();
debug!("Missing chapters: {:?}", missing_chapters);
NovelChapterUpdateIter {
novel: self,
missing_chapters,
current_chapter_index: 0,
}
}
pub fn get_missing_chapters_indexes(&self) -> Result<Vec<usize>, LibraryError> {
let local_chapters_tuples: Vec<(usize, String)> = self
.get_local_chapters()?
.iter()
.map(|c| c.try_into().unwrap())
.collect();
debug!("local chapters: {:?}", local_chapters_tuples);
let available_chapters = self.backend.get_chapter_list()?;
debug!("available chapters: {:?}", available_chapters);
Ok(available_chapters
.iter()
.filter(|c| {
for lc in &local_chapters_tuples {
if c.0 == lc.0 {
if c.1 == lc.1 {
return false;
}
warn!("distant chapter {:?} and local chapter {:?} have the same index but different titles! I won't download them.", c, lc);
return false;
}
}
debug!("Will download chapter {}: {}", c.0, c.1);
true
})
.map(|c| c.0)
.collect::<Vec<_>>())
}
pub fn get_local_chapter_count(&self) -> Result<usize, LibraryError> {
let path = self.chapter_storage_path();
if !path.exists() {
return Ok(0);
}
Ok(fs::read_dir(path)?.count())
}
pub fn get_remote_chapter_count(&self) -> Result<usize, LibraryError> {
Ok(self.backend.get_chapter_count()?)
}
pub fn load_local_chapters(&mut self) -> Result<(), LibraryError> {
self.chapters = self.get_local_chapters()?;
Ok(())
}
pub fn get_local_chapters(&self) -> Result<Vec<Chapter>, LibraryError> {
let path = self.chapter_storage_path();
if !path.exists() {
debug!(
"path {} doesn't exist, returning empty local chapter list",
path.display()
);
return Ok(Vec::new());
}
let mut chapters: Vec<Chapter> = Vec::new();
trace!("Reading directory {} contents", path.display());
let chapter_files = fs::read_dir(&path)?;
for chapter_file in chapter_files {
let chapter_file = chapter_file?;
let chapter_path = chapter_file.path();
trace!("looking at file {}", chapter_path.display());
let mut file = File::open(&chapter_path)?;
let mut content = String::new();
file.read_to_string(&mut content)?;
trace!("Parsing chapter from file {}", chapter_path.display());
let chapter = content.parse()?;
chapters.push(chapter);
}
Ok(chapters)
}
pub fn novel_path(&self) -> &Path {
&self.path
}
#[cfg(feature = "epub")]
pub fn generate_epub(&self) -> Result<PathBuf, EpubGenerationError> {
const TITLE_PAGE_HTML: &str = r#"
<html>
<head>
<style>
</style>
</head>
<body style="width: 30em; margin: auto; text-align: center;">
<h1>FICTION_TITLE</h1>
<p><img src="/cover.png"/></p>
</body>
</html>
"#;
let mut builder =
EpubBuilder::new(ZipLibrary::new()?).map_err(|e| EpubGenerationError::EpubError {
msg: "Could not create new builder".to_string(),
cause: e,
})?;
builder
.metadata("title", self.title()?)
.map_err(|e| EpubGenerationError::EpubError {
msg: "Could not set title".to_string(),
cause: e,
})?
.metadata("author", self.backend.get_authors()?.join(", "))
.map_err(|e| EpubGenerationError::EpubError {
msg: "could not set author metadata".to_string(),
cause: e,
})?
.epub_version(epub_builder::EpubVersion::V30);
builder.add_content(
epub_builder::EpubContent::new(
"cover.xhtml",
TITLE_PAGE_HTML
.replace("FICTION_TITLE", &self.title().unwrap())
.as_bytes(),
)
.reftype(epub_builder::ReferenceType::Cover),
)?;
let cover_path = self.cover_storage_path();
if !cover_path.is_file() {
self.download_cover()?;
}
let mut f = File::open(cover_path)?;
let mut data = Vec::new();
f.read_to_end(&mut data)?;
builder.add_cover_image("cover.png", data.as_bytes(), "image/png")?;
drop(data);
for chapter in &self.chapters {
let title = chapter.title().clone().unwrap();
builder.add_content(
epub_builder::EpubContent::new(
format!("{}.xhtml", title),
chapter.content().as_bytes(),
)
.title(format!("ch{}: {}", chapter.index(), title))
.reftype(epub_builder::ReferenceType::Text),
)?;
}
builder.inline_toc();
let epub_path = self.novel_path().join(format!("{}.epub3", self.title()?));
let mut f = File::create(&epub_path)?;
builder.generate(&mut f)?;
Ok(epub_path)
}
}
#[cfg(feature = "epub")]
#[derive(Error, Debug)]
pub enum EpubGenerationError {
#[error(transparent)]
LibraryError(#[from] LibraryError),
#[error("{msg}: {cause:?}")]
EpubError {
msg: String,
cause: epub_builder::Error,
},
#[error("{msg}: {cause:?}")]
IoError {
msg: String,
cause: std::io::Error,
},
}
#[cfg(feature = "epub")]
impl From<epub_builder::Error> for EpubGenerationError {
fn from(value: Error) -> Self {
Self::EpubError {
msg: String::new(),
cause: value,
}
}
}
#[cfg(feature = "epub")]
impl From<BackendError> for EpubGenerationError {
fn from(value: BackendError) -> Self {
EpubGenerationError::LibraryError(LibraryError::BackendError(value))
}
}
#[cfg(feature = "epub")]
impl From<std::io::Error> for EpubGenerationError {
fn from(value: std::io::Error) -> Self {
Self::IoError {
msg: value.to_string(),
cause: value,
}
}
}
impl TryFrom<Url> for Novel {
type Error = LibraryError;
fn try_from(value: Url) -> Result<Self, Self::Error> {
Self::try_from(NovelConfig {
url: value,
path: Default::default(),
})
}
}
impl TryFrom<NovelConfig> for Novel {
type Error = LibraryError;
fn try_from(value: NovelConfig) -> Result<Self, Self::Error> {
let novel = Self {
url: value.url.clone(),
path: value.path.clone(),
backend: Backends::new(value.url.as_str())?,
chapters: vec![],
};
Ok(novel)
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct NovelConfig {
url: Url,
path: PathBuf,
}
impl From<Novel> for NovelConfig {
fn from(value: Novel) -> Self {
Self {
url: value.url,
path: value.path,
}
}
}