Skip to main content

cadmus_core/
metadata.rs

1use crate::db::types::UnixTimestamp;
2use crate::document::asciify;
3use crate::document::djvu::DjvuOpener;
4use crate::document::epub::EpubDocument;
5use crate::document::html::HtmlDocument;
6use crate::document::pdf::PdfOpener;
7use crate::document::{Document, SimpleTocEntry, TextLocation};
8use crate::geom::Point;
9use crate::helpers::Fp;
10use crate::helpers::datetime_format;
11use chrono::{Local, NaiveDateTime};
12use fxhash::FxHashMap;
13use lazy_static::lazy_static;
14use regex::Regex;
15use serde::{Deserialize, Serialize};
16use std::cmp::Ordering;
17use std::collections::{BTreeMap, BTreeSet};
18use std::ffi::OsStr;
19use std::fmt;
20use std::fs;
21use std::path::{Path, PathBuf};
22use titlecase::titlecase;
23use tracing::{error, warn};
24
25pub const DEFAULT_CONTRAST_EXPONENT: f32 = 1.0;
26pub const DEFAULT_CONTRAST_GRAY: f32 = 224.0;
27
28pub type Metadata = Vec<Info>;
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(default, rename_all = "camelCase")]
32pub struct Info {
33    #[serde(skip_serializing_if = "String::is_empty")]
34    pub title: String,
35    #[serde(skip_serializing_if = "String::is_empty")]
36    pub subtitle: String,
37    #[serde(skip_serializing_if = "String::is_empty")]
38    pub author: String,
39    #[serde(skip_serializing_if = "String::is_empty")]
40    pub year: String,
41    #[serde(skip_serializing_if = "String::is_empty")]
42    pub language: String,
43    #[serde(skip_serializing_if = "String::is_empty")]
44    pub publisher: String,
45    #[serde(skip_serializing_if = "String::is_empty")]
46    pub series: String,
47    #[serde(skip_serializing_if = "String::is_empty")]
48    pub edition: String,
49    #[serde(skip_serializing_if = "String::is_empty")]
50    pub volume: String,
51    #[serde(skip_serializing_if = "String::is_empty")]
52    pub number: String,
53    #[serde(skip_serializing_if = "String::is_empty")]
54    pub identifier: String,
55    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
56    pub categories: BTreeSet<String>,
57    pub file: FileInfo,
58    #[serde(skip_serializing)]
59    pub reader: Option<ReaderInfo>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub reader_info: Option<ReaderInfo>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub toc: Option<Vec<SimpleTocEntry>>,
64    #[serde(with = "datetime_format")]
65    pub added: NaiveDateTime,
66    #[serde(skip)]
67    pub fp: Option<Fp>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(default, rename_all = "camelCase")]
72pub struct FileInfo {
73    pub path: PathBuf,
74    #[serde(skip)]
75    pub absolute_path: PathBuf,
76    pub kind: String,
77    pub size: u64,
78    #[serde(skip)]
79    pub mtime: Option<UnixTimestamp>,
80}
81
82impl Default for FileInfo {
83    fn default() -> Self {
84        FileInfo {
85            path: PathBuf::default(),
86            absolute_path: PathBuf::default(),
87            kind: String::default(),
88            size: u64::default(),
89            mtime: None,
90        }
91    }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(default, rename_all = "camelCase")]
96pub struct Annotation {
97    #[serde(skip_serializing_if = "String::is_empty")]
98    pub note: String,
99    #[serde(skip_serializing_if = "String::is_empty")]
100    pub text: String,
101    pub selection: [TextLocation; 2],
102    #[serde(with = "datetime_format")]
103    pub modified: NaiveDateTime,
104}
105
106impl Default for Annotation {
107    fn default() -> Self {
108        Annotation {
109            note: String::new(),
110            text: String::new(),
111            selection: [TextLocation::Dynamic(0), TextLocation::Dynamic(1)],
112            modified: Local::now().naive_local(),
113        }
114    }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct Margin {
119    pub top: f32,
120    pub right: f32,
121    pub bottom: f32,
122    pub left: f32,
123}
124
125impl Margin {
126    pub fn new(top: f32, right: f32, bottom: f32, left: f32) -> Margin {
127        Margin {
128            top,
129            right,
130            bottom,
131            left,
132        }
133    }
134}
135
136impl Default for Margin {
137    fn default() -> Margin {
138        Margin::new(0.0, 0.0, 0.0, 0.0)
139    }
140}
141
142#[derive(Debug, Copy, Clone, Eq, PartialEq)]
143pub enum PageScheme {
144    Any,
145    EvenOdd,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149#[serde(untagged)]
150pub enum CroppingMargins {
151    Any(Margin),
152    EvenOdd([Margin; 2]),
153}
154
155impl CroppingMargins {
156    pub fn margin(&self, index: usize) -> &Margin {
157        match *self {
158            CroppingMargins::Any(ref margin) => margin,
159            CroppingMargins::EvenOdd(ref pair) => &pair[index % 2],
160        }
161    }
162
163    pub fn margin_mut(&mut self, index: usize) -> &mut Margin {
164        match *self {
165            CroppingMargins::Any(ref mut margin) => margin,
166            CroppingMargins::EvenOdd(ref mut pair) => &mut pair[index % 2],
167        }
168    }
169
170    pub fn apply(&mut self, index: usize, scheme: PageScheme) {
171        let margin = self.margin(index).clone();
172
173        match scheme {
174            PageScheme::Any => *self = CroppingMargins::Any(margin),
175            PageScheme::EvenOdd => *self = CroppingMargins::EvenOdd([margin.clone(), margin]),
176        }
177    }
178
179    pub fn is_split(&self) -> bool {
180        !matches!(*self, CroppingMargins::Any(..))
181    }
182}
183
184#[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq)]
185#[serde(rename_all = "kebab-case")]
186pub enum TextAlign {
187    Justify,
188    Left,
189    Right,
190    Center,
191}
192
193impl TextAlign {
194    pub fn icon_name(&self) -> &str {
195        match self {
196            TextAlign::Justify => "align-justify",
197            TextAlign::Left => "align-left",
198            TextAlign::Right => "align-right",
199            TextAlign::Center => "align-center",
200        }
201    }
202}
203
204impl fmt::Display for TextAlign {
205    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
206        fmt::Debug::fmt(self, f)
207    }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(default, rename_all = "camelCase")]
212pub struct ReaderInfo {
213    #[serde(with = "datetime_format")]
214    pub opened: NaiveDateTime,
215    pub current_page: usize,
216    pub pages_count: usize,
217    pub finished: bool,
218    pub dithered: bool,
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub zoom_mode: Option<ZoomMode>,
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub scroll_mode: Option<ScrollMode>,
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub page_offset: Option<Point>,
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub rotation: Option<i8>,
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub cropping_margins: Option<CroppingMargins>,
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub margin_width: Option<i32>,
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub screen_margin_width: Option<i32>,
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub font_family: Option<String>,
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub font_size: Option<f32>,
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub text_align: Option<TextAlign>,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub line_height: Option<f32>,
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub contrast_exponent: Option<f32>,
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub contrast_gray: Option<f32>,
245    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
246    pub page_names: BTreeMap<usize, String>,
247    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
248    pub bookmarks: BTreeSet<usize>,
249    #[serde(skip_serializing_if = "Vec::is_empty")]
250    pub annotations: Vec<Annotation>,
251}
252
253#[derive(Serialize, Deserialize, Debug, Copy, Clone)]
254pub enum ZoomMode {
255    FitToPage,
256    FitToWidth,
257    Custom(f32),
258}
259
260#[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq)]
261pub enum ScrollMode {
262    Screen,
263    Page,
264}
265
266impl PartialEq for ZoomMode {
267    fn eq(&self, other: &Self) -> bool {
268        match (self, other) {
269            (ZoomMode::FitToPage, ZoomMode::FitToPage) => true,
270            (ZoomMode::FitToWidth, ZoomMode::FitToWidth) => true,
271            (ZoomMode::Custom(z1), ZoomMode::Custom(z2)) => (z1 - z2).abs() < f32::EPSILON,
272            _ => false,
273        }
274    }
275}
276
277impl Eq for ZoomMode {}
278
279impl ReaderInfo {
280    pub fn progress(&self) -> f32 {
281        (self.current_page / self.pages_count) as f32
282    }
283}
284
285impl Default for ReaderInfo {
286    fn default() -> Self {
287        ReaderInfo {
288            opened: Local::now().naive_local(),
289            current_page: 0,
290            pages_count: 1,
291            finished: false,
292            dithered: false,
293            zoom_mode: None,
294            scroll_mode: None,
295            page_offset: None,
296            rotation: None,
297            cropping_margins: None,
298            margin_width: None,
299            screen_margin_width: None,
300            font_family: None,
301            font_size: None,
302            text_align: None,
303            line_height: None,
304            contrast_exponent: None,
305            contrast_gray: None,
306            page_names: BTreeMap::new(),
307            bookmarks: BTreeSet::new(),
308            annotations: Vec::new(),
309        }
310    }
311}
312
313impl Default for Info {
314    fn default() -> Self {
315        Info {
316            title: String::default(),
317            subtitle: String::default(),
318            author: String::default(),
319            year: String::default(),
320            language: String::default(),
321            publisher: String::default(),
322            series: String::default(),
323            edition: String::default(),
324            volume: String::default(),
325            number: String::default(),
326            identifier: String::default(),
327            categories: BTreeSet::new(),
328            file: FileInfo::default(),
329            added: Local::now().naive_local(),
330            reader: None,
331            reader_info: None,
332            toc: None,
333            fp: None,
334        }
335    }
336}
337
338#[derive(Debug, Copy, Clone)]
339pub enum Status {
340    New,
341    Reading(f32),
342    Finished,
343}
344
345#[derive(Debug, Copy, Clone, Eq, PartialEq)]
346pub enum SimpleStatus {
347    New,
348    Reading,
349    Finished,
350}
351
352impl fmt::Display for SimpleStatus {
353    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
354        fmt::Debug::fmt(self, f)
355    }
356}
357
358impl Info {
359    pub fn status(&self) -> Status {
360        if let Some(ref r) = self.reader {
361            if r.finished {
362                Status::Finished
363            } else {
364                Status::Reading(r.current_page as f32 / r.pages_count as f32)
365            }
366        } else {
367            Status::New
368        }
369    }
370
371    pub fn simple_status(&self) -> SimpleStatus {
372        if let Some(ref r) = self.reader {
373            if r.finished {
374                SimpleStatus::Finished
375            } else {
376                SimpleStatus::Reading
377            }
378        } else {
379            SimpleStatus::New
380        }
381    }
382
383    pub fn file_stem(&self) -> String {
384        self.file
385            .path
386            .file_stem()
387            .unwrap()
388            .to_string_lossy()
389            .into_owned()
390    }
391
392    pub fn title(&self) -> String {
393        if self.title.is_empty() {
394            return self.file_stem();
395        }
396
397        let mut title = self.title.clone();
398
399        if !self.number.is_empty() && self.series.is_empty() {
400            title = format!("{} #{}", title, self.number);
401        }
402
403        if !self.volume.is_empty() {
404            title = format!("{} — vol. {}", title, self.volume);
405        }
406
407        if !self.subtitle.is_empty() {
408            title = if self.subtitle.chars().next().unwrap().is_alphanumeric()
409                && title.chars().last().unwrap().is_alphanumeric()
410            {
411                format!("{}: {}", title, self.subtitle)
412            } else {
413                format!("{} {}", title, self.subtitle)
414            };
415        }
416
417        if !self.series.is_empty() && !self.number.is_empty() {
418            title = format!("{} ({} #{})", title, self.series, self.number);
419        }
420
421        title
422    }
423
424    // TODO: handle the following case: *Walter M. Miller Jr.*?
425    // NOTE: e.g.: John Le Carré: the space between *Le* and *Carré*
426    // is a non-breaking space
427    pub fn alphabetic_author(&self) -> &str {
428        alphabetic_author(&self.author)
429    }
430
431    pub fn alphabetic_title(&self) -> &str {
432        alphabetic_title(&self.title, &self.language)
433    }
434
435    pub fn label(&self) -> String {
436        if !self.author.is_empty() {
437            format!("{} · {}", self.title(), &self.author)
438        } else {
439            self.title()
440        }
441    }
442}
443
444/// Returns the sort key for a title by stripping leading articles based on
445/// the book's language, delegating to the [`TITLE_PREFIXES`] regex table.
446///
447/// Used by both [`Info::alphabetic_title`] and the DB sort-rank layer so that
448/// the article-stripping logic lives in exactly one place.
449pub(crate) fn alphabetic_title<'a>(title: &'a str, language: &str) -> &'a str {
450    let lang = if language.is_empty() || language.starts_with("en") {
451        "en"
452    } else if language.starts_with("fr") {
453        "fr"
454    } else {
455        language
456    };
457
458    if let Some(m) = TITLE_PREFIXES.get(lang).and_then(|re| re.find(title)) {
459        return title[m.end()..].trim_start();
460    }
461
462    title
463}
464
465/// Returns the sort key for an author string by extracting the last word of
466/// the first name in a comma-separated list.
467///
468/// Used by both [`Info::alphabetic_author`] and the DB sort-rank layer so that
469/// the extraction logic lives in exactly one place.
470pub(crate) fn alphabetic_author(author: &str) -> &str {
471    author
472        .split(',')
473        .next()
474        .and_then(|a| a.split(' ').next_back())
475        .unwrap_or_default()
476}
477
478pub fn make_query(text: &str) -> Option<Regex> {
479    let any = Regex::new(r"^(\.*|\s)$").unwrap();
480
481    if any.is_match(text) {
482        return None;
483    }
484
485    let text = text
486        .replace('a', "[aáàâä]")
487        .replace('e', "[eéèêë]")
488        .replace('i', "[iíìîï]")
489        .replace('o', "[oóòôö]")
490        .replace('u', "[uúùûü]")
491        .replace('c', "[cç]")
492        .replace("ae", "(ae|æ)")
493        .replace("oe", "(oe|œ)");
494    Regex::new(&format!("(?i){}", text))
495        .map_err(|e| error!("Can't create query: {:#}.", e))
496        .ok()
497}
498
499#[derive(Debug, Clone, Default)]
500pub struct BookQuery {
501    pub free: Option<Regex>,
502    pub title: Option<Regex>,
503    pub subtitle: Option<Regex>,
504    pub author: Option<Regex>,
505    pub year: Option<Regex>,
506    pub language: Option<Regex>,
507    pub publisher: Option<Regex>,
508    pub series: Option<Regex>,
509    pub edition: Option<Regex>,
510    pub volume: Option<Regex>,
511    pub number: Option<Regex>,
512    pub reading: Option<bool>,
513    pub new: Option<bool>,
514    pub finished: Option<bool>,
515    pub annotations: Option<bool>,
516    pub bookmarks: Option<bool>,
517    pub opened_after: Option<(bool, NaiveDateTime)>,
518    pub added_after: Option<(bool, NaiveDateTime)>,
519}
520
521impl BookQuery {
522    pub fn new(text: &str) -> Option<BookQuery> {
523        let mut buf = Vec::new();
524        let mut query = BookQuery::default();
525        for word in text.rsplit(' ') {
526            let mut chars = word.chars().peekable();
527            match chars.next() {
528                Some('\'') => {
529                    let mut invert = false;
530                    if chars.peek() == Some(&'!') {
531                        invert = true;
532                        chars.next();
533                    }
534                    match chars.next() {
535                        Some('t') => {
536                            buf.reverse();
537                            query.title = make_query(&buf.join(" "));
538                            buf.clear();
539                        }
540                        Some('u') => {
541                            buf.reverse();
542                            query.subtitle = make_query(&buf.join(" "));
543                            buf.clear();
544                        }
545                        Some('a') => {
546                            buf.reverse();
547                            query.author = make_query(&buf.join(" "));
548                            buf.clear();
549                        }
550                        Some('y') => {
551                            buf.reverse();
552                            query.year = make_query(&buf.join(" "));
553                            buf.clear();
554                        }
555                        Some('l') => {
556                            buf.reverse();
557                            query.language = make_query(&buf.join(" "));
558                            buf.clear();
559                        }
560                        Some('p') => {
561                            buf.reverse();
562                            query.publisher = make_query(&buf.join(" "));
563                            buf.clear();
564                        }
565                        Some('s') => {
566                            buf.reverse();
567                            query.series = make_query(&buf.join(" "));
568                            buf.clear();
569                        }
570                        Some('e') => {
571                            buf.reverse();
572                            query.edition = make_query(&buf.join(" "));
573                            buf.clear();
574                        }
575                        Some('v') => {
576                            buf.reverse();
577                            query.volume = make_query(&buf.join(" "));
578                            buf.clear();
579                        }
580                        Some('n') => {
581                            buf.reverse();
582                            query.number = make_query(&buf.join(" "));
583                            buf.clear();
584                        }
585                        Some('R') => query.reading = Some(!invert),
586                        Some('N') => query.new = Some(!invert),
587                        Some('F') => query.finished = Some(!invert),
588                        Some('A') => query.annotations = Some(!invert),
589                        Some('B') => query.bookmarks = Some(!invert),
590                        Some('O') => {
591                            buf.reverse();
592                            query.opened_after = NaiveDateTime::parse_from_str(
593                                &buf.join(" "),
594                                datetime_format::FORMAT,
595                            )
596                            .ok()
597                            .map(|opened| (!invert, opened));
598                            buf.clear();
599                        }
600                        Some('D') => {
601                            buf.reverse();
602                            query.added_after = NaiveDateTime::parse_from_str(
603                                &buf.join(" "),
604                                datetime_format::FORMAT,
605                            )
606                            .ok()
607                            .map(|added| (!invert, added));
608                            buf.clear();
609                        }
610                        Some('\'') => buf.push(&word[1..]),
611                        _ => (),
612                    }
613                }
614                _ => buf.push(word),
615            }
616        }
617        buf.reverse();
618        query.free = make_query(&buf.join(" "));
619        if query.free.is_none()
620            && query.title.is_none()
621            && query.subtitle.is_none()
622            && query.author.is_none()
623            && query.year.is_none()
624            && query.language.is_none()
625            && query.publisher.is_none()
626            && query.series.is_none()
627            && query.edition.is_none()
628            && query.volume.is_none()
629            && query.number.is_none()
630            && query.reading.is_none()
631            && query.new.is_none()
632            && query.finished.is_none()
633            && query.annotations.is_none()
634            && query.bookmarks.is_none()
635            && query.opened_after.is_none()
636            && query.added_after.is_none()
637        {
638            None
639        } else {
640            Some(query)
641        }
642    }
643
644    #[inline]
645    pub fn is_match(&self, info: &Info) -> bool {
646        self.free.as_ref().map(|re| {
647            re.is_match(&info.title)
648                || re.is_match(&info.subtitle)
649                || re.is_match(&info.author)
650                || re.is_match(&info.series)
651                || info.file.path.to_str().map_or(false, |s| re.is_match(s))
652        }) != Some(false)
653            && self.title.as_ref().map(|re| re.is_match(&info.title)) != Some(false)
654            && self.subtitle.as_ref().map(|re| re.is_match(&info.subtitle)) != Some(false)
655            && self.author.as_ref().map(|re| re.is_match(&info.author)) != Some(false)
656            && self.year.as_ref().map(|re| re.is_match(&info.year)) != Some(false)
657            && self.language.as_ref().map(|re| re.is_match(&info.language)) != Some(false)
658            && self
659                .publisher
660                .as_ref()
661                .map(|re| re.is_match(&info.publisher))
662                != Some(false)
663            && self.series.as_ref().map(|re| re.is_match(&info.series)) != Some(false)
664            && self.edition.as_ref().map(|re| re.is_match(&info.edition)) != Some(false)
665            && self.volume.as_ref().map(|re| re.is_match(&info.volume)) != Some(false)
666            && self.number.as_ref().map(|re| re.is_match(&info.number)) != Some(false)
667            && self
668                .reading
669                .as_ref()
670                .map(|eq| info.simple_status().eq(&SimpleStatus::Reading) == *eq)
671                != Some(false)
672            && self
673                .new
674                .as_ref()
675                .map(|eq| info.simple_status().eq(&SimpleStatus::New) == *eq)
676                != Some(false)
677            && self
678                .finished
679                .as_ref()
680                .map(|eq| info.simple_status().eq(&SimpleStatus::Finished) == *eq)
681                != Some(false)
682            && self.annotations.as_ref().map(|eq| {
683                info.reader
684                    .as_ref()
685                    .map_or(false, |r| !r.annotations.is_empty())
686                    == *eq
687            }) != Some(false)
688            && self.bookmarks.as_ref().map(|eq| {
689                info.reader
690                    .as_ref()
691                    .map_or(false, |r| !r.bookmarks.is_empty())
692                    == *eq
693            }) != Some(false)
694            && self.opened_after.as_ref().map(|(eq, opened)| {
695                info.reader.as_ref().map_or(false, |r| r.opened.gt(opened)) == *eq
696            }) != Some(false)
697            && self
698                .added_after
699                .as_ref()
700                .map(|(eq, added)| info.added.gt(added) == *eq)
701                != Some(false)
702    }
703
704    #[inline]
705    pub fn is_simple_match(&self, text: &str) -> bool {
706        self.free.as_ref().map_or(true, |q| q.is_match(text))
707    }
708}
709
710#[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq)]
711#[serde(rename_all = "kebab-case")]
712pub enum SortMethod {
713    Opened,
714    Added,
715    Status,
716    Progress,
717    Title,
718    Year,
719    Author,
720    Series,
721    Pages,
722    Size,
723    Kind,
724    FileName,
725    FilePath,
726}
727
728impl SortMethod {
729    pub fn reverse_order(self) -> bool {
730        !matches!(
731            self,
732            SortMethod::Author
733                | SortMethod::Title
734                | SortMethod::Series
735                | SortMethod::Kind
736                | SortMethod::FileName
737                | SortMethod::FilePath
738        )
739    }
740
741    pub fn is_status_related(self) -> bool {
742        matches!(
743            self,
744            SortMethod::Opened | SortMethod::Status | SortMethod::Progress
745        )
746    }
747
748    pub fn label(&self) -> &str {
749        match *self {
750            SortMethod::Opened => "Date Opened",
751            SortMethod::Added => "Date Added",
752            SortMethod::Status => "Status",
753            SortMethod::Progress => "Progress",
754            SortMethod::Author => "Author",
755            SortMethod::Title => "Title",
756            SortMethod::Year => "Year",
757            SortMethod::Series => "Series",
758            SortMethod::Size => "File Size",
759            SortMethod::Kind => "File Type",
760            SortMethod::Pages => "Pages Count",
761            SortMethod::FileName => "File Name",
762            SortMethod::FilePath => "File Path",
763        }
764    }
765
766    pub fn title(self) -> String {
767        format!("Sort by: {}", self.label())
768    }
769}
770
771#[inline]
772#[cfg_attr(feature = "tracing", tracing::instrument)]
773pub fn sorter(sort_method: SortMethod) -> fn(&Info, &Info) -> Ordering {
774    match sort_method {
775        SortMethod::Opened => sort_opened,
776        SortMethod::Added => sort_added,
777        SortMethod::Status => sort_status,
778        SortMethod::Progress => sort_progress,
779        SortMethod::Author => sort_author,
780        SortMethod::Title => sort_title,
781        SortMethod::Year => sort_year,
782        SortMethod::Series => sort_series,
783        SortMethod::Size => sort_size,
784        SortMethod::Kind => sort_kind,
785        SortMethod::Pages => sort_pages,
786        SortMethod::FileName => sort_filename,
787        SortMethod::FilePath => sort_filepath,
788    }
789}
790
791pub fn sort_opened(i1: &Info, i2: &Info) -> Ordering {
792    i1.reader
793        .as_ref()
794        .map(|r1| r1.opened)
795        .cmp(&i2.reader.as_ref().map(|r2| r2.opened))
796}
797
798pub fn sort_added(i1: &Info, i2: &Info) -> Ordering {
799    i1.added.cmp(&i2.added)
800}
801
802pub fn sort_pages(i1: &Info, i2: &Info) -> Ordering {
803    i1.reader
804        .as_ref()
805        .map(|r1| r1.pages_count)
806        .cmp(&i2.reader.as_ref().map(|r2| r2.pages_count))
807}
808
809// FIXME: 'Z'.cmp('É') equals Ordering::Less
810pub fn sort_author(i1: &Info, i2: &Info) -> Ordering {
811    i1.alphabetic_author().cmp(i2.alphabetic_author())
812}
813
814pub fn sort_title(i1: &Info, i2: &Info) -> Ordering {
815    let mut i1_title = i1.alphabetic_title().to_string();
816    let mut i2_title = i2.alphabetic_title().to_string();
817
818    if i1_title.is_empty() {
819        i1_title = i1.file_stem()
820    }
821
822    if i2_title.is_empty() {
823        i2_title = i2.file_stem()
824    }
825
826    natural_cmp(i1_title.as_str(), i2_title.as_str())
827}
828
829pub fn sort_status(i1: &Info, i2: &Info) -> Ordering {
830    match (i1.simple_status(), i2.simple_status()) {
831        (SimpleStatus::Reading, SimpleStatus::Reading)
832        | (SimpleStatus::Finished, SimpleStatus::Finished) => sort_opened(i1, i2),
833        (SimpleStatus::New, SimpleStatus::New) => sort_added(i1, i2),
834        (SimpleStatus::New, SimpleStatus::Finished) => Ordering::Greater,
835        (SimpleStatus::Finished, SimpleStatus::New) => Ordering::Less,
836        (SimpleStatus::New, SimpleStatus::Reading) => Ordering::Less,
837        (SimpleStatus::Reading, SimpleStatus::New) => Ordering::Greater,
838        (SimpleStatus::Finished, SimpleStatus::Reading) => Ordering::Less,
839        (SimpleStatus::Reading, SimpleStatus::Finished) => Ordering::Greater,
840    }
841}
842
843// Ordering: Finished < New < Reading.
844pub fn sort_progress(i1: &Info, i2: &Info) -> Ordering {
845    match (i1.status(), i2.status()) {
846        (Status::Finished, Status::Finished) => Ordering::Equal,
847        (Status::New, Status::New) => Ordering::Equal,
848        (Status::New, Status::Finished) => Ordering::Greater,
849        (Status::Finished, Status::New) => Ordering::Less,
850        (Status::New, Status::Reading(..)) => Ordering::Less,
851        (Status::Reading(..), Status::New) => Ordering::Greater,
852        (Status::Finished, Status::Reading(..)) => Ordering::Less,
853        (Status::Reading(..), Status::Finished) => Ordering::Greater,
854        (Status::Reading(p1), Status::Reading(p2)) => {
855            p1.partial_cmp(&p2).unwrap_or(Ordering::Equal)
856        }
857    }
858}
859
860pub fn sort_size(i1: &Info, i2: &Info) -> Ordering {
861    i1.file.size.cmp(&i2.file.size)
862}
863
864pub fn sort_kind(i1: &Info, i2: &Info) -> Ordering {
865    i1.file.kind.cmp(&i2.file.kind)
866}
867
868pub fn sort_year(i1: &Info, i2: &Info) -> Ordering {
869    i1.year.cmp(&i2.year)
870}
871
872pub fn sort_series(i1: &Info, i2: &Info) -> Ordering {
873    i1.series.cmp(&i2.series).then_with(|| {
874        i1.number
875            .parse::<usize>()
876            .ok()
877            .zip(i2.number.parse::<usize>().ok())
878            .map_or_else(|| i1.number.cmp(&i2.number), |(a, b)| a.cmp(&b))
879    })
880}
881
882pub fn sort_filename(i1: &Info, i2: &Info) -> Ordering {
883    let n1 = i1.file.path.file_name().map(OsStr::to_string_lossy);
884    let n2 = i2.file.path.file_name().map(OsStr::to_string_lossy);
885    match (n1, n2) {
886        (Some(a), Some(b)) => natural_cmp(&a, &b),
887        (a, b) => a.map(|s| s.into_owned()).cmp(&b.map(|s| s.into_owned())),
888    }
889}
890
891pub fn sort_filepath(i1: &Info, i2: &Info) -> Ordering {
892    natural_cmp(
893        &i1.file.path.to_string_lossy(),
894        &i2.file.path.to_string_lossy(),
895    )
896}
897
898/// Compares two strings using natural sort order so that embedded numbers sort
899/// by value rather than by their string representation ("9" < "10" < "100").
900///
901/// Each string is split into alternating runs of numeric and non-numeric
902/// characters. A numeric token is an integer optionally followed by a decimal
903/// part (`.<digits>`), so `"4.5"` is treated as a single number rather than
904/// the three segments `4`, `.`, `5`. Numeric tokens are compared as `f64`
905/// values, which means leading zeros are ignored ("01" == "1") and fractional
906/// parts are respected ("4" < "4.5" < "5"). Non-numeric runs are compared
907/// lexicographically. When one run is numeric and the other is text, the
908/// numeric segment sorts first.
909///
910/// # Examples
911///
912/// ```ignore
913/// // This example uses private API; doc tests cannot access non-public items.
914/// use std::cmp::Ordering;
915///
916/// // Numeric runs compare by value, not string length.
917/// assert_eq!(natural_cmp("9", "10"), Ordering::Less);
918/// assert_eq!(natural_cmp("100", "99"), Ordering::Greater);
919///
920/// // Fractional numbers are supported.
921/// assert_eq!(natural_cmp("Vol 4", "Vol 4.5"), Ordering::Less);
922/// assert_eq!(natural_cmp("Vol 4.5", "Vol 5"), Ordering::Less);
923///
924/// // Leading zeros are ignored: "01" and "1" are numerically equal.
925/// assert_eq!(natural_cmp("01", "1"), Ordering::Equal);
926///
927/// // Mixed strings compare segment by segment.
928/// assert_eq!(natural_cmp("Chapter 9", "Chapter 10"), Ordering::Less);
929/// ```
930#[cfg_attr(feature = "tracing", tracing::instrument(ret(level=tracing::Level::TRACE)))]
931pub(crate) fn natural_cmp(a: &str, b: &str) -> Ordering {
932    let mut a_rest = a;
933    let mut b_rest = b;
934
935    loop {
936        if a_rest.is_empty() && b_rest.is_empty() {
937            return Ordering::Equal;
938        }
939        if a_rest.is_empty() {
940            return Ordering::Less;
941        }
942        if b_rest.is_empty() {
943            return Ordering::Greater;
944        }
945
946        let a_digit = a_rest.starts_with(|c: char| c.is_ascii_digit());
947        let b_digit = b_rest.starts_with(|c: char| c.is_ascii_digit());
948
949        match (a_digit, b_digit) {
950            (true, true) => {
951                let a_num_len = numeric_token_len(a_rest);
952                let b_num_len = numeric_token_len(b_rest);
953                let a_num: f64 = a_rest[..a_num_len].parse().unwrap_or(f64::MAX);
954                let b_num: f64 = b_rest[..b_num_len].parse().unwrap_or(f64::MAX);
955                let ord = a_num.partial_cmp(&b_num).unwrap_or(Ordering::Equal);
956                if ord != Ordering::Equal {
957                    return ord;
958                }
959                a_rest = &a_rest[a_num_len..];
960                b_rest = &b_rest[b_num_len..];
961            }
962            (false, false) => {
963                let a_text_len = a_rest
964                    .find(|c: char| c.is_ascii_digit())
965                    .unwrap_or(a_rest.len());
966                let b_text_len = b_rest
967                    .find(|c: char| c.is_ascii_digit())
968                    .unwrap_or(b_rest.len());
969                let ord = a_rest[..a_text_len].cmp(&b_rest[..b_text_len]);
970                if ord != Ordering::Equal {
971                    return ord;
972                }
973                a_rest = &a_rest[a_text_len..];
974                b_rest = &b_rest[b_text_len..];
975            }
976            (true, false) => return Ordering::Less,
977            (false, true) => return Ordering::Greater,
978        }
979    }
980}
981
982/// Returns the byte length of a numeric token starting at the beginning of
983/// `s`. A numeric token is one or more ASCII digits optionally followed by a
984/// single `'.'` and one or more additional ASCII digits (e.g. `"4"`, `"4.5"`).
985/// A trailing dot with no digits after it (e.g. `"4."`) is not included.
986fn numeric_token_len(s: &str) -> usize {
987    let int_len = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
988
989    let rest = &s[int_len..];
990    if let Some(after_dot) = rest.strip_prefix('.') {
991        let frac_len = after_dot
992            .find(|c: char| !c.is_ascii_digit())
993            .unwrap_or(after_dot.len());
994        if frac_len > 0 {
995            return int_len + 1 + frac_len;
996        }
997    }
998
999    int_len
1000}
1001
1002lazy_static! {
1003    pub static ref TITLE_PREFIXES: FxHashMap<&'static str, Regex> = {
1004        let mut p = FxHashMap::default();
1005        p.insert("en", Regex::new(r"^(The|An?)\s").unwrap());
1006        p.insert(
1007            "fr",
1008            Regex::new(r"^(Les?\s|La\s|L’|Une?\s|Des?\s|Du\s)").unwrap(),
1009        );
1010        p
1011    };
1012}
1013
1014#[inline]
1015#[cfg_attr(feature = "tracing", tracing::instrument(skip(info)))]
1016pub fn extract_metadata_from_document(prefix: &Path, info: &mut Info) {
1017    let path = prefix.join(&info.file.path);
1018
1019    match info.file.kind.as_ref() {
1020        "epub" => match EpubDocument::new(&path) {
1021            Ok(doc) => {
1022                info.title = doc.title().unwrap_or_default();
1023                info.author = doc.author().unwrap_or_default();
1024                info.year = doc.year().unwrap_or_default();
1025                info.publisher = doc.publisher().unwrap_or_default();
1026                if let Some((title, index)) = doc.series() {
1027                    info.series = title;
1028                    info.number = index;
1029                }
1030                info.language = doc.language().unwrap_or_default();
1031                info.categories.append(&mut doc.categories());
1032            }
1033            Err(e) => error!("Can't open {}: {:#}.", info.file.path.display(), e),
1034        },
1035        "html" | "htm" => match HtmlDocument::new(&path) {
1036            Ok(doc) => {
1037                info.title = doc.title().unwrap_or_default();
1038                info.author = doc.author().unwrap_or_default();
1039                info.language = doc.language().unwrap_or_default();
1040            }
1041            Err(e) => error!("Can't open {}: {:#}.", info.file.path.display(), e),
1042        },
1043        "pdf" => match PdfOpener::new().and_then(|o| o.open(path).ok()) {
1044            Some(doc) => {
1045                info.title = doc.title().unwrap_or_default();
1046                info.author = doc.author().unwrap_or_default();
1047            }
1048            None => error!("Can't open {}.", info.file.path.display()),
1049        },
1050        "djvu" | "djv" => match DjvuOpener::new().and_then(|o| o.open(path)) {
1051            Some(doc) => {
1052                info.title = doc.title().unwrap_or_default();
1053                info.author = doc.author().unwrap_or_default();
1054                info.year = doc.year().unwrap_or_default();
1055                info.series = doc.series().unwrap_or_default();
1056                info.publisher = doc.publisher().unwrap_or_default();
1057            }
1058            None => error!("Can't open {}.", info.file.path.display()),
1059        },
1060        _ => {
1061            warn!(
1062                "Don't know how to extract metadata from {}.",
1063                &info.file.kind
1064            );
1065        }
1066    }
1067}
1068
1069pub fn extract_metadata_from_filename(_prefix: &Path, info: &mut Info) {
1070    if let Some(filename) = info.file.path.file_name().and_then(OsStr::to_str) {
1071        let mut start_index = 0;
1072
1073        if filename.starts_with('(') {
1074            start_index += 1;
1075            if let Some(index) = filename[start_index..].find(')') {
1076                info.series = filename[start_index..start_index + index]
1077                    .trim_end()
1078                    .to_string();
1079                start_index += index + 1;
1080            }
1081        }
1082
1083        if let Some(index) = filename[start_index..].find("- ") {
1084            info.author = filename[start_index..start_index + index]
1085                .trim()
1086                .to_string();
1087            start_index += index + 1;
1088        }
1089
1090        let title_start = start_index;
1091
1092        if let Some(index) = filename[start_index..].find('_') {
1093            info.title = filename[start_index..start_index + index]
1094                .trim_start()
1095                .to_string();
1096            start_index += index + 1;
1097        }
1098
1099        if let Some(index) = filename[start_index..].find('-') {
1100            if title_start == start_index {
1101                info.title = filename[start_index..start_index + index]
1102                    .trim_start()
1103                    .to_string();
1104            } else {
1105                info.subtitle = filename[start_index..start_index + index]
1106                    .trim_start()
1107                    .to_string();
1108            }
1109            start_index += index + 1;
1110        }
1111
1112        if let Some(index) = filename[start_index..].find('(') {
1113            info.publisher = filename[start_index..start_index + index]
1114                .trim_end()
1115                .to_string();
1116            start_index += index + 1;
1117        }
1118
1119        if let Some(index) = filename[start_index..].find(')') {
1120            info.year = filename[start_index..start_index + index].to_string();
1121        }
1122    }
1123}
1124
1125pub fn consolidate(_prefix: &Path, info: &mut Info) {
1126    if info.subtitle.is_empty() {
1127        if let Some(index) = info.title.find(':') {
1128            let cur_title = info.title.clone();
1129            let (title, subtitle) = cur_title.split_at(index);
1130            info.title = title.trim_end().to_string();
1131            info.subtitle = subtitle[1..].trim_start().to_string();
1132        }
1133    }
1134
1135    if info.language.is_empty() || info.language.starts_with("en") {
1136        info.title = titlecase(&info.title);
1137        info.subtitle = titlecase(&info.subtitle);
1138    }
1139
1140    info.title = info.title.replace('\'', "’");
1141    info.subtitle = info.subtitle.replace('\'', "’");
1142    info.author = info.author.replace('\'', "’");
1143    if info.year.len() > 4 {
1144        info.year = info.year[..4].to_string();
1145    }
1146    info.series = info.series.replace('\'', "’");
1147    info.publisher = info.publisher.replace('\'', "’");
1148}
1149
1150pub fn rename_from_info(prefix: &Path, info: &mut Info) {
1151    let new_file_name = file_name_from_info(info);
1152    if !new_file_name.is_empty() {
1153        let old_path = prefix.join(&info.file.path);
1154        let new_path = old_path.with_file_name(&new_file_name);
1155        if old_path != new_path {
1156            match fs::rename(&old_path, &new_path) {
1157                Err(e) => error!(
1158                    "Can't rename {} to {}: {:#}.",
1159                    old_path.display(),
1160                    new_path.display(),
1161                    e
1162                ),
1163                Ok(..) => {
1164                    let relat = new_path.strip_prefix(prefix).unwrap_or(&new_path);
1165                    info.file.path = relat.to_path_buf();
1166                }
1167            }
1168        }
1169    }
1170}
1171
1172pub fn file_name_from_info(info: &Info) -> String {
1173    if info.title.is_empty() {
1174        return "".to_string();
1175    }
1176    let mut base = asciify(&info.title);
1177    if !info.subtitle.is_empty() {
1178        base = format!("{} - {}", base, asciify(&info.subtitle));
1179    }
1180    if !info.volume.is_empty() {
1181        base = format!("{} - {}", base, info.volume);
1182    }
1183    if !info.number.is_empty() && info.series.is_empty() {
1184        base = format!("{} - {}", base, info.number);
1185    }
1186    if !info.author.is_empty() {
1187        base = format!("{} - {}", base, asciify(&info.author));
1188    }
1189    base = format!("{}.{}", base, info.file.kind);
1190    base.replace("..", ".")
1191        .replace('/', " ")
1192        .replace('?', "")
1193        .replace('!', "")
1194        .replace(':', "")
1195}
1196
1197#[cfg(test)]
1198mod tests {
1199    use super::*;
1200    use std::path::PathBuf;
1201
1202    fn make_info(title: &str, filename: &str) -> Info {
1203        Info {
1204            title: title.to_string(),
1205            file: FileInfo {
1206                path: PathBuf::from(filename),
1207                absolute_path: PathBuf::new(),
1208                kind: "epub".to_string(),
1209                size: 0,
1210                mtime: None,
1211            },
1212            ..Default::default()
1213        }
1214    }
1215
1216    #[test]
1217    fn natural_cmp_pure_numbers() {
1218        assert_eq!(natural_cmp("9", "10"), Ordering::Less);
1219        assert_eq!(natural_cmp("10", "9"), Ordering::Greater);
1220        assert_eq!(natural_cmp("100", "99"), Ordering::Greater);
1221        assert_eq!(natural_cmp("10", "10"), Ordering::Equal);
1222    }
1223
1224    #[test]
1225    fn natural_cmp_leading_zeros_are_numerically_equal() {
1226        assert_eq!(natural_cmp("01", "1"), Ordering::Equal);
1227        assert_eq!(natural_cmp("001", "1"), Ordering::Equal);
1228        assert_eq!(natural_cmp("007", "7"), Ordering::Equal);
1229    }
1230
1231    #[test]
1232    fn natural_cmp_mixed_strings() {
1233        assert_eq!(natural_cmp("Chapter 9", "Chapter 10"), Ordering::Less);
1234        assert_eq!(natural_cmp("Chapter 10", "Chapter 9"), Ordering::Greater);
1235        assert_eq!(
1236            natural_cmp("Vol 2 Chapter 9", "Vol 2 Chapter 10"),
1237            Ordering::Less
1238        );
1239    }
1240
1241    #[test]
1242    fn natural_cmp_pure_text() {
1243        assert_eq!(natural_cmp("abc", "abd"), Ordering::Less);
1244        assert_eq!(natural_cmp("abd", "abc"), Ordering::Greater);
1245        assert_eq!(natural_cmp("abc", "abc"), Ordering::Equal);
1246    }
1247
1248    #[test]
1249    fn natural_cmp_empty_strings() {
1250        assert_eq!(natural_cmp("", ""), Ordering::Equal);
1251        assert_eq!(natural_cmp("", "a"), Ordering::Less);
1252        assert_eq!(natural_cmp("a", ""), Ordering::Greater);
1253    }
1254
1255    #[test]
1256    fn natural_cmp_fractional_numbers() {
1257        assert_eq!(natural_cmp("Vol 4", "Vol 4.5"), Ordering::Less);
1258        assert_eq!(natural_cmp("Vol 4.5", "Vol 5"), Ordering::Less);
1259        assert_eq!(natural_cmp("Vol 4.5", "Vol 4.5"), Ordering::Equal);
1260        // 4.10 parses as 4.1 which is less than 4.9
1261        assert_eq!(natural_cmp("Vol 4.10", "Vol 4.9"), Ordering::Less);
1262    }
1263
1264    #[test]
1265    fn natural_cmp_decimal_between_integers() {
1266        assert_eq!(natural_cmp("1", "10.5"), Ordering::Less);
1267        assert_eq!(natural_cmp("10", "10.5"), Ordering::Less);
1268        assert_eq!(natural_cmp("10.5", "11"), Ordering::Less);
1269
1270        let mut items = ["11", "1", "10.5", "10", "2"];
1271        items.sort_by(|a, b| natural_cmp(a, b));
1272        assert_eq!(items, ["1", "2", "10", "10.5", "11"]);
1273    }
1274
1275    #[test]
1276    fn sort_title_decimal_volume_between_integers() {
1277        let v1 = make_info("Series Vol. 1", "a.epub");
1278        let v2 = make_info("Series Vol. 2", "b.epub");
1279        let v10 = make_info("Series Vol. 10", "c.epub");
1280        let v10_5 = make_info("Series Vol. 10.5", "d.epub");
1281        let v11 = make_info("Series Vol. 11", "e.epub");
1282
1283        assert_eq!(sort_title(&v1, &v10_5), Ordering::Less);
1284        assert_eq!(sort_title(&v10, &v10_5), Ordering::Less);
1285        assert_eq!(sort_title(&v10_5, &v11), Ordering::Less);
1286
1287        let mut books = [&v11, &v1, &v10_5, &v10, &v2];
1288        books.sort_by(|a, b| sort_title(a, b));
1289        let titles: Vec<_> = books.iter().map(|i| i.title.as_str()).collect();
1290        assert_eq!(
1291            titles,
1292            [
1293                "Series Vol. 1",
1294                "Series Vol. 2",
1295                "Series Vol. 10",
1296                "Series Vol. 10.5",
1297                "Series Vol. 11",
1298            ]
1299        );
1300    }
1301
1302    #[test]
1303    fn natural_cmp_trailing_dot_not_included_in_number() {
1304        // "4." — the dot has no digits after it, so it is not part of the number token.
1305        // "4." therefore sorts the same as "4" for the numeric part, then "." > "" text-wise.
1306        assert_eq!(natural_cmp("4.", "4"), Ordering::Greater);
1307    }
1308
1309    #[test]
1310    fn natural_cmp_digit_before_text_segment() {
1311        assert_eq!(natural_cmp("1abc", "abc"), Ordering::Less);
1312        assert_eq!(natural_cmp("abc", "1abc"), Ordering::Greater);
1313    }
1314
1315    #[test]
1316    fn natural_cmp_three_digit_before_two_digit() {
1317        let mut items = ["100", "9", "10", "1", "99", "01"];
1318        items.sort_by(|a, b| natural_cmp(a, b));
1319        // "01" and "1" are numerically equal so their relative order is
1320        // unspecified; assert only that the numeric ordering is correct.
1321        let without_leading_zero: Vec<_> = items.iter().filter(|&&s| s != "01").copied().collect();
1322        assert_eq!(without_leading_zero, vec!["1", "9", "10", "99", "100"]);
1323        assert!(items.contains(&"01"));
1324    }
1325
1326    #[test]
1327    fn sort_filename_numerical_order() {
1328        let i9 = make_info("", "9 - Title.epub");
1329        let i10 = make_info("", "10 - Title.epub");
1330        let i100 = make_info("", "100 - Title.epub");
1331        assert_eq!(sort_filename(&i9, &i10), Ordering::Less);
1332        assert_eq!(sort_filename(&i100, &i10), Ordering::Greater);
1333        assert_eq!(sort_filename(&i10, &i10), Ordering::Equal);
1334    }
1335
1336    #[test]
1337    fn sort_filename_mixed_names() {
1338        let i1 = make_info("", "Chapter 9.epub");
1339        let i2 = make_info("", "Chapter 10.epub");
1340        assert_eq!(sort_filename(&i1, &i2), Ordering::Less);
1341    }
1342
1343    #[test]
1344    fn sort_filepath_numerical_order() {
1345        let i1 = make_info("", "Library/Vol 2/Chapter 9.epub");
1346        let i2 = make_info("", "Library/Vol 2/Chapter 10.epub");
1347        assert_eq!(sort_filepath(&i1, &i2), Ordering::Less);
1348    }
1349
1350    #[test]
1351    fn sort_filepath_directory_numerical_order() {
1352        let i1 = make_info("", "Vol 9/book.epub");
1353        let i2 = make_info("", "Vol 10/book.epub");
1354        assert_eq!(sort_filepath(&i1, &i2), Ordering::Less);
1355    }
1356
1357    #[test]
1358    fn sort_title_strips_articles_then_natural_sorts() {
1359        let i1 = make_info("The 9th Chapter", "a.epub");
1360        let i2 = make_info("The 10th Chapter", "b.epub");
1361        // After stripping "The ", compares "9th Chapter" vs "10th Chapter" — 9 < 10
1362        assert_eq!(sort_title(&i1, &i2), Ordering::Less);
1363    }
1364
1365    #[test]
1366    fn sort_title_numbered_titles() {
1367        let i1984 = make_info("1984", "a.epub");
1368        let i2001 = make_info("2001: A Space Odyssey", "b.epub");
1369        assert_eq!(sort_title(&i1984, &i2001), Ordering::Less);
1370    }
1371
1372    #[test]
1373    fn sort_title_plain_text_unchanged() {
1374        let ia = make_info("Apple", "a.epub");
1375        let ib = make_info("Banana", "b.epub");
1376        assert_eq!(sort_title(&ia, &ib), Ordering::Less);
1377        assert_eq!(sort_title(&ib, &ia), Ordering::Greater);
1378    }
1379}