Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 5 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,13 @@ No single module owns these, so a change to any one surface has to keep them:
taxonomy orphan sweeps, smart-shelf rules — gates on the same override-aware
membership (the `effective_*_sql!` macros). A looser rule on one makes them
disagree: the palette offers an author the index rejects.
- **Attribution.** Every user-facing name (journal author, rating, shelf owner)
- **Attribution.** Every user-facing name (journal author, reviewer, shelf owner)
reads `COALESCE(display_name, username)`; `username` stays the login and admin
identity.
- **Private notes.** A review's notes reach only their author: another reader
gets an `AttributedReview`, which has no notes field, and nothing they see —
the review's timestamps, the "Recently Interacted" sort — moves on an edit
that touched only the notes.
- **Derived-byte caches.** Thumbnails, the KEPUB cache and the export-EPUB cache
all invalidate on `books.last_modified`, so every metadata or cover write must
bump it (`touch_book_last_modified`) or all three serve stale bytes.
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/rpc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ mod physical;
mod progress;
mod ratings;
mod read_status;
mod reviews;
mod scan;
mod series;
mod settings;
Expand Down Expand Up @@ -59,6 +60,7 @@ pub use physical::*;
pub use progress::*;
pub use ratings::*;
pub use read_status::*;
pub use reviews::*;
pub use scan::*;
pub use series::*;
pub use settings::*;
Expand Down
51 changes: 51 additions & 0 deletions frontend/src/rpc/reviews.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//! Book reviews: read one book's reviews, patch your own, delete it. Mobile
//! uses the analogous REST routes in `server::backend::reviews`.

use dioxus::fullstack::post;
use dioxus::prelude::*;
#[cfg(feature = "server")]
use omnibus_db as db;
use omnibus_shared::{BookReviews, Review, ReviewPatch};

#[cfg(feature = "server")]
use super::{internal_rpc_error, AuthUser, PoolExt};

/// Your review of a book, the other readers' reviews and the summary.
#[post("/api/rpc/reviews/get", pool: PoolExt, user: AuthUser)]
pub async fn rpc_get_book_reviews(uuid: String) -> Result<BookReviews> {
match db::reviews::get_book_reviews(&pool.0, user.id, &uuid).await {
Ok(reviews) => Ok(reviews),
Err(db::reviews::ReviewError::BookNotFound) => {
Err(ServerFnError::new("book not found").into())
}
Err(db::reviews::ReviewError::Sqlx(e)) => {
Err(internal_rpc_error("get book reviews", e).into())
}
}
}

/// Patch your review of a book field by field. `Ok(None)` when the patch left
/// it empty, which deletes it.
#[post("/api/rpc/reviews/update", pool: PoolExt, user: AuthUser)]
pub async fn rpc_update_review(uuid: String, patch: ReviewPatch) -> Result<Option<Review>> {
if let Err(msg) = patch.validate() {
return Err(ServerFnError::new(msg).into());
}
match db::reviews::update_review(&pool.0, user.id, &uuid, &patch).await {
Ok(review) => Ok(review),
Err(db::reviews::ReviewError::BookNotFound) => {
Err(ServerFnError::new("book not found").into())
}
Err(db::reviews::ReviewError::Sqlx(e)) => {
Err(internal_rpc_error("update review", e).into())
}
}
}

/// Delete your whole review of a book, notes included. A no-op when absent.
#[post("/api/rpc/reviews/delete", pool: PoolExt, user: AuthUser)]
pub async fn rpc_delete_review(uuid: String) -> Result<()> {
Ok(db::reviews::delete_review(&pool.0, user.id, &uuid)
.await
.map_err(|e| internal_rpc_error("delete review", e))?)
}
4 changes: 2 additions & 2 deletions mcp/src/server/tests/checkin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,8 @@ fn combined_router_carries_every_family_without_collisions() {
.sum();
let combined = offline_server().tool_router;
assert_eq!(combined.list_all().len(), per_family);
// 22 read + 9 checkin + 6 shelf + 6 metadata + 2 merge + 5 content.
assert_eq!(per_family, 50);
// 23 read + 9 checkin + 6 shelf + 6 metadata + 2 merge + 5 content.
assert_eq!(per_family, 51);
}

#[tokio::test]
Expand Down
1 change: 1 addition & 0 deletions mcp/src/server/tests/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const EXPECTED_TOOLS: &[&str] = &[
"book_read_status",
"book_highlights",
"book_bookmarks",
"book_reviews",
"book_journal_entries",
];

Expand Down
44 changes: 36 additions & 8 deletions mcp/src/tools/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use omnibus_shared::{
AuthorDetail, AuthorSummary, BookProgress, Bookmark, EbookLibrary, EbookMetadata, GenreWeight,
Highlight, HouseholdReader, JournalEntry, LibraryContents, PhysicalCopy, ProgressFormat,
ReadStatusRecord, ResumePoint, SeriesDetail, SeriesSummary, SessionLogPage, Shelf,
ShelfSummary, SortDir, SortKey, StatsRange, StatsSummary, TagWeight,
AuthorDetail, AuthorSummary, BookProgress, BookReviews, Bookmark, EbookLibrary, EbookMetadata,
GenreWeight, Highlight, HouseholdReader, JournalEntry, LibraryContents, PhysicalCopy,
ProgressFormat, ReadStatusRecord, ResumePoint, ReviewSummary, SeriesDetail, SeriesSummary,
SessionLogPage, Shelf, ShelfSummary, SortDir, SortKey, StatsRange, StatsSummary, TagWeight,
};

use crate::client::ClientError;
Expand All @@ -24,8 +24,9 @@ pub mod views;
mod tests;

use views::{
BookProgressView, BookmarkView, HighlightView, JournalEntryView, PhysicalCopyView,
ProgressRecordView, ReadStatusView, ResumePointView, SessionLogEntryView, SessionLogPageView,
BookProgressView, BookmarkView, HighlightView, JournalEntryView, OwnReviewView,
PhysicalCopyView, ProgressRecordView, ReadStatusView, ResumePointView, ReviewView,
SessionLogEntryView, SessionLogPageView,
};

/// A single book handle, as returned in `unique_identifier` by the listing
Expand All @@ -43,7 +44,7 @@ pub struct BookRef {
pub enum BookInclude {
/// Saved positions, one per format the reader has opened.
Progress,
/// want_to_read / reading / finished.
/// unread / reading / finished.
ReadStatus,
/// Kept lines with their notes.
Highlights,
Expand Down Expand Up @@ -263,6 +264,17 @@ pub struct BookmarkList {
pub bookmarks: Vec<BookmarkView>,
}

/// `book_reviews`' answer.
#[derive(Debug, Serialize, JsonSchema)]
pub struct BookReviewsAnswer {
/// The signed-in user's review; null when they haven't reviewed the book.
pub mine: Option<OwnReviewView>,
/// Other readers' reviews: with text first, newest first, then bare ratings.
pub others: Vec<ReviewView>,
/// Every reader's stars summed up; null until two readers have rated the book.
pub summary: Option<ReviewSummary>,
}

/// `book_journal_entries`' answer, newest first.
#[derive(Debug, Serialize, JsonSchema)]
pub struct JournalEntryList {
Expand Down Expand Up @@ -603,7 +615,7 @@ impl OmnibusMcp {
}

#[tool(
description = "The signed-in user's read state for one book (want_to_read / reading / finished, with rating context). `read_status` is null when the book has no state yet — treat that as unread. updated_at and finished_at are ISO 8601, with unix seconds alongside under the same name plus _epoch."
description = "The signed-in user's read state for one book: unread, reading or finished. `read_status` is null when the book has no state yet — treat that as unread. Ratings and reviews come from book_reviews. updated_at and finished_at are ISO 8601, with unix seconds alongside under the same name plus _epoch."
)]
pub async fn book_read_status(
&self,
Expand Down Expand Up @@ -642,6 +654,22 @@ impl OmnibusMcp {
Ok(Json(BookmarkList { bookmarks }))
}

#[tool(
description = "Reviews of one book: the signed-in user's own (stars from 0.5 to 5 in half steps, Markdown text, private notes only they see, spoiler flag), every other reader's stars and text, and a summary (average, count, half-star buckets) once two readers have rated it. A review flagged as a spoiler gives away the plot in its text, never in its stars. Timestamps are ISO 8601, with unix seconds alongside under the same name plus _epoch."
)]
pub async fn book_reviews(
&self,
Parameters(p): Parameters<BookRef>,
) -> Result<Json<BookReviewsAnswer>, ErrorData> {
let path = format!("/api/reviews/{}", p.uuid);
let reviews: BookReviews = self.client.get_json(&path, &[]).await?;
Ok(Json(BookReviewsAnswer {
mine: reviews.mine.map(Into::into),
others: reviews.others.into_iter().map(Into::into).collect(),
summary: reviews.summary,
}))
}

#[tool(
description = "Journal entries for one book, newest first: every user's published entries plus the signed-in user's own drafts, with rendered HTML bodies. created_at and updated_at are ISO 8601, with unix seconds alongside under the same name plus _epoch."
)]
Expand Down
70 changes: 67 additions & 3 deletions mcp/src/tools/read/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::ErrorCode;

use omnibus_shared::{
BookProgress, Bookmark, Contributor, EbookMetadata, Highlight, HighlightColor, HouseholdReader,
PhysicalCopy, ProgressFormat, ProgressRecord, ReadStatus, ReadStatusRecord, ResumePoint,
SessionFormat, SessionLogEntry, SessionLogPage, StatsSummary,
AttributedReview, BookProgress, BookReviews, Bookmark, Contributor, EbookMetadata, Highlight,
HighlightColor, HouseholdReader, PhysicalCopy, ProgressFormat, ProgressRecord, ReadStatus,
ReadStatusRecord, ResumePoint, Review, SessionFormat, SessionLogEntry, SessionLogPage,
StatsSummary,
};

use super::views::iso;
Expand Down Expand Up @@ -256,6 +257,42 @@ async fn copies(Path(_): Path<String>) -> AxumJson<Vec<PhysicalCopy>> {
}])
}

async fn reviews(Path(_): Path<String>) -> AxumJson<BookReviews> {
AxumJson(BookReviews {
mine: Some(Review {
book_uuid: BOOK.into(),
stars: Some(4.5),
body_md: None,
body_html: None,
notes_md: Some("lend to Sam".into()),
notes_html: None,
spoiler: false,
rated_at: Some(WHEN),
created_at: WHEN,
updated_at: WHEN,
rated_at_iso: None,
created_at_iso: None,
updated_at_iso: None,
}),
others: vec![AttributedReview {
user_id: 2,
username: "sam".into(),
has_avatar: false,
stars: None,
body_md: Some("The house is the hero.".into()),
body_html: None,
spoiler: true,
rated_at: None,
created_at: WHEN,
updated_at: WHEN,
rated_at_iso: None,
created_at_iso: None,
updated_at_iso: None,
}],
summary: None,
})
}

/// Boot a stub instance serving every route these tools reach.
async fn stub_service() -> OmnibusMcp {
let app = Router::new()
Expand All @@ -266,6 +303,7 @@ async fn stub_service() -> OmnibusMcp {
.route("/api/read-status/{uuid}", get(read_status))
.route("/api/highlights/book/{uuid}", get(highlights))
.route("/api/bookmarks/book/{uuid}", get(bookmarks))
.route("/api/reviews/{uuid}", get(reviews))
.route("/api/stats", get(stats))
.route("/api/stats/sessions", get(sessions))
.route("/api/physical/{uuid}/copies", get(copies))
Expand Down Expand Up @@ -703,3 +741,29 @@ fn the_two_format_vocabularies_are_documented_wherever_they_disagree() {
);
}
}

// MARK: - Reviews

#[tokio::test]
async fn book_reviews_stamps_each_review_and_leaves_absent_fields_out() {
let service = stub_service().await;

let answer = service
.book_reviews(Parameters(BookRef { uuid: BOOK.into() }))
.await
.unwrap()
.0;

let mine = json_of(&answer.mine);
assert_eq!(mine["notes_md"], "lend to Sam");
assert_eq!(mine["rated_at"], "2026-03-04T00:00:00Z");
assert_eq!(mine["rated_at_epoch"], WHEN);
assert!(mine.get("body_md").is_none(), "absent text is left out");
let other = json_of(&answer.others[0]);
assert!(
other.get("stars").is_none(),
"a text-only review has no stars"
);
assert_eq!(other["spoiler"], true);
assert_eq!(other["created_at"], "2026-03-04T00:00:00Z");
}
80 changes: 77 additions & 3 deletions mcp/src/tools/read/views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ use serde::Serialize;

use omnibus_shared::cross_format::CrossFormatCandidate;
use omnibus_shared::{
BookProgress, Bookmark, Contributor, EbookMetadata, Highlight, HighlightColor, JournalEntry,
JournalStatus, PhysicalCopy, ProgressFormat, ProgressRecord, ReadStatus, ReadStatusRecord,
ResolvedPosition, ResumePoint, SessionFormat, SessionLogEntry, SessionLogPage,
AttributedReview, BookProgress, Bookmark, Contributor, EbookMetadata, Highlight,
HighlightColor, JournalEntry, JournalStatus, PhysicalCopy, ProgressFormat, ProgressRecord,
ReadStatus, ReadStatusRecord, ResolvedPosition, ResumePoint, Review, SessionFormat,
SessionLogEntry, SessionLogPage,
};

/// Unix seconds as an ISO 8601 UTC stamp (`2026-09-04T03:25:36Z`), total over `i64`.
Expand Down Expand Up @@ -287,6 +288,79 @@ impl From<JournalEntry> for JournalEntryView {
}
}

/// The signed-in user's own review of a book, private notes included.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct OwnReviewView {
/// Half-star steps from 0.5 to 5; absent when they gave no stars.
#[serde(skip_serializing_if = "Option::is_none")]
pub stars: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_md: Option<String>,
/// Only the signed-in user ever sees these.
#[serde(skip_serializing_if = "Option::is_none")]
pub notes_md: Option<String>,
pub spoiler: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub rated_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rated_at_epoch: Option<i64>,
pub created_at: String,
pub created_at_epoch: i64,
pub updated_at: String,
pub updated_at_epoch: i64,
}

impl From<Review> for OwnReviewView {
fn from(r: Review) -> Self {
OwnReviewView {
stars: r.stars,
body_md: r.body_md,
notes_md: r.notes_md,
spoiler: r.spoiler,
rated_at: iso_opt(r.rated_at),
rated_at_epoch: r.rated_at,
created_at: iso(r.created_at),
created_at_epoch: r.created_at,
updated_at: iso(r.updated_at),
updated_at_epoch: r.updated_at,
}
}
}

/// Another reader's review of a book. Their private notes never reach here.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct ReviewView {
pub user_id: i64,
pub username: String,
/// Half-star steps from 0.5 to 5; absent when they gave no stars.
#[serde(skip_serializing_if = "Option::is_none")]
pub stars: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_md: Option<String>,
/// The reviewer flagged the text as containing spoilers.
pub spoiler: bool,
pub created_at: String,
pub created_at_epoch: i64,
pub updated_at: String,
pub updated_at_epoch: i64,
}

impl From<AttributedReview> for ReviewView {
fn from(r: AttributedReview) -> Self {
ReviewView {
user_id: r.user_id,
username: r.username,
stars: r.stars,
body_md: r.body_md,
spoiler: r.spoiler,
created_at: iso(r.created_at),
created_at_epoch: r.created_at,
updated_at: iso(r.updated_at),
updated_at_epoch: r.updated_at,
}
}
}

/// One recorded sitting.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct SessionLogEntryView {
Expand Down
Loading
Loading