diff --git a/komga/src/flyway/resources/db/migration/V20200601173217__read_progress.sql b/komga/src/flyway/resources/db/migration/V20200601173217__read_progress.sql new file mode 100644 index 0000000000..d17629a2fd --- /dev/null +++ b/komga/src/flyway/resources/db/migration/V20200601173217__read_progress.sql @@ -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); diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadProgress.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadProgress.kt new file mode 100644 index 0000000000..8fc0d4790a --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadProgress.kt @@ -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() diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadProgressRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadProgressRepository.kt new file mode 100644 index 0000000000..e835574f50 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadProgressRepository.kt @@ -0,0 +1,18 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.ReadProgress + + +interface ReadProgressRepository { + fun findAll(): Collection + fun findByBookIdAndUserId(bookId: Long, userId: Long): ReadProgress? + fun findByUserId(userId: Long): Collection + + fun save(readProgress: ReadProgress) + + fun delete(bookId: Long, userId: Long) + fun deleteByUserId(userId: Long) + fun deleteByBookId(bookId: Long) + fun deleteByBookIds(bookIds: Collection) + fun deleteAll() +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt index f656c4459e..c47a7037be 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt @@ -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 @@ -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 ) { @@ -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) + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt index 9730b25e42..f106a02ba9 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt @@ -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 @@ -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 @@ -52,6 +54,7 @@ class KomgaUserLifecycle( fun deleteUser(user: KomgaUser) { logger.info { "Deleting user: $user" } + readProgressRepository.deleteByUserId(user.id) userRepository.delete(user) expireSessions(user) } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt index 78420e2761..44000ed031 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt @@ -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 @@ -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() @@ -42,7 +45,7 @@ class BookDtoDao( "fileSize" to b.FILE_SIZE ) - override fun findAll(search: BookSearch, pageable: Pageable): Page { + override fun findAll(search: BookSearch, userId: Long, pageable: Pageable): Page { val conditions = search.toCondition() val count = dsl.selectCount() @@ -56,6 +59,7 @@ class BookDtoDao( val dtos = selectBase() .where(conditions) + .and(readProgressCondition(userId)) .orderBy(orderBy) .limit(pageable.pageSize) .offset(pageable.offset) @@ -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)) @@ -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) @@ -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.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 { @@ -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, @@ -142,7 +152,8 @@ class BookDtoDao( fileLastModified = fileLastModified.toUTC(), sizeBytes = fileSize, media = media, - metadata = metadata + metadata = metadata, + readProgress = readProgress ) private fun MediaRecord.toDto() = @@ -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() + ) } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadProgressDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadProgressDao.kt new file mode 100644 index 0000000000..520a6f8dfc --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadProgressDao.kt @@ -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 = + 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 = + 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) { + 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 + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt index 375aae48f5..8a990a097d 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt @@ -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.BookMetadataUpdateDto 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.persistence.BookDtoRepository import org.springframework.core.io.FileSystemResource @@ -38,6 +39,7 @@ import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize 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.PatchMapping import org.springframework.web.bind.annotation.PathVariable @@ -90,7 +92,7 @@ class BookController( mediaStatus = mediaStatus ?: emptyList() ) - return bookDtoRepository.findAll(bookSearch, pageRequest) + return bookDtoRepository.findAll(bookSearch, principal.user.id, pageRequest) .map { it.restrictUrl(!principal.user.roleAdmin) } } @@ -114,6 +116,7 @@ class BookController( BookSearch( libraryIds = libraryIds ), + principal.user.id, pageRequest ).map { it.restrictUrl(!principal.user.roleAdmin) } } @@ -124,7 +127,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: Long ): BookDto = - bookDtoRepository.findByIdOrNull(bookId)?.let { + bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { if (!principal.user.canAccessLibrary(it.libraryId)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) it.restrictUrl(!principal.user.roleAdmin) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -138,7 +141,7 @@ class BookController( if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - return bookDtoRepository.findPreviousInSeries(bookId) + return bookDtoRepository.findPreviousInSeries(bookId, principal.user.id) ?.restrictUrl(!principal.user.roleAdmin) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } @@ -152,7 +155,7 @@ class BookController( if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - return bookDtoRepository.findNextInSeries(bookId) + return bookDtoRepository.findNextInSeries(bookId, principal.user.id) ?.restrictUrl(!principal.user.roleAdmin) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } @@ -342,7 +345,8 @@ class BookController( fun updateMetadata( @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.") - @Valid @RequestBody newMetadata: BookMetadataUpdateDto + @Valid @RequestBody newMetadata: BookMetadataUpdateDto, + @AuthenticationPrincipal principal: KomgaPrincipal ): BookDto = bookMetadataRepository.findByIdOrNull(bookId)?.let { existing -> val updated = with(newMetadata) { @@ -370,9 +374,45 @@ class BookController( ) } bookMetadataRepository.update(updated) - bookDtoRepository.findByIdOrNull(bookId) + bookDtoRepository.findByIdOrNull(bookId, principal.user.id) } ?: 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() = this.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS) .cachePrivate() diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt index fbdebca84b..d7c8150f9f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt @@ -196,6 +196,7 @@ class SeriesController( seriesIds = listOf(seriesId), mediaStatus = mediaStatus ?: emptyList() ), + principal.user.id, pageRequest ).map { it.restrictUrl(!principal.user.roleAdmin) } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookDto.kt index 0626a0572e..e50662720a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookDto.kt @@ -22,7 +22,8 @@ data class BookDto( val sizeBytes: Long, val size: String = BinaryByteUnit.format(sizeBytes), val media: MediaDto, - val metadata: BookMetadataDto + val metadata: BookMetadataDto, + val readProgress: ReadProgressDto? = null ) fun BookDto.restrictUrl(restrict: Boolean) = @@ -62,3 +63,12 @@ data class AuthorDto( 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 +) + diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadProgressUpdateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadProgressUpdateDto.kt new file mode 100644 index 0000000000..55f2582635 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadProgressUpdateDto.kt @@ -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> = [], + val payload: Array> = [] +) + +class ReadProgressUpdateDtoValidator : ConstraintValidator { + override fun isValid(value: ReadProgressUpdateDto?, context: ConstraintValidatorContext?): Boolean = + value != null && ( + value.page != null || (value.completed != null && value.completed) + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/BookDtoRepository.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/BookDtoRepository.kt index 83d184a6bb..1b432b4f6c 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/BookDtoRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/BookDtoRepository.kt @@ -6,8 +6,8 @@ import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable interface BookDtoRepository { - fun findAll(search: BookSearch, pageable: Pageable): Page - fun findByIdOrNull(bookId: Long): BookDto? - fun findPreviousInSeries(bookId: Long): BookDto? - fun findNextInSeries(bookId: Long): BookDto? + fun findAll(search: BookSearch, userId: Long, pageable: Pageable): Page + fun findByIdOrNull(bookId: Long, userId: Long): BookDto? + fun findPreviousInSeries(bookId: Long, userId: Long): BookDto? + fun findNextInSeries(bookId: Long, userId: Long): BookDto? } diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/ReadProgressDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/ReadProgressDaoTest.kt new file mode 100644 index 0000000000..c544e35644 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/ReadProgressDaoTest.kt @@ -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) + } + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt index 64a54ca376..55be6bf751 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt @@ -6,17 +6,21 @@ import org.gotson.komga.domain.model.Author import org.gotson.komga.domain.model.BookMetadata import org.gotson.komga.domain.model.BookPage 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.makeBook import org.gotson.komga.domain.model.makeLibrary import org.gotson.komga.domain.model.makeSeries import org.gotson.komga.domain.persistence.BookMetadataRepository 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.MediaRepository 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.SeriesLifecycle +import org.hamcrest.core.IsNull import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterEach 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.web.servlet.MockMvc 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.patch import java.time.LocalDate @@ -54,6 +59,8 @@ class BookControllerTest( @Autowired private val libraryRepository: LibraryRepository, @Autowired private val libraryLifecycle: LibraryLifecycle, @Autowired private val bookRepository: BookRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val userLifecycle: KomgaUserLifecycle, @Autowired private val mockMvc: MockMvc ) { @@ -70,11 +77,15 @@ class BookControllerTest( fun `setup library`() { 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 fun `teardown library`() { + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } libraryRepository.findAll().forEach { 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()) } + } + } + } } diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/MockSpringSecurity.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/MockSpringSecurity.kt index bb86d21178..5f3b930c74 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/MockSpringSecurity.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/MockSpringSecurity.kt @@ -15,7 +15,8 @@ annotation class WithMockCustomUser( val email: String = "user@example.org", val roles: Array = [], val sharedAllLibraries: Boolean = true, - val sharedLibraries: LongArray = [] + val sharedLibraries: LongArray = [], + val id: Long = 0 ) class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory { @@ -28,7 +29,8 @@ class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory