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 chrono::{Local, NaiveDateTime};
10use fxhash::FxHashMap;
11use lazy_static::lazy_static;
12use regex::Regex;
13use serde::{Deserialize, Serialize};
14use std::cmp::Ordering;
15use std::collections::{BTreeMap, BTreeSet};
16use std::ffi::OsStr;
17use std::fmt;
18use std::fs;
19use std::path::{Path, PathBuf};
20use titlecase::titlecase;
21use tracing::{error, warn};
22
23pub const DEFAULT_CONTRAST_EXPONENT: f32 = 1.0;
24pub const DEFAULT_CONTRAST_GRAY: f32 = 224.0;
25
26pub type Metadata = Vec<Info>;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(default, rename_all = "camelCase")]
30pub struct Info {
31    #[serde(skip_serializing_if = "String::is_empty")]
32    pub title: String,
33    #[serde(skip_serializing_if = "String::is_empty")]
34    pub subtitle: String,
35    #[serde(skip_serializing_if = "String::is_empty")]
36    pub author: String,
37    #[serde(skip_serializing_if = "String::is_empty")]
38    pub year: String,
39    #[serde(skip_serializing_if = "String::is_empty")]
40    pub language: String,
41    #[serde(skip_serializing_if = "String::is_empty")]
42    pub publisher: String,
43    #[serde(skip_serializing_if = "String::is_empty")]
44    pub series: String,
45    #[serde(skip_serializing_if = "String::is_empty")]
46    pub edition: String,
47    #[serde(skip_serializing_if = "String::is_empty")]
48    pub volume: String,
49    #[serde(skip_serializing_if = "String::is_empty")]
50    pub number: String,
51    #[serde(skip_serializing_if = "String::is_empty")]
52    pub identifier: String,
53    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
54    pub categories: BTreeSet<String>,
55    pub file: FileInfo,
56    #[serde(skip_serializing)]
57    pub reader: Option<ReaderInfo>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub reader_info: Option<ReaderInfo>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub toc: Option<Vec<SimpleTocEntry>>,
62    #[serde(with = "datetime_format")]
63    pub added: NaiveDateTime,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67#[serde(default, rename_all = "camelCase")]
68pub struct FileInfo {
69    pub path: PathBuf,
70    #[serde(skip)]
71    pub absolute_path: PathBuf,
72    pub kind: String,
73    pub size: u64,
74}
75
76impl Default for FileInfo {
77    fn default() -> Self {
78        FileInfo {
79            path: PathBuf::default(),
80            absolute_path: PathBuf::default(),
81            kind: String::default(),
82            size: u64::default(),
83        }
84    }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(default, rename_all = "camelCase")]
89pub struct Annotation {
90    #[serde(skip_serializing_if = "String::is_empty")]
91    pub note: String,
92    #[serde(skip_serializing_if = "String::is_empty")]
93    pub text: String,
94    pub selection: [TextLocation; 2],
95    #[serde(with = "datetime_format")]
96    pub modified: NaiveDateTime,
97}
98
99impl Default for Annotation {
100    fn default() -> Self {
101        Annotation {
102            note: String::new(),
103            text: String::new(),
104            selection: [TextLocation::Dynamic(0), TextLocation::Dynamic(1)],
105            modified: Local::now().naive_local(),
106        }
107    }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct Margin {
112    pub top: f32,
113    pub right: f32,
114    pub bottom: f32,
115    pub left: f32,
116}
117
118impl Margin {
119    pub fn new(top: f32, right: f32, bottom: f32, left: f32) -> Margin {
120        Margin {
121            top,
122            right,
123            bottom,
124            left,
125        }
126    }
127}
128
129impl Default for Margin {
130    fn default() -> Margin {
131        Margin::new(0.0, 0.0, 0.0, 0.0)
132    }
133}
134
135#[derive(Debug, Copy, Clone, Eq, PartialEq)]
136pub enum PageScheme {
137    Any,
138    EvenOdd,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142#[serde(untagged)]
143pub enum CroppingMargins {
144    Any(Margin),
145    EvenOdd([Margin; 2]),
146}
147
148impl CroppingMargins {
149    pub fn margin(&self, index: usize) -> &Margin {
150        match *self {
151            CroppingMargins::Any(ref margin) => margin,
152            CroppingMargins::EvenOdd(ref pair) => &pair[index % 2],
153        }
154    }
155
156    pub fn margin_mut(&mut self, index: usize) -> &mut Margin {
157        match *self {
158            CroppingMargins::Any(ref mut margin) => margin,
159            CroppingMargins::EvenOdd(ref mut pair) => &mut pair[index % 2],
160        }
161    }
162
163    pub fn apply(&mut self, index: usize, scheme: PageScheme) {
164        let margin = self.margin(index).clone();
165
166        match scheme {
167            PageScheme::Any => *self = CroppingMargins::Any(margin),
168            PageScheme::EvenOdd => *self = CroppingMargins::EvenOdd([margin.clone(), margin]),
169        }
170    }
171
172    pub fn is_split(&self) -> bool {
173        !matches!(*self, CroppingMargins::Any(..))
174    }
175}
176
177#[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq)]
178#[serde(rename_all = "kebab-case")]
179pub enum TextAlign {
180    Justify,
181    Left,
182    Right,
183    Center,
184}
185
186impl TextAlign {
187    pub fn icon_name(&self) -> &str {
188        match self {
189            TextAlign::Justify => "align-justify",
190            TextAlign::Left => "align-left",
191            TextAlign::Right => "align-right",
192            TextAlign::Center => "align-center",
193        }
194    }
195}
196
197impl fmt::Display for TextAlign {
198    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
199        fmt::Debug::fmt(self, f)
200    }
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(default, rename_all = "camelCase")]
205pub struct ReaderInfo {
206    #[serde(with = "datetime_format")]
207    pub opened: NaiveDateTime,
208    pub current_page: usize,
209    pub pages_count: usize,
210    pub finished: bool,
211    pub dithered: bool,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub zoom_mode: Option<ZoomMode>,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub scroll_mode: Option<ScrollMode>,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub page_offset: Option<Point>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub rotation: Option<i8>,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub cropping_margins: Option<CroppingMargins>,
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub margin_width: Option<i32>,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub screen_margin_width: Option<i32>,
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub font_family: Option<String>,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub font_size: Option<f32>,
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub text_align: Option<TextAlign>,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub line_height: Option<f32>,
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub contrast_exponent: Option<f32>,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub contrast_gray: Option<f32>,
238    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
239    pub page_names: BTreeMap<usize, String>,
240    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
241    pub bookmarks: BTreeSet<usize>,
242    #[serde(skip_serializing_if = "Vec::is_empty")]
243    pub annotations: Vec<Annotation>,
244}
245
246#[derive(Serialize, Deserialize, Debug, Copy, Clone)]
247pub enum ZoomMode {
248    FitToPage,
249    FitToWidth,
250    Custom(f32),
251}
252
253#[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq)]
254pub enum ScrollMode {
255    Screen,
256    Page,
257}
258
259impl PartialEq for ZoomMode {
260    fn eq(&self, other: &Self) -> bool {
261        match (self, other) {
262            (ZoomMode::FitToPage, ZoomMode::FitToPage) => true,
263            (ZoomMode::FitToWidth, ZoomMode::FitToWidth) => true,
264            (ZoomMode::Custom(z1), ZoomMode::Custom(z2)) => (z1 - z2).abs() < f32::EPSILON,
265            _ => false,
266        }
267    }
268}
269
270impl Eq for ZoomMode {}
271
272impl ReaderInfo {
273    pub fn progress(&self) -> f32 {
274        (self.current_page / self.pages_count) as f32
275    }
276}
277
278impl Default for ReaderInfo {
279    fn default() -> Self {
280        ReaderInfo {
281            opened: Local::now().naive_local(),
282            current_page: 0,
283            pages_count: 1,
284            finished: false,
285            dithered: false,
286            zoom_mode: None,
287            scroll_mode: None,
288            page_offset: None,
289            rotation: None,
290            cropping_margins: None,
291            margin_width: None,
292            screen_margin_width: None,
293            font_family: None,
294            font_size: None,
295            text_align: None,
296            line_height: None,
297            contrast_exponent: None,
298            contrast_gray: None,
299            page_names: BTreeMap::new(),
300            bookmarks: BTreeSet::new(),
301            annotations: Vec::new(),
302        }
303    }
304}
305
306impl Default for Info {
307    fn default() -> Self {
308        Info {
309            title: String::default(),
310            subtitle: String::default(),
311            author: String::default(),
312            year: String::default(),
313            language: String::default(),
314            publisher: String::default(),
315            series: String::default(),
316            edition: String::default(),
317            volume: String::default(),
318            number: String::default(),
319            identifier: String::default(),
320            categories: BTreeSet::new(),
321            file: FileInfo::default(),
322            added: Local::now().naive_local(),
323            reader: None,
324            reader_info: None,
325            toc: None,
326        }
327    }
328}
329
330#[derive(Debug, Copy, Clone)]
331pub enum Status {
332    New,
333    Reading(f32),
334    Finished,
335}
336
337#[derive(Debug, Copy, Clone, Eq, PartialEq)]
338pub enum SimpleStatus {
339    New,
340    Reading,
341    Finished,
342}
343
344impl fmt::Display for SimpleStatus {
345    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
346        fmt::Debug::fmt(self, f)
347    }
348}
349
350impl Info {
351    pub fn status(&self) -> Status {
352        if let Some(ref r) = self.reader {
353            if r.finished {
354                Status::Finished
355            } else {
356                Status::Reading(r.current_page as f32 / r.pages_count as f32)
357            }
358        } else {
359            Status::New
360        }
361    }
362
363    pub fn simple_status(&self) -> SimpleStatus {
364        if let Some(ref r) = self.reader {
365            if r.finished {
366                SimpleStatus::Finished
367            } else {
368                SimpleStatus::Reading
369            }
370        } else {
371            SimpleStatus::New
372        }
373    }
374
375    pub fn file_stem(&self) -> String {
376        self.file
377            .path
378            .file_stem()
379            .unwrap()
380            .to_string_lossy()
381            .into_owned()
382    }
383
384    pub fn title(&self) -> String {
385        if self.title.is_empty() {
386            return self.file_stem();
387        }
388
389        let mut title = self.title.clone();
390
391        if !self.number.is_empty() && self.series.is_empty() {
392            title = format!("{} #{}", title, self.number);
393        }
394
395        if !self.volume.is_empty() {
396            title = format!("{} — vol. {}", title, self.volume);
397        }
398
399        if !self.subtitle.is_empty() {
400            title = if self.subtitle.chars().next().unwrap().is_alphanumeric()
401                && title.chars().last().unwrap().is_alphanumeric()
402            {
403                format!("{}: {}", title, self.subtitle)
404            } else {
405                format!("{} {}", title, self.subtitle)
406            };
407        }
408
409        if !self.series.is_empty() && !self.number.is_empty() {
410            title = format!("{} ({} #{})", title, self.series, self.number);
411        }
412
413        title
414    }
415
416    // TODO: handle the following case: *Walter M. Miller Jr.*?
417    // NOTE: e.g.: John Le Carré: the space between *Le* and *Carré*
418    // is a non-breaking space
419    pub fn alphabetic_author(&self) -> &str {
420        self.author
421            .split(',')
422            .next()
423            .and_then(|a| a.split(' ').last())
424            .unwrap_or_default()
425    }
426
427    pub fn alphabetic_title(&self) -> &str {
428        let mut start = 0;
429
430        let lang = if self.language.is_empty() || self.language.starts_with("en") {
431            "en"
432        } else if self.language.starts_with("fr") {
433            "fr"
434        } else {
435            &self.language
436        };
437
438        if let Some(m) = TITLE_PREFIXES.get(lang).and_then(|re| re.find(&self.title)) {
439            start = m.end()
440        }
441
442        &self.title[start..]
443    }
444
445    pub fn label(&self) -> String {
446        if !self.author.is_empty() {
447            format!("{} · {}", self.title(), &self.author)
448        } else {
449            self.title()
450        }
451    }
452}
453
454pub fn make_query(text: &str) -> Option<Regex> {
455    let any = Regex::new(r"^(\.*|\s)$").unwrap();
456
457    if any.is_match(text) {
458        return None;
459    }
460
461    let text = text
462        .replace('a', "[aáàâä]")
463        .replace('e', "[eéèêë]")
464        .replace('i', "[iíìîï]")
465        .replace('o', "[oóòôö]")
466        .replace('u', "[uúùûü]")
467        .replace('c', "[cç]")
468        .replace("ae", "(ae|æ)")
469        .replace("oe", "(oe|œ)");
470    Regex::new(&format!("(?i){}", text))
471        .map_err(|e| error!("Can't create query: {:#}.", e))
472        .ok()
473}
474
475#[derive(Debug, Clone, Default)]
476pub struct BookQuery {
477    pub free: Option<Regex>,
478    pub title: Option<Regex>,
479    pub subtitle: Option<Regex>,
480    pub author: Option<Regex>,
481    pub year: Option<Regex>,
482    pub language: Option<Regex>,
483    pub publisher: Option<Regex>,
484    pub series: Option<Regex>,
485    pub edition: Option<Regex>,
486    pub volume: Option<Regex>,
487    pub number: Option<Regex>,
488    pub reading: Option<bool>,
489    pub new: Option<bool>,
490    pub finished: Option<bool>,
491    pub annotations: Option<bool>,
492    pub bookmarks: Option<bool>,
493    pub opened_after: Option<(bool, NaiveDateTime)>,
494    pub added_after: Option<(bool, NaiveDateTime)>,
495}
496
497impl BookQuery {
498    pub fn new(text: &str) -> Option<BookQuery> {
499        let mut buf = Vec::new();
500        let mut query = BookQuery::default();
501        for word in text.rsplit(' ') {
502            let mut chars = word.chars().peekable();
503            match chars.next() {
504                Some('\'') => {
505                    let mut invert = false;
506                    if chars.peek() == Some(&'!') {
507                        invert = true;
508                        chars.next();
509                    }
510                    match chars.next() {
511                        Some('t') => {
512                            buf.reverse();
513                            query.title = make_query(&buf.join(" "));
514                            buf.clear();
515                        }
516                        Some('u') => {
517                            buf.reverse();
518                            query.subtitle = make_query(&buf.join(" "));
519                            buf.clear();
520                        }
521                        Some('a') => {
522                            buf.reverse();
523                            query.author = make_query(&buf.join(" "));
524                            buf.clear();
525                        }
526                        Some('y') => {
527                            buf.reverse();
528                            query.year = make_query(&buf.join(" "));
529                            buf.clear();
530                        }
531                        Some('l') => {
532                            buf.reverse();
533                            query.language = make_query(&buf.join(" "));
534                            buf.clear();
535                        }
536                        Some('p') => {
537                            buf.reverse();
538                            query.publisher = make_query(&buf.join(" "));
539                            buf.clear();
540                        }
541                        Some('s') => {
542                            buf.reverse();
543                            query.series = make_query(&buf.join(" "));
544                            buf.clear();
545                        }
546                        Some('e') => {
547                            buf.reverse();
548                            query.edition = make_query(&buf.join(" "));
549                            buf.clear();
550                        }
551                        Some('v') => {
552                            buf.reverse();
553                            query.volume = make_query(&buf.join(" "));
554                            buf.clear();
555                        }
556                        Some('n') => {
557                            buf.reverse();
558                            query.number = make_query(&buf.join(" "));
559                            buf.clear();
560                        }
561                        Some('R') => query.reading = Some(!invert),
562                        Some('N') => query.new = Some(!invert),
563                        Some('F') => query.finished = Some(!invert),
564                        Some('A') => query.annotations = Some(!invert),
565                        Some('B') => query.bookmarks = Some(!invert),
566                        Some('O') => {
567                            buf.reverse();
568                            query.opened_after = NaiveDateTime::parse_from_str(
569                                &buf.join(" "),
570                                datetime_format::FORMAT,
571                            )
572                            .ok()
573                            .map(|opened| (!invert, opened));
574                            buf.clear();
575                        }
576                        Some('D') => {
577                            buf.reverse();
578                            query.added_after = NaiveDateTime::parse_from_str(
579                                &buf.join(" "),
580                                datetime_format::FORMAT,
581                            )
582                            .ok()
583                            .map(|added| (!invert, added));
584                            buf.clear();
585                        }
586                        Some('\'') => buf.push(&word[1..]),
587                        _ => (),
588                    }
589                }
590                _ => buf.push(word),
591            }
592        }
593        buf.reverse();
594        query.free = make_query(&buf.join(" "));
595        if query.free.is_none()
596            && query.title.is_none()
597            && query.subtitle.is_none()
598            && query.author.is_none()
599            && query.year.is_none()
600            && query.language.is_none()
601            && query.publisher.is_none()
602            && query.series.is_none()
603            && query.edition.is_none()
604            && query.volume.is_none()
605            && query.number.is_none()
606            && query.reading.is_none()
607            && query.new.is_none()
608            && query.finished.is_none()
609            && query.annotations.is_none()
610            && query.bookmarks.is_none()
611            && query.opened_after.is_none()
612            && query.added_after.is_none()
613        {
614            None
615        } else {
616            Some(query)
617        }
618    }
619
620    #[inline]
621    pub fn is_match(&self, info: &Info) -> bool {
622        self.free.as_ref().map(|re| {
623            re.is_match(&info.title)
624                || re.is_match(&info.subtitle)
625                || re.is_match(&info.author)
626                || re.is_match(&info.series)
627                || info.file.path.to_str().map_or(false, |s| re.is_match(s))
628        }) != Some(false)
629            && self.title.as_ref().map(|re| re.is_match(&info.title)) != Some(false)
630            && self.subtitle.as_ref().map(|re| re.is_match(&info.subtitle)) != Some(false)
631            && self.author.as_ref().map(|re| re.is_match(&info.author)) != Some(false)
632            && self.year.as_ref().map(|re| re.is_match(&info.year)) != Some(false)
633            && self.language.as_ref().map(|re| re.is_match(&info.language)) != Some(false)
634            && self
635                .publisher
636                .as_ref()
637                .map(|re| re.is_match(&info.publisher))
638                != Some(false)
639            && self.series.as_ref().map(|re| re.is_match(&info.series)) != Some(false)
640            && self.edition.as_ref().map(|re| re.is_match(&info.edition)) != Some(false)
641            && self.volume.as_ref().map(|re| re.is_match(&info.volume)) != Some(false)
642            && self.number.as_ref().map(|re| re.is_match(&info.number)) != Some(false)
643            && self
644                .reading
645                .as_ref()
646                .map(|eq| info.simple_status().eq(&SimpleStatus::Reading) == *eq)
647                != Some(false)
648            && self
649                .new
650                .as_ref()
651                .map(|eq| info.simple_status().eq(&SimpleStatus::New) == *eq)
652                != Some(false)
653            && self
654                .finished
655                .as_ref()
656                .map(|eq| info.simple_status().eq(&SimpleStatus::Finished) == *eq)
657                != Some(false)
658            && self.annotations.as_ref().map(|eq| {
659                info.reader
660                    .as_ref()
661                    .map_or(false, |r| !r.annotations.is_empty())
662                    == *eq
663            }) != Some(false)
664            && self.bookmarks.as_ref().map(|eq| {
665                info.reader
666                    .as_ref()
667                    .map_or(false, |r| !r.bookmarks.is_empty())
668                    == *eq
669            }) != Some(false)
670            && self.opened_after.as_ref().map(|(eq, opened)| {
671                info.reader.as_ref().map_or(false, |r| r.opened.gt(opened)) == *eq
672            }) != Some(false)
673            && self
674                .added_after
675                .as_ref()
676                .map(|(eq, added)| info.added.gt(added) == *eq)
677                != Some(false)
678    }
679
680    #[inline]
681    pub fn is_simple_match(&self, text: &str) -> bool {
682        self.free.as_ref().map_or(true, |q| q.is_match(text))
683    }
684}
685
686#[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq)]
687#[serde(rename_all = "kebab-case")]
688pub enum SortMethod {
689    Opened,
690    Added,
691    Status,
692    Progress,
693    Title,
694    Year,
695    Author,
696    Series,
697    Pages,
698    Size,
699    Kind,
700    FileName,
701    FilePath,
702}
703
704impl SortMethod {
705    pub fn reverse_order(self) -> bool {
706        !matches!(
707            self,
708            SortMethod::Author
709                | SortMethod::Title
710                | SortMethod::Series
711                | SortMethod::Kind
712                | SortMethod::FileName
713                | SortMethod::FilePath
714        )
715    }
716
717    pub fn is_status_related(self) -> bool {
718        matches!(
719            self,
720            SortMethod::Opened | SortMethod::Status | SortMethod::Progress
721        )
722    }
723
724    pub fn label(&self) -> &str {
725        match *self {
726            SortMethod::Opened => "Date Opened",
727            SortMethod::Added => "Date Added",
728            SortMethod::Status => "Status",
729            SortMethod::Progress => "Progress",
730            SortMethod::Author => "Author",
731            SortMethod::Title => "Title",
732            SortMethod::Year => "Year",
733            SortMethod::Series => "Series",
734            SortMethod::Size => "File Size",
735            SortMethod::Kind => "File Type",
736            SortMethod::Pages => "Pages Count",
737            SortMethod::FileName => "File Name",
738            SortMethod::FilePath => "File Path",
739        }
740    }
741
742    pub fn title(self) -> String {
743        format!("Sort by: {}", self.label())
744    }
745}
746
747pub fn sort(md: &mut Metadata, sort_method: SortMethod, reverse_order: bool) {
748    let sort_fn = sorter(sort_method);
749
750    if reverse_order {
751        md.sort_by(|a, b| sort_fn(a, b).reverse());
752    } else {
753        md.sort_by(sort_fn);
754    }
755}
756
757#[inline]
758pub fn sorter(sort_method: SortMethod) -> fn(&Info, &Info) -> Ordering {
759    match sort_method {
760        SortMethod::Opened => sort_opened,
761        SortMethod::Added => sort_added,
762        SortMethod::Status => sort_status,
763        SortMethod::Progress => sort_progress,
764        SortMethod::Author => sort_author,
765        SortMethod::Title => sort_title,
766        SortMethod::Year => sort_year,
767        SortMethod::Series => sort_series,
768        SortMethod::Size => sort_size,
769        SortMethod::Kind => sort_kind,
770        SortMethod::Pages => sort_pages,
771        SortMethod::FileName => sort_filename,
772        SortMethod::FilePath => sort_filepath,
773    }
774}
775
776pub fn sort_opened(i1: &Info, i2: &Info) -> Ordering {
777    i1.reader
778        .as_ref()
779        .map(|r1| r1.opened)
780        .cmp(&i2.reader.as_ref().map(|r2| r2.opened))
781}
782
783pub fn sort_added(i1: &Info, i2: &Info) -> Ordering {
784    i1.added.cmp(&i2.added)
785}
786
787pub fn sort_pages(i1: &Info, i2: &Info) -> Ordering {
788    i1.reader
789        .as_ref()
790        .map(|r1| r1.pages_count)
791        .cmp(&i2.reader.as_ref().map(|r2| r2.pages_count))
792}
793
794// FIXME: 'Z'.cmp('É') equals Ordering::Less
795pub fn sort_author(i1: &Info, i2: &Info) -> Ordering {
796    i1.alphabetic_author().cmp(i2.alphabetic_author())
797}
798
799pub fn sort_title(i1: &Info, i2: &Info) -> Ordering {
800    i1.alphabetic_title().cmp(i2.alphabetic_title())
801}
802
803pub fn sort_status(i1: &Info, i2: &Info) -> Ordering {
804    match (i1.simple_status(), i2.simple_status()) {
805        (SimpleStatus::Reading, SimpleStatus::Reading)
806        | (SimpleStatus::Finished, SimpleStatus::Finished) => sort_opened(i1, i2),
807        (SimpleStatus::New, SimpleStatus::New) => sort_added(i1, i2),
808        (SimpleStatus::New, SimpleStatus::Finished) => Ordering::Greater,
809        (SimpleStatus::Finished, SimpleStatus::New) => Ordering::Less,
810        (SimpleStatus::New, SimpleStatus::Reading) => Ordering::Less,
811        (SimpleStatus::Reading, SimpleStatus::New) => Ordering::Greater,
812        (SimpleStatus::Finished, SimpleStatus::Reading) => Ordering::Less,
813        (SimpleStatus::Reading, SimpleStatus::Finished) => Ordering::Greater,
814    }
815}
816
817// Ordering: Finished < New < Reading.
818pub fn sort_progress(i1: &Info, i2: &Info) -> Ordering {
819    match (i1.status(), i2.status()) {
820        (Status::Finished, Status::Finished) => Ordering::Equal,
821        (Status::New, Status::New) => Ordering::Equal,
822        (Status::New, Status::Finished) => Ordering::Greater,
823        (Status::Finished, Status::New) => Ordering::Less,
824        (Status::New, Status::Reading(..)) => Ordering::Less,
825        (Status::Reading(..), Status::New) => Ordering::Greater,
826        (Status::Finished, Status::Reading(..)) => Ordering::Less,
827        (Status::Reading(..), Status::Finished) => Ordering::Greater,
828        (Status::Reading(p1), Status::Reading(p2)) => {
829            p1.partial_cmp(&p2).unwrap_or(Ordering::Equal)
830        }
831    }
832}
833
834pub fn sort_size(i1: &Info, i2: &Info) -> Ordering {
835    i1.file.size.cmp(&i2.file.size)
836}
837
838pub fn sort_kind(i1: &Info, i2: &Info) -> Ordering {
839    i1.file.kind.cmp(&i2.file.kind)
840}
841
842pub fn sort_year(i1: &Info, i2: &Info) -> Ordering {
843    i1.year.cmp(&i2.year)
844}
845
846pub fn sort_series(i1: &Info, i2: &Info) -> Ordering {
847    i1.series.cmp(&i2.series).then_with(|| {
848        usize::from_str_radix(&i1.number, 10)
849            .ok()
850            .zip(usize::from_str_radix(&i2.number, 10).ok())
851            .map_or_else(|| i1.number.cmp(&i2.number), |(a, b)| a.cmp(&b))
852    })
853}
854
855pub fn sort_filename(i1: &Info, i2: &Info) -> Ordering {
856    i1.file.path.file_name().cmp(&i2.file.path.file_name())
857}
858
859pub fn sort_filepath(i1: &Info, i2: &Info) -> Ordering {
860    i1.file.path.cmp(&i2.file.path)
861}
862
863lazy_static! {
864    pub static ref TITLE_PREFIXES: FxHashMap<&'static str, Regex> = {
865        let mut p = FxHashMap::default();
866        p.insert("en", Regex::new(r"^(The|An?)\s").unwrap());
867        p.insert(
868            "fr",
869            Regex::new(r"^(Les?\s|La\s|L’|Une?\s|Des?\s|Du\s)").unwrap(),
870        );
871        p
872    };
873}
874
875#[inline]
876#[cfg_attr(feature = "otel", tracing::instrument(skip(info)))]
877pub fn extract_metadata_from_document(prefix: &Path, info: &mut Info) {
878    let path = prefix.join(&info.file.path);
879
880    match info.file.kind.as_ref() {
881        "epub" => match EpubDocument::new(&path) {
882            Ok(doc) => {
883                info.title = doc.title().unwrap_or_default();
884                info.author = doc.author().unwrap_or_default();
885                info.year = doc.year().unwrap_or_default();
886                info.publisher = doc.publisher().unwrap_or_default();
887                if let Some((title, index)) = doc.series() {
888                    info.series = title;
889                    info.number = index;
890                }
891                info.language = doc.language().unwrap_or_default();
892                info.categories.append(&mut doc.categories());
893            }
894            Err(e) => error!("Can't open {}: {:#}.", info.file.path.display(), e),
895        },
896        "html" | "htm" => match HtmlDocument::new(&path) {
897            Ok(doc) => {
898                info.title = doc.title().unwrap_or_default();
899                info.author = doc.author().unwrap_or_default();
900                info.language = doc.language().unwrap_or_default();
901            }
902            Err(e) => error!("Can't open {}: {:#}.", info.file.path.display(), e),
903        },
904        "pdf" => match PdfOpener::new().and_then(|o| o.open(path).ok()) {
905            Some(doc) => {
906                info.title = doc.title().unwrap_or_default();
907                info.author = doc.author().unwrap_or_default();
908            }
909            None => error!("Can't open {}.", info.file.path.display()),
910        },
911        "djvu" | "djv" => match DjvuOpener::new().and_then(|o| o.open(path)) {
912            Some(doc) => {
913                info.title = doc.title().unwrap_or_default();
914                info.author = doc.author().unwrap_or_default();
915                info.year = doc.year().unwrap_or_default();
916                info.series = doc.series().unwrap_or_default();
917                info.publisher = doc.publisher().unwrap_or_default();
918            }
919            None => error!("Can't open {}.", info.file.path.display()),
920        },
921        _ => {
922            warn!(
923                "Don't know how to extract metadata from {}.",
924                &info.file.kind
925            );
926        }
927    }
928}
929
930pub fn extract_metadata_from_filename(_prefix: &Path, info: &mut Info) {
931    if let Some(filename) = info.file.path.file_name().and_then(OsStr::to_str) {
932        let mut start_index = 0;
933
934        if filename.starts_with('(') {
935            start_index += 1;
936            if let Some(index) = filename[start_index..].find(')') {
937                info.series = filename[start_index..start_index + index]
938                    .trim_end()
939                    .to_string();
940                start_index += index + 1;
941            }
942        }
943
944        if let Some(index) = filename[start_index..].find("- ") {
945            info.author = filename[start_index..start_index + index]
946                .trim()
947                .to_string();
948            start_index += index + 1;
949        }
950
951        let title_start = start_index;
952
953        if let Some(index) = filename[start_index..].find('_') {
954            info.title = filename[start_index..start_index + index]
955                .trim_start()
956                .to_string();
957            start_index += index + 1;
958        }
959
960        if let Some(index) = filename[start_index..].find('-') {
961            if title_start == start_index {
962                info.title = filename[start_index..start_index + index]
963                    .trim_start()
964                    .to_string();
965            } else {
966                info.subtitle = filename[start_index..start_index + index]
967                    .trim_start()
968                    .to_string();
969            }
970            start_index += index + 1;
971        }
972
973        if let Some(index) = filename[start_index..].find('(') {
974            info.publisher = filename[start_index..start_index + index]
975                .trim_end()
976                .to_string();
977            start_index += index + 1;
978        }
979
980        if let Some(index) = filename[start_index..].find(')') {
981            info.year = filename[start_index..start_index + index].to_string();
982        }
983    }
984}
985
986pub fn consolidate(_prefix: &Path, info: &mut Info) {
987    if info.subtitle.is_empty() {
988        if let Some(index) = info.title.find(':') {
989            let cur_title = info.title.clone();
990            let (title, subtitle) = cur_title.split_at(index);
991            info.title = title.trim_end().to_string();
992            info.subtitle = subtitle[1..].trim_start().to_string();
993        }
994    }
995
996    if info.language.is_empty() || info.language.starts_with("en") {
997        info.title = titlecase(&info.title);
998        info.subtitle = titlecase(&info.subtitle);
999    }
1000
1001    info.title = info.title.replace('\'', "’");
1002    info.subtitle = info.subtitle.replace('\'', "’");
1003    info.author = info.author.replace('\'', "’");
1004    if info.year.len() > 4 {
1005        info.year = info.year[..4].to_string();
1006    }
1007    info.series = info.series.replace('\'', "’");
1008    info.publisher = info.publisher.replace('\'', "’");
1009}
1010
1011pub fn rename_from_info(prefix: &Path, info: &mut Info) {
1012    let new_file_name = file_name_from_info(info);
1013    if !new_file_name.is_empty() {
1014        let old_path = prefix.join(&info.file.path);
1015        let new_path = old_path.with_file_name(&new_file_name);
1016        if old_path != new_path {
1017            match fs::rename(&old_path, &new_path) {
1018                Err(e) => error!(
1019                    "Can't rename {} to {}: {:#}.",
1020                    old_path.display(),
1021                    new_path.display(),
1022                    e
1023                ),
1024                Ok(..) => {
1025                    let relat = new_path.strip_prefix(prefix).unwrap_or(&new_path);
1026                    info.file.path = relat.to_path_buf();
1027                }
1028            }
1029        }
1030    }
1031}
1032
1033pub fn file_name_from_info(info: &Info) -> String {
1034    if info.title.is_empty() {
1035        return "".to_string();
1036    }
1037    let mut base = asciify(&info.title);
1038    if !info.subtitle.is_empty() {
1039        base = format!("{} - {}", base, asciify(&info.subtitle));
1040    }
1041    if !info.volume.is_empty() {
1042        base = format!("{} - {}", base, info.volume);
1043    }
1044    if !info.number.is_empty() && info.series.is_empty() {
1045        base = format!("{} - {}", base, info.number);
1046    }
1047    if !info.author.is_empty() {
1048        base = format!("{} - {}", base, asciify(&info.author));
1049    }
1050    base = format!("{}.{}", base, info.file.kind);
1051    base.replace("..", ".")
1052        .replace('/', " ")
1053        .replace('?', "")
1054        .replace('!', "")
1055        .replace(':', "")
1056}