Skip to main content

cadmus_core/
metadata.rs

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