diff --git a/Cargo.lock b/Cargo.lock index 9cc1bf9..2eed675 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,10 +295,14 @@ dependencies = [ "once_cell", "poem", "rust-embed", + "serde", + "serde_json", "tera", "thiserror", "tokio", + "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/calibre-db/src/calibre.rs b/calibre-db/src/calibre.rs index 9226367..8356b91 100644 --- a/calibre-db/src/calibre.rs +++ b/calibre-db/src/calibre.rs @@ -3,7 +3,9 @@ use std::path::Path; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; -use crate::data::{author::Author, book::Book, error::DataStoreError, pagination::SortOrder}; +use crate::data::{ + author::Author, book::Book, error::DataStoreError, pagination::SortOrder, series::Series, +}; #[derive(Debug, Clone)] pub struct Calibre { @@ -25,7 +27,7 @@ impl Calibre { sort_order: SortOrder, ) -> Result, DataStoreError> { let conn = self.pool.get()?; - Book::books(&conn, limit, cursor, sort_order) + Book::multiple(&conn, limit, cursor, sort_order) } pub fn authors( @@ -35,7 +37,7 @@ impl Calibre { sort_order: SortOrder, ) -> Result, DataStoreError> { let conn = self.pool.get()?; - Author::authors(&conn, limit, cursor, sort_order) + Author::multiple(&conn, limit, cursor, sort_order) } pub fn author_books( @@ -58,6 +60,26 @@ impl Calibre { let conn = self.pool.get()?; Book::scalar_book(&conn, id) } + + pub fn book_author(&self, id: u64) -> Result { + let conn = self.pool.get()?; + Author::book_author(&conn, id) + } + + pub fn series( + &self, + limit: u64, + cursor: Option<&str>, + sort_order: SortOrder, + ) -> Result, DataStoreError> { + let conn = self.pool.get()?; + Series::multiple(&conn, limit, cursor, sort_order) + } + + pub fn book_series(&self, id: u64) -> Result, DataStoreError> { + let conn = self.pool.get()?; + Series::book_series(&conn, id) + } } #[cfg(test)] diff --git a/calibre-db/src/data/author.rs b/calibre-db/src/data/author.rs index a804ec5..6bcb988 100644 --- a/calibre-db/src/data/author.rs +++ b/calibre-db/src/data/author.rs @@ -1,4 +1,4 @@ -use rusqlite::{Connection, Row}; +use rusqlite::{named_params, Connection, Row}; use serde::Serialize; use super::{ @@ -8,7 +8,7 @@ use super::{ #[derive(Debug, Serialize)] pub struct Author { - pub id: i32, + pub id: u64, pub name: String, pub sort: String, } @@ -22,12 +22,12 @@ impl Author { }) } - pub fn authors( + pub fn multiple( conn: &Connection, limit: u64, cursor: Option<&str>, sort_order: SortOrder, - ) -> Result, DataStoreError> { + ) -> Result, DataStoreError> { let pagination = Pagination::new("sort", cursor, limit, sort_order); pagination.paginate( conn, @@ -36,4 +36,14 @@ impl Author { Self::from_row, ) } + + pub fn book_author(conn: &Connection, id: u64) -> Result { + let mut stmt = conn.prepare( + "SELECT authors.id, authors.name, authors.sort FROM authors \ + INNER JOIN books_authors_link ON authors.id = books_authors_link.author \ + WHERE books_authors_link.book = (:id)", + )?; + let params = named_params! { ":id": id }; + Ok(stmt.query_row(params, Self::from_row)?) + } } diff --git a/calibre-db/src/data/book.rs b/calibre-db/src/data/book.rs index 18c373b..ebf6b0a 100644 --- a/calibre-db/src/data/book.rs +++ b/calibre-db/src/data/book.rs @@ -8,7 +8,7 @@ use super::{ #[derive(Debug, Serialize)] pub struct Book { - pub id: i32, + pub id: u64, pub title: String, pub sort: String, pub path: String, @@ -24,12 +24,12 @@ impl Book { }) } - pub fn books( + pub fn multiple( conn: &Connection, limit: u64, cursor: Option<&str>, sort_order: SortOrder, - ) -> Result, DataStoreError> { + ) -> Result, DataStoreError> { let pagination = Pagination::new("sort", cursor, limit, sort_order); pagination.paginate( conn, @@ -45,7 +45,7 @@ impl Book { limit: u64, cursor: Option<&str>, sort_order: SortOrder, - ) -> Result, DataStoreError> { + ) -> Result, DataStoreError> { let pagination = Pagination::new("books.sort", cursor, limit, sort_order); pagination.paginate( conn, @@ -57,7 +57,7 @@ impl Book { ) } - pub fn recents(conn: &Connection, limit: u64) -> Result, DataStoreError> { + pub fn recents(conn: &Connection, limit: u64) -> Result, DataStoreError> { let mut stmt = conn.prepare( "SELECT id, title, sort, path FROM books ORDER BY timestamp DESC LIMIT (:limit)", )?; @@ -66,7 +66,7 @@ impl Book { Ok(iter.filter_map(Result::ok).collect()) } - pub fn scalar_book(conn: &Connection, id: u64) -> Result { + pub fn scalar_book(conn: &Connection, id: u64) -> Result { let mut stmt = conn.prepare("SELECT id, title, sort, path FROM books WHERE id = (:id)")?; let params = named_params! { ":id": id }; Ok(stmt.query_row(params, Self::from_row)?) diff --git a/calibre-db/src/data/error.rs b/calibre-db/src/data/error.rs index 35526fc..eef40b2 100644 --- a/calibre-db/src/data/error.rs +++ b/calibre-db/src/data/error.rs @@ -1,9 +1,21 @@ use thiserror::Error; #[derive(Error, Debug)] +#[error("data store error")] pub enum DataStoreError { + #[error("no results")] + NoResults(rusqlite::Error), #[error("sqlite error")] - SqliteError(#[from] rusqlite::Error), + SqliteError(rusqlite::Error), #[error("connection error")] ConnectionError(#[from] r2d2::Error), } + +impl From for DataStoreError { + fn from(error: rusqlite::Error) -> Self { + match error { + rusqlite::Error::QueryReturnedNoRows => DataStoreError::NoResults(error), + _ => DataStoreError::SqliteError(error), + } + } +} diff --git a/calibre-db/src/data/series.rs b/calibre-db/src/data/series.rs new file mode 100644 index 0000000..e81d46d --- /dev/null +++ b/calibre-db/src/data/series.rs @@ -0,0 +1,64 @@ +use rusqlite::{named_params, Connection, Row}; +use serde::Serialize; + +use super::{ + error::DataStoreError, + pagination::{Pagination, SortOrder}, +}; + +#[derive(Debug, Serialize)] +pub struct Series { + pub id: u64, + pub name: String, + pub sort: String, +} + +impl Series { + fn from_row(row: &Row<'_>) -> Result { + Ok(Self { + id: row.get(0)?, + name: row.get(1)?, + sort: row.get(2)?, + }) + } + + pub fn multiple( + conn: &Connection, + limit: u64, + cursor: Option<&str>, + sort_order: SortOrder, + ) -> Result, DataStoreError> { + let pagination = Pagination::new("sort", cursor, limit, sort_order); + pagination.paginate( + conn, + "SELECT id, title, sort, path FROM series", + &[], + Self::from_row, + ) + } + + pub fn book_series( + conn: &Connection, + book_id: u64, + ) -> Result, DataStoreError> { + let mut stmt = conn.prepare( + "SELECT series.id, series.name, series.sort, books.series_index FROM series \ + INNER JOIN books_series_link ON series.id = books_series_link.series \ + INNER JOIN books ON books.id = books_series_link.book \ + WHERE books_series_link.book = (:id)", + )?; + let params = named_params! { ":id": book_id }; + + let from_row = |row: &Row<'_>| { + let series = Self::from_row(row)?; + let series_idx = row.get(3)?; + Ok((series, series_idx)) + }; + + match stmt.query_row(params, from_row) { + Ok(series) => Ok(Some(series)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(DataStoreError::SqliteError(e)), + } + } +} diff --git a/calibre-db/src/lib.rs b/calibre-db/src/lib.rs index 2a70732..c7a0452 100644 --- a/calibre-db/src/lib.rs +++ b/calibre-db/src/lib.rs @@ -4,4 +4,5 @@ pub mod data { pub mod book; pub mod error; pub mod pagination; + pub mod series; } diff --git a/cops-web/Cargo.toml b/cops-web/Cargo.toml index f618e48..d72f021 100644 --- a/cops-web/Cargo.toml +++ b/cops-web/Cargo.toml @@ -9,7 +9,11 @@ clap = { version = "4.5.4", features = ["derive"] } once_cell = "1.19.0" poem = { version = "3.0.0", features = ["embed", "static-files"] } rust-embed = "8.3.0" +serde = { workspace = true } +serde_json = "1.0.116" tera = "1.19.1" thiserror = { workspace = true } tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros"] } +tracing = "0.1.40" tracing-subscriber = "0.3.18" +uuid = { version = "1.8.0", features = ["v4", "fast-rng"] } diff --git a/cops-web/src/data/book.rs b/cops-web/src/data/book.rs new file mode 100644 index 0000000..1f71614 --- /dev/null +++ b/cops-web/src/data/book.rs @@ -0,0 +1,29 @@ +use calibre_db::data::{book::Book as DbBook, series::Series as DbSeries}; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct Book { + pub id: u64, + pub title: String, + pub sort: String, + pub path: String, + pub author: String, + pub series: Option<(String, f64)>, +} + +impl Book { + pub fn from_db_book( + db_book: &DbBook, + db_series: Option<(DbSeries, f64)>, + author: &str, + ) -> Self { + Self { + id: db_book.id, + title: db_book.title.clone(), + sort: db_book.sort.clone(), + path: db_book.path.clone(), + author: author.to_string(), + series: db_series.map(|x| (x.0.name, x.1)), + } + } +} diff --git a/cops-web/src/handlers/error.rs b/cops-web/src/handlers/error.rs new file mode 100644 index 0000000..7c3a1ef --- /dev/null +++ b/cops-web/src/handlers/error.rs @@ -0,0 +1,40 @@ +use calibre_db::data::error::DataStoreError; +use poem::{error::ResponseError, http::StatusCode, Body, Response}; +use tracing::error; +use uuid::Uuid; + +#[derive(Debug, thiserror::Error)] +#[error("sqlite error")] +pub struct SqliteError(pub DataStoreError); + +impl From for SqliteError { + fn from(item: DataStoreError) -> Self { + SqliteError(item) + } +} + +impl ResponseError for SqliteError { + fn status(&self) -> StatusCode { + match &self.0 { + DataStoreError::NoResults(_) => StatusCode::NOT_FOUND, + _ => StatusCode::BAD_GATEWAY, + } + } + + fn as_response(&self) -> Response { + let id = Uuid::new_v4(); + let internal_msg = self.to_string(); + let external_msg = match &self.0 { + DataStoreError::NoResults(_) => "item not found", + _ => "internal server error", + }; + error!("{id}: {internal_msg}"); + + let body = Body::from_json(serde_json::json!({ + "id": id.to_string(), + "message": external_msg, + })) + .unwrap(); + Response::builder().status(self.status()).body(body) + } +} diff --git a/cops-web/src/handlers/recents.rs b/cops-web/src/handlers/recents.rs index 79dc6c2..1f8faa0 100644 --- a/cops-web/src/handlers/recents.rs +++ b/cops-web/src/handlers/recents.rs @@ -7,11 +7,21 @@ use poem::{ }; use tera::Context; -use crate::{app_state::AppState, templates::TEMPLATES}; +use crate::{ + app_state::AppState, data::book::Book, handlers::error::SqliteError, templates::TEMPLATES, +}; #[handler] pub async fn handler(state: Data<&Arc>) -> Result, poem::Error> { - let recent_books = state.calibre.recent_books(50).unwrap(); + let recent_books = state.calibre.recent_books(50).map_err(SqliteError)?; + let recent_books = recent_books + .iter() + .filter_map(|x| { + let author = state.calibre.book_author(x.id).ok()?; + let series = state.calibre.book_series(x.id).ok()?; + Some(Book::from_db_book(x, series, &author.name)) + }) + .collect::>(); let mut context = Context::new(); context.insert("books", &recent_books); diff --git a/cops-web/src/main.rs b/cops-web/src/main.rs index 172190c..39bc97f 100644 --- a/cops-web/src/main.rs +++ b/cops-web/src/main.rs @@ -14,8 +14,12 @@ mod app_state; mod basic_auth; mod cli; mod config; +mod data { + pub mod book; +} mod handlers { pub mod cover; + pub mod error; pub mod recents; } mod templates; diff --git a/cops-web/static/style.css b/cops-web/static/style.css index cd56fcd..81a4170 100644 --- a/cops-web/static/style.css +++ b/cops-web/static/style.css @@ -4,6 +4,10 @@ text-overflow: ellipsis; } +.book-card hgroup { + margin-bottom: 0; +} + .cover { width: 100%; object-fit: contain; diff --git a/cops-web/templates/recents.html b/cops-web/templates/recents.html index 4508360..539586f 100644 --- a/cops-web/templates/recents.html +++ b/cops-web/templates/recents.html @@ -3,10 +3,30 @@

Recent Books

{% for book in books %} -
-
{{ book.title }}
+
+
+
+
{{ book.title }}
+ +

{{ book.author }}

+
+
book cover - +
{% endfor %}
diff --git a/flake.nix b/flake.nix index d9f544f..ea2dde7 100644 --- a/flake.nix +++ b/flake.nix @@ -9,7 +9,6 @@ outputs = { - self, nixpkgs, naersk, fenix, @@ -72,14 +71,11 @@ pkgs = import nixpkgs { inherit system; }; rust = fenix.packages.${system}.stable; in - with pkgs; { - devShells.default = mkShell { + devShells.default = pkgs.mkShell { buildInputs = [ - mosquitto rust.toolchain - rust-analyzer - sea-orm-cli + pkgs.rust-analyzer ]; }; }