use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; /// Format a datetime as Kobo expects: "2024-01-01T00:00:00Z" pub fn format_timestamp(dt: &DateTime) -> String { dt.format("%Y-%m-%dT%H:%M:%SZ").to_string() } /// Supported book formats for Kobo sync. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KoboFormat { Kepub, Epub, Epub3, Epub3Fl, } impl KoboFormat { pub fn as_str(&self) -> &'static str { match self { Self::Kepub => "KEPUB", Self::Epub => "EPUB", Self::Epub3 => "EPUB3", Self::Epub3Fl => "EPUB3FL", } } } /// A download URL entry in book metadata. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct DownloadUrl { pub format: String, pub size: i64, pub url: String, pub platform: String, } impl DownloadUrl { pub fn new(format: &str, size: i64, url: String) -> Self { Self { format: format.to_string(), size, url, platform: "Generic".to_string(), } } } /// Price display structure. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct DisplayPrice { pub currency_code: String, pub total_amount: f64, } impl Default for DisplayPrice { fn default() -> Self { Self { currency_code: "USD".to_string(), total_amount: 0.0, } } } /// Love price display (just total amount). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct LoveDisplayPrice { pub total_amount: f64, } impl Default for LoveDisplayPrice { fn default() -> Self { Self { total_amount: 0.0 } } } /// Publisher information. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct Publisher { pub imprint: String, pub name: String, } /// Contributor role. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct ContributorRole { pub name: String, } /// Series information. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct Series { pub name: String, pub number: i32, pub number_float: f64, pub id: String, } impl Series { pub fn new(name: &str, number: f64) -> Self { let id = Uuid::new_v3(&Uuid::NAMESPACE_DNS, name.as_bytes()); Self { name: name.to_string(), number: number as i32, number_float: number, id: id.to_string(), } } } /// Active period for an entitlement. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct ActivePeriod { pub from: String, } /// Book entitlement - ownership/access metadata. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct BookEntitlement { pub accessibility: String, pub active_period: ActivePeriod, pub created: String, pub cross_revision_id: String, pub id: String, pub is_removed: bool, pub is_hidden_from_archive: bool, pub is_locked: bool, pub last_modified: String, pub origin_category: String, pub revision_id: String, pub status: String, } impl BookEntitlement { pub fn new( book_uuid: &str, created: &DateTime, last_modified: &DateTime, is_archived: bool, ) -> Self { Self { accessibility: "Full".to_string(), active_period: ActivePeriod { from: format_timestamp(created), }, created: format_timestamp(created), cross_revision_id: book_uuid.to_string(), id: book_uuid.to_string(), is_removed: is_archived, is_hidden_from_archive: false, is_locked: false, last_modified: format_timestamp(last_modified), origin_category: "Imported".to_string(), revision_id: book_uuid.to_string(), status: "Active".to_string(), } } } /// Book metadata - bibliographic information. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct BookMetadata { pub categories: Vec, pub cover_image_id: String, pub cross_revision_id: String, pub current_display_price: DisplayPrice, pub current_love_display_price: LoveDisplayPrice, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub download_urls: Vec, pub entitlement_id: String, pub external_ids: Vec, pub genre: String, pub is_eligible_for_kobo_love: bool, pub is_internet_archive: bool, pub is_pre_order: bool, pub is_social_enabled: bool, pub language: String, pub phonetic_pronunciations: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] pub publication_date: Option, pub publisher: Publisher, pub revision_id: String, pub title: String, pub work_id: String, pub contributor_roles: Vec, pub contributors: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub series: Option, } /// Default category UUID used by Kobo. const DEFAULT_CATEGORY_ID: &str = "00000000-0000-0000-0000-000000000001"; /// Build the `CoverImageId` the device echoes into its cover URL template. /// Unversioned covers (`cover_version == 0`) use the bare book uuid, so existing /// devices are unaffected; a replaced cover appends `_v{n}` so the device sees a /// new id and re-fetches the image. [`strip_cover_version`] reverses this on the /// device cover route. pub fn cover_image_id(book_uuid: &str, cover_version: i64) -> String { if cover_version > 0 { format!("{book_uuid}_v{cover_version}") } else { book_uuid.to_string() } } /// Recover the bare book uuid from a device `ImageId` that may carry a `_v{n}` /// cover-version suffix (see [`cover_image_id`]). Only a trailing `_v` followed /// by digits is stripped, so real uuids (hex + hyphens) pass through untouched. pub fn strip_cover_version(image_id: &str) -> &str { match image_id.rsplit_once("_v") { Some((uuid, ver)) if !ver.is_empty() && ver.bytes().all(|b| b.is_ascii_digit()) => uuid, _ => image_id, } } impl BookMetadata { #[allow(clippy::too_many_arguments)] pub fn new( book_uuid: &str, title: &str, authors: &[String], description: Option, publisher_name: Option, language: Option, publication_date: Option>, series: Option, download_urls: Vec, cover_version: i64, ) -> Self { Self { categories: vec![DEFAULT_CATEGORY_ID.to_string()], cover_image_id: cover_image_id(book_uuid, cover_version), cross_revision_id: book_uuid.to_string(), current_display_price: DisplayPrice::default(), current_love_display_price: LoveDisplayPrice::default(), description, download_urls, entitlement_id: book_uuid.to_string(), external_ids: Vec::new(), genre: DEFAULT_CATEGORY_ID.to_string(), is_eligible_for_kobo_love: false, is_internet_archive: false, is_pre_order: false, is_social_enabled: true, language: language.unwrap_or_else(|| "en".to_string()), phonetic_pronunciations: serde_json::json!({}), publication_date: publication_date.map(|dt| format_timestamp(&dt)), publisher: Publisher { imprint: String::new(), name: publisher_name.unwrap_or_default(), }, revision_id: book_uuid.to_string(), title: title.to_string(), work_id: book_uuid.to_string(), contributor_roles: authors .iter() .map(|name| ContributorRole { name: name.clone() }) .collect(), contributors: authors.to_vec(), series, } } } /// Location in a bookmark. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct BookmarkLocation { #[serde(skip_serializing_if = "Option::is_none")] pub value: Option, #[serde(rename = "Type")] #[serde(skip_serializing_if = "Option::is_none")] pub location_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, } /// Current bookmark / reading position. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct CurrentBookmark { pub last_modified: String, #[serde(skip_serializing_if = "Option::is_none")] pub progress_percent: Option, #[serde(skip_serializing_if = "Option::is_none")] pub content_source_progress_percent: Option, #[serde(skip_serializing_if = "Option::is_none")] pub location: Option, } /// Reading statistics. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct Statistics { pub last_modified: String, #[serde(skip_serializing_if = "Option::is_none")] pub spent_reading_minutes: Option, #[serde(skip_serializing_if = "Option::is_none")] pub remaining_time_minutes: Option, } /// Reading status information. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct StatusInfo { pub last_modified: String, pub status: String, pub times_started_reading: i32, #[serde(skip_serializing_if = "Option::is_none")] pub last_time_started_reading: Option, } /// Complete reading state. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct ReadingState { pub entitlement_id: String, pub created: String, pub last_modified: String, pub priority_timestamp: String, pub status_info: StatusInfo, #[serde(skip_serializing_if = "Option::is_none")] pub statistics: Option, #[serde(skip_serializing_if = "Option::is_none")] pub current_bookmark: Option, } /// Reading status enum mapped to Kobo API strings. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReadStatus { Unread, InProgress, Finished, } impl ReadStatus { pub fn as_str(&self) -> &'static str { match self { Self::Unread => "ReadyToRead", Self::InProgress => "Reading", Self::Finished => "Finished", } } pub fn from_kobo_str(s: &str) -> Self { match s { "Reading" => Self::InProgress, "Finished" => Self::Finished, _ => Self::Unread, } } pub fn from_db_int(v: i32) -> Self { match v { 1 => Self::Finished, 2 => Self::InProgress, _ => Self::Unread, } } pub fn to_db_int(self) -> i32 { match self { Self::Unread => 0, Self::Finished => 1, Self::InProgress => 2, } } } /// Clean a progress percentage: if it's a whole number, return as int. pub fn clean_progress(value: f64) -> serde_json::Value { if value.fract() == 0.0 { serde_json::Value::Number(serde_json::Number::from(value as i64)) } else { serde_json::Number::from_f64(value) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null) } } /// Tag/shelf item. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct TagItem { pub revision_id: String, #[serde(rename = "Type")] pub item_type: String, } impl TagItem { pub fn new(book_uuid: &str) -> Self { Self { revision_id: book_uuid.to_string(), item_type: "ProductRevisionTagItem".to_string(), } } } /// Tag/shelf/collection. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct Tag { pub created: String, pub id: String, pub items: Vec, pub last_modified: String, pub name: String, #[serde(rename = "Type")] pub tag_type: String, } impl Tag { pub fn new( shelf_uuid: &str, name: &str, created: &DateTime, last_modified: &DateTime, book_uuids: Vec, ) -> Self { Self { created: format_timestamp(created), id: shelf_uuid.to_string(), items: book_uuids.iter().map(|uuid| TagItem::new(uuid)).collect(), last_modified: format_timestamp(last_modified), name: name.to_string(), tag_type: "UserTag".to_string(), } } } /// A single sync response item (the variants that can appear in the sync array). #[derive(Debug, Clone, Serialize, Deserialize)] pub enum SyncItem { NewEntitlement { #[serde(rename = "BookEntitlement")] book_entitlement: BookEntitlement, #[serde(rename = "BookMetadata")] book_metadata: BookMetadata, #[serde(rename = "ReadingState", skip_serializing_if = "Option::is_none")] reading_state: Option, }, ChangedEntitlement { #[serde(rename = "BookEntitlement")] book_entitlement: BookEntitlement, #[serde(rename = "BookMetadata")] book_metadata: BookMetadata, #[serde(rename = "ReadingState", skip_serializing_if = "Option::is_none")] reading_state: Option, }, ChangedReadingState { #[serde(rename = "ReadingState")] reading_state: ReadingState, }, NewTag { #[serde(rename = "Tag")] tag: Tag, }, ChangedTag { #[serde(rename = "Tag")] tag: Tag, }, DeletedTag { #[serde(rename = "Tag")] tag: DeletedTagInfo, }, } /// Minimal tag info for deletion. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct DeletedTagInfo { pub id: String, pub last_modified: String, } /// Auth response returned to Kobo device. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct AuthResponse { pub access_token: String, pub refresh_token: String, pub token_type: String, pub tracking_id: String, pub user_key: String, } /// Auth request from Kobo device. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct AuthRequest { #[serde(default)] pub user_key: String, } /// State update request from Kobo device. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct StateUpdateRequest { pub reading_states: Vec, } /// A single reading state update. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct ReadingStateUpdate { #[serde(skip_serializing_if = "Option::is_none")] pub current_bookmark: Option, #[serde(skip_serializing_if = "Option::is_none")] pub statistics: Option, #[serde(skip_serializing_if = "Option::is_none")] pub status_info: Option, } /// Bookmark update from device. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct BookmarkUpdate { #[serde(skip_serializing_if = "Option::is_none")] pub progress_percent: Option, #[serde(skip_serializing_if = "Option::is_none")] pub content_source_progress_percent: Option, #[serde(skip_serializing_if = "Option::is_none")] pub location: Option, } /// Statistics update from device. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct StatisticsUpdate { #[serde(skip_serializing_if = "Option::is_none")] pub spent_reading_minutes: Option, #[serde(skip_serializing_if = "Option::is_none")] pub remaining_time_minutes: Option, } /// Status info update from device. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct StatusInfoUpdate { #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, } /// State update response. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct StateUpdateResponse { pub request_result: String, pub update_results: Vec, } /// Individual update result. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct UpdateResult { pub entitlement_id: String, pub current_bookmark_result: OperationResult, pub statistics_result: OperationResult, pub status_info_result: OperationResult, pub last_modified: String, pub priority_timestamp: String, } /// Generic operation result. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct OperationResult { pub result: String, } impl OperationResult { pub fn success() -> Self { Self { result: "Success".to_string(), } } } /// Tag creation request. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct TagCreateRequest { pub name: String, #[serde(default)] pub items: Vec, } /// Tag items request (add/remove items). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct TagItemsRequest { pub items: Vec, } #[cfg(test)] mod tests { use super::{cover_image_id, strip_cover_version}; const UUID: &str = "a1b2c3d4-0000-4000-8000-000000000001"; #[test] fn unversioned_cover_id_is_bare_uuid() { assert_eq!(cover_image_id(UUID, 0), UUID); } #[test] fn versioned_cover_id_has_suffix() { assert_eq!(cover_image_id(UUID, 3), format!("{UUID}_v3")); } #[test] fn strip_recovers_uuid_from_both_forms() { assert_eq!(strip_cover_version(UUID), UUID); assert_eq!(strip_cover_version(&format!("{UUID}_v3")), UUID); assert_eq!(strip_cover_version(&format!("{UUID}_v42")), UUID); } #[test] fn strip_leaves_non_version_suffix_untouched() { // A trailing "_v" without digits, or non-numeric, is not a version. assert_eq!(strip_cover_version("book_vX"), "book_vX"); assert_eq!(strip_cover_version("book_v"), "book_v"); } #[test] fn cover_id_round_trips_through_strip() { assert_eq!(strip_cover_version(&cover_image_id(UUID, 7)), UUID); assert_eq!(strip_cover_version(&cover_image_id(UUID, 0)), UUID); } }