Skip to content

Commit

Permalink
feat(api): manage book read progress per user
Browse files Browse the repository at this point in the history
ability to mark a book as read, unread, or in progress by storing the last page read

related to #25
  • Loading branch information
gotson committed Jun 2, 2020
1 parent 59dd368 commit 17c80cd
Show file tree
Hide file tree
Showing 15 changed files with 568 additions and 28 deletions.
@@ -0,0 +1,15 @@
create table read_progress
(
book_id bigint not null,
user_id bigint not null,
created_date timestamp not null default now(),
last_modified_date timestamp not null default now(),
page integer not null,
completed boolean not null
);

alter table read_progress
add constraint fk_read_progress_book_book_id foreign key (book_id) references book (id);

alter table read_progress
add constraint fk_read_progress_user_user_id foreign key (user_id) references user (id);
@@ -0,0 +1,13 @@
package org.gotson.komga.domain.model

import java.time.LocalDateTime

data class ReadProgress(
val bookId: Long,
val userId: Long,
val page: Int,
val completed: Boolean,

override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable()
@@ -0,0 +1,18 @@
package org.gotson.komga.domain.persistence

import org.gotson.komga.domain.model.ReadProgress


interface ReadProgressRepository {
fun findAll(): Collection<ReadProgress>
fun findByBookIdAndUserId(bookId: Long, userId: Long): ReadProgress?
fun findByUserId(userId: Long): Collection<ReadProgress>

fun save(readProgress: ReadProgress)

fun delete(bookId: Long, userId: Long)
fun deleteByUserId(userId: Long)
fun deleteByBookId(bookId: Long)
fun deleteByBookIds(bookIds: Collection<Long>)
fun deleteAll()
}
Expand Up @@ -4,11 +4,14 @@ import mu.KotlinLogging
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookPageContent
import org.gotson.komga.domain.model.ImageConversionException
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.infrastructure.image.ImageConverter
import org.gotson.komga.infrastructure.image.ImageType
import org.springframework.stereotype.Service
Expand All @@ -20,6 +23,7 @@ class BookLifecycle(
private val bookRepository: BookRepository,
private val mediaRepository: MediaRepository,
private val bookMetadataRepository: BookMetadataRepository,
private val readProgressRepository: ReadProgressRepository,
private val bookAnalyzer: BookAnalyzer,
private val imageConverter: ImageConverter
) {
Expand Down Expand Up @@ -96,9 +100,27 @@ class BookLifecycle(
fun delete(bookId: Long) {
logger.info { "Delete book id: $bookId" }

readProgressRepository.deleteByBookId(bookId)
mediaRepository.delete(bookId)
bookMetadataRepository.delete(bookId)

bookRepository.delete(bookId)
}

fun markReadProgress(book: Book, user: KomgaUser, page: Int) {
val media = mediaRepository.findById(book.id)
require(page >= 1 && page <= media.pages.size) { "Page argument ($page) must be within 1 and book page count (${media.pages.size})" }

readProgressRepository.save(ReadProgress(book.id, user.id, page, page == media.pages.size))
}

fun markReadProgressCompleted(book: Book, user: KomgaUser) {
val media = mediaRepository.findById(book.id)

readProgressRepository.save(ReadProgress(book.id, user.id, media.pages.size, true))
}

fun deleteReadProgress(book: Book, user: KomgaUser) {
readProgressRepository.delete(book.id, user.id)
}
}
Expand Up @@ -4,6 +4,7 @@ import mu.KotlinLogging
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.core.userdetails.UserDetails
Expand All @@ -17,6 +18,7 @@ private val logger = KotlinLogging.logger {}
@Service
class KomgaUserLifecycle(
private val userRepository: KomgaUserRepository,
private val readProgressRepository: ReadProgressRepository,
private val passwordEncoder: PasswordEncoder,
private val sessionRegistry: SessionRegistry

Expand Down Expand Up @@ -52,6 +54,7 @@ class KomgaUserLifecycle(

fun deleteUser(user: KomgaUser) {
logger.info { "Deleting user: $user" }
readProgressRepository.deleteByUserId(user.id)
userRepository.delete(user)
expireSessions(user)
}
Expand Down
Expand Up @@ -5,12 +5,14 @@ import org.gotson.komga.interfaces.rest.dto.AuthorDto
import org.gotson.komga.interfaces.rest.dto.BookDto
import org.gotson.komga.interfaces.rest.dto.BookMetadataDto
import org.gotson.komga.interfaces.rest.dto.MediaDto
import org.gotson.komga.interfaces.rest.dto.ReadProgressDto
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
import org.gotson.komga.jooq.Tables
import org.gotson.komga.jooq.tables.records.BookMetadataAuthorRecord
import org.gotson.komga.jooq.tables.records.BookMetadataRecord
import org.gotson.komga.jooq.tables.records.BookRecord
import org.gotson.komga.jooq.tables.records.MediaRecord
import org.gotson.komga.jooq.tables.records.ReadProgressRecord
import org.jooq.Condition
import org.jooq.DSLContext
import org.jooq.Record
Expand All @@ -31,6 +33,7 @@ class BookDtoDao(
private val b = Tables.BOOK
private val m = Tables.MEDIA
private val d = Tables.BOOK_METADATA
private val r = Tables.READ_PROGRESS
private val a = Tables.BOOK_METADATA_AUTHOR

private val mediaFields = m.fields().filterNot { it.name == m.THUMBNAIL.name }.toTypedArray()
Expand All @@ -42,7 +45,7 @@ class BookDtoDao(
"fileSize" to b.FILE_SIZE
)

override fun findAll(search: BookSearch, pageable: Pageable): Page<BookDto> {
override fun findAll(search: BookSearch, userId: Long, pageable: Pageable): Page<BookDto> {
val conditions = search.toCondition()

val count = dsl.selectCount()
Expand All @@ -56,6 +59,7 @@ class BookDtoDao(

val dtos = selectBase()
.where(conditions)
.and(readProgressCondition(userId))
.orderBy(orderBy)
.limit(pageable.pageSize)
.offset(pageable.offset)
Expand All @@ -68,18 +72,20 @@ class BookDtoDao(
)
}

override fun findByIdOrNull(bookId: Long): BookDto? =
override fun findByIdOrNull(bookId: Long, userId: Long): BookDto? =
selectBase()
.where(b.ID.eq(bookId))
.and(readProgressCondition(userId))
.fetchAndMap()
.firstOrNull()

override fun findPreviousInSeries(bookId: Long): BookDto? = findSibling(bookId, next = false)
override fun findPreviousInSeries(bookId: Long, userId: Long): BookDto? = findSibling(bookId, userId, next = false)

override fun findNextInSeries(bookId: Long): BookDto? = findSibling(bookId, next = true)
override fun findNextInSeries(bookId: Long, userId: Long): BookDto? = findSibling(bookId, userId, next = true)

private fun readProgressCondition(userId: Long): Condition = r.USER_ID.eq(userId).or(r.USER_ID.isNull)

private fun findSibling(bookId: Long, next: Boolean): BookDto? {
private fun findSibling(bookId: Long, userId: Long, next: Boolean): BookDto? {
val record = dsl.select(b.SERIES_ID, d.NUMBER_SORT)
.from(b)
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
Expand All @@ -90,6 +96,7 @@ class BookDtoDao(

return selectBase()
.where(b.SERIES_ID.eq(seriesId))
.and(readProgressCondition(userId))
.orderBy(d.NUMBER_SORT.let { if (next) it.asc() else it.desc() })
.seek(numberSort)
.limit(1)
Expand All @@ -102,20 +109,23 @@ class BookDtoDao(
*b.fields(),
*mediaFields,
*d.fields(),
*a.fields()
*a.fields(),
*r.fields()
).from(b)
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.leftJoin(a).on(d.BOOK_ID.eq(a.BOOK_ID))
.leftJoin(r).on(b.ID.eq(r.BOOK_ID))

private fun ResultQuery<Record>.fetchAndMap() =
fetchGroups(
{ it.into(*b.fields(), *mediaFields, *d.fields()) }, { it.into(a) }
).map { (r, ar) ->
val br = r.into(b)
val mr = r.into(m)
val dr = r.into(d)
br.toDto(mr.toDto(), dr.toDto(ar))
{ it.into(*b.fields(), *mediaFields, *d.fields(), *r.fields()) }, { it.into(a) }
).map { (rec, ar) ->
val br = rec.into(b)
val mr = rec.into(m)
val dr = rec.into(d)
val rr = rec.into(r)
br.toDto(mr.toDto(), dr.toDto(ar), if (rr.userId != null) rr.toDto() else null)
}

private fun BookSearch.toCondition(): Condition {
Expand All @@ -129,7 +139,7 @@ class BookDtoDao(
return c
}

private fun BookRecord.toDto(media: MediaDto, metadata: BookMetadataDto) =
private fun BookRecord.toDto(media: MediaDto, metadata: BookMetadataDto, readProgress: ReadProgressDto?) =
BookDto(
id = id,
seriesId = seriesId,
Expand All @@ -142,7 +152,8 @@ class BookDtoDao(
fileLastModified = fileLastModified.toUTC(),
sizeBytes = fileSize,
media = media,
metadata = metadata
metadata = metadata,
readProgress = readProgress
)

private fun MediaRecord.toDto() =
Expand Down Expand Up @@ -174,4 +185,12 @@ class BookDtoDao(
authors = ar.filter { it.name != null }.map { AuthorDto(it.name, it.role) },
authorsLock = authorsLock
)

private fun ReadProgressRecord.toDto() =
ReadProgressDto(
page = page,
completed = completed,
created = createdDate.toUTC(),
lastModified = lastModifiedDate.toUTC()
)
}
@@ -0,0 +1,91 @@
package org.gotson.komga.infrastructure.jooq

import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.jooq.Tables
import org.gotson.komga.jooq.tables.records.ReadProgressRecord
import org.jooq.DSLContext
import org.springframework.stereotype.Component
import java.time.LocalDateTime

@Component
class ReadProgressDao(
private val dsl: DSLContext
) : ReadProgressRepository {

private val r = Tables.READ_PROGRESS

override fun findAll(): Collection<ReadProgress> =
dsl.selectFrom(r)
.fetchInto(r)
.map { it.toDomain() }

override fun findByBookIdAndUserId(bookId: Long, userId: Long): ReadProgress? =
dsl.selectFrom(r)
.where(r.BOOK_ID.eq(bookId).and(r.USER_ID.eq(userId)))
.fetchOneInto(r)
?.toDomain()

override fun findByUserId(userId: Long): Collection<ReadProgress> =
dsl.selectFrom(r)
.where(r.USER_ID.eq(userId))
.fetchInto(r)
.map { it.toDomain() }


override fun save(readProgress: ReadProgress) {
dsl.mergeInto(r)
.using(dsl.selectOne())
.on(r.BOOK_ID.eq(readProgress.bookId).and(r.USER_ID.eq(readProgress.userId)))
.whenMatchedThenUpdate()
.set(r.PAGE, readProgress.page)
.set(r.COMPLETED, readProgress.completed)
.set(r.LAST_MODIFIED_DATE, LocalDateTime.now())
.whenNotMatchedThenInsert()
.set(r.BOOK_ID, readProgress.bookId)
.set(r.USER_ID, readProgress.userId)
.set(r.PAGE, readProgress.page)
.set(r.COMPLETED, readProgress.completed)
.execute()
}


override fun delete(bookId: Long, userId: Long) {
dsl.deleteFrom(r)
.where(r.BOOK_ID.eq(bookId).and(r.USER_ID.eq(userId)))
.execute()
}

override fun deleteByUserId(userId: Long) {
dsl.deleteFrom(r)
.where(r.USER_ID.eq(userId))
.execute()
}

override fun deleteByBookId(bookId: Long) {
dsl.deleteFrom(r)
.where(r.BOOK_ID.eq(bookId))
.execute()
}

override fun deleteByBookIds(bookIds: Collection<Long>) {
dsl.deleteFrom(r)
.where(r.BOOK_ID.`in`(bookIds))
.execute()
}

override fun deleteAll() {
dsl.deleteFrom(r).execute()
}


private fun ReadProgressRecord.toDomain() =
ReadProgress(
bookId = bookId,
userId = userId,
page = page,
completed = completed,
createdDate = createdDate,
lastModifiedDate = lastModifiedDate
)
}

0 comments on commit 17c80cd

Please sign in to comment.