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 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
440pub(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
461pub(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
805pub 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
839pub 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#[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
978fn 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 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 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 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 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}