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