libwebnovel_storage/
lib.rs

1#![deny(
2    rustdoc::broken_intra_doc_links,
3    rustdoc::private_intra_doc_links,
4    rustdoc::bare_urls,
5    missing_docs
6)]
7#![warn(rustdoc::unescaped_backticks)]
8//! [![Crates.io Version](https://img.shields.io/crates/v/libwebnovel-storage)](https://crates.io/crates/libwebnovel-storage)
9//! [![docs.rs](https://img.shields.io/docsrs/libwebnovel-storage)](https://docs.rs/libwebnovel-storage)
10//!
11//! This is an implementation of a local repository of webnovels. It downloads
12//! webnovels & places them in a coherent manner on the filesystem. It is also
13//! capable of generating ebooks from these downloaded webnovels.
14//!
15//! ## What this does
16//!
17//! Basically, it provides data structures and method easing the work of
18//! implementing a program using [`libwebnovel`](https://crates.io/crates/libwebnovel). Said program would only have to make the "glue" between UI and this library's functions.
19//!
20//! ## Example
21//!
22//! ```rust,no_run
23//! use libwebnovel_storage::{LibraryError, LocalLibrary};
24//! fn main() -> Result<(), LibraryError> {
25//!     let library_path = ".config/my_library/config.toml";
26//!     let mut library = LocalLibrary::load(library_path)?;
27//!     // Add to watchlist & download
28//!     library.add("https://www.royalroad.com/fiction/21220/mother-of-learning")?;
29//!
30//!     // update all novels
31//!     let errors = library.update();
32//!     // or, if you want to have more control over the update process
33//!     // (for instance, printing a progress bar):
34//!     for novel in library.novels_mut() {
35//!         let novel_title = novel.title()?.clone();
36//!         for (i, result) in novel.update_iter().enumerate() {
37//!             if result.is_err() {
38//!                 eprintln!(
39//!                     "Encountered an error while updating novel {}: {}",
40//!                     novel_title,
41//!                     result.unwrap_err()
42//!                 );
43//!             }
44//!             println!("novel {}: updated chapter {}", novel_title, i + 1);
45//!         }
46//!         #[cfg(feature = "epub")]
47//!         println!(
48//!             "Generated ebook path: {}",
49//!             novel.generate_epub().unwrap().display()
50//!         );
51//!     }
52//!
53//!     Ok(())
54//! }
55//! ```
56//!
57//! # `cargo` features
58//!
59//! By default, the `epub` feature is active. If you do not wish to use this
60//! feature, use the following in your `Cargo.toml`:
61//!
62//! ```toml
63//! # Cargo.toml
64//! [dependencies.libwebnovel-storage]
65//! version = '0'
66//! default-features = false
67//! ```
68//!
69//! # TODO
70//!
71//! - [x] a local filesystem representation for a novel library
72//! - [x] bulk updates
73//!   - [x] bulk updates with an iterator, to offer control over looping and get
74//!     update information while they happen
75//! - [x] add epub generation
76//!
77//! ## Legal
78//!
79//! Without explicit refutation in the header of any file in this repository,
80//! all files in this repository are considered under the terms of the AGPL-3
81//! license (of which a copy can be found in the LICENSE file at the root of
82//! this repository) and bearing the mention "Copyright (c) 2024 paulollivier &
83//! contributors".
84//!
85//! Basically, please do not use this code without crediting its writer(s) or
86//! for a commercial project.
87
88use std::fmt::{format, Debug, Formatter};
89use std::fs;
90use std::fs::{create_dir_all, File};
91use std::io::{Cursor, Read, Write};
92use std::path::{Path, PathBuf};
93
94use directories::BaseDirs;
95#[cfg(feature = "epub")]
96use epub_builder::{EpubBuilder, Error, ZipLibrary};
97use fs_extra::dir::{move_dir, CopyOptions};
98#[cfg(feature = "epub")]
99use image::EncodableLayout;
100use image::{ImageError, ImageFormat};
101use libwebnovel::backends::BackendError;
102use libwebnovel::{Backend, Backends, Chapter, ChapterParseError};
103use log::{debug, error, info, trace, warn};
104use serde::{Deserialize, Serialize};
105use thiserror::Error;
106use url::Url;
107
108/// Represents an error that can happen during Library operations
109#[derive(Error, Debug)]
110pub enum LibraryError {
111    /// Wraps a [`std::io::Error`]
112    #[error(transparent)]
113    IoError(#[from] std::io::Error),
114    /// Wraps a [`toml::de::Error`]
115    #[error(transparent)]
116    TomlDeserializationError(#[from] toml::de::Error),
117    /// Wraps a [`toml::ser::Error`]
118    #[error(transparent)]
119    TomlSerializationError(#[from] toml::ser::Error),
120    /// Wraps an [`url::ParseError`]
121    #[error(transparent)]
122    UrlParseError(#[from] url::ParseError),
123    /// Wraps a [`ChapterParseError`]
124    #[error(transparent)]
125    ChapterParseError(#[from] ChapterParseError),
126    /// Wraps a [`BackendError`]
127    #[error(transparent)]
128    BackendError(#[from] BackendError),
129    /// Wraps a [`ImageError`]
130    #[error(transparent)]
131    ImageError(#[from] ImageError),
132    /// Needed for accepting convert error with ?.
133    #[error(transparent)]
134    Infallible(#[from] std::convert::Infallible),
135    /// Wraps an error from fs_extra
136    #[error(transparent)]
137    FsError(#[from] fs_extra::error::Error),
138    /// Returned when attempting to delete a fiction
139    #[error("No novel with URL \"{0}\"was found.")]
140    NoSuchNovel(Url),
141    /// A generic error encountered when parsing something
142    #[error("{0}")]
143    ParseError(String),
144    /// Returned when trying to add a novel that is already watched
145    #[error("Novel at url {0} is already in our watchlist")]
146    NovelAlreadyPresent(Url),
147}
148
149/// A local disk storage.
150/// ```rust
151/// use tempfile::tempdir;
152/// use libwebnovel_storage::LocalLibrary;
153///
154/// // Dummy path to a config file. If the path does not exist, a default configuration will be created
155/// let config_path = "path/to/toml_config";
156/// # let tempdir = tempdir().unwrap();
157/// # let config_path = tempdir.path().join(env!("CARGO_PKG_NAME"));
158///
159/// let mut library = LocalLibrary::load(config_path).unwrap();
160/// // Add & download a given URL
161/// library
162///     .add("https://www.royalroad.com/fiction/21220/mother-of-learning")
163///     .unwrap();
164/// let novel_urls_list = library.list();
165/// assert_eq!(novel_urls_list.len(), 1);
166/// assert_eq!(
167///     &novel_urls_list[0].to_string(),
168///     "https://www.royalroad.com/fiction/21220/mother-of-learning"
169/// );
170/// ```
171#[derive(Serialize, Deserialize, Debug)]
172pub struct LocalLibrary {
173    #[serde(skip)]
174    config_path: PathBuf,
175    library_base_path: PathBuf,
176    novels: Vec<Novel>,
177}
178
179impl Default for LocalLibrary {
180    fn default() -> Self {
181        let dirs = BaseDirs::new().unwrap();
182        Self {
183            config_path: dirs.config_dir().join(env!("CARGO_PKG_NAME")).to_path_buf(),
184            library_base_path: dirs.data_dir().join(env!("CARGO_PKG_NAME")),
185            novels: Vec::new(),
186        }
187    }
188}
189
190impl LocalLibrary {
191    /// Attempts to create a new [`Self`] from a config file.
192    /// ```rust
193    /// use directories::BaseDirs;
194    /// use libwebnovel_storage::LocalLibrary;
195    ///
196    /// let config_path = "path/to/config.toml";
197    /// let library = LocalLibrary::load(config_path).unwrap(); // If the path doesn't exist, a
198    ///                                                         // default configuration will be
199    ///                                                         // loaded
200    /// // Get the default data directory on this platform
201    /// let basedirs = BaseDirs::new().unwrap();
202    /// let data_dir = basedirs.data_dir().join(env!["CARGO_PKG_NAME"]);
203    /// assert_eq!(library.base_path(), data_dir);
204    /// ```
205    pub fn load(config_path: impl Into<PathBuf>) -> Result<Self, LibraryError> {
206        let config_path = config_path.into();
207        if !config_path.exists() {
208            info!("Could not find a configuration file, creating one with default values.");
209            return Ok(Self {
210                config_path,
211                ..Default::default()
212            });
213        }
214        let mut config_file = File::open(&config_path)?;
215        let mut config_str = String::new();
216        config_file.read_to_string(&mut config_str)?;
217        let mut config: Self = toml::from_str(&config_str)?;
218        config.config_path = config_path;
219        Ok(config)
220    }
221
222    /// Changes the library base data path, moving existing content if
223    /// necessary.
224    pub fn set_base_path(
225        &mut self,
226        base_path: impl Into<PathBuf> + AsRef<Path>,
227    ) -> Result<(), LibraryError> {
228        if self.library_base_path.is_dir() {
229            // Then we need to move the content of the directory to the new one given in
230            // parameter
231            create_dir_all(&base_path)?;
232            move_dir(
233                &self.library_base_path,
234                &base_path,
235                &CopyOptions {
236                    overwrite: true,
237                    ..CopyOptions::default()
238                },
239            )?;
240        }
241        self.library_base_path = base_path.into();
242        for novel in self.novels.iter_mut() {
243            novel.path = self
244                .library_base_path
245                .join(novel.backend.immutable_identifier()?);
246        }
247        self.persist()?;
248        Ok(())
249    }
250
251    /// Saves the current config to disk.
252    /// ```rust
253    /// use std::path::Path;
254    ///
255    /// use libwebnovel_storage::LocalLibrary;
256    ///
257    /// let config_path_str = "/tmp/libwebnovel/config.toml";
258    /// let config_path = Path::new(config_path_str);
259    /// # use tempfile::tempdir;
260    /// # let tempdir = tempdir().unwrap();
261    /// # let config_path_str = tempdir.path().join(env!("CARGO_PKG_NAME")).join("config.toml");
262    /// let library = LocalLibrary::load(config_path_str.clone()).unwrap();
263    ///
264    /// let config_path = Path::new(&config_path_str);
265    /// assert!(!config_path.exists());
266    /// library.persist().unwrap();
267    /// # println!("config_path: {:?}", config_path.display());
268    /// # println!("library: {:?}", library);
269    /// assert!(config_path.exists());
270    /// ```
271    pub fn persist(&self) -> Result<(), LibraryError> {
272        info!("Saving file to disk at path {}", self.config_path.display());
273        let toml = toml::to_string(self)?;
274        create_dir_all(self.config_path.parent().unwrap())?;
275        let mut file = File::create(&self.config_path)?;
276        file.write_all(toml.as_bytes())?;
277        Ok(())
278    }
279
280    /// Returns the base path of the library storage
281    pub fn base_path(&self) -> &Path {
282        self.library_base_path.as_path()
283    }
284
285    /// Adds a new webnovel to watch. Won't call [`self.update()`]. Returns the
286    /// title of the novel.
287    pub fn add(&mut self, url: &str) -> Result<String, LibraryError> {
288        let url = url.parse::<Url>()?;
289        if self.novels.iter().any(|n| n.url == url) {
290            return Err(LibraryError::NovelAlreadyPresent(url));
291        }
292        let novel = Novel::new(url, self.base_path())?;
293        let novel_title = novel.title()?;
294        self.novels.push(novel);
295        self.persist()?;
296        Ok(novel_title)
297    }
298
299    /// Returns a list of webnovels currently watched
300    pub fn list(&self) -> Vec<Url> {
301        self.novels.iter().map(|novel| novel.url.clone()).collect()
302    }
303
304    /// Updates all watched novels. If at least one error has been encountered
305    /// during update.
306    pub fn update(&mut self) -> Result<(), Vec<LibraryError>> {
307        let mut errors = Vec::new();
308        for novel in self.novels.iter_mut() {
309            match novel.update() {
310                Ok(()) => {}
311                Err(e) => {
312                    errors.push(e);
313                }
314            }
315        }
316        if !errors.is_empty() {
317            return Err(errors);
318        }
319        Ok(())
320    }
321
322    /// Removes a webnovel from the watchlist and deletes local content. If
323    /// there are duplicates in the novels list, only the first found will be
324    /// removed and deleted.
325    pub fn remove(&mut self, url: &str) -> Result<(), LibraryError> {
326        let url = Url::parse(url)?;
327        self.novels.retain(|novel| {
328            if novel.url == url {
329                let path = novel.novel_path();
330                if let Err(e) = fs::remove_dir_all(path) {
331                    warn!("Failed to remove directory {}: {}", path.display(), e);
332                }
333                return false;
334            }
335            true
336        });
337        Ok(())
338    }
339
340    /// Returns a reference to the internal novels vector.
341    pub fn novels(&self) -> &Vec<Novel> {
342        &self.novels
343    }
344
345    /// Returns a mutable reference to the internal novels vector
346    pub fn novels_mut(&mut self) -> &mut Vec<Novel> {
347        &mut self.novels
348    }
349}
350
351/// Represents a novel.
352/// Stored on disk at the following path: <base_library_path>/<novel.title()>/
353///
354/// TODO: Detect remote novel title changes
355#[derive(Serialize, Deserialize)]
356#[serde(try_from = "NovelConfig", into = "NovelConfig")]
357pub struct Novel {
358    url: Url,
359    path: PathBuf,
360    backend: Backends,
361    chapters: Vec<Chapter>,
362}
363
364impl Debug for Novel {
365    #[allow(dead_code)]
366    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
367        #[derive(Debug)]
368        struct Novel<'a> {
369            url: String,
370            path: &'a PathBuf,
371            backend: &'a Backends,
372            chapters: &'a Vec<Chapter>,
373        }
374        let Self {
375            url,
376            path,
377            backend,
378            chapters,
379        } = self;
380        Debug::fmt(
381            &Novel {
382                url: url.to_string(),
383                path,
384                backend,
385                chapters,
386            },
387            f,
388        )
389    }
390}
391
392impl Clone for Novel {
393    fn clone(&self) -> Self {
394        Self {
395            url: self.url.clone(),
396            path: self.path.clone(),
397            backend: Backends::new(self.url.as_ref()).unwrap(),
398            chapters: self.chapters.clone(),
399        }
400    }
401}
402
403/// An iterator over the update operation of a chapter. Used to be able to
404/// monitor progress from your code.
405///
406/// ```rust
407/// # use std::thread::sleep;
408/// use std::time::Duration;
409///
410/// use libwebnovel_storage::{LibraryError, Novel};
411/// use tempfile::tempdir;
412/// use url::Url;
413/// let path = "random/path";
414/// # let dir = tempdir().unwrap();
415/// # let path = dir.path();
416/// let mut novel = Novel::new(
417///     "https://www.royalroad.com/fiction/21220/mother-of-learning"
418///         .parse::<Url>()
419///         .unwrap(),
420///     path,
421/// )
422/// .unwrap();
423/// for (i, chapter_result) in novel.update_iter().enumerate() {
424///     sleep(Duration::from_micros(500)); // throttleing requests
425///     match chapter_result {
426///         Ok(_chapter_title) => {
427///             println!(":) Chapter update succeded!")
428///         }
429///         Err(e) => {
430///             println!(":'( chapter update failed: {e}")
431///         }
432///     }
433/// }
434/// assert_eq!(novel.get_local_chapter_count().unwrap(), 109);
435/// ```
436pub struct NovelChapterUpdateIter<'a> {
437    novel: &'a mut Novel,
438    missing_chapters: Vec<usize>,
439    current_chapter_index: usize,
440}
441impl Debug for NovelChapterUpdateIter<'_> {
442    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
443        write!(
444            f,
445            "NovelChapterUpdateIter {}/{}:\n  Chapter_Index={},\n  Novel={},\n  base_dir={}",
446            self.current_chapter_index,
447            self.missing_chapters.len(),
448            if self.current_chapter_index >= self.missing_chapters.len() {
449                "Out of bounds (may be normal if end of iter)".to_string()
450            } else {
451                self.missing_chapters[self.current_chapter_index].to_string()
452            },
453            self.novel.url,
454            self.novel.path.display(),
455        )
456    }
457}
458
459impl<'a> Iterator for NovelChapterUpdateIter<'a> {
460    type Item = Result<String, LibraryError>;
461
462    fn next(&mut self) -> Option<Self::Item> {
463        trace!("{:?}", self);
464        if self.current_chapter_index >= self.missing_chapters.len() {
465            return None;
466        }
467        let r = match self
468            .novel
469            .backend
470            .get_chapter(self.missing_chapters[self.current_chapter_index])
471        {
472            Ok(v) => match Novel::persist_chapter(&self.novel.path, &v) {
473                Ok(_) => {
474                    let title = v.title().clone().unwrap();
475                    self.novel.chapters.push(v);
476                    Some(Ok(title))
477                }
478                Err(e) => Some(Err(e)),
479            },
480            Err(e) => Some(Err(LibraryError::from(e))),
481        };
482        self.current_chapter_index += 1;
483        r
484    }
485}
486
487impl<'a> ExactSizeIterator for NovelChapterUpdateIter<'a> {
488    fn len(&self) -> usize {
489        self.missing_chapters.len()
490    }
491}
492
493impl Novel {
494    /// Returns a new Novel from the given URL. Functionally equivalent to
495    /// [`Novel::try_from(Url)`].
496    pub fn new(url: impl Into<Url>, library_path: &Path) -> Result<Self, LibraryError> {
497        let url = url.into();
498        let mut novel = Self {
499            url: url.clone(),
500            path: Default::default(),
501            backend: Backends::new(url.as_ref())?,
502            chapters: vec![],
503        };
504        novel.path = library_path.join(novel.backend.immutable_identifier()?);
505        Ok(novel)
506    }
507
508    /// Returns the url of the novel
509    pub fn url(&self) -> &Url {
510        &self.url
511    }
512
513    /// Returns the title of the novel
514    pub fn title(&self) -> Result<String, LibraryError> {
515        Ok(self.backend.title()?)
516    }
517
518    /// returns where all the chapters are stored. static alternative to
519    /// [`self.chapter_storage_path()`].
520    pub fn get_chapter_storage_path(novel_path: &Path) -> PathBuf {
521        novel_path.join("chapters")
522    }
523
524    /// returns where the cover image is stored. static alternative to
525    /// [`self.cover_storage_path`].
526    pub fn get_cover_storage_path(novel_path: &Path) -> PathBuf {
527        novel_path.join("cover.png")
528    }
529
530    /// Returns where all the chapters are stored. Alternative to the static
531    /// [`Self::get_chapter_storage_path`].
532    pub fn chapter_storage_path(&self) -> PathBuf {
533        Self::get_chapter_storage_path(&self.path)
534    }
535
536    /// Returns where the cover image is stored. Alternative to the static
537    /// [`Self::get_cover_storage_path`].
538    pub fn cover_storage_path(&self) -> PathBuf {
539        Self::get_cover_storage_path(&self.path)
540    }
541
542    fn persist_chapter(novel_path: &Path, chapter: &Chapter) -> Result<(), LibraryError> {
543        let path = Self::get_chapter_storage_path(novel_path);
544        if !path.exists() {
545            create_dir_all(&path)?;
546        }
547        let chapter_file_name = match chapter.title() {
548            None => {
549                format!("{}.html", chapter.index())
550            }
551            Some(title) => {
552                format!("{}-{}.html", chapter.index(), title)
553            }
554        };
555        let chapter_path = path.join(chapter_file_name);
556        let mut file = File::create(chapter_path)?;
557        file.write_all(chapter.to_string().as_bytes())?;
558        Ok(())
559    }
560
561    /// Downloads the cover file and places it in [`self.cover_image_path`],
562    /// performing appropriate image format conversions if necessary.
563    pub fn download_cover(&self) -> Result<(), LibraryError> {
564        let cover_path = self.path.join("cover.png");
565        if !cover_path.is_file() {
566            if !self.path.exists() {
567                create_dir_all(&self.path)?;
568            } else if cover_path.is_dir() {
569                warn!(
570                    "{} is a directory! we will delete it in order to create the cover file",
571                    cover_path.display()
572                );
573                // There's no sense it being a directory. let's delete this shit.
574                fs::remove_dir_all(&cover_path)?;
575            }
576            let cover_data = self.backend.cover()?;
577            let data = match image::guess_format(&cover_data) {
578                Ok(format) => match format {
579                    ImageFormat::Png => cover_data,
580                    iformat => {
581                        info!(
582                            "Cover image is in {}. Converting to png.",
583                            iformat.extensions_str()[0]
584                        );
585                        let cursor = Cursor::new(cover_data);
586                        let img = image::load(cursor, iformat)?;
587                        let mut buffer = Vec::new();
588                        img.write_to(&mut Cursor::new(&mut buffer), ImageFormat::Png)?;
589                        buffer
590                    }
591                },
592                Err(e) => {
593                    // Then, attempt to guess from the URL filename?
594                    // for now, let's just return the data.
595                    warn!("Error trying to guess image: {}", e);
596                    cover_data
597                }
598            };
599            let mut f = File::create(cover_path)?;
600            f.write_all(&data)?;
601        }
602        Ok(())
603    }
604
605    /// Fetch remote chapters & saves them locally. Will attempt to fix
606    /// duplicates, ordering, and detect collisions.
607    pub fn update(&mut self) -> Result<(), LibraryError> {
608        self.load_local_chapters()?;
609
610        // TODO: check errors, not only print them. For instance, if the source is a
611        //       http 429 or equivalent, it may be worthwhile to retry.
612        let _errors = self
613            .update_iter()
614            .filter(Result::is_err)
615            .map(|r| {
616                let err = r.unwrap_err();
617                warn!("{}", err);
618                err
619            })
620            .collect::<Vec<_>>();
621        self.consolidate_chapter_collection();
622        // TODO: Check for remaining duplicates/index collisions
623        // Write all chapters to disk
624        for chapter in &self.chapters {
625            Self::persist_chapter(&self.path, chapter)?;
626        }
627        Ok(())
628    }
629
630    /// Sorts, dedups and tries to fix chapter indexes in the current "live"
631    /// chapter list.
632    pub fn consolidate_chapter_collection(&mut self) {
633        // …sort them…
634        self.chapters.sort_by(self.backend.get_ordering_function());
635        // … and remove duplicates. There should not be any.
636        self.chapters.dedup();
637        // update the indexes accordingly, as it may happen when some chapters have been
638        // deleted from the source.
639        for (i, chapter) in self.chapters.iter_mut().enumerate() {
640            // enumerate is 0-indexed so we must add 1 :p
641            let chapter_index = i + 1;
642            if *chapter.index() != chapter_index {
643                warn!("There could be a conflict in chapter {}: index was expected to be {} but was {}. Setting chapter index to expectation",
644                    chapter.title().clone().unwrap_or("<title_not_found>".to_string()),
645                    chapter_index,
646                    chapter.index()
647                );
648                chapter.set_index(chapter_index);
649            }
650        }
651    }
652
653    /// Returns an iterator that will fetch the chapters, returning a Result for
654    /// each operation. See [`NovelChapterUpdateIter::next`] for more info.
655    /// Will not attempt to handle duplicates & other stuff.
656    ///
657    /// # Panics
658    ///
659    /// Panics when:
660    /// - [`self.get_missing_chapters_indexes`] panics
661    pub fn update_iter(&mut self) -> NovelChapterUpdateIter {
662        let missing_chapters = self.get_missing_chapters_indexes().unwrap();
663        debug!("Missing chapters: {:?}", missing_chapters);
664
665        NovelChapterUpdateIter {
666            novel: self,
667            missing_chapters,
668            current_chapter_index: 0,
669        }
670    }
671
672    /// Returns chapter indexes that are present in the source but not locally.
673    pub fn get_missing_chapters_indexes(&self) -> Result<Vec<usize>, LibraryError> {
674        // Get the local chapters
675        let local_chapters_tuples: Vec<(usize, String)> = self
676            .get_local_chapters()?
677            .iter()
678            .map(|c| c.try_into().unwrap())
679            .collect();
680        debug!("local chapters: {:?}", local_chapters_tuples);
681        // then get the list of chapters from the backend
682        let available_chapters = self.backend.get_chapter_list()?;
683        debug!("available chapters: {:?}", available_chapters);
684        // Extract a list of all chapters present on the source but not locally
685        Ok(available_chapters
686            .iter()
687            .filter(|c| {
688                for lc in &local_chapters_tuples {
689                    if c.0 == lc.0 {
690                        // That means we have an index match, let's check the
691                        // title match.
692                        if c.1 == lc.1 {
693                            // they are the same, no need to download this one.
694                            return false;
695                        }
696                        // if we are here, it means we have the same index but
697                        // the title differs. Tomfoolery is afoot.
698                        warn!("distant chapter {:?} and local chapter {:?} have the same index but different titles! I won't download them.", c, lc);
699                        return false;
700                    }
701                }
702                // if they don't have the same index, consider them missing
703                debug!("Will download chapter {}: {}", c.0, c.1);
704                true
705            })
706            .map(|c| c.0)
707            .collect::<Vec<_>>())
708    }
709
710    /// Returns the count of locally-saved chapters
711    pub fn get_local_chapter_count(&self) -> Result<usize, LibraryError> {
712        let path = self.chapter_storage_path();
713        if !path.exists() {
714            return Ok(0);
715        }
716        // count the number of chapters in self.novel_dir()
717        Ok(fs::read_dir(path)?.count())
718    }
719
720    /// returns the total count of remote chapters
721    pub fn get_remote_chapter_count(&self) -> Result<usize, LibraryError> {
722        Ok(self.backend.get_chapter_count()?)
723    }
724
725    /// Loads local chapters and saves them in `self.chapters`. Overrides any
726    /// currently loaded chapters.
727    pub fn load_local_chapters(&mut self) -> Result<(), LibraryError> {
728        self.chapters = self.get_local_chapters()?;
729        Ok(())
730    }
731
732    /// Returns a vector of all local [`Chapter`].
733    pub fn get_local_chapters(&self) -> Result<Vec<Chapter>, LibraryError> {
734        let path = self.chapter_storage_path();
735        if !path.exists() {
736            debug!(
737                "path {} doesn't exist, returning empty local chapter list",
738                path.display()
739            );
740            return Ok(Vec::new());
741        }
742        let mut chapters: Vec<Chapter> = Vec::new();
743        trace!("Reading directory {} contents", path.display());
744        let chapter_files = fs::read_dir(&path)?;
745        for chapter_file in chapter_files {
746            let chapter_file = chapter_file?;
747            let chapter_path = chapter_file.path();
748            trace!("looking at file {}", chapter_path.display());
749            let mut file = File::open(&chapter_path)?;
750            let mut content = String::new();
751            file.read_to_string(&mut content)?;
752            trace!("Parsing chapter from file {}", chapter_path.display());
753            let chapter = content.parse()?;
754            chapters.push(chapter);
755        }
756        Ok(chapters)
757    }
758
759    /// Returns the root path of where this novel stores its files.
760    pub fn novel_path(&self) -> &Path {
761        &self.path
762    }
763
764    /// Generates an epub from this novel and its currently loaded chapters,
765    /// returning the path it has been saved to.
766    #[cfg(feature = "epub")]
767    pub fn generate_epub(&self) -> Result<PathBuf, EpubGenerationError> {
768        const TITLE_PAGE_HTML: &str = r#"
769        <html>
770            <head>
771                <style>
772
773                </style>
774            </head>
775            <body style="width: 30em; margin: auto; text-align: center;">
776                <h1>FICTION_TITLE</h1>
777                <p><img src="/cover.png"/></p>
778            </body>
779        </html>
780        "#;
781        let mut builder =
782            EpubBuilder::new(ZipLibrary::new()?).map_err(|e| EpubGenerationError::EpubError {
783                msg: "Could not create new builder".to_string(),
784                cause: e,
785            })?;
786        builder
787            .metadata("title", self.title()?)
788            .map_err(|e| EpubGenerationError::EpubError {
789                msg: "Could not set title".to_string(),
790                cause: e,
791            })?
792            .metadata("author", self.backend.get_authors()?.join(", "))
793            .map_err(|e| EpubGenerationError::EpubError {
794                msg: "could not set author metadata".to_string(),
795                cause: e,
796            })?
797            .epub_version(epub_builder::EpubVersion::V30);
798        builder.add_content(
799            epub_builder::EpubContent::new(
800                "cover.xhtml",
801                TITLE_PAGE_HTML
802                    .replace("FICTION_TITLE", &self.title().unwrap())
803                    .as_bytes(),
804            )
805            .reftype(epub_builder::ReferenceType::Cover),
806        )?;
807        let cover_path = self.cover_storage_path();
808        if !cover_path.is_file() {
809            self.download_cover()?;
810        }
811        let mut f = File::open(cover_path)?;
812        let mut data = Vec::new();
813        f.read_to_end(&mut data)?;
814        builder.add_cover_image("cover.png", data.as_bytes(), "image/png")?;
815        drop(data);
816        for chapter in &self.chapters {
817            let title = chapter.title().clone().unwrap();
818            builder.add_content(
819                epub_builder::EpubContent::new(
820                    format!("{}.xhtml", title),
821                    chapter.content().as_bytes(),
822                )
823                .title(format!("ch{}: {}", chapter.index(), title))
824                .reftype(epub_builder::ReferenceType::Text),
825            )?;
826        }
827        builder.inline_toc();
828        let epub_path = self.novel_path().join(format!("{}.epub3", self.title()?));
829        let mut f = File::create(&epub_path)?;
830        builder.generate(&mut f)?;
831        Ok(epub_path)
832    }
833}
834
835/// An error on epub generation
836#[cfg(feature = "epub")]
837#[derive(Error, Debug)]
838pub enum EpubGenerationError {
839    /// Returned when an error related to library management occured
840    #[error(transparent)]
841    LibraryError(#[from] LibraryError),
842    /// Returned when an error related to epub generation occured
843    #[error("{msg}: {cause:?}")]
844    EpubError {
845        /// A human message describing the issue
846        msg: String,
847        /// The underlying error
848        cause: epub_builder::Error,
849    },
850    /// Returned when a filesystem-related error occurs
851    #[error("{msg}: {cause:?}")]
852    IoError {
853        /// A human message describing the issue
854        msg: String,
855        /// The underlying error
856        cause: std::io::Error,
857    },
858}
859
860#[cfg(feature = "epub")]
861impl From<epub_builder::Error> for EpubGenerationError {
862    fn from(value: Error) -> Self {
863        Self::EpubError {
864            msg: String::new(),
865            cause: value,
866        }
867    }
868}
869#[cfg(feature = "epub")]
870impl From<BackendError> for EpubGenerationError {
871    fn from(value: BackendError) -> Self {
872        EpubGenerationError::LibraryError(LibraryError::BackendError(value))
873    }
874}
875#[cfg(feature = "epub")]
876impl From<std::io::Error> for EpubGenerationError {
877    fn from(value: std::io::Error) -> Self {
878        Self::IoError {
879            msg: value.to_string(),
880            cause: value,
881        }
882    }
883}
884
885impl TryFrom<Url> for Novel {
886    type Error = LibraryError;
887
888    /// WARNING: returns an uninitialized Novel.path!
889    fn try_from(value: Url) -> Result<Self, Self::Error> {
890        Self::try_from(NovelConfig {
891            url: value,
892            path: Default::default(),
893        })
894    }
895}
896
897impl TryFrom<NovelConfig> for Novel {
898    type Error = LibraryError;
899
900    fn try_from(value: NovelConfig) -> Result<Self, Self::Error> {
901        let novel = Self {
902            url: value.url.clone(),
903            path: value.path.clone(),
904            backend: Backends::new(value.url.as_str())?,
905            chapters: vec![],
906        };
907        Ok(novel)
908    }
909}
910
911/// A type not destined to be used directly, but rather by serde.
912#[derive(Serialize, Deserialize, Clone, Debug)]
913struct NovelConfig {
914    url: Url,
915    path: PathBuf,
916}
917
918impl From<Novel> for NovelConfig {
919    fn from(value: Novel) -> Self {
920        Self {
921            url: value.url,
922            path: value.path,
923        }
924    }
925}