mirror of
https://github.com/gotson/komga.git
synced 2025-12-22 00:13:30 +01:00
feat(api): manage book read progress per user
ability to mark a book as read, unread, or in progress by storing the last page read related to #25
This commit is contained in:
parent
59dd3682ef
commit
17c80cd1a1
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()
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,14 @@ import mu.KotlinLogging
|
||||||
import org.gotson.komga.domain.model.Book
|
import org.gotson.komga.domain.model.Book
|
||||||
import org.gotson.komga.domain.model.BookPageContent
|
import org.gotson.komga.domain.model.BookPageContent
|
||||||
import org.gotson.komga.domain.model.ImageConversionException
|
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.Media
|
||||||
import org.gotson.komga.domain.model.MediaNotReadyException
|
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.BookMetadataRepository
|
||||||
import org.gotson.komga.domain.persistence.BookRepository
|
import org.gotson.komga.domain.persistence.BookRepository
|
||||||
import org.gotson.komga.domain.persistence.MediaRepository
|
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.ImageConverter
|
||||||
import org.gotson.komga.infrastructure.image.ImageType
|
import org.gotson.komga.infrastructure.image.ImageType
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
@ -20,6 +23,7 @@ class BookLifecycle(
|
||||||
private val bookRepository: BookRepository,
|
private val bookRepository: BookRepository,
|
||||||
private val mediaRepository: MediaRepository,
|
private val mediaRepository: MediaRepository,
|
||||||
private val bookMetadataRepository: BookMetadataRepository,
|
private val bookMetadataRepository: BookMetadataRepository,
|
||||||
|
private val readProgressRepository: ReadProgressRepository,
|
||||||
private val bookAnalyzer: BookAnalyzer,
|
private val bookAnalyzer: BookAnalyzer,
|
||||||
private val imageConverter: ImageConverter
|
private val imageConverter: ImageConverter
|
||||||
) {
|
) {
|
||||||
|
|
@ -96,9 +100,27 @@ class BookLifecycle(
|
||||||
fun delete(bookId: Long) {
|
fun delete(bookId: Long) {
|
||||||
logger.info { "Delete book id: $bookId" }
|
logger.info { "Delete book id: $bookId" }
|
||||||
|
|
||||||
|
readProgressRepository.deleteByBookId(bookId)
|
||||||
mediaRepository.delete(bookId)
|
mediaRepository.delete(bookId)
|
||||||
bookMetadataRepository.delete(bookId)
|
bookMetadataRepository.delete(bookId)
|
||||||
|
|
||||||
bookRepository.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import mu.KotlinLogging
|
||||||
import org.gotson.komga.domain.model.KomgaUser
|
import org.gotson.komga.domain.model.KomgaUser
|
||||||
import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
|
import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
|
||||||
import org.gotson.komga.domain.persistence.KomgaUserRepository
|
import org.gotson.komga.domain.persistence.KomgaUserRepository
|
||||||
|
import org.gotson.komga.domain.persistence.ReadProgressRepository
|
||||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||||
import org.springframework.security.core.session.SessionRegistry
|
import org.springframework.security.core.session.SessionRegistry
|
||||||
import org.springframework.security.core.userdetails.UserDetails
|
import org.springframework.security.core.userdetails.UserDetails
|
||||||
|
|
@ -17,6 +18,7 @@ private val logger = KotlinLogging.logger {}
|
||||||
@Service
|
@Service
|
||||||
class KomgaUserLifecycle(
|
class KomgaUserLifecycle(
|
||||||
private val userRepository: KomgaUserRepository,
|
private val userRepository: KomgaUserRepository,
|
||||||
|
private val readProgressRepository: ReadProgressRepository,
|
||||||
private val passwordEncoder: PasswordEncoder,
|
private val passwordEncoder: PasswordEncoder,
|
||||||
private val sessionRegistry: SessionRegistry
|
private val sessionRegistry: SessionRegistry
|
||||||
|
|
||||||
|
|
@ -52,6 +54,7 @@ class KomgaUserLifecycle(
|
||||||
|
|
||||||
fun deleteUser(user: KomgaUser) {
|
fun deleteUser(user: KomgaUser) {
|
||||||
logger.info { "Deleting user: $user" }
|
logger.info { "Deleting user: $user" }
|
||||||
|
readProgressRepository.deleteByUserId(user.id)
|
||||||
userRepository.delete(user)
|
userRepository.delete(user)
|
||||||
expireSessions(user)
|
expireSessions(user)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.BookDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.BookMetadataDto
|
import org.gotson.komga.interfaces.rest.dto.BookMetadataDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.MediaDto
|
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.interfaces.rest.persistence.BookDtoRepository
|
||||||
import org.gotson.komga.jooq.Tables
|
import org.gotson.komga.jooq.Tables
|
||||||
import org.gotson.komga.jooq.tables.records.BookMetadataAuthorRecord
|
import org.gotson.komga.jooq.tables.records.BookMetadataAuthorRecord
|
||||||
import org.gotson.komga.jooq.tables.records.BookMetadataRecord
|
import org.gotson.komga.jooq.tables.records.BookMetadataRecord
|
||||||
import org.gotson.komga.jooq.tables.records.BookRecord
|
import org.gotson.komga.jooq.tables.records.BookRecord
|
||||||
import org.gotson.komga.jooq.tables.records.MediaRecord
|
import org.gotson.komga.jooq.tables.records.MediaRecord
|
||||||
|
import org.gotson.komga.jooq.tables.records.ReadProgressRecord
|
||||||
import org.jooq.Condition
|
import org.jooq.Condition
|
||||||
import org.jooq.DSLContext
|
import org.jooq.DSLContext
|
||||||
import org.jooq.Record
|
import org.jooq.Record
|
||||||
|
|
@ -31,6 +33,7 @@ class BookDtoDao(
|
||||||
private val b = Tables.BOOK
|
private val b = Tables.BOOK
|
||||||
private val m = Tables.MEDIA
|
private val m = Tables.MEDIA
|
||||||
private val d = Tables.BOOK_METADATA
|
private val d = Tables.BOOK_METADATA
|
||||||
|
private val r = Tables.READ_PROGRESS
|
||||||
private val a = Tables.BOOK_METADATA_AUTHOR
|
private val a = Tables.BOOK_METADATA_AUTHOR
|
||||||
|
|
||||||
private val mediaFields = m.fields().filterNot { it.name == m.THUMBNAIL.name }.toTypedArray()
|
private val mediaFields = m.fields().filterNot { it.name == m.THUMBNAIL.name }.toTypedArray()
|
||||||
|
|
@ -42,7 +45,7 @@ class BookDtoDao(
|
||||||
"fileSize" to b.FILE_SIZE
|
"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 conditions = search.toCondition()
|
||||||
|
|
||||||
val count = dsl.selectCount()
|
val count = dsl.selectCount()
|
||||||
|
|
@ -56,6 +59,7 @@ class BookDtoDao(
|
||||||
|
|
||||||
val dtos = selectBase()
|
val dtos = selectBase()
|
||||||
.where(conditions)
|
.where(conditions)
|
||||||
|
.and(readProgressCondition(userId))
|
||||||
.orderBy(orderBy)
|
.orderBy(orderBy)
|
||||||
.limit(pageable.pageSize)
|
.limit(pageable.pageSize)
|
||||||
.offset(pageable.offset)
|
.offset(pageable.offset)
|
||||||
|
|
@ -68,18 +72,20 @@ class BookDtoDao(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findByIdOrNull(bookId: Long): BookDto? =
|
override fun findByIdOrNull(bookId: Long, userId: Long): BookDto? =
|
||||||
selectBase()
|
selectBase()
|
||||||
.where(b.ID.eq(bookId))
|
.where(b.ID.eq(bookId))
|
||||||
|
.and(readProgressCondition(userId))
|
||||||
.fetchAndMap()
|
.fetchAndMap()
|
||||||
.firstOrNull()
|
.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)
|
val record = dsl.select(b.SERIES_ID, d.NUMBER_SORT)
|
||||||
.from(b)
|
.from(b)
|
||||||
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
|
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
|
||||||
|
|
@ -90,6 +96,7 @@ class BookDtoDao(
|
||||||
|
|
||||||
return selectBase()
|
return selectBase()
|
||||||
.where(b.SERIES_ID.eq(seriesId))
|
.where(b.SERIES_ID.eq(seriesId))
|
||||||
|
.and(readProgressCondition(userId))
|
||||||
.orderBy(d.NUMBER_SORT.let { if (next) it.asc() else it.desc() })
|
.orderBy(d.NUMBER_SORT.let { if (next) it.asc() else it.desc() })
|
||||||
.seek(numberSort)
|
.seek(numberSort)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
@ -102,20 +109,23 @@ class BookDtoDao(
|
||||||
*b.fields(),
|
*b.fields(),
|
||||||
*mediaFields,
|
*mediaFields,
|
||||||
*d.fields(),
|
*d.fields(),
|
||||||
*a.fields()
|
*a.fields(),
|
||||||
|
*r.fields()
|
||||||
).from(b)
|
).from(b)
|
||||||
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
|
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
|
||||||
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
|
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
|
||||||
.leftJoin(a).on(d.BOOK_ID.eq(a.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() =
|
private fun ResultQuery<Record>.fetchAndMap() =
|
||||||
fetchGroups(
|
fetchGroups(
|
||||||
{ it.into(*b.fields(), *mediaFields, *d.fields()) }, { it.into(a) }
|
{ it.into(*b.fields(), *mediaFields, *d.fields(), *r.fields()) }, { it.into(a) }
|
||||||
).map { (r, ar) ->
|
).map { (rec, ar) ->
|
||||||
val br = r.into(b)
|
val br = rec.into(b)
|
||||||
val mr = r.into(m)
|
val mr = rec.into(m)
|
||||||
val dr = r.into(d)
|
val dr = rec.into(d)
|
||||||
br.toDto(mr.toDto(), dr.toDto(ar))
|
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 {
|
private fun BookSearch.toCondition(): Condition {
|
||||||
|
|
@ -129,7 +139,7 @@ class BookDtoDao(
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun BookRecord.toDto(media: MediaDto, metadata: BookMetadataDto) =
|
private fun BookRecord.toDto(media: MediaDto, metadata: BookMetadataDto, readProgress: ReadProgressDto?) =
|
||||||
BookDto(
|
BookDto(
|
||||||
id = id,
|
id = id,
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
|
|
@ -142,7 +152,8 @@ class BookDtoDao(
|
||||||
fileLastModified = fileLastModified.toUTC(),
|
fileLastModified = fileLastModified.toUTC(),
|
||||||
sizeBytes = fileSize,
|
sizeBytes = fileSize,
|
||||||
media = media,
|
media = media,
|
||||||
metadata = metadata
|
metadata = metadata,
|
||||||
|
readProgress = readProgress
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun MediaRecord.toDto() =
|
private fun MediaRecord.toDto() =
|
||||||
|
|
@ -174,4 +185,12 @@ class BookDtoDao(
|
||||||
authors = ar.filter { it.name != null }.map { AuthorDto(it.name, it.role) },
|
authors = ar.filter { it.name != null }.map { AuthorDto(it.name, it.role) },
|
||||||
authorsLock = authorsLock
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
|
||||||
import org.gotson.komga.interfaces.rest.dto.BookDto
|
import org.gotson.komga.interfaces.rest.dto.BookDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto
|
import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.PageDto
|
import org.gotson.komga.interfaces.rest.dto.PageDto
|
||||||
|
import org.gotson.komga.interfaces.rest.dto.ReadProgressUpdateDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.restrictUrl
|
import org.gotson.komga.interfaces.rest.dto.restrictUrl
|
||||||
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
|
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
|
||||||
import org.springframework.core.io.FileSystemResource
|
import org.springframework.core.io.FileSystemResource
|
||||||
|
|
@ -38,6 +39,7 @@ import org.springframework.http.MediaType
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PatchMapping
|
import org.springframework.web.bind.annotation.PatchMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
|
@ -90,7 +92,7 @@ class BookController(
|
||||||
mediaStatus = mediaStatus ?: emptyList()
|
mediaStatus = mediaStatus ?: emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
return bookDtoRepository.findAll(bookSearch, pageRequest)
|
return bookDtoRepository.findAll(bookSearch, principal.user.id, pageRequest)
|
||||||
.map { it.restrictUrl(!principal.user.roleAdmin) }
|
.map { it.restrictUrl(!principal.user.roleAdmin) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,6 +116,7 @@ class BookController(
|
||||||
BookSearch(
|
BookSearch(
|
||||||
libraryIds = libraryIds
|
libraryIds = libraryIds
|
||||||
),
|
),
|
||||||
|
principal.user.id,
|
||||||
pageRequest
|
pageRequest
|
||||||
).map { it.restrictUrl(!principal.user.roleAdmin) }
|
).map { it.restrictUrl(!principal.user.roleAdmin) }
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +127,7 @@ class BookController(
|
||||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||||
@PathVariable bookId: Long
|
@PathVariable bookId: Long
|
||||||
): BookDto =
|
): BookDto =
|
||||||
bookDtoRepository.findByIdOrNull(bookId)?.let {
|
bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let {
|
||||||
if (!principal.user.canAccessLibrary(it.libraryId)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
if (!principal.user.canAccessLibrary(it.libraryId)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||||
it.restrictUrl(!principal.user.roleAdmin)
|
it.restrictUrl(!principal.user.roleAdmin)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
@ -138,7 +141,7 @@ class BookController(
|
||||||
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
return bookDtoRepository.findPreviousInSeries(bookId)
|
return bookDtoRepository.findPreviousInSeries(bookId, principal.user.id)
|
||||||
?.restrictUrl(!principal.user.roleAdmin)
|
?.restrictUrl(!principal.user.roleAdmin)
|
||||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +155,7 @@ class BookController(
|
||||||
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
return bookDtoRepository.findNextInSeries(bookId)
|
return bookDtoRepository.findNextInSeries(bookId, principal.user.id)
|
||||||
?.restrictUrl(!principal.user.roleAdmin)
|
?.restrictUrl(!principal.user.roleAdmin)
|
||||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
}
|
}
|
||||||
|
|
@ -342,7 +345,8 @@ class BookController(
|
||||||
fun updateMetadata(
|
fun updateMetadata(
|
||||||
@PathVariable bookId: Long,
|
@PathVariable bookId: Long,
|
||||||
@Parameter(description = "Metadata fields to update. Set a field to null to unset the metadata. You can omit fields you don't want to update.")
|
@Parameter(description = "Metadata fields to update. Set a field to null to unset the metadata. You can omit fields you don't want to update.")
|
||||||
@Valid @RequestBody newMetadata: BookMetadataUpdateDto
|
@Valid @RequestBody newMetadata: BookMetadataUpdateDto,
|
||||||
|
@AuthenticationPrincipal principal: KomgaPrincipal
|
||||||
): BookDto =
|
): BookDto =
|
||||||
bookMetadataRepository.findByIdOrNull(bookId)?.let { existing ->
|
bookMetadataRepository.findByIdOrNull(bookId)?.let { existing ->
|
||||||
val updated = with(newMetadata) {
|
val updated = with(newMetadata) {
|
||||||
|
|
@ -370,9 +374,45 @@ class BookController(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
bookMetadataRepository.update(updated)
|
bookMetadataRepository.update(updated)
|
||||||
bookDtoRepository.findByIdOrNull(bookId)
|
bookDtoRepository.findByIdOrNull(bookId, principal.user.id)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
@PatchMapping("api/v1/books/{bookId}/read-progress")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
fun markReadProgress(
|
||||||
|
@PathVariable bookId: Long,
|
||||||
|
@Parameter(description = "page can be omitted if completed is set to true. completed can be omitted, and will be set accordingly depending on the page passed and the total number of pages in the book.")
|
||||||
|
@Valid @RequestBody readProgress: ReadProgressUpdateDto,
|
||||||
|
@AuthenticationPrincipal principal: KomgaPrincipal
|
||||||
|
) {
|
||||||
|
bookRepository.findByIdOrNull(bookId)?.let { book ->
|
||||||
|
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (readProgress.completed != null && readProgress.completed)
|
||||||
|
bookLifecycle.markReadProgressCompleted(book, principal.user)
|
||||||
|
else
|
||||||
|
bookLifecycle.markReadProgress(book, principal.user, readProgress.page!!)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
|
||||||
|
}
|
||||||
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("api/v1/books/{bookId}/read-progress")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
fun deleteReadProgress(
|
||||||
|
@PathVariable bookId: Long,
|
||||||
|
@AuthenticationPrincipal principal: KomgaPrincipal
|
||||||
|
) {
|
||||||
|
bookRepository.findByIdOrNull(bookId)?.let { book ->
|
||||||
|
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
|
bookLifecycle.deleteReadProgress(book, principal.user)
|
||||||
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun ResponseEntity.BodyBuilder.setCachePrivate() =
|
private fun ResponseEntity.BodyBuilder.setCachePrivate() =
|
||||||
this.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS)
|
this.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS)
|
||||||
.cachePrivate()
|
.cachePrivate()
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,7 @@ class SeriesController(
|
||||||
seriesIds = listOf(seriesId),
|
seriesIds = listOf(seriesId),
|
||||||
mediaStatus = mediaStatus ?: emptyList()
|
mediaStatus = mediaStatus ?: emptyList()
|
||||||
),
|
),
|
||||||
|
principal.user.id,
|
||||||
pageRequest
|
pageRequest
|
||||||
).map { it.restrictUrl(!principal.user.roleAdmin) }
|
).map { it.restrictUrl(!principal.user.roleAdmin) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ data class BookDto(
|
||||||
val sizeBytes: Long,
|
val sizeBytes: Long,
|
||||||
val size: String = BinaryByteUnit.format(sizeBytes),
|
val size: String = BinaryByteUnit.format(sizeBytes),
|
||||||
val media: MediaDto,
|
val media: MediaDto,
|
||||||
val metadata: BookMetadataDto
|
val metadata: BookMetadataDto,
|
||||||
|
val readProgress: ReadProgressDto? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
fun BookDto.restrictUrl(restrict: Boolean) =
|
fun BookDto.restrictUrl(restrict: Boolean) =
|
||||||
|
|
@ -62,3 +63,12 @@ data class AuthorDto(
|
||||||
val role: String
|
val role: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class ReadProgressDto(
|
||||||
|
val page: Int,
|
||||||
|
val completed: Boolean,
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||||
|
val created: LocalDateTime,
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||||
|
val lastModified: LocalDateTime
|
||||||
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package org.gotson.komga.interfaces.rest.dto
|
||||||
|
|
||||||
|
import javax.validation.Constraint
|
||||||
|
import javax.validation.ConstraintValidator
|
||||||
|
import javax.validation.ConstraintValidatorContext
|
||||||
|
import javax.validation.constraints.Positive
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
@ReadProgressUpdateDtoConstraint
|
||||||
|
data class ReadProgressUpdateDto(
|
||||||
|
@get:Positive val page: Int?,
|
||||||
|
val completed: Boolean?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Constraint(validatedBy = [ReadProgressUpdateDtoValidator::class])
|
||||||
|
@Target(AnnotationTarget.CLASS)
|
||||||
|
@Retention(AnnotationRetention.RUNTIME)
|
||||||
|
annotation class ReadProgressUpdateDtoConstraint(
|
||||||
|
val message: String = "page must be specified if completed is false or null",
|
||||||
|
val groups: Array<KClass<out Any>> = [],
|
||||||
|
val payload: Array<KClass<out Any>> = []
|
||||||
|
)
|
||||||
|
|
||||||
|
class ReadProgressUpdateDtoValidator : ConstraintValidator<ReadProgressUpdateDtoConstraint, ReadProgressUpdateDto> {
|
||||||
|
override fun isValid(value: ReadProgressUpdateDto?, context: ConstraintValidatorContext?): Boolean =
|
||||||
|
value != null && (
|
||||||
|
value.page != null || (value.completed != null && value.completed)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,8 @@ import org.springframework.data.domain.Page
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
|
|
||||||
interface BookDtoRepository {
|
interface BookDtoRepository {
|
||||||
fun findAll(search: BookSearch, pageable: Pageable): Page<BookDto>
|
fun findAll(search: BookSearch, userId: Long, pageable: Pageable): Page<BookDto>
|
||||||
fun findByIdOrNull(bookId: Long): BookDto?
|
fun findByIdOrNull(bookId: Long, userId: Long): BookDto?
|
||||||
fun findPreviousInSeries(bookId: Long): BookDto?
|
fun findPreviousInSeries(bookId: Long, userId: Long): BookDto?
|
||||||
fun findNextInSeries(bookId: Long): BookDto?
|
fun findNextInSeries(bookId: Long, userId: Long): BookDto?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
package org.gotson.komga.infrastructure.jooq
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.gotson.komga.domain.model.KomgaUser
|
||||||
|
import org.gotson.komga.domain.model.ReadProgress
|
||||||
|
import org.gotson.komga.domain.model.makeBook
|
||||||
|
import org.gotson.komga.domain.model.makeLibrary
|
||||||
|
import org.gotson.komga.domain.model.makeSeries
|
||||||
|
import org.gotson.komga.domain.persistence.BookRepository
|
||||||
|
import org.gotson.komga.domain.persistence.KomgaUserRepository
|
||||||
|
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||||
|
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||||
|
import org.junit.jupiter.api.AfterAll
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeAll
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@ExtendWith(SpringExtension::class)
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureTestDatabase
|
||||||
|
class ReadProgressDaoTest(
|
||||||
|
@Autowired private val readProgressDao: ReadProgressDao,
|
||||||
|
@Autowired private val userRepository: KomgaUserRepository,
|
||||||
|
@Autowired private val bookRepository: BookRepository,
|
||||||
|
@Autowired private val seriesRepository: SeriesRepository,
|
||||||
|
@Autowired private val libraryRepository: LibraryRepository
|
||||||
|
) {
|
||||||
|
private var library = makeLibrary()
|
||||||
|
private var series = makeSeries("Series")
|
||||||
|
|
||||||
|
private var user1 = KomgaUser("user1@example.org", "", false)
|
||||||
|
private var user2 = KomgaUser("user2@example.org", "", false)
|
||||||
|
|
||||||
|
private var book1 = makeBook("Book1")
|
||||||
|
private var book2 = makeBook("Book2")
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
fun setup() {
|
||||||
|
library = libraryRepository.insert(library)
|
||||||
|
series = seriesRepository.insert(series.copy(libraryId = library.id))
|
||||||
|
user1 = userRepository.save(user1)
|
||||||
|
user2 = userRepository.save(user2)
|
||||||
|
book1 = bookRepository.insert(book1.copy(libraryId = library.id, seriesId = series.id))
|
||||||
|
book2 = bookRepository.insert(book2.copy(libraryId = library.id, seriesId = series.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun deleteReadProgress() {
|
||||||
|
readProgressDao.deleteAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
fun tearDown() {
|
||||||
|
userRepository.deleteAll()
|
||||||
|
bookRepository.deleteAll()
|
||||||
|
seriesRepository.deleteAll()
|
||||||
|
libraryRepository.deleteAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `given book without user progress when saving progress then progress is saved`() {
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
|
||||||
|
readProgressDao.save(ReadProgress(
|
||||||
|
book1.id,
|
||||||
|
user1.id,
|
||||||
|
5,
|
||||||
|
false
|
||||||
|
))
|
||||||
|
|
||||||
|
val readProgressList = readProgressDao.findByUserId(user1.id)
|
||||||
|
|
||||||
|
assertThat(readProgressList).hasSize(1)
|
||||||
|
with(readProgressList.first()) {
|
||||||
|
assertThat(page).isEqualTo(5)
|
||||||
|
assertThat(completed).isEqualTo(false)
|
||||||
|
assertThat(bookId).isEqualTo(book1.id)
|
||||||
|
assertThat(createdDate)
|
||||||
|
.isAfterOrEqualTo(now)
|
||||||
|
.isEqualTo(lastModifiedDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `given book with user progress when saving progress then progress is updated`() {
|
||||||
|
readProgressDao.save(ReadProgress(
|
||||||
|
book1.id,
|
||||||
|
user1.id,
|
||||||
|
5,
|
||||||
|
false
|
||||||
|
))
|
||||||
|
|
||||||
|
Thread.sleep(5)
|
||||||
|
val modificationDate = LocalDateTime.now()
|
||||||
|
|
||||||
|
readProgressDao.save(ReadProgress(
|
||||||
|
book1.id,
|
||||||
|
user1.id,
|
||||||
|
10,
|
||||||
|
true
|
||||||
|
))
|
||||||
|
|
||||||
|
val readProgressList = readProgressDao.findByUserId(user1.id)
|
||||||
|
|
||||||
|
assertThat(readProgressList).hasSize(1)
|
||||||
|
with(readProgressList.first()) {
|
||||||
|
assertThat(page).isEqualTo(10)
|
||||||
|
assertThat(completed).isEqualTo(true)
|
||||||
|
assertThat(bookId).isEqualTo(book1.id)
|
||||||
|
assertThat(createdDate)
|
||||||
|
.isBefore(modificationDate)
|
||||||
|
.isNotEqualTo(lastModifiedDate)
|
||||||
|
assertThat(lastModifiedDate).isAfterOrEqualTo(modificationDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,17 +6,21 @@ import org.gotson.komga.domain.model.Author
|
||||||
import org.gotson.komga.domain.model.BookMetadata
|
import org.gotson.komga.domain.model.BookMetadata
|
||||||
import org.gotson.komga.domain.model.BookPage
|
import org.gotson.komga.domain.model.BookPage
|
||||||
import org.gotson.komga.domain.model.BookSearch
|
import org.gotson.komga.domain.model.BookSearch
|
||||||
|
import org.gotson.komga.domain.model.KomgaUser
|
||||||
import org.gotson.komga.domain.model.Media
|
import org.gotson.komga.domain.model.Media
|
||||||
import org.gotson.komga.domain.model.makeBook
|
import org.gotson.komga.domain.model.makeBook
|
||||||
import org.gotson.komga.domain.model.makeLibrary
|
import org.gotson.komga.domain.model.makeLibrary
|
||||||
import org.gotson.komga.domain.model.makeSeries
|
import org.gotson.komga.domain.model.makeSeries
|
||||||
import org.gotson.komga.domain.persistence.BookMetadataRepository
|
import org.gotson.komga.domain.persistence.BookMetadataRepository
|
||||||
import org.gotson.komga.domain.persistence.BookRepository
|
import org.gotson.komga.domain.persistence.BookRepository
|
||||||
|
import org.gotson.komga.domain.persistence.KomgaUserRepository
|
||||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||||
import org.gotson.komga.domain.persistence.MediaRepository
|
import org.gotson.komga.domain.persistence.MediaRepository
|
||||||
import org.gotson.komga.domain.persistence.SeriesRepository
|
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||||
|
import org.gotson.komga.domain.service.KomgaUserLifecycle
|
||||||
import org.gotson.komga.domain.service.LibraryLifecycle
|
import org.gotson.komga.domain.service.LibraryLifecycle
|
||||||
import org.gotson.komga.domain.service.SeriesLifecycle
|
import org.gotson.komga.domain.service.SeriesLifecycle
|
||||||
|
import org.hamcrest.core.IsNull
|
||||||
import org.junit.jupiter.api.AfterAll
|
import org.junit.jupiter.api.AfterAll
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.BeforeAll
|
import org.junit.jupiter.api.BeforeAll
|
||||||
|
|
@ -36,6 +40,7 @@ import org.springframework.jdbc.core.JdbcTemplate
|
||||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
import org.springframework.test.web.servlet.MockMvcResultMatchersDsl
|
import org.springframework.test.web.servlet.MockMvcResultMatchersDsl
|
||||||
|
import org.springframework.test.web.servlet.delete
|
||||||
import org.springframework.test.web.servlet.get
|
import org.springframework.test.web.servlet.get
|
||||||
import org.springframework.test.web.servlet.patch
|
import org.springframework.test.web.servlet.patch
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
@ -54,6 +59,8 @@ class BookControllerTest(
|
||||||
@Autowired private val libraryRepository: LibraryRepository,
|
@Autowired private val libraryRepository: LibraryRepository,
|
||||||
@Autowired private val libraryLifecycle: LibraryLifecycle,
|
@Autowired private val libraryLifecycle: LibraryLifecycle,
|
||||||
@Autowired private val bookRepository: BookRepository,
|
@Autowired private val bookRepository: BookRepository,
|
||||||
|
@Autowired private val userRepository: KomgaUserRepository,
|
||||||
|
@Autowired private val userLifecycle: KomgaUserLifecycle,
|
||||||
@Autowired private val mockMvc: MockMvc
|
@Autowired private val mockMvc: MockMvc
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|
@ -70,11 +77,15 @@ class BookControllerTest(
|
||||||
fun `setup library`() {
|
fun `setup library`() {
|
||||||
jdbcTemplate.execute("ALTER SEQUENCE hibernate_sequence RESTART WITH 1")
|
jdbcTemplate.execute("ALTER SEQUENCE hibernate_sequence RESTART WITH 1")
|
||||||
|
|
||||||
library = libraryRepository.insert(library)
|
library = libraryRepository.insert(library) // id = 1
|
||||||
|
userRepository.save(KomgaUser("user@example.org", "", false)) // id = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterAll
|
@AfterAll
|
||||||
fun `teardown library`() {
|
fun `teardown library`() {
|
||||||
|
userRepository.findAll().forEach {
|
||||||
|
userLifecycle.deleteUser(it)
|
||||||
|
}
|
||||||
libraryRepository.findAll().forEach {
|
libraryRepository.findAll().forEach {
|
||||||
libraryLifecycle.deleteLibrary(it)
|
libraryLifecycle.deleteLibrary(it)
|
||||||
}
|
}
|
||||||
|
|
@ -737,4 +748,148 @@ class BookControllerTest(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
inner class ReadProgress {
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = [
|
||||||
|
"""{"completed": false}""",
|
||||||
|
"""{}""",
|
||||||
|
"""{"page":0}"""
|
||||||
|
])
|
||||||
|
@WithMockCustomUser
|
||||||
|
fun `given invalid payload when marking book in progress then validation error is returned`(jsonString: String) {
|
||||||
|
mockMvc.patch("/api/v1/books/1/read-progress") {
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
content = jsonString
|
||||||
|
}.andExpect {
|
||||||
|
status { isBadRequest }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockCustomUser(id = 2)
|
||||||
|
fun `given user when marking book in progress with page read then progress is marked accordingly`() {
|
||||||
|
makeSeries(name = "series", libraryId = library.id).let { series ->
|
||||||
|
seriesLifecycle.createSeries(series).also { created ->
|
||||||
|
val books = listOf(makeBook("1.cbr", libraryId = library.id))
|
||||||
|
seriesLifecycle.addBooks(created, books)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val book = bookRepository.findAll().first()
|
||||||
|
mediaRepository.findById(book.id).let {
|
||||||
|
mediaRepository.update(it.copy(
|
||||||
|
status = Media.Status.READY,
|
||||||
|
pages = (1..10).map { BookPage("$it", "image/jpeg") }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
val jsonString = """
|
||||||
|
{
|
||||||
|
"page": 5
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
mockMvc.patch("/api/v1/books/${book.id}/read-progress") {
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
content = jsonString
|
||||||
|
}.andExpect {
|
||||||
|
status { isNoContent }
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.get("/api/v1/books/${book.id}")
|
||||||
|
.andExpect {
|
||||||
|
status { isOk }
|
||||||
|
jsonPath("$.readProgress.page") { value(5) }
|
||||||
|
jsonPath("$.readProgress.completed") { value(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockCustomUser(id = 2)
|
||||||
|
fun `given user when marking book completed then progress is marked accordingly`() {
|
||||||
|
makeSeries(name = "series", libraryId = library.id).let { series ->
|
||||||
|
seriesLifecycle.createSeries(series).also { created ->
|
||||||
|
val books = listOf(makeBook("1.cbr", libraryId = library.id))
|
||||||
|
seriesLifecycle.addBooks(created, books)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val book = bookRepository.findAll().first()
|
||||||
|
mediaRepository.findById(book.id).let {
|
||||||
|
mediaRepository.update(it.copy(
|
||||||
|
status = Media.Status.READY,
|
||||||
|
pages = (1..10).map { BookPage("$it", "image/jpeg") }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
val jsonString = """
|
||||||
|
{
|
||||||
|
"completed": true
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
mockMvc.patch("/api/v1/books/${book.id}/read-progress") {
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
content = jsonString
|
||||||
|
}.andExpect {
|
||||||
|
status { isNoContent }
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.get("/api/v1/books/${book.id}")
|
||||||
|
.andExpect {
|
||||||
|
status { isOk }
|
||||||
|
jsonPath("$.readProgress.page") { value(10) }
|
||||||
|
jsonPath("$.readProgress.completed") { value(true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockCustomUser(id = 2)
|
||||||
|
fun `given user when deleting read progress then progress is removed`() {
|
||||||
|
makeSeries(name = "series", libraryId = library.id).let { series ->
|
||||||
|
seriesLifecycle.createSeries(series).also { created ->
|
||||||
|
val books = listOf(makeBook("1.cbr", libraryId = library.id))
|
||||||
|
seriesLifecycle.addBooks(created, books)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val book = bookRepository.findAll().first()
|
||||||
|
mediaRepository.findById(book.id).let { media ->
|
||||||
|
mediaRepository.update(media.copy(
|
||||||
|
status = Media.Status.READY,
|
||||||
|
pages = (1..10).map { BookPage("$it", "image/jpeg") }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
val jsonString = """
|
||||||
|
{
|
||||||
|
"page": 5,
|
||||||
|
"completed": false
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
mockMvc.patch("/api/v1/books/${book.id}/read-progress") {
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
content = jsonString
|
||||||
|
}.andExpect {
|
||||||
|
status { isNoContent }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
mockMvc.delete("/api/v1/books/${book.id}/read-progress") {
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
}.andExpect {
|
||||||
|
status { isNoContent }
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.get("/api/v1/books/${book.id}")
|
||||||
|
.andExpect {
|
||||||
|
status { isOk }
|
||||||
|
jsonPath("$.readProgress") { value(IsNull.nullValue()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ annotation class WithMockCustomUser(
|
||||||
val email: String = "user@example.org",
|
val email: String = "user@example.org",
|
||||||
val roles: Array<String> = [],
|
val roles: Array<String> = [],
|
||||||
val sharedAllLibraries: Boolean = true,
|
val sharedAllLibraries: Boolean = true,
|
||||||
val sharedLibraries: LongArray = []
|
val sharedLibraries: LongArray = [],
|
||||||
|
val id: Long = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<WithMockCustomUser> {
|
class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<WithMockCustomUser> {
|
||||||
|
|
@ -28,7 +29,8 @@ class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<With
|
||||||
password = "",
|
password = "",
|
||||||
roleAdmin = customUser.roles.contains("ADMIN"),
|
roleAdmin = customUser.roles.contains("ADMIN"),
|
||||||
sharedAllLibraries = customUser.sharedAllLibraries,
|
sharedAllLibraries = customUser.sharedAllLibraries,
|
||||||
sharedLibrariesIds = customUser.sharedLibraries.toSet()
|
sharedLibrariesIds = customUser.sharedLibraries.toSet(),
|
||||||
|
id = customUser.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val auth = UsernamePasswordAuthenticationToken(principal, "", principal.authorities)
|
val auth = UsernamePasswordAuthenticationToken(principal, "", principal.authorities)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue