diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..5fda93c --- /dev/null +++ b/.containerignore @@ -0,0 +1,2 @@ +/target +Containerfile diff --git a/.gitea/workflows/container.yaml b/.gitea/workflows/container.yaml new file mode 100644 index 0000000..e48b3fd --- /dev/null +++ b/.gitea/workflows/container.yaml @@ -0,0 +1,12 @@ +name: Build Multiarch Container Image +on: [push] +jobs: + call-reusable-workflow: + uses: container/multiarch-build-workflow/.gitea/workflows/build.yaml@main + with: + repository: ${{ gitea.repository }} + ref_name: ${{ gitea.ref_name }} + sha: ${{ gitea.sha }} + registry_url: ${{ secrets.REGISTRY_URL }} + registry_user: ${{ secrets.REGISTRY_USER }} + registry_pw: ${{ secrets.REGISTRY_PW }} diff --git a/LICENSE b/COPYING similarity index 100% rename from LICENSE rename to COPYING diff --git a/Cargo.lock b/Cargo.lock index 3f9b5bd..970005b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -837,6 +837,29 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "little-hesinde" +version = "0.1.0" +dependencies = [ + "calibre-db", + "clap", + "once_cell", + "poem", + "quick-xml", + "rust-embed", + "serde", + "serde_json", + "serde_with", + "tera", + "thiserror", + "time", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "lock_api" version = "0.4.12" @@ -1354,28 +1377,6 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" -[[package]] -name = "rusty-library" -version = "0.1.0" -dependencies = [ - "calibre-db", - "clap", - "once_cell", - "poem", - "quick-xml", - "rust-embed", - "serde", - "serde_json", - "serde_with", - "tera", - "thiserror", - "time", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", -] - [[package]] name = "ryu" version = "1.0.17" @@ -1510,6 +1511,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -1678,6 +1688,7 @@ dependencies = [ "mio", "num_cpus", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.48.0", diff --git a/Cargo.toml b/Cargo.toml index ac2af26..96444e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" members = [ - "calibre-db", "rusty-library", + "calibre-db", "little-hesinde", ] [workspace.dependencies] diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..97352b4 --- /dev/null +++ b/Containerfile @@ -0,0 +1,22 @@ +FROM docker.io/rust:1-alpine3.19 AS builder + +RUN apk --no-cache add musl-dev + +ENV CARGO_CARGO_NEW_VCS="none" +ENV CARGO_BUILD_RUSTFLAGS="-C target-feature=+crt-static" + +WORKDIR /work + +COPY . . + +RUN cargo build --release --target=$(arch)-unknown-linux-musl +RUN cp "./target/$(arch)-unknown-linux-musl/release/little-hesinde" /app + + +FROM scratch + +COPY --from=builder /app /app +CMD ["/app", "--", "/library"] + +VOLUME ["/library"] +EXPOSE 3000 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1540da2 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# Little Hesinde + +I have been a long time user of [cops](https://github.com/seblucas/cops) and its +[fork](https://github.com/mikespub-org/seblucas-cops) in order to access my +ebook collection in a nice way from a browser. My biggest use always has been +the [ODPS](https://en.wikipedia.org/wiki/Open_Publication_Distribution_System) +feed for getting all of that from within [KOReader](https://koreader.rocks/). + +It so happened that exactly when I was looking for a small side project I also +had trouble with a new cops version (not through their fault, only because I +insist on writing my own containers). _How hard can it be_ I thought and went on +hacking something together. The result does at most one tenth of what cops can +do but luckily enough it is the part I need for myself. + +# Building + +## Nix + +A [nix](https://nixos.org/download/) environment with enabled +[nix-commands](https://nixos.wiki/wiki/Flakes) in order to use `nix develop` and +`nix build`. + +A statically linked binary for linux systems (using +[musl](https://musl.libc.org/)) can be compiled by running `nix build` (run +`nix flake show` to get a list of available targets). + +Otherwise run `nix develop` to be dropped into a shell with everything installed +and configured. From there all the usual `cargo` commands are accessible. + +## Classic + +A recent [rust](https://www.rust-lang.org/learn/get-started) installation is all +that is needed. + +From there on `cargo run` and `cargo build` and so on can be used. + +# Configuration + +The binary takes exactly one argument, the path to the calibre library folder. + +The listening port is hardcoded to `3000` for now, as is the listening on all +interfaces. + +# Usage + +Run the binary with the calibre library as an argument and open +http://localhost:3000 (or wherever it should be accessible). +http://localhost:3000/opds is the entry point for the OPDS feed. + +# FAQ + +## No authentication? + +Not planned, put a reverse proxy in front of it that handles access. + +## No search? + +On my todo list once I feel like I need it. + +## Why are the OPDS entries not paginated? + +My hardware (a Kobo Aura One from ~2016) with KOReader works perfectly fine with +parsing the 1MB book feed from own library. Once that changes I might get over +my laziness and implement it. + +## Aren't these database access patterns inefficient? + +They most probably are but my elaborate testing setup (my own calibre library) +works fine with it. + +## Why rust? + +I like the language and wanted to try the +[poem](https://github.com/poem-web/poem) framework. + +## Is it webscale? + +Go away. diff --git a/calibre-db/src/calibre.rs b/calibre-db/src/calibre.rs index 221a4cb..fc6bc53 100644 --- a/calibre-db/src/calibre.rs +++ b/calibre-db/src/calibre.rs @@ -1,3 +1,5 @@ +//! Bundle all functions together. + use std::path::Path; use r2d2::Pool; @@ -7,12 +9,17 @@ use crate::data::{ author::Author, book::Book, error::DataStoreError, pagination::SortOrder, series::Series, }; +/// Top level calibre functions, bundling all sub functions in one place and providing secure access to +/// the database. #[derive(Debug, Clone)] pub struct Calibre { pool: Pool, } impl Calibre { + /// Open a connection to the calibre database. + /// + /// Fail if the database file can not be opened or not be found. pub fn load(path: &Path) -> Result { let manager = SqliteConnectionManager::file(path); let pool = r2d2::Pool::new(manager)?; @@ -20,6 +27,8 @@ impl Calibre { Ok(Self { pool }) } + /// Fetch book data from calibre, starting at `cursor`, fetching up to an amount of `limit` and + /// ordering by `sort_order`. pub fn books( &self, limit: u64, @@ -30,6 +39,8 @@ impl Calibre { Book::multiple(&conn, limit, cursor, sort_order) } + /// Fetch author data from calibre, starting at `cursor`, fetching up to an amount of `limit` and + /// ordering by `sort_order`. pub fn authors( &self, limit: u64, @@ -40,6 +51,8 @@ impl Calibre { Author::multiple(&conn, limit, cursor, sort_order) } + /// Fetch books for an author specified by `author_id`, paginate the books by starting at `cursor`, + /// fetching up to an amount of `limit` and ordering by `sort_order`. pub fn author_books( &self, author_id: u64, @@ -51,21 +64,26 @@ impl Calibre { Book::author_books(&conn, author_id, limit, cursor, sort_order) } + /// Get recent books up to a limit of `limit`. pub fn recent_books(&self, limit: u64) -> Result, DataStoreError> { let conn = self.pool.get()?; Book::recents(&conn, limit) } + /// Get a single book, specified `id`. pub fn scalar_book(&self, id: u64) -> Result { let conn = self.pool.get()?; Book::scalar_book(&conn, id) } + /// Get the author to a book with id `id`. pub fn book_author(&self, id: u64) -> Result { let conn = self.pool.get()?; Author::book_author(&conn, id) } + /// Fetch series data from calibre, starting at `cursor`, fetching up to an amount of `limit` and + /// ordering by `sort_order`. pub fn series( &self, limit: u64, @@ -76,51 +94,61 @@ impl Calibre { Series::multiple(&conn, limit, cursor, sort_order) } + /// Get the series a book with id `id` is in, as well as the book's position within the series. pub fn book_series(&self, id: u64) -> Result, DataStoreError> { let conn = self.pool.get()?; Series::book_series(&conn, id) } + /// Get all books belonging to the series with id `id`. pub fn series_books(&self, id: u64) -> Result, DataStoreError> { let conn = self.pool.get()?; Book::series_books(&conn, id) } + /// Check if there are more authors before the specified cursor. pub fn has_previous_authors(&self, author_sort: &str) -> Result { let conn = self.pool.get()?; Author::has_previous_authors(&conn, author_sort) } + /// Check if there are more authors after the specified cursor. pub fn has_more_authors(&self, author_sort: &str) -> Result { let conn = self.pool.get()?; Author::has_more_authors(&conn, author_sort) } + /// Check if there are more books before the specified cursor. pub fn has_previous_books(&self, book_sort: &str) -> Result { let conn = self.pool.get()?; Book::has_previous_books(&conn, book_sort) } + /// Check if there are more books after the specified cursor. pub fn has_more_books(&self, book_sort: &str) -> Result { let conn = self.pool.get()?; Book::has_more_books(&conn, book_sort) } + /// Check if there are more series before the specified cursor. pub fn has_previous_series(&self, series_sort: &str) -> Result { let conn = self.pool.get()?; Series::has_previous_series(&conn, series_sort) } + /// Check if there are more series after the specified cursor. pub fn has_more_series(&self, series_sort: &str) -> Result { let conn = self.pool.get()?; Series::has_more_series(&conn, series_sort) } + /// Fetch a single author with id `id`. pub fn scalar_author(&self, id: u64) -> Result { let conn = self.pool.get()?; Author::scalar_author(&conn, id) } + /// Fetch a single series with id `id`. pub fn scalar_series(&self, id: u64) -> Result { let conn = self.pool.get()?; Series::scalar_series(&conn, id) diff --git a/calibre-db/src/data/author.rs b/calibre-db/src/data/author.rs index 2ecf9e5..e79da5a 100644 --- a/calibre-db/src/data/author.rs +++ b/calibre-db/src/data/author.rs @@ -1,3 +1,5 @@ +//! Author data. + use rusqlite::{named_params, Connection, Row}; use serde::Serialize; @@ -6,10 +8,14 @@ use super::{ pagination::{Pagination, SortOrder}, }; +/// Author in calibre. #[derive(Debug, Clone, Serialize)] pub struct Author { + /// Id in database. pub id: u64, + /// Full name. pub name: String, + /// Full name for sorting. pub sort: String, } @@ -22,6 +28,8 @@ impl Author { }) } + /// Fetch author data from calibre, starting at `cursor`, fetching up to an amount of `limit` and + /// ordering by `sort_order`. pub fn multiple( conn: &Connection, limit: u64, @@ -37,6 +45,7 @@ impl Author { ) } + /// Get the author to a book with id `id`. pub fn book_author(conn: &Connection, id: u64) -> Result { let mut stmt = conn.prepare( "SELECT authors.id, authors.name, authors.sort FROM authors \ @@ -47,12 +56,14 @@ impl Author { Ok(stmt.query_row(params, Self::from_row)?) } + /// Fetch a single author with id `id`. pub fn scalar_author(conn: &Connection, id: u64) -> Result { let mut stmt = conn.prepare("SELECT id, name, sort FROM authors WHERE id = (:id)")?; let params = named_params! { ":id": id }; Ok(stmt.query_row(params, Self::from_row)?) } + /// Check if there are more authors before the specified cursor. pub fn has_previous_authors( conn: &Connection, sort_name: &str, @@ -60,6 +71,7 @@ impl Author { Pagination::has_prev_or_more(conn, "authors", sort_name, &SortOrder::DESC) } + /// Check if there are more authors after the specified cursor. pub fn has_more_authors(conn: &Connection, sort_name: &str) -> Result { Pagination::has_prev_or_more(conn, "authors", sort_name, &SortOrder::ASC) } diff --git a/calibre-db/src/data/book.rs b/calibre-db/src/data/book.rs index a893b2e..4547008 100644 --- a/calibre-db/src/data/book.rs +++ b/calibre-db/src/data/book.rs @@ -1,3 +1,5 @@ +//! Book data. + use rusqlite::{named_params, Connection, Row}; use serde::Serialize; use time::OffsetDateTime; @@ -7,14 +9,22 @@ use super::{ pagination::{Pagination, SortOrder}, }; -#[derive(Debug, Serialize)] +/// Book in calibre. +#[derive(Debug, Clone, Serialize)] pub struct Book { + /// Id in database. pub id: u64, + /// Book title. pub title: String, + /// Book title for sorting. pub sort: String, + /// Folder of the book within the calibre library. pub path: String, + /// Uuid of the book. pub uuid: String, + /// When was the book last modified. pub last_modified: OffsetDateTime, + /// Optional description. pub description: Option, } @@ -31,6 +41,8 @@ impl Book { }) } + /// Fetch book data from calibre, starting at `cursor`, fetching up to an amount of `limit` and + /// ordering by `sort_order`. pub fn multiple( conn: &Connection, limit: u64, @@ -47,6 +59,8 @@ impl Book { ) } + /// Fetch books for an author specified by `author_id`, paginate the books by starting at `cursor`, + /// fetching up to an amount of `limit` and ordering by `sort_order`. pub fn author_books( conn: &Connection, author_id: u64, @@ -66,6 +80,7 @@ impl Book { ) } + /// Get all books belonging to the series with id `id`. pub fn series_books(conn: &Connection, id: u64) -> Result, DataStoreError> { let mut stmt = conn.prepare( "SELECT books.id, books.title, books.sort, books.path, books.uuid, books.last_modified, comments.text FROM series \ @@ -80,6 +95,7 @@ impl Book { Ok(iter.filter_map(Result::ok).collect()) } + /// Get recent books up to a limit of `limit`. pub fn recents(conn: &Connection, limit: u64) -> Result, DataStoreError> { let mut stmt = conn.prepare( "SELECT books.id, books.title, books.sort, books.path, books.uuid, books.last_modified, comments.text \ @@ -90,6 +106,7 @@ impl Book { Ok(iter.filter_map(Result::ok).collect()) } + /// Get a single book, specified `id`. pub fn scalar_book(conn: &Connection, id: u64) -> Result { let mut stmt = conn.prepare( "SELECT books.id, books.title, books.sort, books.path, books.uuid, books.last_modified, comments.text \ @@ -99,10 +116,12 @@ impl Book { Ok(stmt.query_row(params, Self::from_row)?) } + /// Check if there are more books before the specified cursor. pub fn has_previous_books(conn: &Connection, sort_title: &str) -> Result { Pagination::has_prev_or_more(conn, "books", sort_title, &SortOrder::DESC) } + /// Check if there are more books after the specified cursor. pub fn has_more_books(conn: &Connection, sort_title: &str) -> Result { Pagination::has_prev_or_more(conn, "books", sort_title, &SortOrder::ASC) } diff --git a/calibre-db/src/data/error.rs b/calibre-db/src/data/error.rs index 0680abc..ea78a79 100644 --- a/calibre-db/src/data/error.rs +++ b/calibre-db/src/data/error.rs @@ -1,19 +1,28 @@ +//! Error handling for calibre database access. + use thiserror::Error; use time::error::Parse; +/// Errors from accessing the calibre database. #[derive(Error, Debug)] #[error("data store error")] pub enum DataStoreError { + /// Found no entries for the query. #[error("no results")] NoResults(rusqlite::Error), + /// Error with SQLite. #[error("sqlite error")] SqliteError(rusqlite::Error), + /// Error connecting to the database. #[error("connection error")] ConnectionError(#[from] r2d2::Error), + /// Error wparsing a datetime from the database. #[error("failed to parse datetime")] DateTimeError(#[from] Parse), } +/// Convert an SQLite error into a proper NoResults one if the query +/// returned no rows, return others as is. impl From for DataStoreError { fn from(error: rusqlite::Error) -> Self { match error { diff --git a/calibre-db/src/data/pagination.rs b/calibre-db/src/data/pagination.rs index d08449b..c2f6064 100644 --- a/calibre-db/src/data/pagination.rs +++ b/calibre-db/src/data/pagination.rs @@ -1,22 +1,33 @@ +//! Cursor pagination handling. + use rusqlite::{named_params, Connection, Row, ToSql}; use serde::{Deserialize, Serialize}; use super::error::DataStoreError; +/// How to sort query results. Signifying whether we are paginating forwards or backwards. #[derive(Debug, Copy, Clone, PartialEq, Deserialize, Serialize)] pub enum SortOrder { + /// Forwards ASC, + /// Backwards DESC, } +/// Pagination data. pub struct Pagination<'a> { + /// Sort by this column. pub sort_col: &'a str, + /// Limit returned results. pub limit: u64, + /// Where to start paginating. pub cursor: Option<&'a str>, + /// Paginating forwards or backwards. pub sort_order: SortOrder, } impl<'a> Pagination<'a> { + /// Create a new pagination. pub fn new( sort_col: &'a str, cursor: Option<&'a str>, @@ -40,6 +51,7 @@ impl<'a> Pagination<'a> { .to_string() } + /// Check if there are more items forwards or backwards from `cursor` (direction specified by `sort_order`). pub fn has_prev_or_more( conn: &Connection, table: &str, @@ -57,6 +69,7 @@ impl<'a> Pagination<'a> { Ok(count > 0) } + /// Paginate a statement. pub fn paginate( &self, conn: &Connection, @@ -77,7 +90,8 @@ impl<'a> Pagination<'a> { }; let sort_col = self.sort_col; - // otherwise paginated statements with join will fail + // otherwise paginated statements with join will fails, not happy with this but fine for + // now let sort_col_wrapped = if let Some(index) = sort_col.find('.') { let right_part = &sort_col[index..]; "t".to_owned() + right_part diff --git a/calibre-db/src/data/series.rs b/calibre-db/src/data/series.rs index ffe43bf..b467fde 100644 --- a/calibre-db/src/data/series.rs +++ b/calibre-db/src/data/series.rs @@ -1,3 +1,5 @@ +//! Series data. + use rusqlite::{named_params, Connection, Row}; use serde::Serialize; @@ -6,10 +8,14 @@ use super::{ pagination::{Pagination, SortOrder}, }; +/// Series in calibre. #[derive(Debug, Clone, Serialize)] pub struct Series { + /// Id in database. pub id: u64, + /// Series name. pub name: String, + /// Series name for sorting. pub sort: String, } @@ -22,6 +28,8 @@ impl Series { }) } + /// Fetch series data from calibre, starting at `cursor`, fetching up to an amount of `limit` and + /// ordering by `sort_order`. pub fn multiple( conn: &Connection, limit: u64, @@ -37,12 +45,14 @@ impl Series { ) } + /// Fetch a single series with id `id`. pub fn scalar_series(conn: &Connection, id: u64) -> Result { let mut stmt = conn.prepare("SELECT id, name, sort FROM series WHERE id = (:id)")?; let params = named_params! { ":id": id }; Ok(stmt.query_row(params, Self::from_row)?) } + /// Get the series a book with id `id` is in, as well as the book's position within the series. pub fn book_series( conn: &Connection, book_id: u64, @@ -68,10 +78,12 @@ impl Series { } } + /// Check if there are more series before the specified cursor. pub fn has_previous_series(conn: &Connection, sort_name: &str) -> Result { Pagination::has_prev_or_more(conn, "series", sort_name, &SortOrder::DESC) } + /// Check if there are more series after the specified cursor. pub fn has_more_series(conn: &Connection, sort_name: &str) -> Result { Pagination::has_prev_or_more(conn, "series", sort_name, &SortOrder::ASC) } diff --git a/calibre-db/src/lib.rs b/calibre-db/src/lib.rs index c7a0452..bae8fbc 100644 --- a/calibre-db/src/lib.rs +++ b/calibre-db/src/lib.rs @@ -1,4 +1,7 @@ +//! Read data from a calibre library, leveraging its SQLite metadata database. + pub mod calibre; +/// Data structs for the calibre database. pub mod data { pub mod author; pub mod book; diff --git a/flake.nix b/flake.nix index 68b8983..da1dc6f 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "rusty-library project"; + description = "little-hesinde project"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; diff --git a/rusty-library/Cargo.toml b/little-hesinde/Cargo.toml similarity index 82% rename from rusty-library/Cargo.toml rename to little-hesinde/Cargo.toml index dfa2399..4a10942 100644 --- a/rusty-library/Cargo.toml +++ b/little-hesinde/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "rusty-library" +name = "little-hesinde" version = "0.1.0" edition = "2021" license = { workspace = true } @@ -16,7 +16,8 @@ serde_with = "3.8.1" tera = "1.19.1" thiserror = { workspace = true } time = { workspace = true } -tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros"] } +tokio = { version = "1.37.0", features = ["signal", "rt-multi-thread", "macros"] } +tokio-util = "0.7.11" tracing = "0.1.40" tracing-subscriber = "0.3.18" uuid = { version = "1.8.0", features = ["v4", "fast-rng"] } diff --git a/little-hesinde/src/app_state.rs b/little-hesinde/src/app_state.rs new file mode 100644 index 0000000..7136258 --- /dev/null +++ b/little-hesinde/src/app_state.rs @@ -0,0 +1,13 @@ +//! Data for global app state. + +use calibre_db::calibre::Calibre; + +use crate::config::Config; + +/// Global application state, meant to be used in request handlers. +pub struct AppState { + /// Access calibre database. + pub calibre: Calibre, + /// Access application configuration. + pub config: Config, +} diff --git a/rusty-library/src/cli.rs b/little-hesinde/src/cli.rs similarity index 91% rename from rusty-library/src/cli.rs rename to little-hesinde/src/cli.rs index 5a7f28c..6f1c404 100644 --- a/rusty-library/src/cli.rs +++ b/little-hesinde/src/cli.rs @@ -1,3 +1,5 @@ +//! Cli interface. + use clap::Parser; /// Simple opds server for calibre diff --git a/rusty-library/src/config.rs b/little-hesinde/src/config.rs similarity index 77% rename from rusty-library/src/config.rs rename to little-hesinde/src/config.rs index 0747cf3..9d0b97e 100644 --- a/rusty-library/src/config.rs +++ b/little-hesinde/src/config.rs @@ -1,23 +1,32 @@ +//! Configuration data. + use std::path::{Path, PathBuf}; use thiserror::Error; use crate::cli::Cli; +/// Errors when dealing with application configuration. #[derive(Error, Debug)] pub enum ConfigError { + /// Calibre library path does not exist. #[error("no folder at {0}")] LibraryPathNotFound(String), + /// Calibre database does not exist. #[error("no metadata.db in {0}")] MetadataNotFound(String), } +/// Application configuration. pub struct Config { + /// Calibre library folder. pub library_path: PathBuf, + /// Calibre metadata file path. pub metadata_path: PathBuf, } impl Config { + /// Check if the calibre library from `args` exists and if the calibre database can be found. pub fn load(args: &Cli) -> Result { let library_path = Path::new(&args.library_path).to_path_buf(); diff --git a/rusty-library/src/data/book.rs b/little-hesinde/src/data/book.rs similarity index 59% rename from rusty-library/src/data/book.rs rename to little-hesinde/src/data/book.rs index ac5fc12..849144e 100644 --- a/rusty-library/src/data/book.rs +++ b/little-hesinde/src/data/book.rs @@ -1,17 +1,22 @@ +//! Enrich the [`Book`](struct@calibre_db::data::book::Book) type with additional information. + use std::{collections::HashMap, fmt::Display, path::Path}; use calibre_db::data::{ author::Author as DbAuthor, book::Book as DbBook, series::Series as DbSeries, }; use serde::Serialize; -use time::OffsetDateTime; use crate::app_state::AppState; +/// Wrapper type for a file format string (must be a struct in order to implement traits). #[derive(Debug, Clone, Serialize, Eq, PartialEq, Hash)] pub struct Format(pub String); +/// Wrapper type for a collection of formats, [`Format`](struct@Format) on the left and a String +/// signifying its file path on the right. pub type Formats = HashMap; +/// Recognize `pdf` and `epub` and return their value, everything else transforms to `unknown`. impl Display for Format { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self.0.as_ref() { @@ -22,21 +27,30 @@ impl Display for Format { } } +/// Wrapper around [`Book`](struct@calibre_db::data::book::Book) from the +/// [`calibre-db`](mod@calibre_db) crate. Adding +/// [`Author`](struct@calibre_db::data::author::Author), +/// [`Series`](struct@calibre_db::data::series::Series) as well as +/// [`Format`](struct@Format) information. #[derive(Debug, Clone, Serialize)] pub struct Book { - pub id: u64, - pub title: String, - pub sort: String, - pub path: String, - pub uuid: String, - pub last_modified: OffsetDateTime, - pub description: Option, + /// Book data from the database. + pub data: DbBook, + /// Author information. pub author: DbAuthor, + /// Series information. Series on the left and the index of the book within + /// that series on the right. pub series: Option<(DbSeries, f64)>, + /// Format information. pub formats: Formats, } impl Book { + /// Wrap a [`DbBook`](struct@calibre_db::data::book::Book) in a [`Book`](struct@Book) and + /// enrich it with additional information. + /// + /// `db_series` is an Option tuple with the series on the left and the index of the book within + /// that series on the right. pub fn from_db_book( db_book: &DbBook, db_series: Option<(DbSeries, f64)>, @@ -44,19 +58,15 @@ impl Book { formats: Formats, ) -> Self { Self { - id: db_book.id, - title: db_book.title.clone(), - sort: db_book.sort.clone(), - path: db_book.path.clone(), - uuid: db_book.uuid.clone(), - description: db_book.description.clone(), - last_modified: db_book.last_modified, + data: db_book.clone(), author: author.clone(), series: db_series.map(|x| (x.0, x.1)), formats, } } + /// Find all pdf and epub formats of a book on disk. Search their directory in the library for + /// it. fn formats(book: &DbBook, library_path: &Path) -> Formats { let book_path = library_path.join(&book.path); let mut formats = HashMap::new(); @@ -80,6 +90,8 @@ impl Book { formats } + /// Wrap a [`DbBook`](struct@calibre_db::data::book::Book) in a [`Book`](struct@Book) by + /// fetching additional information about author, formats and series. pub fn full_book(book: &DbBook, state: &AppState) -> Option { let formats = Book::formats(book, &state.config.library_path); let author = state.calibre.book_author(book.id).ok()?; diff --git a/rusty-library/src/handlers/author.rs b/little-hesinde/src/handlers/author.rs similarity index 52% rename from rusty-library/src/handlers/author.rs rename to little-hesinde/src/handlers/author.rs index aee053b..60f7e20 100644 --- a/rusty-library/src/handlers/author.rs +++ b/little-hesinde/src/handlers/author.rs @@ -1,22 +1,23 @@ +//! Handle requests for a single author. + use std::sync::Arc; use calibre_db::data::pagination::SortOrder; use poem::{ - error::InternalServerError, handler, - web::{Data, Html, Path}, -}; -use tera::Context; - -use crate::{ - app_state::AppState, data::book::Book, handlers::error::HandlerError, templates::TEMPLATES, + web::{Data, Path}, + Response, }; +use crate::{app_state::AppState, data::book::Book, handlers::error::HandlerError, Accept}; + +/// Handle a request for an author with `id` and decide whether to render to html or OPDS. #[handler] pub async fn handler( id: Path, + accept: Data<&Accept>, state: Data<&Arc>, -) -> Result, poem::Error> { +) -> Result { let author = state .calibre .scalar_author(*id) @@ -30,13 +31,8 @@ pub async fn handler( .filter_map(|x| Book::full_book(x, &state)) .collect::>(); - let mut context = Context::new(); - context.insert("title", &author.name); - context.insert("nav", "authors"); - context.insert("books", &books); - - TEMPLATES - .render("book_list", &context) - .map_err(InternalServerError) - .map(Html) + match accept.0 { + Accept::Html => crate::handlers::html::author::handler(author, books).await, + Accept::Opds => crate::handlers::opds::author::handler(author, books).await, + } } diff --git a/little-hesinde/src/handlers/authors.rs b/little-hesinde/src/handlers/authors.rs new file mode 100644 index 0000000..f6d4ba1 --- /dev/null +++ b/little-hesinde/src/handlers/authors.rs @@ -0,0 +1,44 @@ +//! Handle requests for multiple authors. + +use std::sync::Arc; + +use calibre_db::{calibre::Calibre, data::pagination::SortOrder}; +use poem::{ + handler, + web::{Data, Path}, + Response, +}; + +use crate::{app_state::AppState, Accept}; + +/// Handle a request for multiple authors, starting at the first. +#[handler] +pub async fn handler_init( + accept: Data<&Accept>, + state: Data<&Arc>, +) -> Result { + authors(&accept, &state.calibre, None, &SortOrder::ASC).await +} + +/// Handle a request for multiple authors, starting at the `cursor` and going in the direction of +/// `sort_order`. +#[handler] +pub async fn handler( + Path((cursor, sort_order)): Path<(String, SortOrder)>, + accept: Data<&Accept>, + state: Data<&Arc>, +) -> Result { + authors(&accept, &state.calibre, Some(&cursor), &sort_order).await +} + +async fn authors( + acccept: &Accept, + calibre: &Calibre, + cursor: Option<&str>, + sort_order: &SortOrder, +) -> Result { + match acccept { + Accept::Html => crate::handlers::html::authors::handler(calibre, cursor, sort_order).await, + Accept::Opds => crate::handlers::opds::authors::handler(calibre, cursor, sort_order).await, + } +} diff --git a/little-hesinde/src/handlers/books.rs b/little-hesinde/src/handlers/books.rs new file mode 100644 index 0000000..9944821 --- /dev/null +++ b/little-hesinde/src/handlers/books.rs @@ -0,0 +1,44 @@ +//! Handle requests for multiple books. + +use std::sync::Arc; + +use calibre_db::data::pagination::SortOrder; +use poem::{ + handler, + web::{Data, Path}, + Response, +}; + +use crate::{app_state::AppState, Accept}; + +/// Handle a request for multiple books, starting at the first. +#[handler] +pub async fn handler_init( + accept: Data<&Accept>, + state: Data<&Arc>, +) -> Result { + books(&accept, &state, None, &SortOrder::ASC).await +} + +/// Handle a request for multiple books, starting at the `cursor` and going in the direction of +/// `sort_order`. +#[handler] +pub async fn handler( + Path((cursor, sort_order)): Path<(String, SortOrder)>, + accept: Data<&Accept>, + state: Data<&Arc>, +) -> Result { + books(&accept, &state, Some(&cursor), &sort_order).await +} + +async fn books( + accept: &Accept, + state: &Arc, + cursor: Option<&str>, + sort_order: &SortOrder, +) -> Result { + match accept { + Accept::Html => crate::handlers::html::books::handler(state, cursor, sort_order).await, + Accept::Opds => crate::handlers::opds::books::handler(state, cursor, sort_order).await, + } +} diff --git a/rusty-library/src/handlers/cover.rs b/little-hesinde/src/handlers/cover.rs similarity index 88% rename from rusty-library/src/handlers/cover.rs rename to little-hesinde/src/handlers/cover.rs index 022cb3d..4a0696d 100644 --- a/rusty-library/src/handlers/cover.rs +++ b/little-hesinde/src/handlers/cover.rs @@ -1,3 +1,5 @@ +//! Handle requests for cover images. + use std::{fs::File, io::Read, sync::Arc}; use poem::{ @@ -9,6 +11,7 @@ use poem::{ use crate::{app_state::AppState, handlers::error::HandlerError}; +/// Handle a request for the cover image of book with id `id`. #[handler] pub async fn handler( id: Path, diff --git a/little-hesinde/src/handlers/download.rs b/little-hesinde/src/handlers/download.rs new file mode 100644 index 0000000..1f25c56 --- /dev/null +++ b/little-hesinde/src/handlers/download.rs @@ -0,0 +1,50 @@ +//! Handle requests for specific formats of a book. + +use std::sync::Arc; + +use tokio::fs::File; + +use poem::{ + error::NotFoundError, + handler, + web::{Data, Path}, + Body, IntoResponse, Response, +}; +use tokio_util::io::ReaderStream; + +use crate::{ + app_state::AppState, + data::book::{Book, Format}, + handlers::error::HandlerError, + opds::media_type::MediaType, +}; + +/// Handle a request for a book with id `id` in format `format`. +#[handler] +pub async fn handler( + Path((id, format)): Path<(u64, String)>, + state: Data<&Arc>, +) -> Result { + let book = state + .calibre + .scalar_book(id) + .map_err(HandlerError::DataError)?; + let book = Book::full_book(&book, &state).ok_or(NotFoundError)?; + let format = Format(format); + let file_name = book.formats.get(&format).ok_or(NotFoundError)?; + let file_path = state + .config + .library_path + .join(book.data.path) + .join(file_name); + + let mut file = File::open(file_path).await.map_err(|_| NotFoundError)?; + let stream = ReaderStream::new(file); + let body = Body::from_bytes_stream(stream); + + let content_type: MediaType = format.into(); + Ok(body + .with_content_type(format!("{content_type}")) + .with_header("Content-Disposition", format!("filename=\"{file_name}\"")) + .into_response()) +} diff --git a/rusty-library/src/handlers/error.rs b/little-hesinde/src/handlers/error.rs similarity index 78% rename from rusty-library/src/handlers/error.rs rename to little-hesinde/src/handlers/error.rs index d3f89f1..176b431 100644 --- a/rusty-library/src/handlers/error.rs +++ b/little-hesinde/src/handlers/error.rs @@ -1,3 +1,5 @@ +//! Error handling for requests handlers. + use calibre_db::data::error::DataStoreError; use poem::{error::ResponseError, http::StatusCode, Body, Response}; use thiserror::Error; @@ -6,15 +8,22 @@ use uuid::Uuid; use crate::opds::error::OpdsError; +/// Errors happening during handling of requests. #[derive(Error, Debug)] #[error("opds error")] pub enum HandlerError { + /// Error rendering OPDS. #[error("opds error")] OpdsError(#[from] OpdsError), + /// Error fetching data from calibre. #[error("data error")] DataError(#[from] DataStoreError), } +/// Convert a [`HandlerError`](enum@HandlerError) into a suitable response error. +/// +/// Log the real error (internal) with an uuid and send a suitable error message to the user with +/// the same uuid (for correlation purposes). impl ResponseError for HandlerError { fn status(&self) -> StatusCode { match &self { diff --git a/little-hesinde/src/handlers/html/author.rs b/little-hesinde/src/handlers/html/author.rs new file mode 100644 index 0000000..d27c6d3 --- /dev/null +++ b/little-hesinde/src/handlers/html/author.rs @@ -0,0 +1,21 @@ +//! Handle a single author for html. + +use calibre_db::data::author::Author; +use poem::{error::InternalServerError, web::Html, IntoResponse, Response}; +use tera::Context; + +use crate::{data::book::Book, templates::TEMPLATES}; + +/// Render a single author in html. +pub async fn handler(author: Author, books: Vec) -> Result { + let mut context = Context::new(); + context.insert("title", &author.name); + context.insert("nav", "authors"); + context.insert("books", &books); + + Ok(TEMPLATES + .render("book_list", &context) + .map_err(InternalServerError) + .map(Html)? + .into_response()) +} diff --git a/little-hesinde/src/handlers/html/authors.rs b/little-hesinde/src/handlers/html/authors.rs new file mode 100644 index 0000000..6db29d4 --- /dev/null +++ b/little-hesinde/src/handlers/html/authors.rs @@ -0,0 +1,21 @@ +//! Handle multiple authors in html. + +use calibre_db::{calibre::Calibre, data::pagination::SortOrder}; +use poem::Response; + +use crate::handlers::paginated; + +/// Render all authors paginated by cursor in html. +pub async fn handler( + calibre: &Calibre, + cursor: Option<&str>, + sort_order: &SortOrder, +) -> Result { + paginated::render( + "authors", + || calibre.authors(25, cursor, sort_order), + |author| author.sort.clone(), + |cursor| calibre.has_previous_authors(cursor), + |cursor| calibre.has_more_authors(cursor), + ) +} diff --git a/little-hesinde/src/handlers/html/books.rs b/little-hesinde/src/handlers/html/books.rs new file mode 100644 index 0000000..14700ae --- /dev/null +++ b/little-hesinde/src/handlers/html/books.rs @@ -0,0 +1,26 @@ +//! Handle multiple books in html. + +use calibre_db::data::pagination::SortOrder; +use poem::Response; + +use crate::{app_state::AppState, data::book::Book, handlers::paginated}; + +/// Render all books paginated by cursor in html. +pub async fn handler( + state: &AppState, + cursor: Option<&str>, + sort_order: &SortOrder, +) -> Result { + paginated::render( + "books", + || { + state + .calibre + .books(25, cursor, sort_order) + .map(|x| x.iter().filter_map(|y| Book::full_book(y, state)).collect()) + }, + |book| book.data.sort.clone(), + |cursor| state.calibre.has_previous_books(cursor), + |cursor| state.calibre.has_more_books(cursor), + ) +} diff --git a/little-hesinde/src/handlers/html/recent.rs b/little-hesinde/src/handlers/html/recent.rs new file mode 100644 index 0000000..05c142f --- /dev/null +++ b/little-hesinde/src/handlers/html/recent.rs @@ -0,0 +1,20 @@ +//! Handle recent books in html. + +use poem::{error::InternalServerError, web::Html, IntoResponse, Response}; +use tera::Context; + +use crate::{data::book::Book, templates::TEMPLATES}; + +/// Render recent books as html. +pub async fn handler(recent_books: Vec) -> Result { + let mut context = Context::new(); + context.insert("title", "Recent Books"); + context.insert("nav", "recent"); + context.insert("books", &recent_books); + + Ok(TEMPLATES + .render("book_list", &context) + .map_err(InternalServerError) + .map(Html)? + .into_response()) +} diff --git a/little-hesinde/src/handlers/html/series.rs b/little-hesinde/src/handlers/html/series.rs new file mode 100644 index 0000000..856939d --- /dev/null +++ b/little-hesinde/src/handlers/html/series.rs @@ -0,0 +1,21 @@ +//! Handle multiple series in html. + +use calibre_db::{calibre::Calibre, data::pagination::SortOrder}; +use poem::Response; + +use crate::handlers::paginated; + +/// Render all series paginated by cursor as html. +pub async fn handler( + calibre: &Calibre, + cursor: Option<&str>, + sort_order: &SortOrder, +) -> Result { + paginated::render( + "series", + || calibre.series(25, cursor, sort_order), + |series| series.sort.clone(), + |cursor| calibre.has_previous_series(cursor), + |cursor| calibre.has_more_series(cursor), + ) +} diff --git a/little-hesinde/src/handlers/html/series_single.rs b/little-hesinde/src/handlers/html/series_single.rs new file mode 100644 index 0000000..148aad4 --- /dev/null +++ b/little-hesinde/src/handlers/html/series_single.rs @@ -0,0 +1,21 @@ +//! Handle a single series in html. + +use calibre_db::data::series::Series; +use poem::{error::InternalServerError, web::Html, IntoResponse, Response}; +use tera::Context; + +use crate::{data::book::Book, templates::TEMPLATES}; + +/// Render a single series as html. +pub async fn handler(series: Series, books: Vec) -> Result { + let mut context = Context::new(); + context.insert("title", &series.name); + context.insert("nav", "series"); + context.insert("books", &books); + + Ok(TEMPLATES + .render("book_list", &context) + .map_err(InternalServerError) + .map(Html)? + .into_response()) +} diff --git a/little-hesinde/src/handlers/opds/author.rs b/little-hesinde/src/handlers/opds/author.rs new file mode 100644 index 0000000..69fbb2a --- /dev/null +++ b/little-hesinde/src/handlers/opds/author.rs @@ -0,0 +1,38 @@ +//! Handle a single author for opds. + +use calibre_db::data::author::Author; +use poem::{IntoResponse, Response}; +use time::OffsetDateTime; + +use crate::{ + data::book::Book, + handlers::error::HandlerError, + opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation}, +}; + +/// Render a single author as an OPDS entry embedded in a feed. +pub async fn handler(author: Author, books: Vec) -> Result { + let entries: Vec = books.into_iter().map(Entry::from).collect(); + let now = OffsetDateTime::now_utc(); + + let self_link = Link { + href: format!("/opds/authors/{}", author.id), + media_type: MediaType::Navigation, + rel: Relation::Myself, + title: None, + count: None, + }; + let feed = Feed::create( + now, + &format!("little-hesinde:author:{}", author.id), + &author.name, + self_link, + vec![], + entries, + ); + let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; + + Ok(xml + .with_content_type("application/atom+xml") + .into_response()) +} diff --git a/little-hesinde/src/handlers/opds/authors.rs b/little-hesinde/src/handlers/opds/authors.rs new file mode 100644 index 0000000..ce8a744 --- /dev/null +++ b/little-hesinde/src/handlers/opds/authors.rs @@ -0,0 +1,48 @@ +//! Handle multiple authors for opds. + +use calibre_db::{ + calibre::Calibre, + data::{author::Author as DbAuthor, pagination::SortOrder}, +}; +use poem::{IntoResponse, Response}; +use time::OffsetDateTime; + +use crate::{ + handlers::error::HandlerError, + opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation}, +}; + +/// Render all authors as OPDS entries embedded in a feed. +pub async fn handler( + calibre: &Calibre, + _cursor: Option<&str>, + _sort_order: &SortOrder, +) -> Result { + let authors: Vec = calibre + .authors(u32::MAX.into(), None, &SortOrder::ASC) + .map_err(HandlerError::DataError)?; + + let entries: Vec = authors.into_iter().map(Entry::from).collect(); + let now = OffsetDateTime::now_utc(); + + let self_link = Link { + href: "/opds/authors".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Myself, + title: None, + count: None, + }; + let feed = Feed::create( + now, + "little-hesinde:authors", + "All Authors", + self_link, + vec![], + entries, + ); + let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; + + Ok(xml + .with_content_type("application/atom+xml") + .into_response()) +} diff --git a/little-hesinde/src/handlers/opds/books.rs b/little-hesinde/src/handlers/opds/books.rs new file mode 100644 index 0000000..c1c8a74 --- /dev/null +++ b/little-hesinde/src/handlers/opds/books.rs @@ -0,0 +1,49 @@ +//! Handle multiple books for opds. + +use calibre_db::data::pagination::SortOrder; +use poem::{IntoResponse, Response}; +use time::OffsetDateTime; + +use crate::{ + app_state::AppState, + data::book::Book, + handlers::error::HandlerError, + opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation}, +}; + +/// Render all books as OPDS entries embedded in a feed. +pub async fn handler( + state: &AppState, + _cursor: Option<&str>, + _sort_order: &SortOrder, +) -> Result { + let books: Vec = state + .calibre + .books(u32::MAX.into(), None, &SortOrder::ASC) + .map(|x| x.iter().filter_map(|y| Book::full_book(y, state)).collect()) + .map_err(HandlerError::DataError)?; + + let entries: Vec = books.into_iter().map(Entry::from).collect(); + let now = OffsetDateTime::now_utc(); + + let self_link = Link { + href: "/opds/books".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Myself, + title: None, + count: None, + }; + let feed = Feed::create( + now, + "little-hesinde:books", + "All Books", + self_link, + vec![], + entries, + ); + let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; + + Ok(xml + .with_content_type("application/atom+xml") + .into_response()) +} diff --git a/little-hesinde/src/handlers/opds/feed.rs b/little-hesinde/src/handlers/opds/feed.rs new file mode 100644 index 0000000..f402aff --- /dev/null +++ b/little-hesinde/src/handlers/opds/feed.rs @@ -0,0 +1,109 @@ +//! Handle the OPDS root feed. + +use poem::{handler, web::WithContentType, IntoResponse}; +use time::OffsetDateTime; + +use crate::{ + handlers::error::HandlerError, + opds::{ + content::Content, entry::Entry, feed::Feed, link::Link, media_type::MediaType, + relation::Relation, + }, +}; + +/// Render a root OPDS feed with links to the subsections (authors, books, series and recent). +#[handler] +pub async fn handler() -> Result, poem::Error> { + let now = OffsetDateTime::now_utc(); + + let self_link = Link { + href: "/opds".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Myself, + title: None, + count: None, + }; + let books_entry = Entry { + title: "Books".to_string(), + id: "little-hesinde:books".to_string(), + updated: now, + content: Some(Content { + media_type: MediaType::Text, + content: "Index of all books".to_string(), + }), + author: None, + links: vec![Link { + href: "/opds/books".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Subsection, + title: None, + count: None, + }], + }; + + let authors_entry = Entry { + title: "Authors".to_string(), + id: "little-hesinde:authors".to_string(), + updated: now, + content: Some(Content { + media_type: MediaType::Text, + content: "Index of all authors".to_string(), + }), + author: None, + links: vec![Link { + href: "/opds/authors".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Subsection, + title: None, + count: None, + }], + }; + + let series_entry = Entry { + title: "Series".to_string(), + id: "little-hesinde:series".to_string(), + updated: now, + content: Some(Content { + media_type: MediaType::Text, + content: "Index of all series".to_string(), + }), + author: None, + links: vec![Link { + href: "/opds/series".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Subsection, + title: None, + count: None, + }], + }; + + let recents_entry = Entry { + title: "Recent Additions".to_string(), + id: "little-hesinde:recentbooks".to_string(), + updated: now, + content: Some(Content { + media_type: MediaType::Text, + content: "Recently added books".to_string(), + }), + author: None, + links: vec![Link { + href: "/opds/recent".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Subsection, + title: None, + count: None, + }], + }; + + let feed = Feed::create( + now, + "little-hesinde:catalog", + "Little Hesinde", + self_link, + vec![], + vec![authors_entry, series_entry, books_entry, recents_entry], + ); + let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; + + Ok(xml.with_content_type("application/atom+xml")) +} diff --git a/little-hesinde/src/handlers/opds/recent.rs b/little-hesinde/src/handlers/opds/recent.rs new file mode 100644 index 0000000..8e36691 --- /dev/null +++ b/little-hesinde/src/handlers/opds/recent.rs @@ -0,0 +1,37 @@ +//! Handle recent books for OPDS. + +use poem::{IntoResponse, Response}; +use time::OffsetDateTime; + +use crate::{ + data::book::Book, + handlers::error::HandlerError, + opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation}, +}; + +/// Render recent books as OPDS entries embedded in a feed. +pub async fn handler(recent_books: Vec) -> Result { + let entries: Vec = recent_books.into_iter().map(Entry::from).collect(); + let now = OffsetDateTime::now_utc(); + + let self_link = Link { + href: "/opds/recent".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Myself, + title: None, + count: None, + }; + let feed = Feed::create( + now, + "little-hesinde:recentbooks", + "Recent Books", + self_link, + vec![], + entries, + ); + let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; + + Ok(xml + .with_content_type("application/atom+xml") + .into_response()) +} diff --git a/little-hesinde/src/handlers/opds/series.rs b/little-hesinde/src/handlers/opds/series.rs new file mode 100644 index 0000000..9aeedf2 --- /dev/null +++ b/little-hesinde/src/handlers/opds/series.rs @@ -0,0 +1,45 @@ +//! Handle multiple series for OPDS. + +use calibre_db::{calibre::Calibre, data::pagination::SortOrder}; +use poem::{IntoResponse, Response}; +use time::OffsetDateTime; + +use crate::{ + handlers::error::HandlerError, + opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation}, +}; + +/// Render all series as OPDS entries embedded in a feed. +pub async fn handler( + calibre: &Calibre, + _cursor: Option<&str>, + _sort_order: &SortOrder, +) -> Result { + let series = calibre + .series(u32::MAX.into(), None, &SortOrder::ASC) + .map_err(HandlerError::DataError)?; + + let entries: Vec = series.into_iter().map(Entry::from).collect(); + let now = OffsetDateTime::now_utc(); + + let self_link = Link { + href: "/opds/series".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Myself, + title: None, + count: None, + }; + let feed = Feed::create( + now, + "little-hesinde:series", + "All Series", + self_link, + vec![], + entries, + ); + let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; + + Ok(xml + .with_content_type("application/atom+xml") + .into_response()) +} diff --git a/little-hesinde/src/handlers/opds/series_single.rs b/little-hesinde/src/handlers/opds/series_single.rs new file mode 100644 index 0000000..20d68ba --- /dev/null +++ b/little-hesinde/src/handlers/opds/series_single.rs @@ -0,0 +1,38 @@ +//! Handle a single series for opds. + +use calibre_db::data::series::Series; +use poem::{IntoResponse, Response}; +use time::OffsetDateTime; + +use crate::{ + data::book::Book, + handlers::error::HandlerError, + opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation}, +}; + +/// Render a single series as an OPDS entry embedded in a feed. +pub async fn handler(series: Series, books: Vec) -> Result { + let entries: Vec = books.into_iter().map(Entry::from).collect(); + let now = OffsetDateTime::now_utc(); + + let self_link = Link { + href: format!("/opds/series/{}", series.id), + media_type: MediaType::Navigation, + rel: Relation::Myself, + title: None, + count: None, + }; + let feed = Feed::create( + now, + &format!("little-hesinde:series:{}", series.id), + &series.name, + self_link, + vec![], + entries, + ); + let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; + + Ok(xml + .with_content_type("application/atom+xml") + .into_response()) +} diff --git a/rusty-library/src/handlers/paginated.rs b/little-hesinde/src/handlers/paginated.rs similarity index 82% rename from rusty-library/src/handlers/paginated.rs rename to little-hesinde/src/handlers/paginated.rs index 2b83b43..76a92af 100644 --- a/rusty-library/src/handlers/paginated.rs +++ b/little-hesinde/src/handlers/paginated.rs @@ -1,7 +1,9 @@ +//! Deal with cursor pagination. + use std::fmt::Debug; use calibre_db::data::error::DataStoreError; -use poem::{error::InternalServerError, web::Html}; +use poem::{error::InternalServerError, web::Html, IntoResponse, Response}; use serde::Serialize; use tera::Context; @@ -9,13 +11,14 @@ use crate::templates::TEMPLATES; use super::error::HandlerError; +/// Render a tera template with paginated items and generate back and forth links. pub fn render( template: &str, fetcher: F, sort_field: S, has_previous: P, has_more: M, -) -> Result, poem::Error> +) -> Result where F: Fn() -> Result, DataStoreError>, S: Fn(&T) -> String, @@ -42,8 +45,9 @@ where context.insert("nav", template); context.insert(template, &items); - TEMPLATES + Ok(TEMPLATES .render(template, &context) .map_err(InternalServerError) - .map(Html) + .map(Html)? + .into_response()) } diff --git a/little-hesinde/src/handlers/recent.rs b/little-hesinde/src/handlers/recent.rs new file mode 100644 index 0000000..44f268c --- /dev/null +++ b/little-hesinde/src/handlers/recent.rs @@ -0,0 +1,28 @@ +//! Handle requests for recent books. + +use std::sync::Arc; + +use poem::{handler, web::Data, Response}; + +use crate::{app_state::AppState, data::book::Book, handlers::error::HandlerError, Accept}; + +/// Handle a request recent books and decide whether to render to html or OPDS. +#[handler] +pub async fn handler( + accept: Data<&Accept>, + state: Data<&Arc>, +) -> Result { + let recent_books = state + .calibre + .recent_books(25) + .map_err(HandlerError::DataError)?; + let recent_books = recent_books + .iter() + .filter_map(|x| Book::full_book(x, &state)) + .collect::>(); + + match accept.0 { + Accept::Html => crate::handlers::html::recent::handler(recent_books).await, + Accept::Opds => crate::handlers::opds::recent::handler(recent_books).await, + } +} diff --git a/little-hesinde/src/handlers/series.rs b/little-hesinde/src/handlers/series.rs new file mode 100644 index 0000000..9c701e9 --- /dev/null +++ b/little-hesinde/src/handlers/series.rs @@ -0,0 +1,48 @@ +//! Handle requests for multiple series. + +use std::sync::Arc; + +use calibre_db::data::pagination::SortOrder; +use poem::{ + handler, + web::{Data, Path}, + Response, +}; + +use crate::{app_state::AppState, Accept}; + +/// Handle a request for multiple series, starting at the first. +#[handler] +pub async fn handler_init( + accept: Data<&Accept>, + state: Data<&Arc>, +) -> Result { + series(&accept, &state, None, &SortOrder::ASC).await +} + +/// Handle a request for multiple series, starting at the `cursor` and going in the direction of +/// `sort_order`. +#[handler] +pub async fn handler( + Path((cursor, sort_order)): Path<(String, SortOrder)>, + accept: Data<&Accept>, + state: Data<&Arc>, +) -> Result { + series(&accept, &state, Some(&cursor), &sort_order).await +} + +async fn series( + accept: &Accept, + state: &Arc, + cursor: Option<&str>, + sort_order: &SortOrder, +) -> Result { + match accept { + Accept::Html => { + crate::handlers::html::series::handler(&state.calibre, cursor, sort_order).await + } + Accept::Opds => { + crate::handlers::opds::series::handler(&state.calibre, cursor, sort_order).await + } + } +} diff --git a/little-hesinde/src/handlers/series_single.rs b/little-hesinde/src/handlers/series_single.rs new file mode 100644 index 0000000..7b41b7a --- /dev/null +++ b/little-hesinde/src/handlers/series_single.rs @@ -0,0 +1,37 @@ +//! Handle requests for a single series. + +use std::sync::Arc; + +use poem::{ + handler, + web::{Data, Path}, + Response, +}; + +use crate::{app_state::AppState, data::book::Book, handlers::error::HandlerError, Accept}; + +/// Handle a request for a series with `id` and decide whether to render to html or OPDS. +#[handler] +pub async fn handler( + id: Path, + accept: Data<&Accept>, + state: Data<&Arc>, +) -> Result { + let series = state + .calibre + .scalar_series(*id) + .map_err(HandlerError::DataError)?; + let books = state + .calibre + .series_books(*id) + .map_err(HandlerError::DataError)?; + let books = books + .iter() + .filter_map(|x| Book::full_book(x, &state)) + .collect::>(); + + match accept.0 { + Accept::Html => crate::handlers::html::series_single::handler(series, books).await, + Accept::Opds => crate::handlers::opds::series_single::handler(series, books).await, + } +} diff --git a/little-hesinde/src/lib.rs b/little-hesinde/src/lib.rs new file mode 100644 index 0000000..951b117 --- /dev/null +++ b/little-hesinde/src/lib.rs @@ -0,0 +1,139 @@ +//! A very simple ebook server for a calibre library, providing a html interface as well as an OPDS feed. +//! +//! Shamelessly written to scratch my own itches. + +use std::sync::Arc; + +use app_state::AppState; +use calibre_db::calibre::Calibre; +use config::Config; +use poem::{ + endpoint::EmbeddedFilesEndpoint, get, listener::TcpListener, middleware::Tracing, EndpointExt, + Route, Server, +}; +use rust_embed::RustEmbed; +use tokio::signal; +use tracing::info; + +pub mod app_state; +pub mod cli; +pub mod config; +/// Data structs and their functions. +pub mod data { + pub mod book; +} +/// Request handlers. Because it can not be guaranteed that a proper accept header is sent, the +/// routes are doubled and the decision on whether to render html or OPDS is made with internal +/// data on the respective routes. +pub mod handlers { + /// Handle requests for html. + pub mod html { + pub mod author; + pub mod authors; + pub mod books; + pub mod recent; + pub mod series; + pub mod series_single; + } + /// Handle requests for OPDS. + pub mod opds { + pub mod author; + pub mod authors; + pub mod books; + pub mod feed; + pub mod recent; + pub mod series; + pub mod series_single; + } + pub mod author; + pub mod authors; + pub mod books; + pub mod cover; + pub mod download; + pub mod error; + pub mod paginated; + pub mod recent; + pub mod series; + pub mod series_single; +} +/// OPDS data structs. +pub mod opds { + pub mod author; + pub mod content; + pub mod entry; + pub mod error; + pub mod feed; + pub mod link; + pub mod media_type; + pub mod relation; +} +pub mod templates; + +/// Internal marker data in lieu of a proper `Accept` header. +#[derive(Debug, Clone, Copy)] +pub enum Accept { + /// Render as html. + Html, + /// Render as OPDS. + Opds, +} + +/// Embedd static files. +#[derive(RustEmbed)] +#[folder = "static"] +pub struct Files; + +/// Main entry point to run the ebook server with a calibre library specified in `config`. +pub async fn run(config: Config) -> Result<(), std::io::Error> { + let calibre = Calibre::load(&config.metadata_path).expect("failed to load calibre database"); + let app_state = Arc::new(AppState { calibre, config }); + + let html_routes = Route::new() + .at("/", get(handlers::recent::handler)) + .at("/books", get(handlers::books::handler_init)) + .at("/books/:cursor/:sort_order", get(handlers::books::handler)) + .at("/series", get(handlers::series::handler_init)) + .at( + "/series/:cursor/:sort_order", + get(handlers::series::handler), + ) + .at("/series/:id", get(handlers::series_single::handler)) + .at("/authors", get(handlers::authors::handler_init)) + .at("/authors/:id", get(handlers::author::handler)) + .at( + "/authors/:cursor/:sort_order", + get(handlers::authors::handler), + ) + .at("/cover/:id", get(handlers::cover::handler)) + .at("/book/:id/:format", get(handlers::download::handler)) + .nest("/static", EmbeddedFilesEndpoint::::new()) + .data(Accept::Html); + + let opds_routes = Route::new() + .at("/", get(handlers::opds::feed::handler)) + .at("/recent", get(handlers::recent::handler)) + .at("/books", get(handlers::books::handler_init)) + .at("/authors", get(handlers::authors::handler_init)) + .at("/authors/:id", get(handlers::author::handler)) + .at("/series", get(handlers::series::handler_init)) + .at("/series/:id", get(handlers::series_single::handler)) + .data(Accept::Opds); + + let app = Route::new() + .nest("/", html_routes) + .nest("/opds", opds_routes) + .data(app_state) + .with(Tracing); + + let server = Server::new(TcpListener::bind("[::]:3000")) + .name("cops-web") + .run(app); + + tokio::select! { + _ = server => {}, + _ = signal::ctrl_c() => { + info!("Received Ctrl+C, shutting down..."); + }, + } + Ok(()) +} diff --git a/rusty-library/src/main.rs b/little-hesinde/src/main.rs similarity index 79% rename from rusty-library/src/main.rs rename to little-hesinde/src/main.rs index 0001fb5..1d01085 100644 --- a/rusty-library/src/main.rs +++ b/little-hesinde/src/main.rs @@ -1,5 +1,5 @@ use clap::Parser; -use rusty_library::{cli::Cli, config::Config}; +use little_hesinde::{cli::Cli, config::Config}; #[tokio::main] async fn main() -> Result<(), std::io::Error> { @@ -11,5 +11,5 @@ async fn main() -> Result<(), std::io::Error> { let args = Cli::parse(); let config = Config::load(&args).expect("failed to load configuration"); - rusty_library::run(config).await + little_hesinde::run(config).await } diff --git a/rusty-library/src/opds/author.rs b/little-hesinde/src/opds/author.rs similarity index 86% rename from rusty-library/src/opds/author.rs rename to little-hesinde/src/opds/author.rs index 6350245..72ba315 100644 --- a/rusty-library/src/opds/author.rs +++ b/little-hesinde/src/opds/author.rs @@ -1,10 +1,16 @@ +//! Author data. + use serde::Serialize; +/// Author information. #[derive(Debug, Serialize)] #[serde(rename = "author")] pub struct Author { + /// Full name. pub name: String, + /// Where to find the author. pub uri: String, + /// Optional email address. #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, } diff --git a/rusty-library/src/opds/content.rs b/little-hesinde/src/opds/content.rs similarity index 62% rename from rusty-library/src/opds/content.rs rename to little-hesinde/src/opds/content.rs index accc7ee..d9de97c 100644 --- a/rusty-library/src/opds/content.rs +++ b/little-hesinde/src/opds/content.rs @@ -1,12 +1,17 @@ +//! Content data. + use serde::Serialize; use super::media_type::MediaType; +/// Content of different types, used for example for description of an entry. #[derive(Debug, Serialize)] #[serde(rename = "content")] pub struct Content { + /// Media type of this content. #[serde(rename = "@type")] pub media_type: MediaType, + /// Actual content. #[serde(rename = "$value")] pub content: String, } diff --git a/rusty-library/src/opds/entry.rs b/little-hesinde/src/opds/entry.rs similarity index 78% rename from rusty-library/src/opds/entry.rs rename to little-hesinde/src/opds/entry.rs index ef518ed..6686990 100644 --- a/rusty-library/src/opds/entry.rs +++ b/little-hesinde/src/opds/entry.rs @@ -1,3 +1,5 @@ +//! Entry data. + use calibre_db::data::{author::Author as DbAuthor, series::Series}; use serde::Serialize; use time::OffsetDateTime; @@ -8,20 +10,32 @@ use super::{ author::Author, content::Content, link::Link, media_type::MediaType, relation::Relation, }; +/// Fundamental piece of OPDS, holding information about entries (for example a book). #[derive(Debug, Serialize)] #[serde(rename = "entry")] pub struct Entry { + /// Title of the entry. pub title: String, + /// Id, for example a uuid. pub id: String, + /// When was this entry updated last. #[serde(with = "time::serde::rfc3339")] pub updated: OffsetDateTime, + /// Optional content. #[serde(skip_serializing_if = "Option::is_none")] + /// Optional author information. pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub author: Option, + /// List of links, for example to download an entry. #[serde(rename = "link")] pub links: Vec, } +/// Convert a book into an OPDS entry. +/// +/// Add the cover and formats as link with a proper media type. +/// Add author and content information. impl From for Entry { fn from(value: Book) -> Self { let author = Author { @@ -30,7 +44,7 @@ impl From for Entry { email: None, }; let mut links = vec![Link { - href: format!("/cover/{}", value.id), + href: format!("/cover/{}", value.data.id), media_type: MediaType::Jpeg, rel: Relation::Image, title: None, @@ -43,15 +57,15 @@ impl From for Entry { .collect(); links.append(&mut format_links); - let content = value.description.map(|desc| Content { + let content = value.data.description.map(|desc| Content { media_type: MediaType::Html, content: desc, }); Self { - title: value.title.clone(), - id: format!("urn:uuid:{}", value.uuid), - updated: value.last_modified, + title: value.data.title.clone(), + id: format!("urn:uuid:{}", value.data.uuid), + updated: value.data.last_modified, content, author: Some(author), links, @@ -59,6 +73,9 @@ impl From for Entry { } } +/// Convert author information into an OPDS entry. +/// +/// Add the author link. impl From for Entry { fn from(value: DbAuthor) -> Self { let links = vec![Link { @@ -71,7 +88,7 @@ impl From for Entry { Self { title: value.name.clone(), - id: format!("rusty:authors:{}", value.id), + id: format!("little-hesinde:authors:{}", value.id), updated: OffsetDateTime::now_utc(), content: None, author: None, @@ -80,6 +97,9 @@ impl From for Entry { } } +/// Convert series information into an OPDS entry. +/// +/// Add the series link. impl From for Entry { fn from(value: Series) -> Self { let links = vec![Link { @@ -92,7 +112,7 @@ impl From for Entry { Self { title: value.name.clone(), - id: format!("rusty:series:{}", value.id), + id: format!("little-hesinde:series:{}", value.id), updated: OffsetDateTime::now_utc(), content: None, author: None, diff --git a/rusty-library/src/opds/error.rs b/little-hesinde/src/opds/error.rs similarity index 65% rename from rusty-library/src/opds/error.rs rename to little-hesinde/src/opds/error.rs index c644cab..2bc0806 100644 --- a/rusty-library/src/opds/error.rs +++ b/little-hesinde/src/opds/error.rs @@ -1,15 +1,21 @@ +//! Error handling for OPDS data. + use std::string::FromUtf8Error; use quick_xml::DeError; use thiserror::Error; +/// Errors happening during handling OPDS data. #[derive(Error, Debug)] #[error("opds error")] pub enum OpdsError { + /// Error serializing OPDS data. #[error("failed to serialize struct")] SerializingError(#[from] DeError), + /// Error parsing OPDS xml structure. #[error("xml failure")] XmlError(#[from] quick_xml::Error), + /// Error decoding xml as UTF-8. #[error("failed to decode as utf-8")] Utf8Error(#[from] FromUtf8Error), } diff --git a/rusty-library/src/opds/feed.rs b/little-hesinde/src/opds/feed.rs similarity index 56% rename from rusty-library/src/opds/feed.rs rename to little-hesinde/src/opds/feed.rs index 518d09b..bd8d28b 100644 --- a/rusty-library/src/opds/feed.rs +++ b/little-hesinde/src/opds/feed.rs @@ -1,3 +1,5 @@ +//! Root feed data. + use std::io::Cursor; use quick_xml::{ @@ -8,24 +10,73 @@ use quick_xml::{ use serde::Serialize; use time::OffsetDateTime; -use super::{author::Author, entry::Entry, error::OpdsError, link::Link}; +use super::{ + author::Author, entry::Entry, error::OpdsError, link::Link, media_type::MediaType, + relation::Relation, +}; +/// Root feed element of OPDS. #[derive(Debug, Serialize)] #[serde(rename = "feed")] pub struct Feed { + /// Title, often shown in OPDS clients. pub title: String, + /// Feed id. pub id: String, + /// When was the feed updated last. #[serde(with = "time::serde::rfc3339")] pub updated: OffsetDateTime, + /// Icon for the feed. pub icon: String, + /// Feed author. pub author: Author, + /// Links, for example home or self. #[serde(rename = "link")] pub links: Vec, + /// Entries inside the feed (books, series, subsections, ...) #[serde(rename = "entry")] pub entries: Vec, } impl Feed { + /// Create a feed with the specified data. + pub fn create( + now: OffsetDateTime, + id: &str, + title: &str, + self_link: Link, + mut additional_links: Vec, + entries: Vec, + ) -> Self { + let author = Author { + name: "Thallian".to_string(), + uri: "https://code.vanwa.ch/shu/little-hesinde".to_string(), + email: None, + }; + let mut links = vec![ + Link { + href: "/opds".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Start, + title: Some("Home".to_string()), + count: None, + }, + self_link, + ]; + links.append(&mut additional_links); + + Self { + title: title.to_string(), + id: id.to_string(), + updated: now, + icon: "favicon.ico".to_string(), + author, + links, + entries, + } + } + + /// Serialize a feed to OPDS xml. pub fn as_xml(&self) -> Result { let xml = to_string(&self)?; let mut reader = Reader::from_str(&xml); diff --git a/rusty-library/src/opds/link.rs b/little-hesinde/src/opds/link.rs similarity index 83% rename from rusty-library/src/opds/link.rs rename to little-hesinde/src/opds/link.rs index 1cfe1ba..f19e28f 100644 --- a/rusty-library/src/opds/link.rs +++ b/little-hesinde/src/opds/link.rs @@ -1,32 +1,41 @@ +//! Link data. + use serde::Serialize; use crate::data::book::{Book, Format}; use super::{media_type::MediaType, relation::Relation}; +/// Link element in OPDS. #[derive(Debug, Serialize)] #[serde(rename = "link")] pub struct Link { + /// Actual hyperlink. #[serde(rename = "@href")] pub href: String, + /// Type of the target. #[serde(rename = "@type")] pub media_type: MediaType, + /// Relation of the target. #[serde(rename = "@rel")] pub rel: Relation, + /// Optional link title. #[serde(rename = "@title")] #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, + /// Optional count (how many entries at the target). #[serde(rename = "@thr:count")] #[serde(skip_serializing_if = "Option::is_none")] pub count: Option, } +/// Convert a format from a book into a link where it is downloadable. impl From<(&Book, (&Format, &str))> for Link { fn from(value: (&Book, (&Format, &str))) -> Self { let format = value.1 .0.clone(); let media_type: MediaType = format.into(); Self { - href: format!("/book/{}/{}", value.0.id, value.1 .0), + href: format!("/book/{}/{}", value.0.data.id, value.1 .0), media_type, rel: media_type.into(), title: Some(value.1 .0 .0.clone()), diff --git a/rusty-library/src/opds/media_type.rs b/little-hesinde/src/opds/media_type.rs similarity index 75% rename from rusty-library/src/opds/media_type.rs rename to little-hesinde/src/opds/media_type.rs index a9547ce..7a98869 100644 --- a/rusty-library/src/opds/media_type.rs +++ b/little-hesinde/src/opds/media_type.rs @@ -1,18 +1,24 @@ +//! Media types for OPDS elements. + use serde_with::SerializeDisplay; use crate::data::book::Format; +/// Valid media types. #[derive(Debug, Copy, Clone, SerializeDisplay)] pub enum MediaType { + /// A link with this type is meant to acquire a certain thing, for example an entry. Acquisition, Epub, Html, Jpeg, + /// A link with this type is meant for navigation around a feed. Navigation, Pdf, Text, } +/// Convert `epub` and `pdf` formats to their respective media type. Everything else is `Text`. impl From for MediaType { fn from(value: Format) -> Self { match value.0.as_ref() { @@ -23,6 +29,7 @@ impl From for MediaType { } } +/// Display the respective mime types of the respective media types. impl std::fmt::Display for MediaType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/rusty-library/src/opds/relation.rs b/little-hesinde/src/opds/relation.rs similarity index 82% rename from rusty-library/src/opds/relation.rs rename to little-hesinde/src/opds/relation.rs index 52ac158..8a9a9d3 100644 --- a/rusty-library/src/opds/relation.rs +++ b/little-hesinde/src/opds/relation.rs @@ -1,10 +1,14 @@ +//! Relation data. + use serde_with::SerializeDisplay; use super::media_type::MediaType; +/// Types of relations for links. #[derive(Debug, SerializeDisplay)] pub enum Relation { Image, + /// Refer to the self feed. Myself, Start, Subsection, @@ -12,6 +16,9 @@ pub enum Relation { Acquisition, } +/// Convert a media type int a relation. +/// +/// This is not always deterministic but for the ones I actually use so far it is correct. impl From for Relation { fn from(value: MediaType) -> Self { match value { @@ -26,6 +33,7 @@ impl From for Relation { } } +/// Specify how to represent all relations in OPDS. impl std::fmt::Display for Relation { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/rusty-library/src/templates.rs b/little-hesinde/src/templates.rs similarity index 90% rename from rusty-library/src/templates.rs rename to little-hesinde/src/templates.rs index 532f84c..c0c7cd8 100644 --- a/rusty-library/src/templates.rs +++ b/little-hesinde/src/templates.rs @@ -1,6 +1,9 @@ +//! Tera templates. + use once_cell::sync::Lazy; use tera::Tera; +/// All tera templates, globally accessible. pub static TEMPLATES: Lazy = Lazy::new(|| { let mut tera = Tera::default(); tera.add_raw_templates(vec![ diff --git a/rusty-library/static/pico.min.css b/little-hesinde/static/pico.min.css similarity index 100% rename from rusty-library/static/pico.min.css rename to little-hesinde/static/pico.min.css diff --git a/rusty-library/static/style.css b/little-hesinde/static/style.css similarity index 89% rename from rusty-library/static/style.css rename to little-hesinde/static/style.css index e0e813b..6a9a15b 100644 --- a/rusty-library/static/style.css +++ b/little-hesinde/static/style.css @@ -39,3 +39,8 @@ nav ul li { text-align: center; height: 6rem; } + +footer small { + display: flex; + justify-content: space-between; +} diff --git a/rusty-library/templates/authors.html b/little-hesinde/templates/authors.html similarity index 100% rename from rusty-library/templates/authors.html rename to little-hesinde/templates/authors.html diff --git a/rusty-library/templates/base.html b/little-hesinde/templates/base.html similarity index 79% rename from rusty-library/templates/base.html rename to little-hesinde/templates/base.html index 2ade26a..b0c38cf 100644 --- a/rusty-library/templates/base.html +++ b/little-hesinde/templates/base.html @@ -6,7 +6,7 @@ - Rusty Library + Little Hesinde
@@ -27,7 +27,11 @@ diff --git a/rusty-library/templates/book_card.html b/little-hesinde/templates/book_card.html similarity index 80% rename from rusty-library/templates/book_card.html rename to little-hesinde/templates/book_card.html index 318fb93..db491eb 100644 --- a/rusty-library/templates/book_card.html +++ b/little-hesinde/templates/book_card.html @@ -1,7 +1,7 @@
-
{{ book.title }}
+
{{ book.data.title }}

{{ book.author.name }}

@@ -14,7 +14,7 @@ {% endif %}
- book cover + book cover