From b80f0045f9e744dc28687c170ff8f8d56293d87d Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Mon, 29 Apr 2024 16:23:20 -0700 Subject: [PATCH 1/5] Add missing close handler --- .../components/table/EntityTableColumnConfiguration.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/components/table/EntityTableColumnConfiguration.tsx b/packages/browser/src/components/table/EntityTableColumnConfiguration.tsx index d2d3cecde..1f8e6ed62 100644 --- a/packages/browser/src/components/table/EntityTableColumnConfiguration.tsx +++ b/packages/browser/src/components/table/EntityTableColumnConfiguration.tsx @@ -138,7 +138,14 @@ export default function EntityTableColumnConfiguration({ entity, configuration, - From 76a4bbc51c635f673b3cf99716ca7c7190403251 Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Wed, 1 May 2024 17:16:37 -0700 Subject: [PATCH 2/5] WIP: schema changes for tracking read history --- core/prisma/schema.prisma | 42 ++++++--- core/src/db/entity/media/entity.rs | 90 ++++++++++--------- core/src/db/entity/media/mod.rs | 4 +- core/src/db/entity/media/reading_session.rs | 89 ++++++++++++++++++ core/src/db/entity/smart_list/entity.rs | 16 ++-- .../src/db/entity/smart_list/prisma_macros.rs | 49 +++++----- 6 files changed, 203 insertions(+), 87 deletions(-) create mode 100644 core/src/db/entity/media/reading_session.rs diff --git a/core/prisma/schema.prisma b/core/prisma/schema.prisma index 7f068471b..d3ba6530e 100644 --- a/core/prisma/schema.prisma +++ b/core/prisma/schema.prisma @@ -22,9 +22,10 @@ model User { max_sessions_allowed Int? // null = unlimited permissions String? // comma separated list, e.g. "book_club:create, file:upload, file:download" - reviews Review[] - read_progresses ReadProgress[] - reading_lists ReadingList[] + reviews Review[] + active_reading_sessions ActiveReadingSession[] + finished_reading_sessions FinishedReadingSession[] + reading_lists ReadingList[] book_club_memberships BookClubMember[] book_club_invitations BookClubInvitation[] @@ -193,7 +194,8 @@ model Media { series Series? @relation(fields: [series_id], references: [id], onDelete: Cascade) series_id String? - read_progresses ReadProgress[] + active_user_reading_sessions ActiveReadingSession[] + finished_user_reading_sessions FinishedReadingSession[] tags Tag[] reading_list_items ReadingListItem[] annotations MediaAnnotation[] @@ -266,15 +268,34 @@ model Review { @@map("reviews") } -model ReadProgress { +model FinishedReadingSession { + id String @id @default(cuid()) + + started_at DateTime + completed_at DateTime @default(now()) + + // TODO: Support reading duration in the future + + media_id String + media Media @relation(fields: [media_id], references: [id], onDelete: Cascade) + + user_id String + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + + @@map("finished_reading_sessions") +} + +model ActiveReadingSession { id String @id @default(uuid()) - page Int + // TODO: Support reading duration in the future + + page Int? percentage_completed Float? // 0.0 - 1.0 epubcfi String? - is_completed Boolean @default(false) - completed_at DateTime? - updated_at DateTime @updatedAt + + started_at DateTime @default(now()) + updated_at DateTime @updatedAt media_id String media Media @relation(fields: [media_id], references: [id], onDelete: Cascade) @@ -282,9 +303,8 @@ model ReadProgress { user_id String user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - // literally cant stand this name lol read_progresses @@unique([user_id, media_id]) - @@map("read_progresses") + @@map("reading_sessions") } model Bookmark { diff --git a/core/src/db/entity/media/entity.rs b/core/src/db/entity/media/entity.rs index 7e88377d0..c7c112cb7 100644 --- a/core/src/db/entity/media/entity.rs +++ b/core/src/db/entity/media/entity.rs @@ -10,10 +10,12 @@ use crate::{ FileStatus, }, error::CoreError, - prisma::{media, read_progress}, + prisma::{active_reading_session, media}, }; -use super::{Bookmark, ReadProgress}; +use super::{ActiveReadingSession, Bookmark, FinishedReadingSession}; + +// TODO: Now that we have a single ActiveReadingSession, reevaluate if we need root-level fields for current_page, current_epubcfi, etc. #[derive(Debug, Clone, Deserialize, Serialize, Type, Default, ToSchema)] pub struct Media { @@ -30,7 +32,6 @@ pub struct Media { pub updated_at: String, /// The timestamp when the media was created. pub created_at: String, - // TODO: rename file_modified_at /// The timestamp when the file was last modified on disk. pub modified_at: Option, /// The hash of the file contents. Used to ensure only one instance of a file in the database. @@ -47,14 +48,21 @@ pub struct Media { // The series this media belongs to. Will be `None` only if the relation is not loaded. #[serde(skip_serializing_if = "Option::is_none")] pub series: Option, - /// The read progresses of the media. Will be `None` only if the relation is not loaded. + /// The active reading sessions for the media. Will be `None` only if the relation is not loaded. + /// + /// Note: This is scoped to the current user, only. #[serde(skip_serializing_if = "Option::is_none")] - pub read_progresses: Option>, - /// The current page of the media, computed from `read_progresses`. Will be `None` only - /// if the `read_progresses` relation is not loaded. + pub active_reading_session: Option, + /// The finished reading sessions for the media. Will be `None` only if the relation is not loaded. + /// + /// Note: This is scoped to the current user, only. + pub finished_reading_sessions: Option>, + /// The current page of the media, computed from `active_reading_sessions`. Will be `None` only + /// if the `read_progresses` relation is not loaded or there is no progress. #[serde(skip_serializing_if = "Option::is_none")] pub current_page: Option, - /// The current progress in terms of epubcfi + /// The current progress in terms of epubcfi, computed from `active_reading_sessions`. Will be + /// `None` only if the `read_progresses` relation is not loaded or there is no progress. #[serde(skip_serializing_if = "Option::is_none")] pub current_epubcfi: Option, /// Whether or not the media is completed. Only None if the relation is not loaded. @@ -74,23 +82,22 @@ impl Cursor for Media { } } -impl TryFrom for Media { +impl TryFrom for Media { type Error = CoreError; /// Creates a [Media] instance from the loaded relation of a [media::Data] on - /// a [read_progress::Data] instance. If the relation is not loaded, it will + /// a [active_reading_session::Data] instance. If the relation is not loaded, it will /// return an error. - fn try_from(data: read_progress::Data) -> Result { - let relation = data.media(); - - if relation.is_err() { + fn try_from(data: active_reading_session::Data) -> Result { + let Ok(media) = data.media() else { return Err(CoreError::InvalidQuery( "Failed to load media for read progress".to_string(), )); - } + }; - let mut media = Media::from(relation.unwrap().to_owned()); - media.current_page = Some(data.page); + let mut media = Media::from(media.to_owned()); + media.current_page = data.page; + media.current_epubcfi = data.epubcfi; Ok(media) } @@ -109,28 +116,28 @@ impl From for Media { Err(_e) => None, }; - let (read_progresses, current_page, is_completed, epubcfi) = - match data.read_progresses() { - Ok(read_progresses) => { - let progress = read_progresses - .iter() - .map(|rp| rp.to_owned().into()) - .collect::>(); - - // Note: ugh. - if let Some(p) = progress.first().cloned() { - ( - Some(progress), - Some(p.page), - Some(p.is_completed), - p.epubcfi, - ) - } else { - (Some(progress), None, None, None) - } - }, - Err(_e) => (None, None, None, None), - }; + let active_reading_session = match data.active_user_reading_sessions() { + Ok(sessions) => sessions.first().cloned().map(ActiveReadingSession::from), + Err(_e) => None, + }; + let (current_page, current_epubcfi) = active_reading_session + .as_ref() + .map(|session| (session.page, session.epubcfi.clone())) + .unwrap_or((None, None)); + + let finished_reading_sessions = match data.finished_user_reading_sessions() { + Ok(sessions) => Some( + sessions + .iter() + .map(|data| FinishedReadingSession::from(data.to_owned())) + .collect::>(), + ), + Err(_e) => None, + }; + let is_completed = finished_reading_sessions + .as_ref() + .map(Vec::is_empty) + .map(|b| !b); let tags = match data.tags() { Ok(tags) => Some(tags.iter().map(|tag| tag.to_owned().into()).collect()), @@ -164,9 +171,10 @@ impl From for Media { series_id: data.series_id.unwrap(), metadata, series, - read_progresses, + active_reading_session, + finished_reading_sessions, current_page, - current_epubcfi: epubcfi, + current_epubcfi, is_completed, tags, bookmarks, diff --git a/core/src/db/entity/media/mod.rs b/core/src/db/entity/media/mod.rs index 6d5889b5f..111cf7f85 100644 --- a/core/src/db/entity/media/mod.rs +++ b/core/src/db/entity/media/mod.rs @@ -2,10 +2,10 @@ mod annotation; mod bookmark; mod entity; pub(crate) mod prisma_macros; -mod progress; +mod reading_session; pub(crate) mod utils; pub use annotation::*; pub use bookmark::*; pub use entity::*; -pub use progress::*; +pub use reading_session::*; diff --git a/core/src/db/entity/media/reading_session.rs b/core/src/db/entity/media/reading_session.rs new file mode 100644 index 000000000..e4abc61ed --- /dev/null +++ b/core/src/db/entity/media/reading_session.rs @@ -0,0 +1,89 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use utoipa::ToSchema; + +use crate::prisma::{active_reading_session, finished_reading_session}; + +use crate::db::entity::{Media, User}; + +#[derive(Debug, Clone, Deserialize, Serialize, Type, ToSchema, Default)] +pub struct ActiveReadingSession { + pub id: String, + /// The current page, None if the media is not image-based + pub page: Option, + /// The current epubcfi + pub epubcfi: Option, + // The percentage completed + pub percentage_completed: Option, + /// The timestamp when the reading session was started + pub started_at: String, + /// The ID of the media which has progress. + pub media_id: String, + /// The media which has progress. Will be `None` if the relation is not loaded. + pub media: Option>, + /// The ID of the user who this progress belongs to. + pub user_id: String, + /// The user who this progress belongs to. Will be `None` if the relation is not loaded. + pub user: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Type, ToSchema, Default)] +pub struct FinishedReadingSession { + pub id: String, + /// The timestamp when the reading session was started + pub started_at: String, + /// The timestamp when the reading session was completed + pub completed_at: String, + /// The ID of the media which has progress. + pub media_id: String, + /// The media which has progress. Will be `None` if the relation is not loaded. + pub media: Option, + /// The ID of the user who this progress belongs to. + pub user_id: String, + /// The user who this progress belongs to. Will be `None` if the relation is not loaded. + pub user: Option, +} + +impl From for ActiveReadingSession { + fn from(data: active_reading_session::Data) -> ActiveReadingSession { + let media = match data.media() { + Ok(media) => Some(Box::new(media.to_owned().into())), + Err(_) => None, + }; + + let user = data.user().ok().cloned().map(User::from); + + ActiveReadingSession { + id: data.id, + page: data.page, + epubcfi: data.epubcfi, + started_at: data.started_at.to_rfc3339(), + percentage_completed: data.percentage_completed, + media_id: data.media_id, + media, + user_id: data.user_id, + user, + } + } +} + +impl From for FinishedReadingSession { + fn from(data: finished_reading_session::Data) -> FinishedReadingSession { + let media = match data.media() { + Ok(media) => Some(media.to_owned().into()), + Err(_) => None, + }; + + let user = data.user().ok().cloned().map(User::from); + + FinishedReadingSession { + id: data.id, + started_at: data.started_at.to_rfc3339(), + completed_at: data.completed_at.to_rfc3339(), + media_id: data.media_id, + media, + user_id: data.user_id, + user, + } + } +} diff --git a/core/src/db/entity/smart_list/entity.rs b/core/src/db/entity/smart_list/entity.rs index c9d7c58d7..eb177890d 100644 --- a/core/src/db/entity/smart_list/entity.rs +++ b/core/src/db/entity/smart_list/entity.rs @@ -13,7 +13,9 @@ use crate::{ }, filter::{FilterGroup, FilterJoin, MediaSmartFilter, SmartFilter}, }, - prisma::{library, media, read_progress, series, smart_list, user, PrismaClient}, + prisma::{ + active_reading_session, library, media, series, smart_list, user, PrismaClient, + }, utils::chain_optional_iter, CoreError, CoreResult, }; @@ -99,8 +101,8 @@ impl SmartList { .media() .find_many(params_for_user) .with(media::metadata::fetch()) - .with(media::read_progresses::fetch(vec![ - read_progress::user_id::equals(for_user.id.clone()), + .with(media::active_user_reading_sessions::fetch(vec![ + active_reading_session::user_id::equals(for_user.id.clone()), ])) .exec() .await?; @@ -114,8 +116,8 @@ impl SmartList { .media() .find_many(params_for_user) .with(media::metadata::fetch()) - .with(media::read_progresses::fetch(vec![ - read_progress::user_id::equals(for_user.id.clone()), + .with(media::active_user_reading_sessions::fetch(vec![ + active_reading_session::user_id::equals(for_user.id.clone()), ])) .exec() .await?; @@ -160,9 +162,7 @@ impl SmartList { let books = client .media() .find_many(params_for_user) - .include(media_grouped_by_library::include(vec![ - read_progress::user_id::equals(for_user.id.clone()), - ])) + .include(media_grouped_by_library::include(for_user.id.clone())) .exec() .await?; diff --git a/core/src/db/entity/smart_list/prisma_macros.rs b/core/src/db/entity/smart_list/prisma_macros.rs index 17bf62607..210255445 100644 --- a/core/src/db/entity/smart_list/prisma_macros.rs +++ b/core/src/db/entity/smart_list/prisma_macros.rs @@ -2,10 +2,10 @@ use std::str::FromStr; use crate::{ db::{ - entity::{Media, MediaMetadata, ReadProgress}, + entity::{ActiveReadingSession, FinishedReadingSession, Media, MediaMetadata}, FileStatus, }, - prisma::{media, read_progress}, + prisma::{active_reading_session, finished_reading_session, media}, }; media::select!(media_only_series_id { series_id }); @@ -14,33 +14,31 @@ media::include!(media_grouped_by_series { series: include { metadata } }); -media::include!((progress_filters: Vec) => media_grouped_by_library { - read_progresses(progress_filters) +media::include!((user_id: String) => media_grouped_by_library { + active_user_reading_sessions(vec![active_reading_session::user_id::equals(user_id.clone())]) + finished_user_reading_sessions(vec![finished_reading_session::user_id::equals(user_id.clone())]) metadata series: select { library_id } }); impl From for Media { fn from(data: media_grouped_by_library::Data) -> Self { - let (read_progresses, current_page, is_completed, epubcfi) = { - let progress = data - .read_progresses - .iter() - .map(|rp| rp.to_owned().into()) - .collect::>(); + let active_reading_session = data + .active_user_reading_sessions + .first() + .cloned() + .map(ActiveReadingSession::from); + let (current_page, current_epubcfi) = active_reading_session + .as_ref() + .map(|session| (session.page, session.epubcfi.clone())) + .unwrap_or((None, None)); - // Note: ugh. - if let Some(p) = progress.first().cloned() { - ( - Some(progress), - Some(p.page), - Some(p.is_completed), - p.epubcfi, - ) - } else { - (Some(progress), None, None, None) - } - }; + let finished_reading_sessions = data + .finished_user_reading_sessions + .iter() + .map(|data| FinishedReadingSession::from(data.to_owned())) + .collect::>(); + let is_completed = !finished_reading_sessions.is_empty(); Media { id: data.id, @@ -56,10 +54,11 @@ impl From for Media { status: FileStatus::from_str(&data.status).unwrap_or(FileStatus::Error), series_id: data.series_id.unwrap_or_default(), metadata: data.metadata.map(|m| MediaMetadata::from(m.to_owned())), - read_progresses, + active_reading_session, + finished_reading_sessions: Some(finished_reading_sessions), current_page, - current_epubcfi: epubcfi, - is_completed, + current_epubcfi, + is_completed: Some(is_completed), ..Default::default() } } From 86f78835106215091ab7ac6370509f95334ef152 Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Fri, 3 May 2024 18:01:12 -0700 Subject: [PATCH 3/5] WIP: lots of refactoring for read sessions --- apps/server/src/routers/api/v1/epub.rs | 128 +++--- apps/server/src/routers/api/v1/media.rs | 373 +++++++++--------- apps/server/src/routers/api/v1/series.rs | 142 ++++--- apps/server/src/routers/api/v1/user.rs | 4 +- apps/server/src/routers/opds.rs | 147 +++---- apps/server/src/routers/utoipa.rs | 2 +- .../migration.sql | 57 +++ core/src/db/entity/media/prisma_macros.rs | 15 +- core/src/db/entity/media/reading_session.rs | 29 +- core/src/db/entity/user/entity.rs | 55 ++- core/src/db/filter/smart_filter.rs | 3 +- core/src/lib.rs | 10 +- core/src/opds/entry.rs | 15 +- 13 files changed, 592 insertions(+), 388 deletions(-) create mode 100644 core/prisma/migrations/20240503151831_historical_reading_sessions/migration.sql diff --git a/apps/server/src/routers/api/v1/epub.rs b/apps/server/src/routers/api/v1/epub.rs index b1a1898d4..f7e5a7b30 100644 --- a/apps/server/src/routers/api/v1/epub.rs +++ b/apps/server/src/routers/api/v1/epub.rs @@ -6,13 +6,18 @@ use axum::{ routing::{get, put}, Json, Router, }; -use prisma_client_rust::chrono::Utc; use serde::Deserialize; use specta::Type; use stump_core::{ - db::entity::{Bookmark, Epub, ReadProgress, UpdateEpubProgress}, + db::entity::{ + ActiveReadingSession, Bookmark, Epub, FinishedReadingSession, + ProgressUpdateReturn, UpdateEpubProgress, + }, filesystem::media::EpubProcessor, - prisma::{bookmark, media, media_annotation, read_progress, user}, + prisma::{ + active_reading_session, bookmark, finished_reading_session, media, + media_annotation, user, + }, }; use tower_sessions::Session; use utoipa::ToSchema; @@ -43,7 +48,7 @@ pub(crate) fn mount(app_state: AppState) -> Router { .layer(from_extractor_with_state::(app_state)) } -/// Get an Epub by ID. The `read_progress` relation is loaded. +/// Get an Epub by ID async fn get_epub_by_id( Path(id): Path, State(ctx): State, @@ -55,8 +60,11 @@ async fn get_epub_by_id( .db .media() .find_unique(media::id::equals(id.clone())) - .with(media::read_progresses::fetch(vec![ - read_progress::user_id::equals(user_id.clone()), + .with(media::active_user_reading_sessions::fetch(vec![ + active_reading_session::user_id::equals(user_id.clone()), + ])) + .with(media::finished_user_reading_sessions::fetch(vec![ + finished_reading_session::user_id::equals(user_id.clone()), ])) .with(media::annotations::fetch(vec![ media_annotation::user_id::equals(user_id), @@ -82,68 +90,72 @@ async fn update_epub_progress( State(ctx): State, session: Session, Json(input): Json, -) -> APIResult> { - let db = &ctx.db; +) -> APIResult> { + let client = &ctx.db; let user_id = get_session_user(&session)?.id; - let input_is_complete = input.is_complete.unwrap_or(input.percentage >= 1.0); - let input_completed_at = input_is_complete.then(|| Utc::now().into()); - - // NOTE: I am running this in a transaction with 2 queries because I don't want to update the - // is_complete and completed_at unless a book is *newly* completed. - // TODO: I will eventually add a way to set a book as uncompleted - let progress = db - ._transaction() - .run(|client| async move { - let existing_progress = client - .read_progress() - .find_unique(read_progress::user_id_media_id(user_id.clone(), id.clone())) - .exec() - .await?; - - if let Some(progress) = existing_progress { - let already_completed = progress.is_completed; - let is_completed = already_completed || input_is_complete; - let completed_at = progress.completed_at.or(input_completed_at); + let is_complete = input.is_complete.unwrap_or(input.percentage >= 1.0); - client - .read_progress() - .update( - read_progress::user_id_media_id(user_id.clone(), id.clone()), - vec![ - read_progress::epubcfi::set(Some(input.epubcfi)), - read_progress::is_completed::set(is_completed), - read_progress::percentage_completed::set(Some( - input.percentage, - )), - read_progress::completed_at::set(completed_at), - ], - ) + if is_complete { + let finished_session = client + ._transaction() + .run(|tx| async move { + let deleted_session = tx + .active_reading_session() + .delete(active_reading_session::user_id_media_id( + user_id.clone(), + id.clone(), + )) .exec() .await - } else { - client - .read_progress() + .ok(); + tracing::trace!(?deleted_session, "Deleted active reading session"); + + tx.finished_reading_session() .create( - -1, - media::id::equals(id), - user::id::equals(user_id), - vec![ - read_progress::epubcfi::set(Some(input.epubcfi)), - read_progress::is_completed::set(input_is_complete), - read_progress::percentage_completed::set(Some( - input.percentage, - )), - read_progress::completed_at::set(input_completed_at), - ], + deleted_session.map(|s| s.started_at).unwrap_or_default(), + media::id::equals(id.clone()), + user::id::equals(user_id.clone()), + vec![], ) .exec() .await - } - }) - .await?; + }) + .await?; + tracing::trace!(?finished_session, "Created finished reading session"); - Ok(Json(ReadProgress::from(progress))) + Ok(Json(ProgressUpdateReturn::Finished( + FinishedReadingSession::from(finished_session), + ))) + } else { + let active_session = client + .active_reading_session() + .upsert( + active_reading_session::user_id_media_id(user_id.clone(), id.clone()), + ( + media::id::equals(id.clone()), + user::id::equals(user_id.clone()), + vec![ + active_reading_session::epubcfi::set(Some(input.epubcfi.clone())), + active_reading_session::percentage_completed::set(Some( + input.percentage, + )), + ], + ), + vec![ + active_reading_session::epubcfi::set(Some(input.epubcfi)), + active_reading_session::percentage_completed::set(Some( + input.percentage, + )), + ], + ) + .exec() + .await?; + + Ok(Json(ProgressUpdateReturn::Active( + ActiveReadingSession::from(active_session), + ))) + } } async fn get_bookmarks( diff --git a/apps/server/src/routers/api/v1/media.rs b/apps/server/src/routers/api/v1/media.rs index 3680e37ce..f572426ff 100644 --- a/apps/server/src/routers/api/v1/media.rs +++ b/apps/server/src/routers/api/v1/media.rs @@ -9,7 +9,7 @@ use axum::{ use axum_extra::extract::Query; use prisma_client_rust::{ and, - chrono::{Duration, Utc}, + chrono::Duration, operator::{self, or}, or, raw, Direction, PrismaValue, }; @@ -18,7 +18,13 @@ use serde_qs::axum::QsQuery; use stump_core::{ config::StumpConfig, db::{ - entity::{LibraryOptions, Media, ReadProgress, User, UserPermission}, + entity::{ + macros::{ + finished_reading_session_with_book_pages, reading_session_with_book_pages, + }, + ActiveReadingSession, FinishedReadingSession, LibraryOptions, Media, + ProgressUpdateReturn, User, UserPermission, + }, query::pagination::{PageQuery, Pageable, Pagination, PaginationQuery}, CountQueryReturn, }, @@ -33,9 +39,9 @@ use stump_core::{ read_entire_file, ContentType, FileParts, PathUtils, }, prisma::{ - library, library_options, + active_reading_session, finished_reading_session, library, library_options, media::{self, OrderByParam as MediaOrderByParam, WhereParam}, - media_metadata, read_progress, series, series_metadata, tag, user, PrismaClient, + media_metadata, series, series_metadata, tag, user, PrismaClient, }, }; use tower_sessions::Session; @@ -108,17 +114,28 @@ pub(crate) fn apply_media_read_status_filter( or(read_status .into_iter() .map(|rs| match rs { - ReadStatus::Reading => media::read_progresses::some(vec![and![ - read_progress::user_id::equals(user_id.clone()), - read_progress::is_completed::equals(false) - ]]), - ReadStatus::Completed => media::read_progresses::some(vec![and![ - read_progress::user_id::equals(user_id.clone()), - read_progress::is_completed::equals(true) - ]]), - ReadStatus::Unread => media::read_progresses::none(vec![ - read_progress::user_id::equals(user_id.clone()), - ]), + ReadStatus::Reading => { + media::active_user_reading_sessions::some(vec![and![ + active_reading_session::user_id::equals(user_id.clone()), + ]]) + }, + ReadStatus::Completed => { + media::finished_user_reading_sessions::some(vec![and![ + finished_reading_session::user_id::equals(user_id.clone()), + ]]) + }, + ReadStatus::Unread => { + and![ + media::active_user_reading_sessions::none(vec![ + active_reading_session::user_id::equals(user_id.clone()), + ]), + media::finished_user_reading_sessions::none(vec![ + finished_reading_session::user_id::equals( + user_id.clone() + ), + ]) + ] + }, }) .collect()) })], @@ -244,13 +261,12 @@ pub(crate) fn apply_media_pagination<'a>( /// is currently in progress pub(crate) fn apply_in_progress_filter_for_user( user_id: String, -) -> read_progress::WhereParam { +) -> active_reading_session::WhereParam { and![ - read_progress::user_id::equals(user_id), + active_reading_session::user_id::equals(user_id), or![ - read_progress::page::gt(0), - read_progress::epubcfi::not(None), - read_progress::is_completed::equals(false) + active_reading_session::page::gt(0), + active_reading_session::epubcfi::not(None), ] ] } @@ -352,8 +368,11 @@ async fn get_media( let mut query = client .media() .find_many(where_conditions.clone()) - .with(media::read_progresses::fetch(vec![ - read_progress::user_id::equals(user_id), + .with(media::active_user_reading_sessions::fetch(vec![ + active_reading_session::user_id::equals(user_id.clone()), + ])) + .with(media::finished_user_reading_sessions::fetch(vec![ + finished_reading_session::user_id::equals(user_id), ])) .with(media::metadata::fetch()) .order_by(order_by_param); @@ -510,13 +529,9 @@ async fn get_in_progress_media( let pagination_cloned = pagination.clone(); let is_unpaged = pagination.is_unpaged(); - let read_progress_filter = and![ - read_progress::user_id::equals(user_id.clone()), - read_progress::is_completed::equals(false) - ]; - - let where_conditions = vec![media::read_progresses::some(vec![ - read_progress_filter.clone() + let read_progress_filter = active_reading_session::user_id::equals(user_id.clone()); + let where_conditions = vec![media::active_user_reading_sessions::some(vec![ + read_progress_filter.clone(), ])] .into_iter() .chain(apply_media_library_not_hidden_for_user_filter(&user)) @@ -530,7 +545,9 @@ async fn get_in_progress_media( let mut query = client .media() .find_many(where_conditions.clone()) - .with(media::read_progresses::fetch(vec![read_progress_filter])) + .with(media::active_user_reading_sessions::fetch(vec![ + read_progress_filter, + ])) .with(media::metadata::fetch()) // TODO: check back in -> https://github.com/prisma/prisma/issues/18188 // FIXME: not the proper ordering, BUT I cannot order based on a relation... @@ -611,8 +628,8 @@ async fn get_recently_added_media( let mut query = client .media() .find_many(where_conditions.clone()) - .with(media::read_progresses::fetch(vec![ - read_progress::user_id::equals(user_id), + .with(media::active_user_reading_sessions::fetch(vec![ + active_reading_session::user_id::equals(user_id), ])) .with(media::metadata::fetch()) .order_by(media::created_at::order(Direction::Desc)); @@ -742,8 +759,11 @@ async fn get_media_by_id( let mut query = db .media() .find_first(where_params) - .with(media::read_progresses::fetch(vec![ - read_progress::user_id::equals(user_id), + .with(media::active_user_reading_sessions::fetch(vec![ + active_reading_session::user_id::equals(user_id.clone()), + ])) + .with(media::finished_user_reading_sessions::fetch(vec![ + finished_reading_session::user_id::equals(user_id), ])) .with(media::metadata::fetch()); @@ -911,8 +931,8 @@ async fn get_media_page( let media = db .media() .find_first(where_params) - .with(media::read_progresses::fetch(vec![ - read_progress::user_id::equals(user_id), + .with(media::active_user_reading_sessions::fetch(vec![ + active_reading_session::user_id::equals(user_id), ])) .exec() .await? @@ -1234,94 +1254,68 @@ async fn update_media_progress( Path((id, page)): Path<(String, i32)>, State(ctx): State, session: Session, -) -> APIResult> { +) -> APIResult> { let user = get_session_user(&session)?; let user_id = user.id.clone(); - let db = &ctx.db; + let client = &ctx.db; // TODO: check library access? They don't gain access to the book here, so perhaps // it is acceptable to not check library access here? - // let client = &ctx.db; - // let read_progress = client - // .read_progress() - // .upsert( - // read_progress::user_id_media_id(user_id.clone(), id.clone()), - // ( - // page, - // media::id::equals(id.clone()), - // user::id::equals(user_id.clone()), - // vec![], - // ), - // vec![read_progress::page::set(page)], - // ) - // .with(read_progress::media::fetch()) - // .exec() - // .await?; - - // let is_completed = read_progress - // .media - // .as_ref() - // .map(|media| media.pages == page) - // .unwrap_or_default(); - - // let read_progress = if is_completed { - // client - // .read_progress() - // .update( - // read_progress::id::equals(read_progress.id.clone()), - // vec![read_progress::is_completed::set(true)], - // ) - // .exec() - // .await? - // } else { - // read_progress - // }; - - let timeout = Duration::seconds(10).num_milliseconds() as u64; - let read_progress = db - ._transaction() - .with_max_wait(timeout) - .with_timeout(timeout) - .run(|client| async move { - let read_progress = client - .read_progress() - .upsert( - read_progress::user_id_media_id(user_id.clone(), id.clone()), - ( - page, + let active_session = client + .active_reading_session() + .upsert( + active_reading_session::user_id_media_id(user_id.clone(), id.clone()), + ( + media::id::equals(id.clone()), + user::id::equals(user_id.clone()), + vec![active_reading_session::page::set(Some(page))], + ), + vec![active_reading_session::page::set(Some(page))], + ) + .include(reading_session_with_book_pages::include()) + .exec() + .await?; + let is_completed = active_session.media.pages == page; + + if is_completed { + let timeout = Duration::seconds(10).num_milliseconds() as u64; + let finished_session = client + ._transaction() + .with_max_wait(timeout) + .with_timeout(timeout) + .run(|tx| async move { + let deleted_session = tx + .active_reading_session() + .delete(active_reading_session::user_id_media_id( + user_id.clone(), + id.clone(), + )) + .exec() + .await + .ok(); + tracing::trace!(?deleted_session, "Deleted active reading session"); + + tx.finished_reading_session() + .create( + deleted_session.map(|s| s.started_at).unwrap_or_default(), media::id::equals(id.clone()), user::id::equals(user_id.clone()), vec![], - ), - vec![read_progress::page::set(page)], - ) - .with(read_progress::media::fetch()) - .exec() - .await?; - - let is_completed = read_progress - .media - .as_ref() - .map(|media| media.pages == page) - .unwrap_or_default(); - - if is_completed { - client - .read_progress() - .update( - read_progress::id::equals(read_progress.id.clone()), - vec![read_progress::is_completed::set(true)], ) .exec() .await - } else { - Ok(read_progress) - } - }) - .await?; - - Ok(Json(ReadProgress::from(read_progress))) + }) + .await?; + tracing::trace!(?finished_session, "Created finished reading session"); + Ok(Json(ProgressUpdateReturn::Finished( + FinishedReadingSession::from(finished_session), + ))) + } else { + Ok(Json(ProgressUpdateReturn::Active( + ActiveReadingSession::from(active_session), + ))) + } } #[utoipa::path( @@ -1341,7 +1335,7 @@ async fn get_media_progress( Path(id): Path, State(ctx): State, session: Session, -) -> APIResult>> { +) -> APIResult>> { let db = &ctx.db; let user = get_session_user(&session)?; let user_id = user.id.clone(); @@ -1358,18 +1352,20 @@ async fn get_media_progress( ); let result = db - .read_progress() + .active_reading_session() .find_first(vec![ - read_progress::user_id::equals(user_id.clone()), - read_progress::media_id::equals(id.clone()), - read_progress::media::is(media_where_params), + active_reading_session::user_id::equals(user_id.clone()), + active_reading_session::media_id::equals(id.clone()), + active_reading_session::media::is(media_where_params), ]) .exec() .await?; - Ok(Json(result.map(ReadProgress::from))) + Ok(Json(result.map(ActiveReadingSession::from))) } +// TODO: new API for managing finished sessions + #[utoipa::path( delete, path = "/api/v1/media/:id/progress", @@ -1391,13 +1387,13 @@ async fn delete_media_progress( let client = &ctx.db; let user_id = get_session_user(&session)?.id; - let deleted_rp = client - .read_progress() - .delete(read_progress::user_id_media_id(user_id, id)) + let deleted_session = client + .active_reading_session() + .delete(active_reading_session::user_id_media_id(user_id, id)) .exec() .await?; - tracing::trace!(?deleted_rp, "Deleted read progress"); + tracing::trace!(?deleted_session, "Deleted reading session"); Ok(Json(MediaIsComplete::default())) } @@ -1405,7 +1401,7 @@ async fn delete_media_progress( #[derive(Default, Deserialize, Serialize, ToSchema, specta::Type)] pub struct MediaIsComplete { is_completed: bool, - completed_at: Option, + last_completed_at: Option, } #[utoipa::path( @@ -1442,17 +1438,21 @@ async fn get_is_media_completed( ); let result = client - .read_progress() + .finished_reading_session() .find_first(vec![ - read_progress::user_id::equals(user_id.clone()), - read_progress::media_id::equals(id.clone()), - read_progress::media::is(media_where_params), + finished_reading_session::user_id::equals(user_id.clone()), + finished_reading_session::media_id::equals(id.clone()), + finished_reading_session::media::is(media_where_params), ]) + .order_by(finished_reading_session::completed_at::order( + Direction::Desc, + )) + .include(finished_reading_session_with_book_pages::include()) .exec() .await? - .map(|rp| MediaIsComplete { - is_completed: rp.is_completed, - completed_at: rp.completed_at.map(|ca| ca.to_rfc3339()), + .map(|ars| MediaIsComplete { + is_completed: true, + last_completed_at: Some(ars.completed_at.to_rfc3339()), }) .unwrap_or_default(); @@ -1500,58 +1500,75 @@ async fn put_media_complete_status( [age_restrictions], ); - let result: Result = client - ._transaction() - .run(|tx| async move { - let media = tx - .media() - .find_first(media_where_params) - .exec() - .await? - .ok_or(APIError::NotFound(String::from("Media not found")))?; + let book = client + .media() + .find_first(media_where_params.clone()) + .with(media::metadata::fetch()) + .exec() + .await? + .ok_or(APIError::NotFound(String::from("Media not found")))?; - let is_completed = payload.is_complete; - let (pages, completed_at) = if is_completed { - (payload.page.or(Some(media.pages)), Some(Utc::now().into())) - } else { - (payload.page, None) - }; - - let extension = media.extension.to_lowercase(); - let fallback_page = if extension.contains("epub") { -1 } else { 0 }; - - let updated_or_created_rp = tx - .read_progress() - .upsert( - read_progress::user_id_media_id(user_id.clone(), id.clone()), - ( - pages.unwrap_or(fallback_page), + let extension = book.extension.to_lowercase(); + + let is_completed = payload.is_complete; + + if is_completed { + let finished_session = client + ._transaction() + .run(|tx| async move { + let deleted_session = tx + .active_reading_session() + .delete(active_reading_session::user_id_media_id( + user_id.clone(), + id.clone(), + )) + .exec() + .await + .ok(); + tracing::trace!(?deleted_session, "Deleted active reading session"); + + tx.finished_reading_session() + .create( + deleted_session.map(|s| s.started_at).unwrap_or_default(), media::id::equals(id.clone()), user::id::equals(user_id.clone()), - vec![ - read_progress::is_completed::set(is_completed), - read_progress::completed_at::set(completed_at), - ], - ), - chain_optional_iter( - [ - read_progress::is_completed::set(is_completed), - read_progress::completed_at::set(completed_at), - ], - [pages.map(read_progress::page::set)], - ), - ) - .exec() - .await?; - Ok(updated_or_created_rp) - }) - .await; - let updated_or_created_rp = result?; + vec![], + ) + .exec() + .await + }) + .await?; + tracing::trace!(?finished_session, "Created finished reading session"); + + Ok(Json(MediaIsComplete { + is_completed: true, + last_completed_at: Some(finished_session.completed_at.to_rfc3339()), + })) + } else { + let page = match extension.as_str() { + "epub" => -1, + _ => payload.page.unwrap_or(book.pages), + }; + let updated_or_created_session = client + .active_reading_session() + .upsert( + active_reading_session::user_id_media_id(user_id.clone(), id.clone()), + ( + media::id::equals(id.clone()), + user::id::equals(user_id.clone()), + vec![active_reading_session::page::set(Some(page))], + ), + vec![active_reading_session::page::set(Some(page))], + ) + .exec() + .await?; + tracing::trace!( + ?updated_or_created_session, + "Updated or created active reading session" + ); - Ok(Json(MediaIsComplete { - is_completed: updated_or_created_rp.is_completed, - completed_at: updated_or_created_rp.completed_at.map(|ca| ca.to_rfc3339()), - })) + Ok(Json(MediaIsComplete::default())) + } } #[utoipa::path( diff --git a/apps/server/src/routers/api/v1/series.rs b/apps/server/src/routers/api/v1/series.rs index 38402bc26..20e3b0039 100644 --- a/apps/server/src/routers/api/v1/series.rs +++ b/apps/server/src/routers/api/v1/series.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use axum::{ extract::{DefaultBodyLimit, Multipart, Path, State}, middleware::from_extractor_with_state, @@ -11,7 +13,10 @@ use serde_qs::axum::QsQuery; use stump_core::{ config::StumpConfig, db::{ - entity::{LibraryOptions, Media, Series, User, UserPermission}, + entity::{ + macros::finished_reading_session_series_complete, LibraryOptions, Media, + Series, User, UserPermission, + }, query::{ ordering::QueryOrder, pagination::{PageQuery, Pageable, Pagination, PaginationQuery}, @@ -30,9 +35,9 @@ use stump_core::{ ContentType, FileParts, PathUtils, }, prisma::{ - library, + active_reading_session, finished_reading_session, library, media::{self, OrderByParam as MediaOrderByParam}, - media_metadata, read_progress, + media_metadata, series::{self, OrderByParam, WhereParam}, series_metadata, }, @@ -271,11 +276,15 @@ async fn get_series( .run(|client| async move { let mut query = db.series().find_many(where_conditions.clone()); if load_media { - query = query.with(series::media::fetch(vec![]).with( - media::read_progresses::fetch(vec![read_progress::user_id::equals( - user_id, - )]), - )); + query = query.with( + series::media::fetch(vec![]) + .with(media::active_user_reading_sessions::fetch(vec![ + active_reading_session::user_id::equals(user_id.clone()), + ])) + .with(media::finished_user_reading_sessions::fetch(vec![ + finished_reading_session::user_id::equals(user_id), + ])), + ); } if !is_unpaged { @@ -382,8 +391,11 @@ async fn get_series_by_id( if load_media { query = query.with( series::media::fetch(vec![]) - .with(media::read_progresses::fetch(vec![ - read_progress::user_id::equals(user_id), + .with(media::active_user_reading_sessions::fetch(vec![ + active_reading_session::user_id::equals(user_id.clone()), + ])) + .with(media::finished_user_reading_sessions::fetch(vec![ + finished_reading_session::user_id::equals(user_id), ])) .order_by(media::name::order(Direction::Asc)), ); @@ -846,8 +858,11 @@ async fn get_series_media( let mut query = client .media() .find_many(media_where_params.clone()) - .with(media::read_progresses::fetch(vec![ - read_progress::user_id::equals(user_id), + .with(media::active_user_reading_sessions::fetch(vec![ + active_reading_session::user_id::equals(user_id.clone()), + ])) + .with(media::finished_user_reading_sessions::fetch(vec![ + finished_reading_session::user_id::equals(user_id), ])) .order_by(order_by_param); @@ -947,8 +962,11 @@ async fn get_next_in_series( .find_first(where_params) .with( series::media::fetch(media_where_params) - .with(media::read_progresses::fetch(vec![ - read_progress::user_id::equals(user_id), + .with(media::active_user_reading_sessions::fetch(vec![ + active_reading_session::user_id::equals(user_id.clone()), + ])) + .with(media::finished_user_reading_sessions::fetch(vec![ + finished_reading_session::user_id::equals(user_id), ])) .order_by(media::name::order(Direction::Asc)), ) @@ -964,26 +982,22 @@ async fn get_next_in_series( let next_book = media .iter() .find(|m| { - if let Some(progress_list) = m.read_progresses.as_ref() { - // No progress means it is up next (for this user)! - if progress_list.is_empty() { - true - } else { - // Note: this should never really exceed len == 1, but :shrug: - progress_list - .first() - .map(|rp| { - !rp.is_completed - || rp - .percentage_completed - .map(|pc| pc <= 1.0) - .unwrap_or(false) || (rp.page < m.pages && rp.page > 0) - }) - .unwrap_or(true) - } - } else { - // case unread should be first in queue - true + match m + .active_user_reading_sessions() + .ok() + .and_then(|sessions| sessions.first()) + { + // If there is a percentage, and it is less than 1.0, then it is next! + Some(session) if session.epubcfi.is_some() => session + .percentage_completed + .map(|value| value < 1.0) + .unwrap_or(false), + // If there is a page, and it is less than the total pages, then it is next! + Some(session) if session.page.is_some() => { + session.page.unwrap_or(1) < m.pages + }, + // No session means it is up next! + _ => true, } }) .or_else(|| media.first()); @@ -1022,34 +1036,54 @@ async fn get_series_is_complete( session: Session, ) -> APIResult> { let client = &ctx.db; - let user_id = get_session_user(&session)?.id; - // TODO: library access or age restriction! + let user = get_session_user(&session)?; + let user_id = user.id.clone(); + let age_restrictions = user.age_restriction.as_ref().map(|ar| { + ( + apply_series_age_restriction(ar.age, ar.restrict_on_unset), + apply_media_age_restriction(ar.age, ar.restrict_on_unset), + ) + }); - let media_count = client - .media() - .count(vec![media::series_id::equals(Some(id.clone()))]) - .exec() - .await?; + let series_where_params = chain_optional_iter( + [series::id::equals(id.clone())] + .into_iter() + .chain(apply_series_library_not_hidden_for_user_filter(&user)) + .collect::>(), + [age_restrictions.as_ref().map(|(sr, _)| sr.clone())], + ); + let media_where_params = chain_optional_iter( + [media::series::is(series_where_params)], + [age_restrictions.as_ref().map(|(_, mr)| mr.clone())], + ); + + let media_count = client.media().count(media_where_params).exec().await?; - let rp = client - .read_progress() + let finished_sessions = client + .finished_reading_session() .find_many(vec![ - read_progress::user_id::equals(user_id), - read_progress::media::is(vec![media::series_id::equals(Some(id))]), - read_progress::is_completed::equals(true), + finished_reading_session::user_id::equals(user_id), + finished_reading_session::media::is(vec![media::series_id::equals(Some(id))]), ]) - .order_by(read_progress::completed_at::order(Direction::Desc)) + .order_by(finished_reading_session::completed_at::order( + Direction::Desc, + )) + .select(finished_reading_session_series_complete::select()) .exec() .await?; - let is_complete = rp.len() == media_count as usize; - let completed_at = is_complete - .then(|| { - rp.first() - .and_then(|rp| rp.completed_at.map(|ca| ca.to_rfc3339())) - }) - .flatten(); + let completed_at = finished_sessions + .first() + .map(|s| s.completed_at.to_rfc3339()); + // TODO(prisma): grouping/distinct not supported + let unique_sessions = finished_sessions + .into_iter() + .map(|s| s.media_id) + .collect::>(); + // Note: I am performing >= in the event that a user lost access to book(s) in the series + // and the count is less than the total media count. + let is_complete = unique_sessions.len() >= media_count as usize; Ok(Json(SeriesIsComplete { is_complete, diff --git a/apps/server/src/routers/api/v1/user.rs b/apps/server/src/routers/api/v1/user.rs index 173a17d05..24ff9b674 100644 --- a/apps/server/src/routers/api/v1/user.rs +++ b/apps/server/src/routers/api/v1/user.rs @@ -151,7 +151,9 @@ async fn get_users( let mut query = client.user().find_many(vec![]); if include_user_read_progress { - query = query.with(user::read_progresses::fetch(vec![])); + query = query + .with(user::active_reading_sessions::fetch(vec![])) + .with(user::finished_reading_sessions::fetch(vec![])); } if include_session_count { diff --git a/apps/server/src/routers/opds.rs b/apps/server/src/routers/opds.rs index d06b6553a..8a4d45f36 100644 --- a/apps/server/src/routers/opds.rs +++ b/apps/server/src/routers/opds.rs @@ -6,7 +6,7 @@ use axum::{ }; use prisma_client_rust::{chrono, Direction}; use stump_core::{ - db::query::pagination::PageQuery, + db::{entity::UserPermission, query::pagination::PageQuery}, filesystem::{ image::{GenericImageProcessor, ImageProcessor, ImageProcessorOptions}, media::get_page, @@ -17,7 +17,7 @@ use stump_core::{ feed::OpdsFeed, link::{OpdsLink, OpdsLinkRel, OpdsLinkType}, }, - prisma::{library, media, read_progress, series, user}, + prisma::{active_reading_session, library, media, series, user}, }; use tower_sessions::Session; use tracing::{debug, trace, warn}; @@ -28,7 +28,7 @@ use crate::{ filter::chain_optional_iter, middleware::auth::Auth, utils::{ - get_session_user, + enforce_session_permissions, get_session_user, http::{ImageResponse, NamedFile, Xml}, }, }; @@ -191,43 +191,38 @@ async fn keep_reading(State(ctx): State, session: Session) -> APIResul let db = &ctx.db; let user_id = get_session_user(&session)?.id; - let read_progress_conditions = vec![apply_in_progress_filter_for_user(user_id)]; + let in_progress_filter = vec![apply_in_progress_filter_for_user(user_id)]; let media = db .media() - .find_many(vec![media::read_progresses::some( - read_progress_conditions.clone(), + .find_many(vec![media::active_user_reading_sessions::some( + in_progress_filter.clone(), )]) - .with(media::read_progresses::fetch(read_progress_conditions)) + .with(media::active_user_reading_sessions::fetch( + in_progress_filter, + )) .order_by(media::name::order(Direction::Asc)) .exec() - .await? - .into_iter() - .filter(|m| match m.read_progresses() { - // Read progresses relation on media is one to many, there is a dual key - // on read_progresses table linking a user and media. Therefore, there should - // only be 1 item in this vec for each media resulting from the query. - Ok(progresses) => { - if progresses.len() != 1 { - return false; - } - - let progress = &progresses[0]; - if let Some(_epubcfi) = progress.epubcfi.as_ref() { - // TODO: check/test this logic - !progress.is_completed - || progress - .percentage_completed - .map(|value| value < 1.0) - .unwrap_or(false) - } else { - progress.page < m.pages - } + .await?; + + let books_in_progress = media.into_iter().filter(|m| { + match m + .active_user_reading_sessions() + .ok() + .and_then(|sessions| sessions.first()) + { + Some(session) if session.epubcfi.is_some() => session + .percentage_completed + .map(|value| value < 1.0) + .unwrap_or(false), + Some(session) if session.page.is_some() => { + session.page.unwrap_or(1) < m.pages }, _ => false, - }); + } + }); - let entries: Vec = media.into_iter().map(OpdsEntry::from).collect(); + let entries = books_in_progress.into_iter().map(OpdsEntry::from).collect(); let feed = OpdsFeed::new( "keepReading".to_string(), @@ -573,7 +568,7 @@ async fn get_book_page( pagination: Query, session: Session, ) -> APIResult { - let db = &ctx.db; + let client = &ctx.db; let user = get_session_user(&session)?; let user_id = user.id; @@ -590,44 +585,56 @@ async fn get_book_page( correct_page = page + 1; } - let result: Result<(media::Data, read_progress::Data), APIError> = db - ._transaction() - .run(|client| async move { - let book = db - .media() - .find_first(chain_optional_iter( - [media::id::equals(id.clone())], - [age_restrictions], - )) - .exec() - .await? - .ok_or(APIError::NotFound(String::from("Book not found")))?; - - let is_completed = book.pages == correct_page; - - let read_progress = client - .read_progress() - .upsert( - read_progress::user_id_media_id(user_id.clone(), id.clone()), - ( - correct_page, - media::id::equals(id.clone()), - user::id::equals(user_id.clone()), - vec![read_progress::is_completed::set(is_completed)], - ), - vec![ - read_progress::page::set(correct_page), - read_progress::is_completed::set(is_completed), - ], - ) - .exec() - .await?; - - Ok((book, read_progress)) - }) - .await; + let book = client + .media() + .find_first(chain_optional_iter( + [media::id::equals(id.clone())], + [age_restrictions], + )) + .exec() + .await? + .ok_or(APIError::NotFound(String::from("Book not found")))?; + let is_completed = book.pages == correct_page; + + if is_completed { + let deleted_session = client + .active_reading_session() + .delete(active_reading_session::user_id_media_id( + user_id.clone(), + id.clone(), + )) + .exec() + .await + .ok(); + tracing::trace!(?deleted_session, "Deleted active reading session"); + + let finished_session = client + .finished_reading_session() + .create( + deleted_session.map(|s| s.started_at).unwrap_or_default(), + media::id::equals(id.clone()), + user::id::equals(user_id.clone()), + vec![], + ) + .exec() + .await?; + tracing::trace!(?finished_session, "Created finished reading session"); + } else { + client + .active_reading_session() + .upsert( + active_reading_session::user_id_media_id(user_id.clone(), id.clone()), + ( + media::id::equals(id.clone()), + user::id::equals(user_id.clone()), + vec![active_reading_session::page::set(Some(correct_page))], + ), + vec![active_reading_session::page::set(Some(correct_page))], + ) + .exec() + .await?; + } - let (book, _) = result?; let (content_type, image_buffer) = get_page(book.path.as_str(), correct_page, &ctx.config)?; handle_opds_image_response(content_type, image_buffer) @@ -640,7 +647,7 @@ async fn download_book( session: Session, ) -> APIResult { let db = &ctx.db; - let user = get_session_user(&session)?; + let user = enforce_session_permissions(&session, &[UserPermission::DownloadFile])?; let age_restrictions = user .age_restriction .as_ref() diff --git a/apps/server/src/routers/utoipa.rs b/apps/server/src/routers/utoipa.rs index 0a6e88ac8..f04452400 100644 --- a/apps/server/src/routers/utoipa.rs +++ b/apps/server/src/routers/utoipa.rs @@ -133,7 +133,7 @@ use super::api::{ ), components( schemas( - Library, LibraryOptions, Media, ReadingList, ReadProgress, Series, Tag, User, + Library, LibraryOptions, Media, ReadingList, ActiveReadingSession, FinishedReadingSession, Series, Tag, User, UserPreferences, LibraryPattern, LibraryScanMode, LogLevel, ClaimResponse, StumpVersion, FileStatus, PageableDirectoryListing, DirectoryListing, DirectoryListingFile, CursorInfo, PageInfo, PageableLibraries, diff --git a/core/prisma/migrations/20240503151831_historical_reading_sessions/migration.sql b/core/prisma/migrations/20240503151831_historical_reading_sessions/migration.sql new file mode 100644 index 000000000..f443abfcb --- /dev/null +++ b/core/prisma/migrations/20240503151831_historical_reading_sessions/migration.sql @@ -0,0 +1,57 @@ +-- CreateTable +CREATE TABLE "finished_reading_sessions" ( + "id" TEXT NOT NULL PRIMARY KEY, + "started_at" DATETIME NOT NULL, + "completed_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "media_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + CONSTRAINT "finished_reading_sessions_media_id_fkey" FOREIGN KEY ("media_id") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "finished_reading_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "reading_sessions" ( + "id" TEXT NOT NULL PRIMARY KEY, + "page" INTEGER, + "percentage_completed" REAL, + "epubcfi" TEXT, + "started_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL, + "media_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + CONSTRAINT "reading_sessions_media_id_fkey" FOREIGN KEY ("media_id") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "reading_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "reading_sessions_user_id_media_id_key" ON "reading_sessions"("user_id", "media_id"); + +-- For each record in `read_progresses` that has `is_completed` set to `true`, insert a record into `finished_reading_sessions` +-- Note: Unfortunately there was no `started_at` field in the `read_progresses` table, so we are using the `updated_at` field as a substitute. + +-- InsertData +INSERT INTO "finished_reading_sessions" ("id", "started_at", "completed_at", "media_id", "user_id") +SELECT "id", IFNULL("updated_at", CURRENT_TIMESTAMP), IFNULL("completed_at", CURRENT_TIMESTAMP), "media_id", "user_id" +FROM "read_progresses" +WHERE "is_completed" = 1; + +-- For each record in `read_progresses` that has `is_completed` set to `false`, insert a record into `reading_sessions` +-- Note: Unfortunately there was no `started_at` field in the `read_progresses` table, so we are using the `updated_at` field as a substitute. + +-- InsertData +INSERT OR IGNORE INTO "reading_sessions" ("id", "page", "percentage_completed", "epubcfi", "started_at", "updated_at", "media_id", "user_id") +SELECT "id", "page", "percentage_completed", "epubcfi", IFNULL("updated_at", CURRENT_TIMESTAMP), IFNULL("updated_at", CURRENT_TIMESTAMP), "media_id", "user_id" +FROM "read_progresses" +WHERE "is_completed" = 0; + + +/* + Warnings: + + - You are about to drop the `read_progresses` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "read_progresses"; +PRAGMA foreign_keys=on; diff --git a/core/src/db/entity/media/prisma_macros.rs b/core/src/db/entity/media/prisma_macros.rs index 2cdfd29e5..5e367fc44 100644 --- a/core/src/db/entity/media/prisma_macros.rs +++ b/core/src/db/entity/media/prisma_macros.rs @@ -1,6 +1,19 @@ -use crate::prisma::media; +use crate::prisma::{active_reading_session, finished_reading_session, media}; media::select!(media_path_modified_at_select { path modified_at }); + +active_reading_session::include!(reading_session_with_book_pages { + media: select { pages } +}); + +finished_reading_session::include!(finished_reading_session_with_book_pages { + media: select { pages } +}); + +finished_reading_session::select!(finished_reading_session_series_complete { + media_id + completed_at +}); diff --git a/core/src/db/entity/media/reading_session.rs b/core/src/db/entity/media/reading_session.rs index e4abc61ed..eb876d217 100644 --- a/core/src/db/entity/media/reading_session.rs +++ b/core/src/db/entity/media/reading_session.rs @@ -6,6 +6,8 @@ use crate::prisma::{active_reading_session, finished_reading_session}; use crate::db::entity::{Media, User}; +use super::prisma_macros::reading_session_with_book_pages; + #[derive(Debug, Clone, Deserialize, Serialize, Type, ToSchema, Default)] pub struct ActiveReadingSession { pub id: String, @@ -24,7 +26,7 @@ pub struct ActiveReadingSession { /// The ID of the user who this progress belongs to. pub user_id: String, /// The user who this progress belongs to. Will be `None` if the relation is not loaded. - pub user: Option, + pub user: Option>, } #[derive(Debug, Clone, Deserialize, Serialize, Type, ToSchema, Default)] @@ -44,6 +46,13 @@ pub struct FinishedReadingSession { pub user: Option, } +#[derive(Debug, Clone, Deserialize, Serialize, Type, ToSchema)] +#[serde(untagged)] +pub enum ProgressUpdateReturn { + Active(ActiveReadingSession), + Finished(FinishedReadingSession), +} + impl From for ActiveReadingSession { fn from(data: active_reading_session::Data) -> ActiveReadingSession { let media = match data.media() { @@ -62,7 +71,23 @@ impl From for ActiveReadingSession { media_id: data.media_id, media, user_id: data.user_id, - user, + user: user.map(Box::new), + } + } +} + +impl From for ActiveReadingSession { + fn from(value: reading_session_with_book_pages::Data) -> Self { + ActiveReadingSession { + id: value.id, + page: value.page, + epubcfi: value.epubcfi, + percentage_completed: value.percentage_completed, + started_at: value.started_at.to_rfc3339(), + media_id: value.media_id, + media: None, + user_id: value.user_id, + user: None, } } } diff --git a/core/src/db/entity/user/entity.rs b/core/src/db/entity/user/entity.rs index 439b26bde..b9cb30a31 100644 --- a/core/src/db/entity/user/entity.rs +++ b/core/src/db/entity/user/entity.rs @@ -3,8 +3,8 @@ use specta::Type; use utoipa::ToSchema; use crate::{ - db::entity::{Cursor, ReadProgress}, - prisma, + db::entity::{ActiveReadingSession, Cursor, FinishedReadingSession}, + prisma::user, }; use super::{ @@ -13,29 +13,45 @@ use super::{ #[derive(Default, Debug, Clone, Serialize, Deserialize, Type, ToSchema)] pub struct User { + /// The ID of the user pub id: String, + /// The username of the user. + /// + /// Note: This is a unique field. pub username: String, + /// A boolean to indicate if the user is the server owner pub is_server_owner: bool, + /// The URL of the user's avatar, if any pub avatar_url: Option, + /// A timestamp of when the user was created, in RFC3339 format pub created_at: String, + /// A timestamp of when the user last logged in, in RFC3339 format pub last_login: Option, + /// A boolean to indicate if the user is locked, which prevents them from logging in pub is_locked: bool, - + /// The permissions of the user, influences what actions throughout the app they can perform pub permissions: Vec, + /// The maximum number of sessions the user is allowed to have at once #[serde(skip_serializing_if = "Option::is_none")] pub max_sessions_allowed: Option, - + /// The number of login sessions the user has. Will be `None` if the relation is not loaded. #[serde(skip_serializing_if = "Option::is_none")] pub login_sessions_count: Option, - + /// The user preferences of the user #[serde(skip_serializing_if = "Option::is_none")] pub user_preferences: Option, + /// The login activity/history of the user. Will be `None` if the relation is not loaded. #[serde(skip_serializing_if = "Option::is_none")] pub login_activity: Option>, + /// The age restriction spec for the user, which restricts the content they can access #[serde(skip_serializing_if = "Option::is_none")] pub age_restriction: Option, + /// The active reading sessions for the user. Will be `None` if the relation is not loaded. #[serde(skip_serializing_if = "Option::is_none")] - pub read_progresses: Option>, + pub active_reading_sessions: Option>, + /// The finished reading sessions for the user. Will be `None` if the relation is not loaded. + #[serde(skip_serializing_if = "Option::is_none")] + pub finished_reading_sessions: Option>, } impl User { @@ -50,16 +66,30 @@ impl Cursor for User { } } -impl From for User { - fn from(data: prisma::user::Data) -> User { +impl From for User { + fn from(data: user::Data) -> User { let user_preferences = data .user_preferences() .map(|up| up.cloned().map(UserPreferences::from)) .ok() .flatten(); - let read_progresses = data - .read_progresses() - .map(|rps| rps.iter().cloned().map(ReadProgress::from).collect()) + let active_reading_sessions = data + .active_reading_sessions() + .map(|ars| { + ars.clone() + .into_iter() + .map(ActiveReadingSession::from) + .collect() + }) + .ok(); + let finished_reading_sessions = data + .finished_reading_sessions() + .map(|frs| { + frs.clone() + .into_iter() + .map(FinishedReadingSession::from) + .collect() + }) .ok(); let age_restriction = data .age_restriction() @@ -86,7 +116,8 @@ impl From for User { user_preferences, avatar_url: data.avatar_url, age_restriction, - read_progresses, + active_reading_sessions, + finished_reading_sessions, created_at: data.created_at.to_rfc3339(), last_login: data.last_login.map(|dt| dt.to_rfc3339()), login_activity, diff --git a/core/src/db/filter/smart_filter.rs b/core/src/db/filter/smart_filter.rs index ae2dad198..04b5add9d 100644 --- a/core/src/db/filter/smart_filter.rs +++ b/core/src/db/filter/smart_filter.rs @@ -593,7 +593,8 @@ mod tests { modified_at: None, pages: 30, path: "test-path".to_string(), - read_progresses: None, + active_user_reading_sessions: None, + finished_user_reading_sessions: None, reading_list_items: None, size: 100, status: "READY".to_string(), diff --git a/core/src/lib.rs b/core/src/lib.rs index 9b6e670eb..7bfb2c969 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -353,7 +353,15 @@ mod tests { file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; - file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; + file.write_all( + format!("{}\n\n", ts_export::()?).as_bytes(), + )?; + file.write_all( + format!("{}\n\n", ts_export::()?).as_bytes(), + )?; + file.write_all( + format!("{}\n\n", ts_export::()?).as_bytes(), + )?; file.write_all( format!("{}\n\n", ts_export::()?).as_bytes(), diff --git a/core/src/opds/entry.rs b/core/src/opds/entry.rs index 9a4ae80f3..bc52c4d77 100644 --- a/core/src/opds/entry.rs +++ b/core/src/opds/entry.rs @@ -168,16 +168,13 @@ impl From for OpdsEntry { let FileParts { file_name, .. } = path_buf.file_parts(); let file_name_encoded = encode(&file_name); - let progress_info = value - .read_progresses() + let active_reading_session = value + .active_user_reading_sessions() .ok() - .and_then(|progresses| progresses.first()); - - let (current_page, last_read_at) = if let Some(progress) = progress_info { - (Some(progress.page), Some(progress.updated_at)) - } else { - (None, None) - }; + .and_then(|sessions| sessions.first().cloned()); + let (current_page, last_read_at) = active_reading_session + .map(|session| (session.page, Some(session.updated_at))) + .unwrap_or((None, None)); let target_pages = if let Some(page) = current_page { vec![1, page] From 863145f3ba5e7b7f0e7075a05e7e3a8d90125ad4 Mon Sep 17 00:00:00 2001 From: Aaron Leopold Date: Sat, 4 May 2024 13:58:41 -0700 Subject: [PATCH 4/5] WIP: refactor UI areas wrt progress --- apps/server/src/routers/api/v1/library.rs | 1 + core/src/db/entity/media/progress.rs | 54 ----- packages/api/src/config.ts | 6 +- packages/api/src/epub.ts | 4 +- packages/api/src/media.ts | 7 +- packages/api/src/server.ts | 2 +- packages/api/tsconfig.json | 1 + packages/browser/package.json | 5 + .../browser/src/components/book/BookCard.tsx | 36 ++-- .../src/components/filters/FilterProvider.tsx | 2 + .../navigation/mobile/LayoutModeButtons.tsx | 28 --- .../components/readers/epub/EpubJsReader.tsx | 4 +- .../src/scenes/book/BookOverviewScene.tsx | 7 +- .../server/email/devices/DevicesTable.tsx | 4 +- .../scenes/settings/server/jobs/JobTable.tsx | 193 +++++++++--------- .../settings/server/users/UsersStats.tsx | 122 +++++++++-- .../create-or-update/UserPermissionsForm.tsx | 10 + .../login-activity/LoginActivityTable.tsx | 9 +- .../server/users/user-table/UserTable.tsx | 4 +- .../smartList/items/table/groupColumns.tsx | 7 +- .../smartList/items/table/mediaColumns.tsx | 4 +- packages/browser/tsconfig.json | 1 + packages/client/src/queries/media.ts | 4 +- packages/client/tsconfig.json | 1 + packages/components/src/form/Form.tsx | 2 +- packages/components/tsconfig.json | 1 + packages/i18n/tsconfig.json | 1 + packages/types/generated.ts | 12 +- packages/types/index.ts | 2 +- packages/types/tsconfig.json | 1 + yarn.lock | 55 ++++- 31 files changed, 337 insertions(+), 253 deletions(-) delete mode 100644 core/src/db/entity/media/progress.rs delete mode 100644 packages/browser/src/components/navigation/mobile/LayoutModeButtons.tsx diff --git a/apps/server/src/routers/api/v1/library.rs b/apps/server/src/routers/api/v1/library.rs index cf6fa1d70..2dcf30fad 100644 --- a/apps/server/src/routers/api/v1/library.rs +++ b/apps/server/src/routers/api/v1/library.rs @@ -294,6 +294,7 @@ pub struct LibraryStatsParams { all_users: bool, } +// TODO(historical-read-session): refactor query #[utoipa::path( get, path = "/api/v1/libraries/stats", diff --git a/core/src/db/entity/media/progress.rs b/core/src/db/entity/media/progress.rs deleted file mode 100644 index 352ca1615..000000000 --- a/core/src/db/entity/media/progress.rs +++ /dev/null @@ -1,54 +0,0 @@ -use serde::{Deserialize, Serialize}; -use specta::Type; -use utoipa::ToSchema; - -use crate::prisma; - -use crate::db::entity::{Media, User}; - -#[derive(Debug, Clone, Deserialize, Serialize, Type, ToSchema, Default)] -pub struct ReadProgress { - pub id: String, - /// The current page - pub page: i32, - /// The current epubcfi - pub epubcfi: Option, - // The percentage completed - pub percentage_completed: Option, - /// boolean to indicate if the media is completed - pub is_completed: bool, - /// The timestamp when the progress was completed - pub completed_at: Option, - /// The ID of the media which has progress. - pub media_id: String, - /// The media which has progress. Will be `None` if the relation is not loaded. - pub media: Option, - /// The ID of the user who this progress belongs to. - pub user_id: String, - /// The user who this progress belongs to. Will be `None` if the relation is not loaded. - pub user: Option, -} - -impl From for ReadProgress { - fn from(data: prisma::read_progress::Data) -> ReadProgress { - let media = match data.media() { - Ok(media) => Some(media.to_owned().into()), - Err(_) => None, - }; - - let user = data.user().ok().cloned().map(User::from); - - ReadProgress { - id: data.id, - page: data.page, - epubcfi: data.epubcfi, - is_completed: data.is_completed, - completed_at: data.completed_at.map(|t| t.to_rfc3339()), - percentage_completed: data.percentage_completed, - media_id: data.media_id, - media, - user_id: data.user_id, - user, - } - } -} diff --git a/packages/api/src/config.ts b/packages/api/src/config.ts index 20e123caf..423fb561a 100644 --- a/packages/api/src/config.ts +++ b/packages/api/src/config.ts @@ -1,12 +1,12 @@ import type { ClaimResponse } from '@stump/types' import { API } from '.' -import { ApiResult } from './types' +import { APIResult } from './types' -export function ping(): Promise> { +export function ping(): Promise> { return API.get('/ping') } -export async function checkIsClaimed(): Promise> { +export async function checkIsClaimed(): Promise> { return API.get('/claim') } diff --git a/packages/api/src/epub.ts b/packages/api/src/epub.ts index c9d072d12..51e6f0874 100644 --- a/packages/api/src/epub.ts +++ b/packages/api/src/epub.ts @@ -3,7 +3,7 @@ import type { CreateOrUpdateBookmark, DeleteBookmark, Epub, - ReadProgress, + ProgressUpdateReturn, UpdateEpubProgress, } from '@stump/types' @@ -20,7 +20,7 @@ export function getEpubById(id: string): Promise> { export function updateEpubProgress( payload: UpdateEpubProgress & { id: string }, -): Promise> { +): Promise> { return API.put(`/epub/${payload.id}/progress`, payload) } diff --git a/packages/api/src/media.ts b/packages/api/src/media.ts index 2f16bd86a..1d322c393 100644 --- a/packages/api/src/media.ts +++ b/packages/api/src/media.ts @@ -2,8 +2,8 @@ import type { Media, MediaIsComplete, PatchMediaThumbnail, + ProgressUpdateReturn, PutMediaCompletionStatus, - ReadProgress, } from '@stump/types' import { API } from './axios' @@ -73,7 +73,10 @@ export function getMediaPage(id: string, page: number): string { return `${API.getUri()}/media/${id}/page/${page}` } -export function updateMediaProgress(id: string, page: number): Promise> { +export function updateMediaProgress( + id: string, + page: number, +): Promise> { return API.put(`/media/${id}/progress/${page}`) } diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index ba9356513..ff4d0314a 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -7,7 +7,7 @@ export function getStumpVersion(): Promise> { return API.post('/version') } -export function checkForServerUpdate(): Promise> { +export function checkForServerUpdate(): Promise> { return API.get('/check-for-update') } diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index b29e62cc1..fd6495d96 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "outDir": "./dist", "skipLibCheck": true, "paths": { "@stump/types": ["../types/index.ts"], diff --git a/packages/browser/package.json b/packages/browser/package.json index 56502ebb2..90280bfcf 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -6,6 +6,7 @@ "private": true, "main": "src/index.ts", "scripts": { + "check-types": "tsc --build tsconfig.json", "lint": "eslint --ext .ts,.tsx,.cts,.mts,.js,.jsx,.cjs,.mjs --fix --report-unused-disable-directives --no-error-on-unmatched-pattern --exit-on-fatal-error --ignore-path ../../.gitignore ." }, "exports": { @@ -32,7 +33,9 @@ "framer-motion": "^10.18.0", "i18next": "^23.11.2", "immer": "^10.0.4", + "lodash.groupby": "^4.6.0", "lodash.isequal": "^4.5.0", + "lodash.sortby": "^4.7.0", "lodash.uniqby": "^4.7.0", "lucide-react": "^0.368.0", "nprogress": "^0.2.0", @@ -62,7 +65,9 @@ "zustand": "^4.5.2" }, "devDependencies": { + "@types/lodash.groupby": "^4.6.9", "@types/lodash.isequal": "^4.5.8", + "@types/lodash.sortby": "^4.7.9", "@types/lodash.uniqby": "^4.7.9", "@types/node": "^20.12.7", "@types/nprogress": "^0.2.3", diff --git a/packages/browser/src/components/book/BookCard.tsx b/packages/browser/src/components/book/BookCard.tsx index 4bd297839..dadacb90c 100644 --- a/packages/browser/src/components/book/BookCard.tsx +++ b/packages/browser/src/components/book/BookCard.tsx @@ -3,7 +3,7 @@ import { prefetchMedia } from '@stump/client' import { EntityCard, Text } from '@stump/components' import { FileStatus, Media } from '@stump/types' import pluralize from 'pluralize' -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import paths from '@/paths' import { formatBytes } from '@/utils/format' @@ -56,8 +56,8 @@ export default function BookCard({ const progressString = getProgress() if (progressString) { const isEpubProgress = !!media.current_epubcfi - const pagesLeft = media.pages - (media.current_page || 0) + return (
@@ -81,30 +81,28 @@ export default function BookCard({ ) } - function getProgress() { - if (isCoverOnly || !media.current_page) { + const getProgress = useCallback(() => { + const { active_reading_session, finished_reading_sessions } = media + + if (isCoverOnly || (!active_reading_session && !finished_reading_sessions)) { return null - } + } else if (active_reading_session) { + const { epubcfi, percentage_completed, page } = active_reading_session - if (media.current_epubcfi) { - const firstWithPercent = media.read_progresses?.find((rp) => !!rp.percentage_completed) - if (firstWithPercent) { - return Math.round(firstWithPercent.percentage_completed! * 100) - } - } else { - const page = media.current_page - const pages = media.pages + if (epubcfi && percentage_completed) { + return Math.round(percentage_completed * 100) + } else if (page) { + const pages = media.pages - const percent = Math.round((page / pages) * 100) - if (percent > 100) { - return 100 + const percent = Math.round((page / pages) * 100) + return Math.min(percent, 100) } - - return percent + } else if (finished_reading_sessions?.length) { + return 100 } return null - } + }, [isCoverOnly, media]) const href = useMemo(() => { if (onSelect) { diff --git a/packages/browser/src/components/filters/FilterProvider.tsx b/packages/browser/src/components/filters/FilterProvider.tsx index c8395d8a9..1c042b5f9 100644 --- a/packages/browser/src/components/filters/FilterProvider.tsx +++ b/packages/browser/src/components/filters/FilterProvider.tsx @@ -4,6 +4,8 @@ import { useSearchParams } from 'react-router-dom' import { FilterContext } from './context' +// TODO: clean up this file! + type Props = { children: React.ReactNode } diff --git a/packages/browser/src/components/navigation/mobile/LayoutModeButtons.tsx b/packages/browser/src/components/navigation/mobile/LayoutModeButtons.tsx deleted file mode 100644 index 0dbd23f36..000000000 --- a/packages/browser/src/components/navigation/mobile/LayoutModeButtons.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { IconButton } from '@stump/components' -import { Grid2X2, Rows } from 'lucide-react' - -import { useLayoutMode } from '@/hooks' - -export default function LayoutModeButtons() { - const { layoutMode, setLayoutMode } = useLayoutMode() - - const viewAsGrid = layoutMode === 'GRID' - - return ( -
- setLayoutMode('GRID')} - variant={viewAsGrid ? 'subtle-dark' : 'subtle'} - > - - - - setLayoutMode('LIST')} - variant={viewAsGrid ? 'subtle' : 'subtle-dark'} - > - - -
- ) -} diff --git a/packages/browser/src/components/readers/epub/EpubJsReader.tsx b/packages/browser/src/components/readers/epub/EpubJsReader.tsx index c72ec7441..ab2948242 100644 --- a/packages/browser/src/components/readers/epub/EpubJsReader.tsx +++ b/packages/browser/src/components/readers/epub/EpubJsReader.tsx @@ -219,7 +219,7 @@ export default function EpubJsReader({ id, initialCfi }: EpubJsReaderProps) { applyEpubPreferences(rendition_, epubPreferences) setRendition(rendition_) - const targetCfi = epub?.media_entity.read_progresses?.at(0)?.epubcfi ?? initialCfi + const targetCfi = epub?.media_entity.active_reading_session?.epubcfi ?? initialCfi if (targetCfi) { rendition_.display(targetCfi) } else if (defaultLoc) { @@ -604,7 +604,7 @@ export default function EpubJsReader({ id, initialCfi }: EpubJsReaderProps) { }, toc: epub.toc, }, - progress: epub.media_entity.read_progresses?.[0]?.percentage_completed || null, + progress: epub.media_entity.active_reading_session?.percentage_completed || null, }} controls={{ getCfiPreviewText, diff --git a/packages/browser/src/scenes/book/BookOverviewScene.tsx b/packages/browser/src/scenes/book/BookOverviewScene.tsx index 6844572ba..b0b956fd4 100644 --- a/packages/browser/src/scenes/book/BookOverviewScene.tsx +++ b/packages/browser/src/scenes/book/BookOverviewScene.tsx @@ -1,6 +1,7 @@ import { useMediaByIdQuery } from '@stump/client' import { Badge, ButtonOrLink, Heading, Spacer, Text } from '@stump/components' import dayjs from 'dayjs' +import sortBy from 'lodash.sortby' import { Suspense, useEffect, useMemo } from 'react' import { Helmet } from 'react-helmet' import { useParams } from 'react-router' @@ -76,7 +77,11 @@ export default function BookOverviewScene() {
) } - const completedAt = media.read_progresses?.find((p) => !!p.completed_at)?.completed_at + + // TODO(historical-read-session): double check default order + const completedAt = sortBy(media.finished_reading_sessions, ({ completed_at }) => + dayjs(completed_at).toDate(), + ).at(-1)?.completed_at const genres = media.metadata?.genre?.filter((g) => !!g) ?? [] const links = media.metadata?.links?.filter((l) => !!l) ?? [] diff --git a/packages/browser/src/scenes/settings/server/email/devices/DevicesTable.tsx b/packages/browser/src/scenes/settings/server/email/devices/DevicesTable.tsx index 8ab261fab..57f831cc9 100644 --- a/packages/browser/src/scenes/settings/server/email/devices/DevicesTable.tsx +++ b/packages/browser/src/scenes/settings/server/email/devices/DevicesTable.tsx @@ -2,7 +2,7 @@ import { useEmailDevicesQuery } from '@stump/client' import { Badge, Card, Heading, Text } from '@stump/components' import { useLocaleContext } from '@stump/i18n' import { RegisteredEmailDevice } from '@stump/types' -import { createColumnHelper } from '@tanstack/react-table' +import { ColumnDef, createColumnHelper } from '@tanstack/react-table' import { CircleSlash2 } from 'lucide-react' import React, { useMemo, useState } from 'react' @@ -47,7 +47,7 @@ const baseColumns = [ ), id: 'status', }), -] +] as ColumnDef[] type Props = { onSelectForUpdate: (device: RegisteredEmailDevice | null) => void diff --git a/packages/browser/src/scenes/settings/server/jobs/JobTable.tsx b/packages/browser/src/scenes/settings/server/jobs/JobTable.tsx index 30b4dcc7e..de6ea6e16 100644 --- a/packages/browser/src/scenes/settings/server/jobs/JobTable.tsx +++ b/packages/browser/src/scenes/settings/server/jobs/JobTable.tsx @@ -1,7 +1,7 @@ import { Badge, Card, Heading, Text } from '@stump/components' import { useLocaleContext } from '@stump/i18n' import { CoreJobOutput, JobStatus, PersistedJob } from '@stump/types' -import { createColumnHelper } from '@tanstack/react-table' +import { ColumnDef, createColumnHelper } from '@tanstack/react-table' import dayjs from 'dayjs' import duration from 'dayjs/plugin/duration' import relativeTime from 'dayjs/plugin/relativeTime' @@ -32,108 +32,109 @@ export default function JobTable() { const [inspectingData, setInspectingData] = useState() const columns = useMemo( - () => [ - columnHelper.accessor('name', { - cell: ({ - row: { - original: { name }, - }, - }) => ( - - {name} - - ), - header: t(`${LOCALE_BASE}.columns.name`), - }), - columnHelper.accessor('description', { - cell: ({ - row: { - original: { description }, + () => + [ + columnHelper.accessor('name', { + cell: ({ + row: { + original: { name }, + }, + }) => ( + + {name} + + ), + header: t(`${LOCALE_BASE}.columns.name`), + }), + columnHelper.accessor('description', { + cell: ({ + row: { + original: { description }, + }, + }) => ( + + {description} + + ), + header: t(`${LOCALE_BASE}.columns.description`), + }), + columnHelper.accessor('created_at', { + cell: ({ row: { original: job } }) => { + if (job.created_at) { + return ( + + {dayjs(job.created_at).format('YYYY-MM-DD HH:mm:ss')} + + ) + } + + return null }, - }) => ( - - {description} - - ), - header: t(`${LOCALE_BASE}.columns.description`), - }), - columnHelper.accessor('created_at', { - cell: ({ row: { original: job } }) => { - if (job.created_at) { + header: t(`${LOCALE_BASE}.columns.createdAt`), + }), + columnHelper.display({ + cell: ({ row }) => { + const getBadgeVariant = (status: JobStatus) => { + if (status === 'COMPLETED') { + return 'success' + } else if (status === 'CANCELLED') { + return 'warning' + } else if (status === 'FAILED') { + return 'error' + } else { + return 'primary' + } + } + + const job = row.original + return ( - - {dayjs(job.created_at).format('YYYY-MM-DD HH:mm:ss')} - + + {job.status.charAt(0).toUpperCase() + job.status.slice(1).toLowerCase()} + ) - } - - return null - }, - header: t(`${LOCALE_BASE}.columns.createdAt`), - }), - columnHelper.display({ - cell: ({ row }) => { - const getBadgeVariant = (status: JobStatus) => { - if (status === 'COMPLETED') { - return 'success' - } else if (status === 'CANCELLED') { - return 'warning' - } else if (status === 'FAILED') { - return 'error' - } else { - return 'primary' - } - } - - const job = row.original - - return ( - - {job.status.charAt(0).toUpperCase() + job.status.slice(1).toLowerCase()} - - ) - }, - header: 'Status', - id: 'status', - }), - columnHelper.display({ - cell: ({ row: { original: job } }) => { - const displayDuration = (duration: duration.Duration) => { - //? TODO(aaron): This might be funny to have two formats, I think I should - //? either just always show ms or just accept the 'rounding' of the duration - if (duration.asSeconds() < 1) { - return duration.format('HH:mm:ss:SSS') + }, + header: 'Status', + id: 'status', + }), + columnHelper.display({ + cell: ({ row: { original: job } }) => { + const displayDuration = (duration: duration.Duration) => { + //? TODO(aaron): This might be funny to have two formats, I think I should + //? either just always show ms or just accept the 'rounding' of the duration + if (duration.asSeconds() < 1) { + return duration.format('HH:mm:ss:SSS') + } + + return duration.format('HH:mm:ss') } - return duration.format('HH:mm:ss') - } + const isRunningOrQueued = job.status === 'RUNNING' || job.status === 'QUEUED' - const isRunningOrQueued = job.status === 'RUNNING' || job.status === 'QUEUED' + if (job.status === 'RUNNING') { + return + } else if (!isRunningOrQueued && job.ms_elapsed !== null) { + return ( + + {displayDuration(dayjs.duration(Number(job.ms_elapsed)))} + + ) + } - if (job.status === 'RUNNING') { - return - } else if (!isRunningOrQueued && job.ms_elapsed !== null) { - return ( - - {displayDuration(dayjs.duration(Number(job.ms_elapsed)))} - - ) - } - - return null - }, - header: t(`${LOCALE_BASE}.columns.elapsed`), - id: 'ms_elapsed', - }), - columnHelper.display({ - cell: ({ row }) => - isServerOwner ? ( - - ) : null, - id: 'actions', - size: 28, - }), - ], + return null + }, + header: t(`${LOCALE_BASE}.columns.elapsed`), + id: 'ms_elapsed', + }), + columnHelper.display({ + cell: ({ row }) => + isServerOwner ? ( + + ) : null, + id: 'actions', + size: 28, + }), + ] as ColumnDef[], [t, isServerOwner], ) diff --git a/packages/browser/src/scenes/settings/server/users/UsersStats.tsx b/packages/browser/src/scenes/settings/server/users/UsersStats.tsx index 34bc2953d..ead44d668 100644 --- a/packages/browser/src/scenes/settings/server/users/UsersStats.tsx +++ b/packages/browser/src/scenes/settings/server/users/UsersStats.tsx @@ -1,6 +1,10 @@ import { Statistic } from '@stump/components' -import { User } from '@stump/types' +import { ActiveReadingSession, FinishedReadingSession, User } from '@stump/types' +import groupBy from 'lodash.groupby' +import sortBy from 'lodash.sortby' +import uniqBy from 'lodash.uniqby' import pluralize from 'pluralize' +import { useMemo } from 'react' import { useUserManagementContext } from './context' @@ -14,36 +18,78 @@ type BookReadStats = { inProgressCount: number } +type TopBook = { + bookId: string + readers: string[] +} + export default function UsersStats() { const { users } = useUserManagementContext() - const powerReader = users.reduce( - (acc, user) => { - const finishedBooks = - user.read_progresses?.filter((progress) => progress.is_completed).length || 0 - - if (acc.finishedBookCount < finishedBooks) { - return { - finishedBookCount: finishedBooks, - user, - } - } + /** + * A map of users to their finished reading sessions, memoized mostly to only run + * the uniqBy fewer times + */ + const finishedSessionMap = useMemo( + () => + users.reduce( + (acc, user) => ({ + ...acc, + [user.id]: uniqBy(user.finished_reading_sessions || [], ({ media_id }) => media_id), + }), + {} as Record, + ), + [users], + ) - return acc - }, - { finishedBookCount: 0, user: null }, + /** + * A map of users to their active reading sessions + */ + const activeSessionMap = useMemo( + () => + users.reduce( + (acc, user) => ({ + ...acc, + [user.id]: user.active_reading_sessions || [], + }), + {} as Record, + ), + [users], ) + /** + * The user who has completed the most books + */ + const powerReader = useMemo( + () => + users.reduce( + (acc, user) => { + const finishedBooks = finishedSessionMap[user.id]?.length || 0 + if (acc.finishedBookCount < finishedBooks) { + return { + finishedBookCount: finishedBooks, + user, + } + } + + return acc + }, + { finishedBookCount: 0, user: null }, + ), + [finishedSessionMap, users], + ) + + /** + * The total number of finsihed books throughout all users on the server + */ const booksRead = users.reduce( (acc, user) => { - const booksUserRead = - user.read_progresses?.filter((progress) => progress.is_completed).length || 0 - const booksUserInProgress = - user.read_progresses?.filter((progress) => !progress.is_completed).length || 0 + const booksUserFinished = finishedSessionMap[user.id]?.length || 0 + const booksWithProgress = activeSessionMap[user.id]?.length || 0 return { - finishedBookCount: acc.finishedBookCount + booksUserRead, - inProgressCount: acc.inProgressCount + booksUserInProgress, + finishedBookCount: acc.finishedBookCount + booksUserFinished, + inProgressCount: acc.inProgressCount + booksWithProgress, } }, { @@ -52,6 +98,40 @@ export default function UsersStats() { }, ) + /** + * The IDs of the top-3 books read on the server + */ + const topBooks = useMemo(() => { + const usersAndBooks = users.reduce( + (acc, user) => { + const finishedUserBooks = (finishedSessionMap[user.id] || []).map( + ({ media_id, user_id }) => ({ media_id, user_id }), + ) + const activeUserBooks = (activeSessionMap[user.id] || []).map(({ media_id, user_id }) => ({ + media_id, + user_id, + })) + const allForUser = finishedUserBooks.concat(activeUserBooks) + const dedupedAll = uniqBy(allForUser, ({ media_id }) => media_id) + return acc.concat(dedupedAll) + }, + [] as { + media_id: string + user_id: string + }[], + ) + const groupedByBook = groupBy(usersAndBooks, ({ media_id }) => media_id) + + return sortBy( + Object.entries(groupedByBook).map(([bookId, values]) => ({ + bookId, + readers: values.map(({ user_id }) => user_id), + })), + ({ readers }) => readers.length, + ).slice(0, 3) + }, [users, activeSessionMap, finishedSessionMap]) + console.debug(topBooks) + return (
diff --git a/packages/browser/src/scenes/settings/server/users/create-or-update/UserPermissionsForm.tsx b/packages/browser/src/scenes/settings/server/users/create-or-update/UserPermissionsForm.tsx index 28fcb2c87..928b0bf88 100644 --- a/packages/browser/src/scenes/settings/server/users/create-or-update/UserPermissionsForm.tsx +++ b/packages/browser/src/scenes/settings/server/users/create-or-update/UserPermissionsForm.tsx @@ -12,6 +12,11 @@ import { Schema } from './CreateOrUpdateUserForm' export const allPermissions = [ 'bookclub:read', 'bookclub:create', + 'email:arbitrary_send', + 'email:send', + 'emailer:create', + 'emailer:manage', + 'emailer:read', 'file:explorer', 'file:upload', 'file:download', @@ -34,6 +39,11 @@ export const userPermissionSchema = z.enum(allPermissions) const associatedPermissions: Record = { 'bookclub:create': ['bookclub:read'], 'bookclub:read': [], + 'email:arbitrary_send': ['email:send'], + 'email:send': ['emailer:read'], + 'emailer:create': ['emailer:read', 'emailer:manage', 'email:send'], + 'emailer:manage': ['emailer:read'], + 'emailer:read': [], 'file:download': [], 'file:explorer': [], 'file:upload': [], diff --git a/packages/browser/src/scenes/settings/server/users/login-activity/LoginActivityTable.tsx b/packages/browser/src/scenes/settings/server/users/login-activity/LoginActivityTable.tsx index f511e1810..2fa3d3a48 100644 --- a/packages/browser/src/scenes/settings/server/users/login-activity/LoginActivityTable.tsx +++ b/packages/browser/src/scenes/settings/server/users/login-activity/LoginActivityTable.tsx @@ -1,7 +1,12 @@ import { useLoginActivityQuery } from '@stump/client' import { Badge, Card, Text } from '@stump/components' import { LoginActivity } from '@stump/types' -import { createColumnHelper, getPaginationRowModel, PaginationState } from '@tanstack/react-table' +import { + ColumnDef, + createColumnHelper, + getPaginationRowModel, + PaginationState, +} from '@tanstack/react-table' import dayjs from 'dayjs' import React, { useState } from 'react' @@ -68,7 +73,7 @@ const baseColumns = [ header: 'Auth result', id: 'authentication_successful', }), -] +] as ColumnDef[] export default function LoginActivityTable() { const { loginActivity } = useLoginActivityQuery({}) diff --git a/packages/browser/src/scenes/settings/server/users/user-table/UserTable.tsx b/packages/browser/src/scenes/settings/server/users/user-table/UserTable.tsx index 26f6d6087..1eaaeac48 100644 --- a/packages/browser/src/scenes/settings/server/users/user-table/UserTable.tsx +++ b/packages/browser/src/scenes/settings/server/users/user-table/UserTable.tsx @@ -1,6 +1,6 @@ import { Badge, Card, Text, ToolTip } from '@stump/components' import { User } from '@stump/types' -import { createColumnHelper } from '@tanstack/react-table' +import { ColumnDef, createColumnHelper } from '@tanstack/react-table' import dayjs from 'dayjs' import { HelpCircle } from 'lucide-react' import { useMemo, useState } from 'react' @@ -83,7 +83,7 @@ const baseColumns = [ header: 'Status', id: 'is_locked', }), -] +] as ColumnDef[] export default function UserTable() { const [inspectingUser, setInspectingUser] = useState(null) diff --git a/packages/browser/src/scenes/smartList/items/table/groupColumns.tsx b/packages/browser/src/scenes/smartList/items/table/groupColumns.tsx index f525b88a7..91a8dff98 100644 --- a/packages/browser/src/scenes/smartList/items/table/groupColumns.tsx +++ b/packages/browser/src/scenes/smartList/items/table/groupColumns.tsx @@ -1,5 +1,5 @@ import { cn, Text } from '@stump/components' -import { Library, Series, SmartListItemGroup, SmartListTableColumnSelection } from '@stump/types' +import { Library, ReactTableColumnSort, Series, SmartListItemGroup } from '@stump/types' import { ColumnDef, createColumnHelper } from '@tanstack/react-table' import { ChevronDown } from 'lucide-react' @@ -119,10 +119,7 @@ export const defaultLibraryColumns = [ export const buildDefaultColumns = (isGroupedBySeries: boolean) => isGroupedBySeries ? defaultSeriesColumns : defaultLibraryColumns -export const buildColumns = ( - isGroupedBySeries: boolean, - columns?: SmartListTableColumnSelection[], -) => { +export const buildColumns = (isGroupedBySeries: boolean, columns?: ReactTableColumnSort[]) => { if (!columns?.length) { return buildDefaultColumns(isGroupedBySeries) } diff --git a/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx b/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx index 0ffb4145c..767cf5590 100644 --- a/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx +++ b/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx @@ -1,5 +1,5 @@ import { Link, Text } from '@stump/components' -import { Media, SmartListTableColumnSelection } from '@stump/types' +import { Media, ReactTableColumnSort } from '@stump/types' import { ColumnDef, createColumnHelper } from '@tanstack/react-table' import dayjs from 'dayjs' @@ -373,7 +373,7 @@ export const defaultColumns = [ * A helper function to build the columns for the table based on the stored column selection. If * no columns are selected, or if the selection is empty, the default columns will be used. */ -export const buildColumns = (columns?: SmartListTableColumnSelection[]) => { +export const buildColumns = (columns?: ReactTableColumnSort[]) => { if (!columns || columns.length === 0) { return defaultColumns } diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json index 5f6bfea3f..cace3b2e9 100644 --- a/packages/browser/tsconfig.json +++ b/packages/browser/tsconfig.json @@ -7,6 +7,7 @@ "jsx": "preserve", "module": "ES2022", "moduleResolution": "Node", + "outDir": "./dist", "paths": { "@stump/api": ["../api/src/index.ts"], "@stump/api/*": ["../api/src/*"], diff --git a/packages/client/src/queries/media.ts b/packages/client/src/queries/media.ts index 5ce49927c..dcbbb54fc 100644 --- a/packages/client/src/queries/media.ts +++ b/packages/client/src/queries/media.ts @@ -1,5 +1,5 @@ import { mediaApi, mediaQueryKeys } from '@stump/api' -import type { Media, ReadProgress } from '@stump/types' +import type { Media, ProgressUpdateReturn } from '@stump/types' import { AxiosError } from 'axios' import { @@ -159,7 +159,7 @@ export function useMediaCursorQuery(options: CursorQueryOptions) { // progress, since this is focused around page numbers. export function useUpdateMediaProgress( mediaId: string, - options?: MutationOptions, + options?: MutationOptions, ) { const { mutate: updateReadProgress, diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 558bec70f..1cfe492e3 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "outDir": "./dist", "jsx": "preserve", "skipLibCheck": true, "paths": { diff --git a/packages/components/src/form/Form.tsx b/packages/components/src/form/Form.tsx index 7bcdafaac..9b83fff21 100644 --- a/packages/components/src/form/Form.tsx +++ b/packages/components/src/form/Form.tsx @@ -3,7 +3,7 @@ import { FieldValues, FormProvider, SubmitHandler, UseFormReturn } from 'react-h import { cn } from '../utils' -type FormProps = { +export type FormProps = { form: UseFormReturn onSubmit: SubmitHandler fieldsetClassName?: string diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json index 7633e6cdd..f96cf5853 100644 --- a/packages/components/tsconfig.json +++ b/packages/components/tsconfig.json @@ -3,6 +3,7 @@ "include": ["src"], "references": [], "compilerOptions": { + "outDir": "./dist", "jsx": "preserve", "esModuleInterop": true, "module": "es2022", diff --git a/packages/i18n/tsconfig.json b/packages/i18n/tsconfig.json index a337ca79b..b3c885f3c 100644 --- a/packages/i18n/tsconfig.json +++ b/packages/i18n/tsconfig.json @@ -3,6 +3,7 @@ "include": ["src", "src/**/*.json"], "references": [], "compilerOptions": { + "outDir": "./dist", "jsx": "preserve", "esModuleInterop": true, "module": "es2022", diff --git a/packages/types/generated.ts b/packages/types/generated.ts index 0bd140c29..61ef38f6d 100644 --- a/packages/types/generated.ts +++ b/packages/types/generated.ts @@ -54,7 +54,7 @@ export type ThumbnailGenerationJobParams = { variant: ThumbnailGenerationJobVari export type ThumbnailGenerationOutput = { visited_files: BigInt; generated_thumbnails: BigInt; removed_thumbnails: BigInt } -export type User = { id: string; username: string; is_server_owner: boolean; avatar_url: string | null; created_at: string; last_login: string | null; is_locked: boolean; permissions: UserPermission[]; max_sessions_allowed?: number | null; login_sessions_count?: number | null; user_preferences?: UserPreferences | null; login_activity?: LoginActivity[] | null; age_restriction?: AgeRestriction | null; read_progresses?: ReadProgress[] | null } +export type User = { id: string; username: string; is_server_owner: boolean; avatar_url: string | null; created_at: string; last_login: string | null; is_locked: boolean; permissions: UserPermission[]; max_sessions_allowed?: number | null; login_sessions_count?: number | null; user_preferences?: UserPreferences | null; login_activity?: LoginActivity[] | null; age_restriction?: AgeRestriction | null; active_reading_sessions?: ActiveReadingSession[] | null; finished_reading_sessions?: FinishedReadingSession[] | null } /** * Permissions that can be granted to a user. Some permissions are implied by others, @@ -133,7 +133,7 @@ export type Series = { id: string; name: string; path: string; description: stri */ export type MediaMetadata = { title: string | null; series: string | null; number: number | null; volume: number | null; summary: string | null; notes: string | null; age_rating?: number | null; genre?: string[] | null; year: number | null; month: number | null; day: number | null; writers?: string[] | null; pencillers?: string[] | null; inkers?: string[] | null; colorists?: string[] | null; letterers?: string[] | null; cover_artists?: string[] | null; editors?: string[] | null; publisher: string | null; links?: string[] | null; characters?: string[] | null; teams?: string[] | null; page_count: number | null } -export type Media = { id: string; name: string; size: BigInt; extension: string; pages: number; updated_at: string; created_at: string; modified_at: string | null; hash: string | null; path: string; status: FileStatus; series_id: string; metadata: MediaMetadata | null; series?: Series | null; read_progresses?: ReadProgress[] | null; current_page?: number | null; current_epubcfi?: string | null; is_completed?: boolean | null; tags?: Tag[] | null; bookmarks?: Bookmark[] | null } +export type Media = { id: string; name: string; size: BigInt; extension: string; pages: number; updated_at: string; created_at: string; modified_at: string | null; hash: string | null; path: string; status: FileStatus; series_id: string; metadata: MediaMetadata | null; series?: Series | null; active_reading_session?: ActiveReadingSession | null; finished_reading_sessions: FinishedReadingSession[] | null; current_page?: number | null; current_epubcfi?: string | null; is_completed?: boolean | null; tags?: Tag[] | null; bookmarks?: Bookmark[] | null } /** * A model representing a bookmark in the database. Bookmarks are used to save specific locations @@ -143,7 +143,11 @@ export type Bookmark = { id: string; preview_content: string | null; epubcfi: st export type MediaAnnotation = { id: string; highlighted_text: string | null; page: number | null; page_coordinates_x: number | null; page_coordinates_y: number | null; epubcfi: string | null; notes: string | null; media_id: string; media?: Media | null } -export type ReadProgress = { id: string; page: number; epubcfi: string | null; percentage_completed: number | null; is_completed: boolean; completed_at: string | null; media_id: string; media: Media | null; user_id: string; user: User | null } +export type ActiveReadingSession = { id: string; page: number | null; epubcfi: string | null; percentage_completed: number | null; started_at: string; media_id: string; media: Media | null; user_id: string; user: User | null } + +export type FinishedReadingSession = { id: string; started_at: string; completed_at: string; media_id: string; media: Media | null; user_id: string; user: User | null } + +export type ProgressUpdateReturn = ActiveReadingSession | FinishedReadingSession /** * A struct representing a sort order for a column using react-table (tanstack) @@ -334,7 +338,7 @@ export type LibraryStatsParams = { all_users?: boolean } export type PutMediaCompletionStatus = { is_complete: boolean; page?: number | null } -export type MediaIsComplete = { is_completed: boolean; completed_at: string | null } +export type MediaIsComplete = { is_completed: boolean; last_completed_at: string | null } export type MediaMetadataOverview = { genres: string[]; writers: string[]; pencillers: string[]; inkers: string[]; colorists: string[]; letterers: string[]; editors: string[]; publishers: string[]; characters: string[]; teams: string[] } diff --git a/packages/types/index.ts b/packages/types/index.ts index af49efa0d..03294dfec 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -32,7 +32,7 @@ export interface Pageable { // Note: I am separating these options / exclusions in case I want to use either independently. export type MediaOrderByExclusions = Extract< keyof Media, - 'currentPage' | 'series' | 'readProgresses' | 'tags' | 'id' + 'current_page' | 'series' | 'tags' | 'id' | 'active_reading_session' | 'finished_reading_sessions' > export type MediaOrderByOptions = Partial> // TODO: I HATE THIS diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json index da5f349c7..699f7e909 100644 --- a/packages/types/tsconfig.json +++ b/packages/types/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "outDir": "./dist", "skipLibCheck": true, "module": "NodeNext" }, diff --git a/yarn.lock b/yarn.lock index 472e25921..c783fde5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5903,6 +5903,13 @@ dependencies: localforage "*" +"@types/lodash.groupby@^4.6.9": + version "4.6.9" + resolved "https://registry.yarnpkg.com/@types/lodash.groupby/-/lodash.groupby-4.6.9.tgz#ea1aa9da1038ca50894d1fe1a3b5dabdf865d99c" + integrity sha512-z2xtCX2ko7GrqORnnYea4+ksT7jZNAvaOcLd6mP9M7J09RHvJs06W8BGdQQAX8ARef09VQLdeRilSOcfHlDQJQ== + dependencies: + "@types/lodash" "*" + "@types/lodash.isequal@^4.5.8": version "4.5.8" resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz#b30bb6ff6a5f6c19b3daf389d649ac7f7a250499" @@ -5910,6 +5917,13 @@ dependencies: "@types/lodash" "*" +"@types/lodash.sortby@^4.7.9": + version "4.7.9" + resolved "https://registry.yarnpkg.com/@types/lodash.sortby/-/lodash.sortby-4.7.9.tgz#e4af00e03daece7a939378ff64f0d44e638d7db6" + integrity sha512-PDmjHnOlndLS59GofH0pnxIs+n9i4CWeXGErSB5JyNFHu2cmvW6mQOaUKjG8EDPkni14IgF8NsRW8bKvFzTm9A== + dependencies: + "@types/lodash" "*" + "@types/lodash.uniqby@^4.7.9": version "4.7.9" resolved "https://registry.yarnpkg.com/@types/lodash.uniqby/-/lodash.uniqby-4.7.9.tgz#10bacba9cf3263c6e07ae11d953de6ada6605104" @@ -12820,6 +12834,11 @@ lodash.get@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== +lodash.groupby@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz#0b08a1dcf68397c397855c3239783832df7403d1" + integrity sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw== + lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" @@ -12865,6 +12884,11 @@ lodash.once@^4.0.0: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== +lodash.sortby@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" + integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== + lodash.throttle@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" @@ -17781,7 +17805,16 @@ string-argv@0.3.2: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -17886,7 +17919,7 @@ stringify-object@3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -17907,6 +17940,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -19659,7 +19699,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -19677,6 +19717,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 19534fee20f3a1cf7d0e0eeab5592e88b5fed249 Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Tue, 28 May 2024 16:43:02 -0700 Subject: [PATCH 5/5] fix awkwardness wrt PUT media progress --- apps/server/src/routers/api/v1/media.rs | 5 +- packages/api/src/media.ts | 6 ++ .../browser/src/components/book/BookCard.tsx | 4 +- .../book/BookCompletionToggleButton.tsx | 62 ++++++++++++++----- packages/components/src/card/EntityCard.tsx | 2 +- 5 files changed, 60 insertions(+), 19 deletions(-) diff --git a/apps/server/src/routers/api/v1/media.rs b/apps/server/src/routers/api/v1/media.rs index f572426ff..d905f36f3 100644 --- a/apps/server/src/routers/api/v1/media.rs +++ b/apps/server/src/routers/api/v1/media.rs @@ -629,7 +629,10 @@ async fn get_recently_added_media( .media() .find_many(where_conditions.clone()) .with(media::active_user_reading_sessions::fetch(vec![ - active_reading_session::user_id::equals(user_id), + active_reading_session::user_id::equals(user_id.clone()), + ])) + .with(media::finished_user_reading_sessions::fetch(vec![ + finished_reading_session::user_id::equals(user_id), ])) .with(media::metadata::fetch()) .order_by(media::created_at::order(Direction::Desc)); diff --git a/packages/api/src/media.ts b/packages/api/src/media.ts index 1d322c393..03f7e8971 100644 --- a/packages/api/src/media.ts +++ b/packages/api/src/media.ts @@ -101,6 +101,10 @@ export function putMediaCompletion( return API.put(`/media/${id}/progress/complete`, payload) } +export function deleteActiveReadingSession(bookId: string) { + return API.delete(`/media/${bookId}/progress`) +} + /** * Start the analysis of a book by media id. * @@ -111,6 +115,7 @@ export function startMediaAnalysis(id: string) { } export const mediaApi = { + deleteActiveReadingSession, getInProgressMedia, getMedia, getMediaById, @@ -132,6 +137,7 @@ export const mediaQueryKeys: Record = { getMedia: 'media.get', getMediaById: 'media.getById', getMediaByPath: 'media.getByPath', + deleteActiveReadingSession: 'media.deleteActiveReadingSession', getMediaPage: 'media.getPage', getMediaThumbnail: 'media.getThumbnail', getMediaWithCursor: 'media.getWithCursor', diff --git a/packages/browser/src/components/book/BookCard.tsx b/packages/browser/src/components/book/BookCard.tsx index dadacb90c..3e1a5e4ae 100644 --- a/packages/browser/src/components/book/BookCard.tsx +++ b/packages/browser/src/components/book/BookCard.tsx @@ -54,7 +54,7 @@ export default function BookCard({ } const progressString = getProgress() - if (progressString) { + if (progressString != null) { const isEpubProgress = !!media.current_epubcfi const pagesLeft = media.pages - (media.current_page || 0) @@ -95,7 +95,7 @@ export default function BookCard({ const pages = media.pages const percent = Math.round((page / pages) * 100) - return Math.min(percent, 100) + return Math.min(Math.max(percent, 0), 100) // Clamp between 0 and 100 } } else if (finished_reading_sessions?.length) { return 100 diff --git a/packages/browser/src/scenes/book/BookCompletionToggleButton.tsx b/packages/browser/src/scenes/book/BookCompletionToggleButton.tsx index 3066faed7..c294c0ffc 100644 --- a/packages/browser/src/scenes/book/BookCompletionToggleButton.tsx +++ b/packages/browser/src/scenes/book/BookCompletionToggleButton.tsx @@ -2,7 +2,7 @@ import { mediaApi, mediaQueryKeys } from '@stump/api' import { invalidateQueries, useMutation } from '@stump/client' import { Button } from '@stump/components' import { Media, PutMediaCompletionStatus } from '@stump/types' -import React, { useMemo } from 'react' +import React, { useCallback, useMemo } from 'react' import toast from 'react-hot-toast' import { EBOOK_EXTENSION } from '@/utils/patterns' @@ -14,33 +14,65 @@ type Props = { } export default function BookCompletionToggleButton({ book }: Props) { - const { mutateAsync } = useMutation( + const { mutateAsync: completeBook } = useMutation( [mediaQueryKeys.putMediaCompletion, book.id], (payload: PutMediaCompletionStatus) => mediaApi.putMediaCompletion(book.id, payload), ) + const { mutateAsync: deleteCurrentSession } = useMutation( + [mediaQueryKeys.deleteActiveReadingSession, book.id], + () => mediaApi.deleteActiveReadingSession(book.id), + ) + const isCompleted = useMemo(() => isReadAgainPrompt(book), [book]) + const hasProgress = useMemo(() => !!book.active_reading_session, [book]) const isEpub = useMemo(() => book.extension.match(EBOOK_EXTENSION), [book]) - const handleClick = async () => { - const willBeComplete = !isCompleted - const page = isEpub ? undefined : willBeComplete ? book.pages : 0 - try { - await mutateAsync({ - is_complete: willBeComplete, - page, - }) - invalidateQueries({ keys: [mediaQueryKeys.getMediaById] }) - } catch (error) { - console.error(error) - toast.error('Failed to update book completion status') + const handleClick = useCallback(async () => { + // If we've got progress and have previously finished the book, we just need + // to clear the current progress + if (hasProgress && isCompleted) { + try { + await deleteCurrentSession() + invalidateQueries({ keys: [mediaQueryKeys.getMediaById] }) + } catch (error) { + console.error(error) + toast.error('Failed to clear progress') + } + } else { + const willBeComplete = !isCompleted + const page = isEpub ? undefined : willBeComplete ? book.pages : 0 + try { + await completeBook({ + is_complete: willBeComplete, + page, + }) + invalidateQueries({ keys: [mediaQueryKeys.getMediaById] }) + } catch (error) { + console.error(error) + toast.error('Failed to update book completion status') + } + } + }, [book, completeBook, isCompleted, isEpub, hasProgress, deleteCurrentSession]) + + // There really isn't anything to do here if the book is completed and has no progress. Eventually, + // we will support clearing the completion history. + if (isCompleted && !hasProgress) { + return null + } + + const renderContent = () => { + if (hasProgress) { + return 'Clear progress' + } else { + return `Mark as ${isCompleted ? 'unread' : 'read'}` } } return (
) diff --git a/packages/components/src/card/EntityCard.tsx b/packages/components/src/card/EntityCard.tsx index a6fb58e24..ad1e2f27f 100644 --- a/packages/components/src/card/EntityCard.tsx +++ b/packages/components/src/card/EntityCard.tsx @@ -101,7 +101,7 @@ export function EntityCard({ * is to offset the progress bar from the bottom of the image */ const renderProgress = () => { - if (progress) { + if (progress != null) { return }