From 20fb2188f9af9fe34f62e9b644d3e2d5d48ea760 Mon Sep 17 00:00:00 2001 From: Phil Denhoff Date: Sun, 28 Jun 2026 23:34:16 -0700 Subject: [PATCH] refactor(core): extract LibraryBook DTOs + injectable cover/file URL layer Move the frontend-facing DTOs (LibraryBook, LibraryAuthor, LocalOrRemoteUrl, the file list, Identifier) and the libcalibre->DTO conversion out of the Tauri crate into a new platform-agnostic `citadel-core` crate, so a future citadel-server can serialize the same shapes over HTTP. The cover/file URL scheme is now injected via a `BookUrlBuilder` trait instead of being hardcoded to Tauri's asset:// protocol: - AssetUrlBuilder reproduces the desktop app's asset://localhost/... output byte-for-byte, including the ?v= cache-bust and has_cover trust. - HttpUrlBuilder adds the Remote { url } path (https://.../api/books//...) for a future HTTP caller. citadel-core depends only on libcalibre/serde/specta/urlencoding -- no tauri. The Tauri shell re-exports LibraryBook/LibraryAuthor from citadel-core, injects AssetUrlBuilder, and cover_thumbs now shares citadel_core::path_to_asset_url (libs/util.rs removed). A unit test exercises both builders over the same Book. bindings.ts is unchanged (specta emits identical names/shapes). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 14 +- Cargo.toml | 2 +- crates/citadel-core/Cargo.toml | 20 ++ crates/citadel-core/src/author.rs | 36 ++++ crates/citadel-core/src/book.rs | 156 ++++++++++++++ crates/citadel-core/src/lib.rs | 18 ++ crates/citadel-core/src/url.rs | 290 +++++++++++++++++++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/src/book.rs | 132 +----------- src-tauri/src/libs/calibre/author.rs | 25 --- src-tauri/src/libs/calibre/book.rs | 123 ++---------- src-tauri/src/libs/cover_thumbs.rs | 5 +- src-tauri/src/libs/util.rs | 13 -- src-tauri/src/main.rs | 1 - 14 files changed, 551 insertions(+), 285 deletions(-) create mode 100644 crates/citadel-core/Cargo.toml create mode 100644 crates/citadel-core/src/author.rs create mode 100644 crates/citadel-core/src/book.rs create mode 100644 crates/citadel-core/src/lib.rs create mode 100644 crates/citadel-core/src/url.rs delete mode 100644 src-tauri/src/libs/util.rs diff --git a/Cargo.lock b/Cargo.lock index 96127bff..e3fe910a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -642,11 +642,23 @@ dependencies = [ "inout", ] +[[package]] +name = "citadel-core" +version = "0.1.0" +dependencies = [ + "chrono", + "libcalibre", + "serde", + "specta", + "urlencoding", +] + [[package]] name = "citadel-rs" -version = "0.6.0" +version = "0.6.1" dependencies = [ "chrono", + "citadel-core", "diesel", "epub", "image", diff --git a/Cargo.toml b/Cargo.toml index 2de864e4..5c9a1bdb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/libcalibre", "src-tauri"] +members = ["crates/citadel-core", "crates/libcalibre", "src-tauri"] # Cover thumbnail generation decodes/resizes JPEGs through these crates; # unoptimized they run 20-50x slower, turning the background thumbnail warm diff --git a/crates/citadel-core/Cargo.toml b/crates/citadel-core/Cargo.toml new file mode 100644 index 00000000..f51e8680 --- /dev/null +++ b/crates/citadel-core/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "citadel-core" +version = "0.1.0" +edition = "2021" + +# Platform-agnostic frontend-facing DTOs (`LibraryBook`, `LibraryAuthor`, +# cover/file URLs) and the `libcalibre` -> DTO conversion. Depends on neither +# Tauri nor any HTTP framework: the URL scheme is injected by the caller, so +# both the Tauri app (`asset://`) and a future `citadel-server` (`https://`) +# can build the same shapes. See AGENTS.md. + +[dependencies] +libcalibre = { path = "../libcalibre" } +serde = { version = "1.0", features = ["derive"] } +# Pinned to src-tauri's specta so the `Type` derive emits identical bindings. +specta = { version = "=2.0.0-rc.22", features = ["derive"] } +urlencoding = "2.1.3" + +[dev-dependencies] +chrono = { version = "0.4.31", features = ["serde"] } diff --git a/crates/citadel-core/src/author.rs b/crates/citadel-core/src/author.rs new file mode 100644 index 00000000..56c15da3 --- /dev/null +++ b/crates/citadel-core/src/author.rs @@ -0,0 +1,36 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, specta::Type, Deserialize, Clone)] +pub struct LibraryAuthor { + pub id: String, + pub name: String, + pub sortable_name: String, + /// Number of books in the library linked to this author, from + /// `Library::author_book_counts` (one GROUP BY pass over + /// `books_authors_link`). The Authors page renders this directly + /// instead of deriving counts from the whole book list. + pub book_count: u32, +} + +impl LibraryAuthor { + /// Build the DTO from a hydrated author plus the library-wide + /// `Library::author_book_counts` map (authors absent from the map have + /// no linked books). + pub fn from_author( + author: &libcalibre::library::Author, + book_counts: &HashMap, + ) -> Self { + LibraryAuthor { + id: author.id.as_i32().to_string(), + name: author.name.clone(), + sortable_name: author.sort.clone(), + book_count: book_counts + .get(&author.id) + .copied() + .map(|count| u32::try_from(count).unwrap_or(u32::MAX)) + .unwrap_or(0), + } + } +} diff --git a/crates/citadel-core/src/book.rs b/crates/citadel-core/src/book.rs new file mode 100644 index 00000000..2d1338d1 --- /dev/null +++ b/crates/citadel-core/src/book.rs @@ -0,0 +1,156 @@ +use std::{collections::HashMap, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::author::LibraryAuthor; +use crate::url::BookUrlBuilder; + +#[derive(Serialize, specta::Type, Deserialize, Clone)] +pub enum LocalOrRemote { + Local, + Remote, +} + +#[derive(Serialize, specta::Type, Deserialize, Clone)] +pub struct LocalOrRemoteUrl { + pub kind: LocalOrRemote, + pub url: String, + pub local_path: Option, +} + +#[derive(Serialize, specta::Type, Deserialize, Clone, Debug)] +pub struct LocalFile { + /// The absolute path to the file, including extension. + pub path: PathBuf, + /// The MIME type of the file. Common values are `application/pdf` and `application/epub+zip`. + pub mime_type: String, +} + +#[derive(Serialize, specta::Type, Deserialize, Clone, Debug)] +pub struct RemoteFile { + pub url: String, +} + +#[derive(Serialize, specta::Type, Deserialize, Clone, Debug)] +pub enum BookFile { + Local(LocalFile), + Remote(RemoteFile), +} + +#[derive(Serialize, specta::Type, Deserialize, Clone)] +pub struct LibraryBook { + pub id: String, + pub uuid: Option, + pub title: String, + pub author_list: Vec, + pub tag_list: Vec, + + pub sortable_title: Option, + + pub file_list: Vec, + + pub cover_image: Option, + + pub identifier_list: Vec, + + pub description: Option, + + pub is_read: bool, + + pub series: Option, + pub series_index: Option, + + /// Canonical Calibre language codes (ISO 639-2/3, e.g. `eng`, `fra`), + /// ordered. Empty when the book has no language metadata. + pub language_list: Vec, +} + +impl LibraryBook { + /// Build the frontend DTO from a hydrated `libcalibre` book. + /// + /// `url_builder` decides the cover/file URL scheme — the Tauri app passes + /// an [`AssetUrlBuilder`](crate::AssetUrlBuilder), a server passes an + /// [`HttpUrlBuilder`](crate::HttpUrlBuilder). `cache_bust_cover` is a hint + /// forwarded to the builder (the asset builder appends the cover's mtime so + /// the webview re-fetches a replaced cover; see [`AssetUrlBuilder`]). + pub fn from_library_book( + book: &libcalibre::library::Book, + library_path: &str, + author_book_counts: &HashMap, + url_builder: &dyn BookUrlBuilder, + cache_bust_cover: bool, + ) -> Self { + Self { + id: book.id.as_i32().to_string(), + uuid: Some(book.uuid.clone()), + title: book.title.clone(), + author_list: book + .authors + .iter() + .map(|author| LibraryAuthor::from_author(author, author_book_counts)) + .collect(), + tag_list: book.tags.clone(), + sortable_title: book.sortable_title.clone(), + identifier_list: book.identifiers.iter().map(Identifier::from).collect(), + description: book.description.clone(), + is_read: book.is_read, + series: book.series.clone(), + series_index: book.series_index, + language_list: book.language_codes.clone(), + cover_image: book_cover_url(book, library_path, url_builder, cache_bust_cover), + file_list: book + .files + .iter() + .map(|file| { + let file_name_with_ext = + format!("{}.{}", file.name, file.format.to_lowercase()); + let file_path = PathBuf::from(library_path) + .join(&book.book_dir_path) + .join(file_name_with_ext); + + url_builder.file_url(book, file, &file_path) + }) + .collect(), + } + } +} + +/// Build the cover-image URL for a book, or `None` when it has no cover. +/// +/// Trusts the books table's `has_cover` flag rather than statting cover.jpg per +/// book — the per-book `exists` check dominated list rendering at thousands of +/// books. A stale flag yields a dangling URL, which the frontend's cover +/// `onerror` fallback absorbs. +fn book_cover_url( + book: &libcalibre::library::Book, + library_path: &str, + url_builder: &dyn BookUrlBuilder, + cache_bust: bool, +) -> Option { + if !book.has_cover { + return None; + } + + let cover_relative_path = format!("{}/cover.jpg", &book.book_dir_path); + let cover_image_path = PathBuf::from(library_path).join(&cover_relative_path); + + Some(url_builder.cover_url(book, &cover_image_path, cache_bust)) +} + +/// Book identifiers, such as ISBN, DOI, Google Books ID, etc. +#[derive(Serialize, Deserialize, Clone, specta::Type)] +pub struct Identifier { + pub id: i32, + pub label: String, + pub value: String, +} + +impl From<&libcalibre::BookIdentifier> for Identifier { + fn from(identifier: &libcalibre::BookIdentifier) -> Self { + Identifier { + id: identifier.id, + label: identifier.label.clone(), + value: identifier.value.clone(), + } + } +} diff --git a/crates/citadel-core/src/lib.rs b/crates/citadel-core/src/lib.rs new file mode 100644 index 00000000..937f4bce --- /dev/null +++ b/crates/citadel-core/src/lib.rs @@ -0,0 +1,18 @@ +//! Platform-agnostic core for Citadel. +//! +//! Owns the frontend-facing DTOs ([`LibraryBook`], [`LibraryAuthor`], +//! [`LocalOrRemoteUrl`], the file list) and the conversion from a `libcalibre` +//! [`Book`](libcalibre::library::Book) into them. None of this depends on +//! Tauri: the cover/file URL scheme is injected through [`BookUrlBuilder`], so +//! the Tauri app supplies an [`AssetUrlBuilder`] (`asset://`) and a future +//! `citadel-server` supplies an [`HttpUrlBuilder`] (`https://…/api/…`). + +mod author; +mod book; +mod url; + +pub use author::LibraryAuthor; +pub use book::{ + BookFile, Identifier, LibraryBook, LocalFile, LocalOrRemote, LocalOrRemoteUrl, RemoteFile, +}; +pub use url::{path_to_asset_url, AssetUrlBuilder, BookUrlBuilder, HttpUrlBuilder}; diff --git a/crates/citadel-core/src/url.rs b/crates/citadel-core/src/url.rs new file mode 100644 index 00000000..f7f3e60d --- /dev/null +++ b/crates/citadel-core/src/url.rs @@ -0,0 +1,290 @@ +use std::path::Path; +use std::time::UNIX_EPOCH; + +use crate::book::{BookFile, LocalFile, LocalOrRemote, LocalOrRemoteUrl, RemoteFile}; + +/// Strategy for turning a book's on-disk cover/file paths into the +/// URL-bearing DTO the frontend consumes. +/// +/// The URL scheme is the only thing that differs between deployments, so it is +/// injected here rather than hardcoded: the Tauri app passes an +/// [`AssetUrlBuilder`] (`asset://`), a future `citadel-server` passes an +/// [`HttpUrlBuilder`] (`https://…/api/…`). Both receive the same `libcalibre` +/// book and the absolute on-host path; each decides URL, kind, and whether to +/// retain the local path. +pub trait BookUrlBuilder { + /// URL representation of a book's cover image. `cover_path` is the + /// absolute path to `cover.jpg` on the machine hosting the library (only + /// meaningful to a local builder). `cache_bust` asks the builder to make + /// the URL change when the cover bytes change. + fn cover_url( + &self, + book: &libcalibre::library::Book, + cover_path: &Path, + cache_bust: bool, + ) -> LocalOrRemoteUrl; + + /// URL representation of one of a book's files. `file_path` is the absolute + /// path on the host; `file` is the `libcalibre` record (name, format, …). + fn file_url( + &self, + book: &libcalibre::library::Book, + file: &libcalibre::BookFileInfo, + file_path: &Path, + ) -> BookFile; +} + +/// Builds Tauri `asset://` URLs that point at files on the local filesystem. +/// This reproduces exactly the URLs the desktop app has always emitted: +/// `Local` cover/file DTOs that retain their absolute `local_path`. +pub struct AssetUrlBuilder; + +impl BookUrlBuilder for AssetUrlBuilder { + fn cover_url( + &self, + _book: &libcalibre::library::Book, + cover_path: &Path, + cache_bust: bool, + ) -> LocalOrRemoteUrl { + let mut url = path_to_asset_url(cover_path); + + // The cover path is stable (`…/cover.jpg`), so the webview caches it by + // URL and keeps showing a replaced cover (e.g. after a metadata + // lookup). Tag the URL with the cover's mtime so it re-fetches. Only + // the single-book path does this — the list paths stay stat-free, which + // matters at thousands of books (and the grid already cache-busts via + // per-mtime thumbnail file names). + if cache_bust { + if let Some(mtime_ms) = cover_mtime_ms(cover_path) { + url = format!("{url}?v={mtime_ms}"); + } + } + + LocalOrRemoteUrl { + kind: LocalOrRemote::Local, + local_path: Some(cover_path.to_path_buf()), + url, + } + } + + fn file_url( + &self, + _book: &libcalibre::library::Book, + file: &libcalibre::BookFileInfo, + file_path: &Path, + ) -> BookFile { + BookFile::Local(LocalFile { + path: file_path.to_path_buf(), + mime_type: file.format.clone(), + }) + } +} + +/// Builds `https://…/api/…` URLs for a client/server deployment, where the +/// browser fetches covers and files over HTTP rather than from the local disk. +/// Emits `Remote` DTOs (no `local_path`). +pub struct HttpUrlBuilder { + base_url: String, +} + +impl HttpUrlBuilder { + /// `base_url` is the server origin without a trailing slash, e.g. + /// `https://library.example.com`. + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + } + } +} + +impl BookUrlBuilder for HttpUrlBuilder { + fn cover_url( + &self, + book: &libcalibre::library::Book, + _cover_path: &Path, + _cache_bust: bool, + ) -> LocalOrRemoteUrl { + LocalOrRemoteUrl { + kind: LocalOrRemote::Remote, + local_path: None, + url: format!("{}/api/books/{}/cover", self.base_url, book.id.as_i32()), + } + } + + fn file_url( + &self, + book: &libcalibre::library::Book, + file: &libcalibre::BookFileInfo, + _file_path: &Path, + ) -> BookFile { + BookFile::Remote(RemoteFile { + url: format!( + "{}/api/books/{}/files/{}.{}", + self.base_url, + book.id.as_i32(), + file.name, + file.format.to_lowercase() + ), + }) + } +} + +/// Converts an absolute file path to a Tauri `asset://` URL the frontend can +/// load. Windows/Android use the `http://asset.localhost/` form instead. +pub fn path_to_asset_url(file_path: &Path) -> String { + let os_name = std::env::consts::OS; + let protocol = "asset"; + let path = urlencoding::encode(file_path.to_str().unwrap()); + if os_name == "windows" || os_name == "android" { + format!("http://{}.localhost/{}", protocol, path) + } else { + format!("{}://localhost/{}", protocol, path) + } +} + +/// The cover file's modification time in epoch milliseconds, used as a +/// cache-busting token. `None` if the file is missing or unreadable. +fn cover_mtime_ms(path: &Path) -> Option { + let modified = std::fs::metadata(path).ok()?.modified().ok()?; + let since = modified.duration_since(UNIX_EPOCH).ok()?; + Some(since.as_millis() as i64) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::book::{BookFile, LibraryBook, LocalOrRemote}; + + use super::*; + + fn test_book( + has_cover: bool, + files: Vec, + ) -> libcalibre::library::Book { + libcalibre::library::Book { + id: libcalibre::BookId::from(1), + uuid: "test-uuid".to_string(), + title: "Title".to_string(), + sortable_title: None, + authors: vec![], + tags: vec![], + series: None, + series_index: None, + description: None, + language_codes: vec![], + identifiers: vec![], + has_cover, + is_read: false, + files, + created_at: chrono::DateTime::UNIX_EPOCH.naive_utc(), + updated_at: chrono::DateTime::UNIX_EPOCH.naive_utc(), + book_dir_path: "Author/Title (1)".to_string(), + } + } + + fn epub_file() -> libcalibre::BookFileInfo { + libcalibre::BookFileInfo { + id: 1, + format: "EPUB".to_string(), + name: "Title - Author".to_string(), + uncompressed_size: 0, + } + } + + #[test] + fn no_cover_flag_yields_no_cover_url() { + let book = LibraryBook::from_library_book( + &test_book(false, vec![]), + "/library", + &HashMap::new(), + &AssetUrlBuilder, + false, + ); + assert!(book.cover_image.is_none()); + } + + /// `has_cover` is trusted without statting cover.jpg (the per-book + /// `PathBuf::exists` dominated list-all at 5k books). A stale flag yields a + /// dangling URL, which the frontend's onerror fallback absorbs. With + /// `cache_bust = true` and the cover file absent there is no mtime, so the + /// URL carries no `?v=` token. + #[test] + fn asset_builder_trusts_has_cover_without_filesystem_check() { + let book = LibraryBook::from_library_book( + &test_book(true, vec![]), + "/definitely/not/a/real/library", + &HashMap::new(), + &AssetUrlBuilder, + true, + ); + + let cover = book.cover_image.expect("has_cover implies a cover URL"); + assert!(matches!(cover.kind, LocalOrRemote::Local)); + let path = cover.local_path.expect("local cover keeps its path"); + assert!(path.ends_with("cover.jpg")); + assert!(!path.exists()); + assert!(cover.url.starts_with("asset://") || cover.url.contains("asset.localhost")); + assert!(cover.url.contains("cover.jpg")); + assert!(!cover.url.contains("?v=")); + } + + /// The acceptance criterion: the same `Book` serialized through the two + /// builders produces local `asset://` shapes vs remote `https://…/api/…` + /// shapes, with identical book metadata otherwise. + #[test] + fn asset_and_http_builders_diverge_only_on_urls() { + let book = test_book(true, vec![epub_file()]); + + let local = LibraryBook::from_library_book( + &book, + "/library", + &HashMap::new(), + &AssetUrlBuilder, + false, + ); + let remote = LibraryBook::from_library_book( + &book, + "/library", + &HashMap::new(), + &HttpUrlBuilder::new("https://example.com"), + false, + ); + + // Same underlying book metadata. + assert_eq!(local.id, remote.id); + assert_eq!(local.title, remote.title); + + // Cover: local asset URL with a retained path vs remote HTTP URL. + let local_cover = local.cover_image.expect("asset cover"); + assert!(matches!(local_cover.kind, LocalOrRemote::Local)); + assert!(local_cover.local_path.is_some()); + assert!(local_cover.url.contains("cover.jpg")); + + let remote_cover = remote.cover_image.expect("http cover"); + assert!(matches!(remote_cover.kind, LocalOrRemote::Remote)); + assert!(remote_cover.local_path.is_none()); + assert_eq!( + remote_cover.url, + "https://example.com/api/books/1/cover".to_string() + ); + + // File list: Local(LocalFile) vs Remote(RemoteFile). + match &local.file_list[0] { + BookFile::Local(f) => { + assert!(f.path.ends_with("Title - Author.epub")); + assert_eq!(f.mime_type, "EPUB"); + } + BookFile::Remote(_) => panic!("asset builder should emit a local file"), + } + match &remote.file_list[0] { + BookFile::Remote(f) => { + assert_eq!( + f.url, + "https://example.com/api/books/1/files/Title - Author.epub".to_string() + ); + } + BookFile::Local(_) => panic!("http builder should emit a remote file"), + } + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e3b6a57c..762bc007 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ chrono = { version = "0.4.31", features = ["serde"] } diesel = { version = "2.1.0", features = ["sqlite", "chrono", "returning_clauses_for_sqlite_3_35"] } epub = "2.1.1" mobi = "0.8.0" +citadel-core = { path = "../crates/citadel-core" } libcalibre = { path = "../crates/libcalibre" } log = "0.4" quick-xml = "0.38" diff --git a/src-tauri/src/book.rs b/src-tauri/src/book.rs index ece11cd1..dbd704d1 100644 --- a/src-tauri/src/book.rs +++ b/src-tauri/src/book.rs @@ -3,119 +3,7 @@ use std::{collections::HashMap, path::PathBuf}; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; -#[derive(Serialize, specta::Type, Deserialize, Clone)] -pub enum LocalOrRemote { - Local, - Remote, -} - -#[derive(Serialize, specta::Type, Deserialize, Clone)] -pub struct LocalOrRemoteUrl { - pub kind: LocalOrRemote, - pub url: String, - pub local_path: Option, -} -#[derive(Serialize, specta::Type, Deserialize, Clone, Debug)] -pub struct LocalFile { - /// The absolute path to the file, including extension. - pub path: PathBuf, - /// The MIME type of the file. Common values are `application/pdf` and `application/epub+zip`. - pub mime_type: String, -} - -#[derive(Serialize, specta::Type, Deserialize, Clone, Debug)] -pub struct RemoteFile { - pub url: String, -} - -#[derive(Serialize, specta::Type, Deserialize, Clone, Debug)] -pub enum BookFile { - Local(LocalFile), - Remote(RemoteFile), -} - -#[derive(Serialize, specta::Type, Deserialize, Clone)] -pub struct LibraryBook { - pub id: String, - pub uuid: Option, - pub title: String, - pub author_list: Vec, - pub tag_list: Vec, - - pub sortable_title: Option, - - pub file_list: Vec, - - pub cover_image: Option, - - pub identifier_list: Vec, - - pub description: Option, - - pub is_read: bool, - - pub series: Option, - pub series_index: Option, - - /// Canonical Calibre language codes (ISO 639-2/3, e.g. `eng`, `fra`), - /// ordered. Empty when the book has no language metadata. - pub language_list: Vec, -} - -impl LibraryBook { - pub fn from_library_book( - book: &libcalibre::library::Book, - library_path: &str, - author_book_counts: &HashMap, - ) -> Self { - Self { - id: book.id.as_i32().to_string(), - uuid: Some(book.uuid.clone()), - title: book.title.clone(), - author_list: book - .authors - .iter() - .map(|author| LibraryAuthor::from_author(author, author_book_counts)) - .collect(), - tag_list: book.tags.clone(), - sortable_title: book.sortable_title.clone(), - identifier_list: book.identifiers.iter().map(Identifier::from).collect(), - description: book.description.clone(), - is_read: book.is_read, - series: book.series.clone(), - series_index: book.series_index, - language_list: book.language_codes.clone(), - cover_image: None, - file_list: book - .files - .iter() - .map(|file| { - let file_name_with_ext = - format!("{}.{}", file.name, file.format.to_lowercase()); - - BookFile::Local(LocalFile { - path: PathBuf::from(library_path) - .join(&book.book_dir_path) - .join(file_name_with_ext), - mime_type: file.format.clone(), - }) - }) - .collect(), - } - } -} - -#[derive(Serialize, specta::Type, Deserialize, Clone)] -pub struct LibraryAuthor { - pub id: String, - pub name: String, - pub sortable_name: String, - /// Number of books in the library linked to this author, from - /// `Library::author_book_counts` (one GROUP BY pass over - /// `books_authors_link`). The Authors page renders this directly - /// instead of deriving counts from the whole book list. - pub book_count: u32, -} +pub use citadel_core::{LibraryAuthor, LibraryBook}; #[derive(Serialize, Deserialize, specta::Type)] pub enum ImportableBookType { @@ -186,21 +74,3 @@ impl ImportableBookMetadata { } } } - -/// Book identifiers, such as ISBN, DOI, Google Books ID, etc. -#[derive(Serialize, Deserialize, Clone, specta::Type)] -pub struct Identifier { - pub id: i32, - pub label: String, - pub value: String, -} - -impl From<&libcalibre::BookIdentifier> for Identifier { - fn from(identifier: &libcalibre::BookIdentifier) -> Self { - Identifier { - id: identifier.id, - label: identifier.label.clone(), - value: identifier.value.clone(), - } - } -} diff --git a/src-tauri/src/libs/calibre/author.rs b/src-tauri/src/libs/calibre/author.rs index ef42593a..509fac44 100644 --- a/src-tauri/src/libs/calibre/author.rs +++ b/src-tauri/src/libs/calibre/author.rs @@ -1,30 +1,5 @@ -use std::collections::HashMap; - use serde::{Deserialize, Serialize}; -use crate::book::LibraryAuthor; - -impl LibraryAuthor { - /// Build the DTO from a hydrated author plus the library-wide - /// `Library::author_book_counts` map (authors absent from the map have - /// no linked books). - pub fn from_author( - author: &libcalibre::library::Author, - book_counts: &HashMap, - ) -> Self { - LibraryAuthor { - id: author.id.as_i32().to_string(), - name: author.name.clone(), - sortable_name: author.sort.clone(), - book_count: book_counts - .get(&author.id) - .copied() - .map(|count| u32::try_from(count).unwrap_or(u32::MAX)) - .unwrap_or(0), - } - } -} - #[derive(Serialize, specta::Type, Deserialize, Clone)] pub struct NewAuthor { pub name: String, diff --git a/src-tauri/src/libs/calibre/book.rs b/src-tauri/src/libs/calibre/book.rs index 3ef30315..b03beac6 100644 --- a/src-tauri/src/libs/calibre/book.rs +++ b/src-tauri/src/libs/calibre/book.rs @@ -1,74 +1,25 @@ -use std::{ - collections::HashMap, - path::{Path, PathBuf}, - time::UNIX_EPOCH, -}; +use std::collections::HashMap; +use citadel_core::{AssetUrlBuilder, LibraryBook}; use libcalibre::{AuthorId, Library}; -use crate::{ - book::{LibraryBook, LocalOrRemote, LocalOrRemoteUrl}, - libs::util, -}; - -/// Generate a LocalOrRemoteUrl for the cover image of a book. -/// -/// Trusts the `has_cover` flag from the books table (Calibre keeps it -/// accurate, and our own cover writes — `Library::set_book_cover` and -/// `Library::add_book` — set it alongside writing cover.jpg) instead of -/// statting cover.jpg per book: at 5k books the per-book -/// `PathBuf::exists` dominated the (since-retired) whole-library list -/// command (~90% of 340ms). -/// If the flag is stale (file deleted behind Calibre's back) the URL -/// dangles and the frontend's cover `onerror` fallback takes over. -fn book_cover_image( - library_root: &str, - book: &libcalibre::library::Book, - cache_bust: bool, -) -> Option { - if !book.has_cover { - return None; - } - - let cover_relative_path = format!("{}/cover.jpg", &book.book_dir_path); - let cover_image_path = PathBuf::from(library_root).join(&cover_relative_path); - let mut url = util::path_to_asset_url(&cover_image_path); - - // The cover path is stable (`…/cover.jpg`), so the webview caches it by URL - // and keeps showing a replaced cover (e.g. after a metadata lookup). Tag the - // URL with the cover's mtime so it re-fetches. Only the single-book path does - // this — the list paths stay stat-free, which matters at thousands of books - // (and the grid already cache-busts via per-mtime thumbnail file names). - if cache_bust { - if let Some(mtime_ms) = cover_mtime_ms(&cover_image_path) { - url = format!("{url}?v={mtime_ms}"); - } - } - - Some(LocalOrRemoteUrl { - kind: LocalOrRemote::Local, - local_path: Some(cover_image_path), - url, - }) -} - -/// The cover file's modification time in epoch milliseconds, used as a -/// cache-busting token. `None` if the file is missing or unreadable. -fn cover_mtime_ms(path: &Path) -> Option { - let modified = std::fs::metadata(path).ok()?.modified().ok()?; - let since = modified.duration_since(UNIX_EPOCH).ok()?; - Some(since.as_millis() as i64) -} - +/// Hydrate one `libcalibre` book into the frontend DTO using the desktop +/// app's `asset://` URL scheme. `cache_bust_cover` forwards to the builder: +/// the single-book path sets it so a freshly applied cover re-fetches; the +/// list paths leave it off to stay stat-free at thousands of books. fn to_library_book( library_root: &str, book: &libcalibre::library::Book, author_book_counts: &HashMap, cache_bust_cover: bool, ) -> LibraryBook { - let mut library_book = LibraryBook::from_library_book(book, library_root, author_book_counts); - library_book.cover_image = book_cover_image(library_root, book, cache_bust_cover); - library_book + LibraryBook::from_library_book( + book, + library_root, + author_book_counts, + &AssetUrlBuilder, + cache_bust_cover, + ) } /// One book, hydrated exactly like a `query_page` item (authors, tags, @@ -120,51 +71,3 @@ pub fn query_page( page.total, )) } - -#[cfg(test)] -mod tests { - use super::*; - - fn test_book(has_cover: bool) -> libcalibre::library::Book { - libcalibre::library::Book { - id: libcalibre::BookId::from(1), - uuid: "test-uuid".to_string(), - title: "Title".to_string(), - sortable_title: None, - authors: vec![], - tags: vec![], - series: None, - series_index: None, - description: None, - language_codes: vec![], - identifiers: vec![], - has_cover, - is_read: false, - files: vec![], - created_at: chrono::DateTime::UNIX_EPOCH.naive_utc(), - updated_at: chrono::DateTime::UNIX_EPOCH.naive_utc(), - book_dir_path: "Author/Title (1)".to_string(), - } - } - - #[test] - fn no_cover_flag_yields_no_cover_url() { - assert!(book_cover_image("/library", &test_book(false), false).is_none()); - } - - /// `has_cover` is trusted without statting cover.jpg (the per-book - /// `PathBuf::exists` dominated list-all at 5k books). A stale flag - /// yields a dangling URL, which the frontend's onerror fallback absorbs. - #[test] - fn has_cover_flag_trusted_without_filesystem_check() { - // cache_bust=true also exercises the graceful path when the cover file - // is absent: no mtime, so the URL is returned without a `?v=` token. - let cover = book_cover_image("/definitely/not/a/real/library", &test_book(true), true) - .expect("has_cover implies a cover URL"); - assert!(matches!(cover.kind, LocalOrRemote::Local)); - let path = cover.local_path.expect("local cover keeps its path"); - assert!(path.ends_with("cover.jpg")); - assert!(!path.exists()); - assert!(cover.url.contains("cover.jpg")); - } -} diff --git a/src-tauri/src/libs/cover_thumbs.rs b/src-tauri/src/libs/cover_thumbs.rs index 97cb1dc2..c6997482 100644 --- a/src-tauri/src/libs/cover_thumbs.rs +++ b/src-tauri/src/libs/cover_thumbs.rs @@ -18,11 +18,10 @@ use std::hash::{DefaultHasher, Hash, Hasher}; use std::path::{Path, PathBuf}; use std::sync::Mutex; +use citadel_core::path_to_asset_url; use image::imageops::FilterType; use serde::{Deserialize, Serialize}; -use crate::libs::util; - /// Thumbnail width in device pixels: 2× the grid's ~150 CSS px cells. const THUMB_WIDTH: u32 = 300; /// Tall covers are capped so a degenerate aspect ratio cannot explode the @@ -108,7 +107,7 @@ fn cover_mtime_ms(cover_path: &Path) -> Option { fn to_thumbnail(book_id: &str, cache_dir: &Path, meta: &ThumbMeta) -> CoverThumbnail { CoverThumbnail { book_id: book_id.to_string(), - url: util::path_to_asset_url(&cache_dir.join(&meta.file_name)), + url: path_to_asset_url(&cache_dir.join(&meta.file_name)), width: meta.width, height: meta.height, } diff --git a/src-tauri/src/libs/util.rs b/src-tauri/src/libs/util.rs deleted file mode 100644 index 306e6195..00000000 --- a/src-tauri/src/libs/util.rs +++ /dev/null @@ -1,13 +0,0 @@ -use std::path::Path; - -/// Converts an absolute file path to a URL that can be used by a Tauri frontend. -pub fn path_to_asset_url(file_path: &Path) -> String { - let os_name = std::env::consts::OS; - let protocol = "asset"; - let path = urlencoding::encode(file_path.to_str().unwrap()); - if os_name == "windows" || os_name == "android" { - format!("http://{}.localhost/{}", protocol, path) - } else { - format!("{}://localhost/{}", protocol, path) - } -} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 78207cc0..fdc16779 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -12,7 +12,6 @@ pub mod libs { pub mod calibre; pub mod cover_thumbs; pub mod file_formats; - mod util; } mod book; mod menu;