1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Book {
9 pub id: Uuid,
10 pub title: String,
11 pub subtitle: Option<String>,
12 pub description: Option<String>,
13 pub page_count: Option<i32>,
14 pub pub_date: Option<String>,
15 pub language: Option<String>,
16 pub format: BookFormat,
17 pub duration_minutes: Option<i32>,
19 pub cover_hash: Option<String>,
20 pub work_id: Option<Uuid>,
22 pub status: ReadingStatus,
23 pub rating: Option<i32>,
24 pub created_at: DateTime<Utc>,
25 pub updated_at: DateTime<Utc>,
26}
27
28impl Book {
29 pub fn new(title: impl Into<String>) -> Self {
30 let now = Utc::now();
31 Self {
32 id: Uuid::now_v7(),
33 title: title.into(),
34 subtitle: None,
35 description: None,
36 page_count: None,
37 pub_date: None,
38 language: None,
39 format: BookFormat::Physical,
40 duration_minutes: None,
41 cover_hash: None,
42 work_id: None,
43 status: ReadingStatus::WantToRead,
44 rating: None,
45 created_at: now,
46 updated_at: now,
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53pub enum BookFormat {
54 Physical,
55 Ebook,
56 Audiobook,
57}
58
59impl BookFormat {
60 pub fn as_str(&self) -> &'static str {
61 match self {
62 Self::Physical => "physical",
63 Self::Ebook => "ebook",
64 Self::Audiobook => "audiobook",
65 }
66 }
67}
68
69impl std::fmt::Display for BookFormat {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 f.write_str(self.as_str())
72 }
73}
74
75impl std::str::FromStr for BookFormat {
76 type Err = crate::TokuError;
77
78 fn from_str(s: &str) -> Result<Self, Self::Err> {
79 match s.to_lowercase().as_str() {
80 "physical" => Ok(Self::Physical),
81 "ebook" => Ok(Self::Ebook),
82 "audiobook" => Ok(Self::Audiobook),
83 _ => Err(crate::TokuError::InvalidFormat(s.to_string())),
84 }
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ReadingSession {
91 pub id: Uuid,
92 pub book_id: Uuid,
93 pub started_at: DateTime<Utc>,
94 pub finished_at: Option<DateTime<Utc>>,
95 pub start_page: Option<i32>,
96 pub end_page: Option<i32>,
97 pub rating: Option<i32>,
98 pub notes: Option<String>,
99 pub created_at: DateTime<Utc>,
100}
101
102impl ReadingSession {
103 pub fn new(book_id: Uuid) -> Self {
104 let now = Utc::now();
105 Self {
106 id: Uuid::now_v7(),
107 book_id,
108 started_at: now,
109 finished_at: None,
110 start_page: None,
111 end_page: None,
112 rating: None,
113 notes: None,
114 created_at: now,
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121pub enum ProgressType {
122 Page,
123 Percent,
124 Chapter,
125 Duration,
127}
128
129impl ProgressType {
130 pub fn as_str(&self) -> &'static str {
131 match self {
132 Self::Page => "page",
133 Self::Percent => "percent",
134 Self::Chapter => "chapter",
135 Self::Duration => "duration",
136 }
137 }
138}
139
140impl std::fmt::Display for ProgressType {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 f.write_str(self.as_str())
143 }
144}
145
146impl std::str::FromStr for ProgressType {
147 type Err = crate::TokuError;
148
149 fn from_str(s: &str) -> Result<Self, Self::Err> {
150 match s.to_lowercase().as_str() {
151 "page" => Ok(Self::Page),
152 "percent" => Ok(Self::Percent),
153 "chapter" => Ok(Self::Chapter),
154 "duration" => Ok(Self::Duration),
155 _ => Err(crate::TokuError::InvalidProgressType(s.to_string())),
156 }
157 }
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ReadingProgress {
163 pub id: Uuid,
164 pub book_id: Uuid,
165 pub session_id: Option<Uuid>,
166 pub progress_type: ProgressType,
167 pub value: i32,
168 pub note: Option<String>,
169 pub logged_at: DateTime<Utc>,
170 pub created_at: DateTime<Utc>,
171}
172
173impl ReadingProgress {
174 pub fn new(book_id: Uuid, progress_type: ProgressType, value: i32) -> Self {
175 let now = Utc::now();
176 Self {
177 id: Uuid::now_v7(),
178 book_id,
179 session_id: None,
180 progress_type,
181 value,
182 note: None,
183 logged_at: now,
184 created_at: now,
185 }
186 }
187}
188
189pub fn parse_duration_to_minutes(s: &str) -> Result<i32, crate::TokuError> {
193 let s = s.trim();
194
195 if let Some(h_pos) = s.find('h') {
197 let hours_str = &s[..h_pos];
198 let rest = s[h_pos + 1..].trim();
199
200 if rest.is_empty() {
201 let hours: f64 = hours_str
203 .parse()
204 .map_err(|_| crate::TokuError::InvalidDuration(s.to_string()))?;
205 return Ok((hours * 60.0).round() as i32);
206 }
207
208 let mins_str = rest.trim_end_matches('m');
209 let hours: f64 = hours_str
210 .parse()
211 .map_err(|_| crate::TokuError::InvalidDuration(s.to_string()))?;
212 let mins: f64 = mins_str
213 .parse()
214 .map_err(|_| crate::TokuError::InvalidDuration(s.to_string()))?;
215 return Ok((hours * 60.0 + mins).round() as i32);
216 }
217
218 if let Some(m_str) = s.strip_suffix('m') {
220 let mins: f64 = m_str
221 .parse()
222 .map_err(|_| crate::TokuError::InvalidDuration(s.to_string()))?;
223 return Ok(mins.round() as i32);
224 }
225
226 let mins: f64 = s
228 .parse()
229 .map_err(|_| crate::TokuError::InvalidDuration(s.to_string()))?;
230 Ok(mins.round() as i32)
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
235pub enum ReadingStatus {
236 WantToRead,
237 Reading,
238 Read,
239 Abandoned,
240 OnHold,
241}
242
243impl ReadingStatus {
244 pub fn as_str(&self) -> &'static str {
245 match self {
246 Self::WantToRead => "want-to-read",
247 Self::Reading => "reading",
248 Self::Read => "read",
249 Self::Abandoned => "abandoned",
250 Self::OnHold => "on-hold",
251 }
252 }
253
254 pub fn can_transition_to(&self, target: &ReadingStatus) -> bool {
256 matches!(
257 (self, target),
258 (ReadingStatus::WantToRead, ReadingStatus::Reading)
259 | (ReadingStatus::Reading, ReadingStatus::Read)
260 | (ReadingStatus::Reading, ReadingStatus::Abandoned)
261 | (ReadingStatus::Reading, ReadingStatus::OnHold)
262 | (ReadingStatus::OnHold, ReadingStatus::Reading)
263 | (ReadingStatus::Abandoned, ReadingStatus::Reading)
264 | (ReadingStatus::Read, ReadingStatus::Reading) )
266 }
267}
268
269impl std::fmt::Display for ReadingStatus {
270 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271 f.write_str(self.as_str())
272 }
273}
274
275impl std::str::FromStr for ReadingStatus {
276 type Err = crate::TokuError;
277
278 fn from_str(s: &str) -> Result<Self, Self::Err> {
279 match s.to_lowercase().as_str() {
280 "want-to-read" | "to-read" => Ok(Self::WantToRead),
281 "reading" | "currently-reading" => Ok(Self::Reading),
282 "read" => Ok(Self::Read),
283 "abandoned" | "dnf" => Ok(Self::Abandoned),
284 "on-hold" | "paused" => Ok(Self::OnHold),
285 _ => Err(crate::TokuError::InvalidStatus(s.to_string())),
286 }
287 }
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
292pub enum ContributorRole {
293 Author,
294 Editor,
295 Translator,
296 Illustrator,
297 Narrator,
298}
299
300impl ContributorRole {
301 pub fn as_str(&self) -> &'static str {
302 match self {
303 Self::Author => "author",
304 Self::Editor => "editor",
305 Self::Translator => "translator",
306 Self::Illustrator => "illustrator",
307 Self::Narrator => "narrator",
308 }
309 }
310}
311
312impl std::fmt::Display for ContributorRole {
313 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314 f.write_str(self.as_str())
315 }
316}
317
318impl std::str::FromStr for ContributorRole {
319 type Err = crate::TokuError;
320
321 fn from_str(s: &str) -> Result<Self, Self::Err> {
322 match s.to_lowercase().as_str() {
323 "author" => Ok(Self::Author),
324 "editor" => Ok(Self::Editor),
325 "translator" => Ok(Self::Translator),
326 "illustrator" => Ok(Self::Illustrator),
327 "narrator" => Ok(Self::Narrator),
328 _ => Err(crate::TokuError::InvalidRole(s.to_string())),
329 }
330 }
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct Author {
336 pub id: Uuid,
337 pub name: String,
338 pub sort_name: Option<String>,
339}
340
341impl Author {
342 pub fn new(name: impl Into<String>) -> Self {
343 let name = name.into();
344 let sort_name = guess_sort_name(&name);
345 Self {
346 id: Uuid::now_v7(),
347 name,
348 sort_name: Some(sort_name),
349 }
350 }
351}
352
353fn guess_sort_name(name: &str) -> String {
355 let parts: Vec<&str> = name.split_whitespace().collect();
356 if parts.len() <= 1 {
357 return name.to_string();
358 }
359 let last = parts.last().unwrap();
360 let rest: Vec<&str> = parts[..parts.len() - 1].to_vec();
361 format!("{}, {}", last, rest.join(" "))
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct Shelf {
368 pub id: Uuid,
369 pub name: String,
370 pub is_smart: bool,
371 pub smart_filter: Option<String>,
372 pub created_at: DateTime<Utc>,
373}
374
375impl Shelf {
376 pub fn new(name: impl Into<String>) -> Self {
377 Self {
378 id: Uuid::now_v7(),
379 name: name.into(),
380 is_smart: false,
381 smart_filter: None,
382 created_at: Utc::now(),
383 }
384 }
385
386 pub fn new_smart(name: impl Into<String>, filter_json: String) -> Self {
387 Self {
388 id: Uuid::now_v7(),
389 name: name.into(),
390 is_smart: true,
391 smart_filter: Some(filter_json),
392 created_at: Utc::now(),
393 }
394 }
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
399pub enum TagType {
400 General,
401 Mood,
402 Pace,
403 ContentWarning,
404}
405
406impl TagType {
407 pub fn as_str(&self) -> &'static str {
408 match self {
409 Self::General => "general",
410 Self::Mood => "mood",
411 Self::Pace => "pace",
412 Self::ContentWarning => "content_warning",
413 }
414 }
415}
416
417impl std::fmt::Display for TagType {
418 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
419 f.write_str(self.as_str())
420 }
421}
422
423impl std::str::FromStr for TagType {
424 type Err = crate::TokuError;
425
426 fn from_str(s: &str) -> Result<Self, Self::Err> {
427 match s.to_lowercase().as_str() {
428 "general" => Ok(Self::General),
429 "mood" => Ok(Self::Mood),
430 "pace" => Ok(Self::Pace),
431 "content_warning" | "content-warning" | "cw" => Ok(Self::ContentWarning),
432 _ => Err(crate::TokuError::InvalidTagType(s.to_string())),
433 }
434 }
435}
436
437#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
439pub enum PaceRating {
440 Fast,
441 Medium,
442 Slow,
443}
444
445impl PaceRating {
446 pub fn as_str(&self) -> &'static str {
447 match self {
448 Self::Fast => "fast",
449 Self::Medium => "medium",
450 Self::Slow => "slow",
451 }
452 }
453}
454
455impl std::fmt::Display for PaceRating {
456 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457 f.write_str(self.as_str())
458 }
459}
460
461impl std::str::FromStr for PaceRating {
462 type Err = crate::TokuError;
463
464 fn from_str(s: &str) -> Result<Self, Self::Err> {
465 match s.to_lowercase().as_str() {
466 "fast" => Ok(Self::Fast),
467 "medium" | "med" => Ok(Self::Medium),
468 "slow" => Ok(Self::Slow),
469 _ => Err(crate::TokuError::InvalidPaceRating(s.to_string())),
470 }
471 }
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct Tag {
478 pub id: Uuid,
479 pub name: String,
480 pub tag_type: TagType,
481 pub created_at: DateTime<Utc>,
482}
483
484impl Tag {
485 pub fn new(name: impl Into<String>) -> Self {
486 Self {
487 id: Uuid::now_v7(),
488 name: name.into(),
489 tag_type: TagType::General,
490 created_at: Utc::now(),
491 }
492 }
493
494 pub fn with_type(name: impl Into<String>, tag_type: TagType) -> Self {
495 Self {
496 id: Uuid::now_v7(),
497 name: name.into(),
498 tag_type,
499 created_at: Utc::now(),
500 }
501 }
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct BookAuthor {
507 pub book_id: Uuid,
508 pub author_id: Uuid,
509 pub role: ContributorRole,
510 pub position: i32,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct Series {
516 pub id: Uuid,
517 pub name: String,
518 pub total_books: Option<i32>,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct BookSeries {
524 pub book_id: Uuid,
525 pub series_id: Uuid,
526 pub position: Option<String>,
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct Work {
533 pub id: Uuid,
534 pub title: String,
535 pub original_language: Option<String>,
536 pub first_published: Option<String>,
537 pub created_at: DateTime<Utc>,
538}
539
540impl Work {
541 pub fn new(title: impl Into<String>) -> Self {
542 Self {
543 id: Uuid::now_v7(),
544 title: title.into(),
545 original_language: None,
546 first_published: None,
547 created_at: Utc::now(),
548 }
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 #[test]
557 fn book_new_has_defaults() {
558 let book = Book::new("Dune");
559 assert_eq!(book.title, "Dune");
560 assert_eq!(book.status, ReadingStatus::WantToRead);
561 assert_eq!(book.format, BookFormat::Physical);
562 assert!(book.subtitle.is_none());
563 assert!(book.rating.is_none());
564 }
565
566 #[test]
567 fn work_new_has_defaults() {
568 let work = Work::new("Dune");
569 assert_eq!(work.title, "Dune");
570 assert!(work.original_language.is_none());
571 assert!(work.first_published.is_none());
572 }
573
574 #[test]
575 fn author_sort_name() {
576 let author = Author::new("Frank Herbert");
579 assert_eq!(author.sort_name.as_deref(), Some("Herbert, Frank"));
580 }
581
582 #[test]
583 fn author_single_name() {
584 let author = Author::new("Voltaire");
585 assert_eq!(author.sort_name.as_deref(), Some("Voltaire"));
586 }
587
588 #[test]
589 fn reading_status_roundtrip() {
590 for status in [
591 ReadingStatus::WantToRead,
592 ReadingStatus::Reading,
593 ReadingStatus::Read,
594 ReadingStatus::Abandoned,
595 ReadingStatus::OnHold,
596 ] {
597 let parsed: ReadingStatus = status.as_str().parse().unwrap();
598 assert_eq!(parsed, status);
599 }
600 }
601
602 #[test]
603 fn book_format_roundtrip() {
604 for fmt in [
605 BookFormat::Physical,
606 BookFormat::Ebook,
607 BookFormat::Audiobook,
608 ] {
609 let parsed: BookFormat = fmt.as_str().parse().unwrap();
610 assert_eq!(parsed, fmt);
611 }
612 }
613
614 #[test]
615 fn reading_status_goodreads_aliases() {
616 assert_eq!(
617 "currently-reading".parse::<ReadingStatus>().unwrap(),
618 ReadingStatus::Reading
619 );
620 assert_eq!(
621 "to-read".parse::<ReadingStatus>().unwrap(),
622 ReadingStatus::WantToRead
623 );
624 assert_eq!(
625 "dnf".parse::<ReadingStatus>().unwrap(),
626 ReadingStatus::Abandoned
627 );
628 }
629
630 #[test]
633 fn valid_transitions() {
634 let valid = [
635 (ReadingStatus::WantToRead, ReadingStatus::Reading),
636 (ReadingStatus::Reading, ReadingStatus::Read),
637 (ReadingStatus::Reading, ReadingStatus::Abandoned),
638 (ReadingStatus::Reading, ReadingStatus::OnHold),
639 (ReadingStatus::OnHold, ReadingStatus::Reading),
640 (ReadingStatus::Abandoned, ReadingStatus::Reading),
641 (ReadingStatus::Read, ReadingStatus::Reading), ];
643
644 for (from, to) in &valid {
645 assert!(from.can_transition_to(to), "{from} → {to} should be valid");
646 }
647 }
648
649 #[test]
650 fn invalid_transitions() {
651 let invalid = [
652 (ReadingStatus::WantToRead, ReadingStatus::Read),
653 (ReadingStatus::WantToRead, ReadingStatus::Abandoned),
654 (ReadingStatus::WantToRead, ReadingStatus::OnHold),
655 (ReadingStatus::Read, ReadingStatus::Abandoned),
656 (ReadingStatus::Read, ReadingStatus::OnHold),
657 (ReadingStatus::Read, ReadingStatus::WantToRead),
658 (ReadingStatus::Abandoned, ReadingStatus::Read),
659 (ReadingStatus::Abandoned, ReadingStatus::OnHold),
660 (ReadingStatus::OnHold, ReadingStatus::Read),
661 (ReadingStatus::OnHold, ReadingStatus::Abandoned),
662 (ReadingStatus::Reading, ReadingStatus::Reading),
664 (ReadingStatus::WantToRead, ReadingStatus::WantToRead),
665 ];
666
667 for (from, to) in &invalid {
668 assert!(
669 !from.can_transition_to(to),
670 "{from} → {to} should be invalid"
671 );
672 }
673 }
674
675 #[test]
676 fn reading_session_new_defaults() {
677 let book_id = Uuid::now_v7();
678 let session = ReadingSession::new(book_id);
679 assert_eq!(session.book_id, book_id);
680 assert!(session.finished_at.is_none());
681 assert!(session.rating.is_none());
682 assert!(session.notes.is_none());
683 }
684
685 #[test]
686 fn progress_type_roundtrip() {
687 for pt in [
688 ProgressType::Page,
689 ProgressType::Percent,
690 ProgressType::Chapter,
691 ProgressType::Duration,
692 ] {
693 let parsed: ProgressType = pt.as_str().parse().unwrap();
694 assert_eq!(parsed, pt);
695 }
696 }
697
698 #[test]
699 fn progress_type_display() {
700 assert_eq!(ProgressType::Page.to_string(), "page");
701 assert_eq!(ProgressType::Duration.to_string(), "duration");
702 }
703
704 #[test]
705 fn progress_type_invalid() {
706 assert!("invalid".parse::<ProgressType>().is_err());
707 }
708
709 #[test]
710 fn reading_progress_new_defaults() {
711 let book_id = Uuid::now_v7();
712 let progress = ReadingProgress::new(book_id, ProgressType::Page, 42);
713 assert_eq!(progress.book_id, book_id);
714 assert_eq!(progress.progress_type, ProgressType::Page);
715 assert_eq!(progress.value, 42);
716 assert!(progress.session_id.is_none());
717 assert!(progress.note.is_none());
718 }
719
720 #[test]
721 fn parse_duration_hours_minutes() {
722 assert_eq!(parse_duration_to_minutes("5h30m").unwrap(), 330);
723 assert_eq!(parse_duration_to_minutes("1h0m").unwrap(), 60);
724 assert_eq!(parse_duration_to_minutes("0h45m").unwrap(), 45);
725 }
726
727 #[test]
728 fn parse_duration_minutes_only() {
729 assert_eq!(parse_duration_to_minutes("330m").unwrap(), 330);
730 assert_eq!(parse_duration_to_minutes("90m").unwrap(), 90);
731 }
732
733 #[test]
734 fn parse_duration_hours_only() {
735 assert_eq!(parse_duration_to_minutes("5h").unwrap(), 300);
736 assert_eq!(parse_duration_to_minutes("5.5h").unwrap(), 330);
737 assert_eq!(parse_duration_to_minutes("2.25h").unwrap(), 135);
738 }
739
740 #[test]
741 fn parse_duration_plain_number() {
742 assert_eq!(parse_duration_to_minutes("90").unwrap(), 90);
743 }
744
745 #[test]
746 fn parse_duration_invalid() {
747 assert!(parse_duration_to_minutes("abc").is_err());
748 assert!(parse_duration_to_minutes("").is_err());
749 }
750
751 #[test]
752 fn tag_type_roundtrip() {
753 for tt in [
754 TagType::General,
755 TagType::Mood,
756 TagType::Pace,
757 TagType::ContentWarning,
758 ] {
759 let parsed: TagType = tt.as_str().parse().unwrap();
760 assert_eq!(parsed, tt);
761 }
762 }
763
764 #[test]
765 fn tag_type_aliases() {
766 assert_eq!(
767 "content-warning".parse::<TagType>().unwrap(),
768 TagType::ContentWarning
769 );
770 assert_eq!("cw".parse::<TagType>().unwrap(), TagType::ContentWarning);
771 }
772
773 #[test]
774 fn tag_type_invalid() {
775 assert!("unknown".parse::<TagType>().is_err());
776 }
777
778 #[test]
779 fn pace_rating_roundtrip() {
780 for pr in [PaceRating::Fast, PaceRating::Medium, PaceRating::Slow] {
781 let parsed: PaceRating = pr.as_str().parse().unwrap();
782 assert_eq!(parsed, pr);
783 }
784 }
785
786 #[test]
787 fn pace_rating_alias_med() {
788 assert_eq!("med".parse::<PaceRating>().unwrap(), PaceRating::Medium);
789 }
790
791 #[test]
792 fn pace_rating_invalid() {
793 assert!("very-fast".parse::<PaceRating>().is_err());
794 }
795
796 #[test]
797 fn tag_with_type() {
798 let tag = Tag::with_type("adventurous", TagType::Mood);
799 assert_eq!(tag.name, "adventurous");
800 assert_eq!(tag.tag_type, TagType::Mood);
801 }
802
803 #[test]
804 fn tag_new_defaults_to_general() {
805 let tag = Tag::new("sci-fi");
806 assert_eq!(tag.tag_type, TagType::General);
807 }
808}