mirror of
https://github.com/gotson/komga.git
synced 2026-05-06 03:27:08 +02:00
feat(api): find previous/next book in readlist
this also works for users with limited access to libraries
This commit is contained in:
parent
cdca78b38f
commit
bcfb203f74
4 changed files with 215 additions and 11 deletions
|
|
@ -121,9 +121,28 @@ class BookDtoDao(
|
||||||
.fetchAndMap()
|
.fetchAndMap()
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
|
|
||||||
override fun findPreviousInSeries(bookId: String, userId: String): BookDto? = findSibling(bookId, userId, next = false)
|
override fun findPreviousInSeries(bookId: String, userId: String): BookDto? =
|
||||||
|
findSiblingSeries(bookId, userId, next = false)
|
||||||
|
|
||||||
override fun findNextInSeries(bookId: String, userId: String): BookDto? = findSibling(bookId, userId, next = true)
|
override fun findNextInSeries(bookId: String, userId: String): BookDto? =
|
||||||
|
findSiblingSeries(bookId, userId, next = true)
|
||||||
|
|
||||||
|
|
||||||
|
override fun findPreviousInReadList(
|
||||||
|
readListId: String,
|
||||||
|
bookId: String,
|
||||||
|
userId: String,
|
||||||
|
filterOnLibraryIds: Collection<String>?
|
||||||
|
): BookDto? =
|
||||||
|
findSiblingReadList(readListId, bookId, userId, filterOnLibraryIds, next = false)
|
||||||
|
|
||||||
|
override fun findNextInReadList(
|
||||||
|
readListId: String,
|
||||||
|
bookId: String,
|
||||||
|
userId: String,
|
||||||
|
filterOnLibraryIds: Collection<String>?
|
||||||
|
): BookDto? =
|
||||||
|
findSiblingReadList(readListId, bookId, userId, filterOnLibraryIds, next = true)
|
||||||
|
|
||||||
|
|
||||||
override fun findOnDeck(libraryIds: Collection<String>, userId: String, pageable: Pageable): Page<BookDto> {
|
override fun findOnDeck(libraryIds: Collection<String>, userId: String, pageable: Pageable): Page<BookDto> {
|
||||||
|
|
@ -163,7 +182,7 @@ class BookDtoDao(
|
||||||
|
|
||||||
private fun readProgressCondition(userId: String): Condition = r.USER_ID.eq(userId).or(r.USER_ID.isNull)
|
private fun readProgressCondition(userId: String): Condition = r.USER_ID.eq(userId).or(r.USER_ID.isNull)
|
||||||
|
|
||||||
private fun findSibling(bookId: String, userId: String, next: Boolean): BookDto? {
|
private fun findSiblingSeries(bookId: String, userId: String, 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))
|
||||||
|
|
@ -181,6 +200,31 @@ class BookDtoDao(
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun findSiblingReadList(
|
||||||
|
readListId: String,
|
||||||
|
bookId: String,
|
||||||
|
userId: String,
|
||||||
|
filterOnLibraryIds: Collection<String>?,
|
||||||
|
next: Boolean
|
||||||
|
): BookDto? {
|
||||||
|
val numberSort = dsl.select(rlb.NUMBER)
|
||||||
|
.from(b)
|
||||||
|
.leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID))
|
||||||
|
.where(b.ID.eq(bookId))
|
||||||
|
.and(rlb.READLIST_ID.eq(readListId))
|
||||||
|
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
|
||||||
|
.fetchOne(0, Int::class.java)
|
||||||
|
|
||||||
|
return selectBase(userId, JoinConditions(selectReadListNumber = true))
|
||||||
|
.where(rlb.READLIST_ID.eq(readListId))
|
||||||
|
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
|
||||||
|
.orderBy(rlb.NUMBER.let { if (next) it.asc() else it.desc() })
|
||||||
|
.seek(numberSort)
|
||||||
|
.limit(1)
|
||||||
|
.fetchAndMap()
|
||||||
|
.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
private fun selectBase(userId: String, joinConditions: JoinConditions = JoinConditions()) =
|
private fun selectBase(userId: String, joinConditions: JoinConditions = JoinConditions()) =
|
||||||
dsl.selectDistinct(
|
dsl.selectDistinct(
|
||||||
*b.fields(),
|
*b.fields(),
|
||||||
|
|
|
||||||
|
|
@ -73,10 +73,28 @@ class ReadListController(
|
||||||
)
|
)
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
principal.user.sharedAllLibraries && libraryIds == null -> readListRepository.findAll(searchTerm, pageable = pageRequest)
|
principal.user.sharedAllLibraries && libraryIds == null -> readListRepository.findAll(
|
||||||
principal.user.sharedAllLibraries && libraryIds != null -> readListRepository.findAllByLibraries(libraryIds, null, searchTerm, pageable = pageRequest)
|
searchTerm,
|
||||||
!principal.user.sharedAllLibraries && libraryIds != null -> readListRepository.findAllByLibraries(libraryIds, principal.user.sharedLibrariesIds, searchTerm, pageable = pageRequest)
|
pageable = pageRequest
|
||||||
else -> readListRepository.findAllByLibraries(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds, searchTerm, pageable = pageRequest)
|
)
|
||||||
|
principal.user.sharedAllLibraries && libraryIds != null -> readListRepository.findAllByLibraries(
|
||||||
|
libraryIds,
|
||||||
|
null,
|
||||||
|
searchTerm,
|
||||||
|
pageable = pageRequest
|
||||||
|
)
|
||||||
|
!principal.user.sharedAllLibraries && libraryIds != null -> readListRepository.findAllByLibraries(
|
||||||
|
libraryIds,
|
||||||
|
principal.user.sharedLibrariesIds,
|
||||||
|
searchTerm,
|
||||||
|
pageable = pageRequest
|
||||||
|
)
|
||||||
|
else -> readListRepository.findAllByLibraries(
|
||||||
|
principal.user.sharedLibrariesIds,
|
||||||
|
principal.user.sharedLibrariesIds,
|
||||||
|
searchTerm,
|
||||||
|
pageable = pageRequest
|
||||||
|
)
|
||||||
}.map { it.toDto() }
|
}.map { it.toDto() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,10 +126,12 @@ class ReadListController(
|
||||||
@Valid @RequestBody readList: ReadListCreationDto
|
@Valid @RequestBody readList: ReadListCreationDto
|
||||||
): ReadListDto =
|
): ReadListDto =
|
||||||
try {
|
try {
|
||||||
readListLifecycle.addReadList(ReadList(
|
readListLifecycle.addReadList(
|
||||||
name = readList.name,
|
ReadList(
|
||||||
bookIds = readList.bookIds.toIndexedMap()
|
name = readList.name,
|
||||||
)).toDto()
|
bookIds = readList.bookIds.toIndexedMap()
|
||||||
|
)
|
||||||
|
).toDto()
|
||||||
} catch (e: DuplicateNameException) {
|
} catch (e: DuplicateNameException) {
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
|
||||||
}
|
}
|
||||||
|
|
@ -174,5 +194,37 @@ class ReadListController(
|
||||||
)
|
)
|
||||||
.map { it.restrictUrl(!principal.user.roleAdmin) }
|
.map { it.restrictUrl(!principal.user.roleAdmin) }
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
@GetMapping("{id}/books/{bookId}/previous")
|
||||||
|
fun getBookSiblingPrevious(
|
||||||
|
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||||
|
@PathVariable id: String,
|
||||||
|
@PathVariable bookId: String
|
||||||
|
): BookDto =
|
||||||
|
readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList ->
|
||||||
|
bookDtoRepository.findPreviousInReadList(
|
||||||
|
id,
|
||||||
|
bookId,
|
||||||
|
principal.user.id,
|
||||||
|
principal.user.getAuthorizedLibraryIds(null)
|
||||||
|
)
|
||||||
|
?.restrictUrl(!principal.user.roleAdmin)
|
||||||
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
@GetMapping("{id}/books/{bookId}/next")
|
||||||
|
fun getBookSiblingNext(
|
||||||
|
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||||
|
@PathVariable id: String,
|
||||||
|
@PathVariable bookId: String
|
||||||
|
): BookDto =
|
||||||
|
readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList ->
|
||||||
|
bookDtoRepository.findNextInReadList(
|
||||||
|
id,
|
||||||
|
bookId,
|
||||||
|
principal.user.id,
|
||||||
|
principal.user.getAuthorizedLibraryIds(null)
|
||||||
|
)
|
||||||
|
?.restrictUrl(!principal.user.roleAdmin)
|
||||||
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ import org.springframework.data.domain.Pageable
|
||||||
|
|
||||||
interface BookDtoRepository {
|
interface BookDtoRepository {
|
||||||
fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable): Page<BookDto>
|
fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable): Page<BookDto>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find books that are part of a readlist, optionally filtered by library
|
||||||
|
*/
|
||||||
fun findByReadListId(
|
fun findByReadListId(
|
||||||
readListId: String,
|
readListId: String,
|
||||||
userId: String,
|
userId: String,
|
||||||
|
|
@ -15,7 +19,23 @@ interface BookDtoRepository {
|
||||||
): Page<BookDto>
|
): Page<BookDto>
|
||||||
|
|
||||||
fun findByIdOrNull(bookId: String, userId: String): BookDto?
|
fun findByIdOrNull(bookId: String, userId: String): BookDto?
|
||||||
|
|
||||||
fun findPreviousInSeries(bookId: String, userId: String): BookDto?
|
fun findPreviousInSeries(bookId: String, userId: String): BookDto?
|
||||||
fun findNextInSeries(bookId: String, userId: String): BookDto?
|
fun findNextInSeries(bookId: String, userId: String): BookDto?
|
||||||
|
|
||||||
|
fun findPreviousInReadList(
|
||||||
|
readListId: String,
|
||||||
|
bookId: String,
|
||||||
|
userId: String,
|
||||||
|
filterOnLibraryIds: Collection<String>?
|
||||||
|
): BookDto?
|
||||||
|
|
||||||
|
fun findNextInReadList(
|
||||||
|
readListId: String,
|
||||||
|
bookId: String,
|
||||||
|
userId: String,
|
||||||
|
filterOnLibraryIds: Collection<String>?
|
||||||
|
): BookDto?
|
||||||
|
|
||||||
fun findOnDeck(libraryIds: Collection<String>, userId: String, pageable: Pageable): Page<BookDto>
|
fun findOnDeck(libraryIds: Collection<String>, userId: String, pageable: Pageable): Page<BookDto>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,94 @@ class ReadListControllerTest(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
inner class Siblings {
|
||||||
|
@Test
|
||||||
|
@WithMockCustomUser
|
||||||
|
fun `given user with access to all libraries when getting book siblings then it is returned or not found`() {
|
||||||
|
makeReadLists()
|
||||||
|
|
||||||
|
val first = booksLibrary1.first().id // Book_1
|
||||||
|
val second = booksLibrary1[1].id // Book_2
|
||||||
|
val last = booksLibrary2.last().id // Book_10
|
||||||
|
|
||||||
|
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}/books/${first}/previous")
|
||||||
|
.andExpect { status { isNotFound() } }
|
||||||
|
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}/books/${first}/next")
|
||||||
|
.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.name") { value("Book_2") }
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}/books/${second}/previous")
|
||||||
|
.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.name") { value("Book_1") }
|
||||||
|
}
|
||||||
|
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}/books/${second}/next")
|
||||||
|
.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.name") { value("Book_3") }
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}/books/${last}/previous")
|
||||||
|
.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.name") { value("Book_9") }
|
||||||
|
}
|
||||||
|
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}/books/${last}/next")
|
||||||
|
.andExpect { status { isNotFound() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"])
|
||||||
|
fun `given user with access to a single library when getting book siblings then it takes into account the library filter`() {
|
||||||
|
makeReadLists()
|
||||||
|
|
||||||
|
val first = booksLibrary1.first().id // Book_1
|
||||||
|
val second = booksLibrary1[1].id // Book_2
|
||||||
|
val last = booksLibrary1.last().id // Book_5
|
||||||
|
|
||||||
|
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}/books/${first}/previous")
|
||||||
|
.andExpect { status { isNotFound() } }
|
||||||
|
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}/books/${first}/next")
|
||||||
|
.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.name") { value("Book_2") }
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}/books/${second}/previous")
|
||||||
|
.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.name") { value("Book_1") }
|
||||||
|
}
|
||||||
|
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}/books/${second}/next")
|
||||||
|
.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.name") { value("Book_3") }
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}/books/${last}/previous")
|
||||||
|
.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.name") { value("Book_4") }
|
||||||
|
}
|
||||||
|
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}/books/${last}/next")
|
||||||
|
.andExpect { status { isNotFound() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"])
|
||||||
|
fun `given user with access to a single library when getting books from single read list from another library then return not found`() {
|
||||||
|
makeReadLists()
|
||||||
|
|
||||||
|
mockMvc.get("/api/v1/readlists/${rlLib2.id}/books")
|
||||||
|
.andExpect {
|
||||||
|
status { isNotFound() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
inner class Creation {
|
inner class Creation {
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue