Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 20 additions & 0 deletions crates/citadel-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
36 changes: 36 additions & 0 deletions crates/citadel-core/src/author.rs
Original file line number Diff line number Diff line change
@@ -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<libcalibre::AuthorId, i64>,
) -> 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),
}
}
}
156 changes: 156 additions & 0 deletions crates/citadel-core/src/book.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf>,
}

#[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<String>,
pub title: String,
pub author_list: Vec<LibraryAuthor>,
pub tag_list: Vec<String>,

pub sortable_title: Option<String>,

pub file_list: Vec<BookFile>,

pub cover_image: Option<LocalOrRemoteUrl>,

pub identifier_list: Vec<Identifier>,

pub description: Option<String>,

pub is_read: bool,

pub series: Option<String>,
pub series_index: Option<f32>,

/// 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<String>,
}

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<libcalibre::AuthorId, i64>,
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<LocalOrRemoteUrl> {
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(),
}
}
}
18 changes: 18 additions & 0 deletions crates/citadel-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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};
Loading
Loading