mirror of
https://github.com/gotson/komga.git
synced 2025-12-21 16:03:03 +01:00
feat: read lists
a read list is a collection of books read lists can be managed in the same way collections are metadata will be optionally imported from ComicInfo to create read lists closes #106
This commit is contained in:
parent
728d5f4c7d
commit
f0c864f4eb
26 changed files with 1422 additions and 50 deletions
|
|
@ -0,0 +1,20 @@
|
|||
CREATE TABLE READLIST
|
||||
(
|
||||
ID varchar NOT NULL PRIMARY KEY,
|
||||
NAME varchar NOT NULL,
|
||||
BOOK_COUNT int NOT NULL,
|
||||
CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE READLIST_BOOK
|
||||
(
|
||||
READLIST_ID varchar NOT NULL,
|
||||
BOOK_ID varchar NOT NULL,
|
||||
NUMBER int NOT NULL,
|
||||
PRIMARY KEY (READLIST_ID, BOOK_ID),
|
||||
FOREIGN KEY (READLIST_ID) REFERENCES READLIST (ID),
|
||||
FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID)
|
||||
);
|
||||
|
||||
alter table library
|
||||
add column IMPORT_COMICINFO_READLIST boolean NOT NULL DEFAULT 1;
|
||||
|
|
@ -11,5 +11,8 @@ data class BookMetadataPatch(
|
|||
val publisher: String?,
|
||||
val ageRating: Int?,
|
||||
val releaseDate: LocalDate?,
|
||||
val authors: List<Author>?
|
||||
val authors: List<Author>?,
|
||||
|
||||
val readList: String?,
|
||||
val readListNumber: Int?
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ data class Library(
|
|||
val importComicInfoBook: Boolean = true,
|
||||
val importComicInfoSeries: Boolean = true,
|
||||
val importComicInfoCollection: Boolean = true,
|
||||
val importComicInfoReadList: Boolean = true,
|
||||
val importEpubBook: Boolean = true,
|
||||
val importEpubSeries: Boolean = true,
|
||||
val importLocalArtwork: Boolean = true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
import com.github.f4b6a3.tsid.TsidCreator
|
||||
import java.time.LocalDateTime
|
||||
import java.util.SortedMap
|
||||
|
||||
data class ReadList(
|
||||
val name: String,
|
||||
|
||||
val bookIds: SortedMap<Int, String> = sortedMapOf(),
|
||||
|
||||
val id: String = TsidCreator.getTsidString256(),
|
||||
|
||||
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||
override val lastModifiedDate: LocalDateTime = LocalDateTime.now(),
|
||||
|
||||
/**
|
||||
* Indicates that the bookIds have been filtered and is not exhaustive.
|
||||
*/
|
||||
val filtered: Boolean = false
|
||||
) : Auditable()
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package org.gotson.komga.domain.persistence
|
||||
|
||||
import org.gotson.komga.domain.model.ReadList
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
|
||||
interface ReadListRepository {
|
||||
fun findByIdOrNull(readListId: String): ReadList?
|
||||
fun findAll(search: String? = null, pageable: Pageable): Page<ReadList>
|
||||
|
||||
/**
|
||||
* Find one ReadList by readListId,
|
||||
* optionally with only bookIds filtered by the provided filterOnLibraryIds.
|
||||
*/
|
||||
fun findByIdOrNull(readListId: String, filterOnLibraryIds: Collection<String>?): ReadList?
|
||||
|
||||
/**
|
||||
* Find all ReadList with at least one Book belonging to the provided belongsToLibraryIds,
|
||||
* optionally with only bookIds filtered by the provided filterOnLibraryIds.
|
||||
*/
|
||||
fun findAllByLibraries(belongsToLibraryIds: Collection<String>, filterOnLibraryIds: Collection<String>?, search: String? = null, pageable: Pageable): Page<ReadList>
|
||||
|
||||
/**
|
||||
* Find all ReadList that contains the provided containsBookId,
|
||||
* optionally with only bookIds filtered by the provided filterOnLibraryIds.
|
||||
*/
|
||||
fun findAllByBook(containsBookId: String, filterOnLibraryIds: Collection<String>?): Collection<ReadList>
|
||||
|
||||
fun findByNameOrNull(name: String): ReadList?
|
||||
|
||||
fun insert(readList: ReadList)
|
||||
fun update(readList: ReadList)
|
||||
|
||||
fun removeBookFromAll(bookId: String)
|
||||
fun removeBookFromAll(bookIds: Collection<String>)
|
||||
|
||||
fun delete(readListId: String)
|
||||
|
||||
fun deleteAll()
|
||||
fun existsByName(name: String): Boolean
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import org.gotson.komga.domain.model.ThumbnailBook
|
|||
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.ReadListRepository
|
||||
import org.gotson.komga.domain.persistence.ReadProgressRepository
|
||||
import org.gotson.komga.domain.persistence.ThumbnailBookRepository
|
||||
import org.gotson.komga.infrastructure.image.ImageConverter
|
||||
|
|
@ -30,6 +31,7 @@ class BookLifecycle(
|
|||
private val bookMetadataRepository: BookMetadataRepository,
|
||||
private val readProgressRepository: ReadProgressRepository,
|
||||
private val thumbnailBookRepository: ThumbnailBookRepository,
|
||||
private val readListRepository: ReadListRepository,
|
||||
private val bookAnalyzer: BookAnalyzer,
|
||||
private val imageConverter: ImageConverter
|
||||
) {
|
||||
|
|
@ -195,6 +197,8 @@ class BookLifecycle(
|
|||
logger.info { "Delete book id: $bookId" }
|
||||
|
||||
readProgressRepository.deleteByBookId(bookId)
|
||||
readListRepository.removeBookFromAll(bookId)
|
||||
|
||||
mediaRepository.delete(bookId)
|
||||
thumbnailBookRepository.deleteByBookId(bookId)
|
||||
bookMetadataRepository.delete(bookId)
|
||||
|
|
@ -206,6 +210,8 @@ class BookLifecycle(
|
|||
logger.info { "Delete all books: $bookIds" }
|
||||
|
||||
readProgressRepository.deleteByBookIds(bookIds)
|
||||
readListRepository.removeBookFromAll(bookIds)
|
||||
|
||||
mediaRepository.deleteByBookIds(bookIds)
|
||||
thumbnailBookRepository.deleteByBookIds(bookIds)
|
||||
bookMetadataRepository.deleteByBookIds(bookIds)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package org.gotson.komga.domain.service
|
|||
|
||||
import mu.KotlinLogging
|
||||
import org.gotson.komga.domain.model.Book
|
||||
import org.gotson.komga.domain.model.ReadList
|
||||
import org.gotson.komga.domain.model.Series
|
||||
import org.gotson.komga.domain.model.SeriesCollection
|
||||
import org.gotson.komga.domain.model.SeriesMetadataPatch
|
||||
|
|
@ -9,6 +10,7 @@ import org.gotson.komga.domain.persistence.BookMetadataRepository
|
|||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||
import org.gotson.komga.domain.persistence.MediaRepository
|
||||
import org.gotson.komga.domain.persistence.ReadListRepository
|
||||
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
|
||||
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
|
||||
import org.gotson.komga.infrastructure.metadata.BookMetadataProvider
|
||||
|
|
@ -34,6 +36,8 @@ class MetadataLifecycle(
|
|||
private val seriesLifecycle: SeriesLifecycle,
|
||||
private val collectionRepository: SeriesCollectionRepository,
|
||||
private val collectionLifecycle: SeriesCollectionLifecycle,
|
||||
private val readListRepository: ReadListRepository,
|
||||
private val readListLifecycle: ReadListLifecycle,
|
||||
private val localArtworkProvider: LocalArtworkProvider
|
||||
) {
|
||||
|
||||
|
|
@ -45,20 +49,64 @@ class MetadataLifecycle(
|
|||
|
||||
bookMetadataProviders.forEach { provider ->
|
||||
when {
|
||||
provider is ComicInfoProvider && !library.importComicInfoBook -> logger.info { "Library is not set to import book metadata from ComicInfo, skipping" }
|
||||
provider is ComicInfoProvider && !library.importComicInfoBook && !library.importComicInfoReadList -> logger.info { "Library is not set to import book and read lists metadata from ComicInfo, skipping" }
|
||||
provider is EpubMetadataProvider && !library.importEpubBook -> logger.info { "Library is not set to import book metadata from Epub, skipping" }
|
||||
else -> {
|
||||
logger.debug { "Provider: $provider" }
|
||||
provider.getBookMetadataFromBook(book, media)?.let { bPatch ->
|
||||
val patch = provider.getBookMetadataFromBook(book, media)
|
||||
|
||||
bookMetadataRepository.findById(book.id).let {
|
||||
logger.debug { "Original metadata: $it" }
|
||||
val patched = metadataApplier.apply(bPatch, it)
|
||||
logger.debug { "Patched metadata: $patched" }
|
||||
// handle book metadata
|
||||
if ((provider is ComicInfoProvider && library.importComicInfoBook) ||
|
||||
(provider is EpubMetadataProvider && library.importEpubBook)) {
|
||||
patch?.let { bPatch ->
|
||||
bookMetadataRepository.findById(book.id).let {
|
||||
logger.debug { "Original metadata: $it" }
|
||||
val patched = metadataApplier.apply(bPatch, it)
|
||||
logger.debug { "Patched metadata: $patched" }
|
||||
|
||||
bookMetadataRepository.update(patched)
|
||||
bookMetadataRepository.update(patched)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handle read lists
|
||||
if (provider is ComicInfoProvider && library.importComicInfoReadList) {
|
||||
patch?.let { bPatch ->
|
||||
val readList = bPatch.readList
|
||||
val readListNumber = bPatch.readListNumber
|
||||
|
||||
|
||||
if (readList != null) {
|
||||
readListRepository.findByNameOrNull(readList).let { existing ->
|
||||
if (existing != null) {
|
||||
if (existing.bookIds.containsValue(book.id))
|
||||
logger.debug { "Book is already in existing readlist '${existing.name}'" }
|
||||
else {
|
||||
val map = existing.bookIds.toSortedMap()
|
||||
val key = if (readListNumber != null && existing.bookIds.containsKey(readListNumber)) {
|
||||
logger.debug { "Existing readlist '${existing.name}' already contains a book at position $readListNumber, adding book '${book.name}' at the end" }
|
||||
existing.bookIds.lastKey() + 1
|
||||
} else {
|
||||
logger.debug { "Adding book '${book.name}' to existing readlist '${existing.name}'" }
|
||||
readListNumber ?: existing.bookIds.lastKey() + 1
|
||||
}
|
||||
map[key] = book.id
|
||||
readListLifecycle.updateReadList(
|
||||
existing.copy(bookIds = map)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.debug { "Adding book '${book.name}' to new readlist '$readList'" }
|
||||
readListLifecycle.addReadList(ReadList(
|
||||
name = readList,
|
||||
bookIds = mapOf((readListNumber ?: 0) to book.id).toSortedMap()
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -85,7 +133,7 @@ class MetadataLifecycle(
|
|||
|
||||
// handle series metadata
|
||||
if ((provider is ComicInfoProvider && library.importComicInfoSeries) ||
|
||||
(provider is EpubMetadataProvider && !library.importEpubSeries)) {
|
||||
(provider is EpubMetadataProvider && library.importEpubSeries)) {
|
||||
val title = patches.uniqueOrNull { it.title }
|
||||
val titleSort = patches.uniqueOrNull { it.titleSort }
|
||||
val status = patches.uniqueOrNull { it.status }
|
||||
|
|
@ -113,15 +161,15 @@ class MetadataLifecycle(
|
|||
collectionRepository.findByNameOrNull(collection).let { existing ->
|
||||
if (existing != null) {
|
||||
if (existing.seriesIds.contains(series.id))
|
||||
logger.debug { "Series is already in existing collection ${existing.name}" }
|
||||
logger.debug { "Series is already in existing collection '${existing.name}'" }
|
||||
else {
|
||||
logger.debug { "Adding series ${series.name} to existing collection ${existing.name}" }
|
||||
logger.debug { "Adding series '${series.name}' to existing collection '${existing.name}'" }
|
||||
collectionLifecycle.updateCollection(
|
||||
existing.copy(seriesIds = existing.seriesIds + series.id)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.debug { "Adding series ${series.name} to new collection $collection" }
|
||||
logger.debug { "Adding series '${series.name}' to new collection '$collection'" }
|
||||
collectionLifecycle.addCollection(SeriesCollection(
|
||||
name = collection,
|
||||
seriesIds = listOf(series.id)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
package org.gotson.komga.domain.service
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.gotson.komga.domain.model.DuplicateNameException
|
||||
import org.gotson.komga.domain.model.ReadList
|
||||
import org.gotson.komga.domain.persistence.ReadListRepository
|
||||
import org.gotson.komga.infrastructure.image.MosaicGenerator
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Service
|
||||
class ReadListLifecycle(
|
||||
private val readListRepository: ReadListRepository,
|
||||
private val bookLifecycle: BookLifecycle,
|
||||
private val mosaicGenerator: MosaicGenerator
|
||||
) {
|
||||
|
||||
@Throws(
|
||||
DuplicateNameException::class
|
||||
)
|
||||
fun addReadList(readList: ReadList): ReadList {
|
||||
logger.info { "Adding new read list: $readList" }
|
||||
|
||||
if (readListRepository.existsByName(readList.name))
|
||||
throw DuplicateNameException("Read list name already exists")
|
||||
|
||||
readListRepository.insert(readList)
|
||||
|
||||
return readListRepository.findByIdOrNull(readList.id)!!
|
||||
}
|
||||
|
||||
fun updateReadList(toUpdate: ReadList) {
|
||||
val existing = readListRepository.findByIdOrNull(toUpdate.id)
|
||||
?: throw IllegalArgumentException("Cannot update read list that does not exist")
|
||||
|
||||
if (existing.name != toUpdate.name && readListRepository.existsByName(toUpdate.name))
|
||||
throw DuplicateNameException("Read list name already exists")
|
||||
|
||||
readListRepository.update(toUpdate)
|
||||
}
|
||||
|
||||
fun deleteReadList(readListId: String) {
|
||||
readListRepository.delete(readListId)
|
||||
}
|
||||
|
||||
fun getThumbnailBytes(readList: ReadList): ByteArray {
|
||||
val ids = with(mutableListOf<String>()) {
|
||||
while (size < 4) {
|
||||
this += readList.bookIds.values.take(4)
|
||||
}
|
||||
this.take(4)
|
||||
}
|
||||
|
||||
val images = ids.mapNotNull { bookLifecycle.getThumbnailBytes(it) }
|
||||
return mosaicGenerator.createMosaic(images)
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ class BookDtoDao(
|
|||
private val r = Tables.READ_PROGRESS
|
||||
private val a = Tables.BOOK_METADATA_AUTHOR
|
||||
private val s = Tables.SERIES
|
||||
private val rlb = Tables.READLIST_BOOK
|
||||
|
||||
private val sorts = mapOf(
|
||||
"name" to DSL.lower(b.NAME),
|
||||
|
|
@ -52,24 +53,38 @@ class BookDtoDao(
|
|||
"media.comment" to DSL.lower(m.COMMENT),
|
||||
"media.mediaType" to DSL.lower(m.MEDIA_TYPE),
|
||||
"metadata.numberSort" to d.NUMBER_SORT,
|
||||
"readProgress.lastModified" to r.LAST_MODIFIED_DATE
|
||||
"readProgress.lastModified" to r.LAST_MODIFIED_DATE,
|
||||
"readList.number" to rlb.NUMBER
|
||||
)
|
||||
|
||||
override fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable): Page<BookDto> {
|
||||
val conditions = search.toCondition()
|
||||
|
||||
val count = dsl.selectCount()
|
||||
return findAll(conditions, userId, pageable)
|
||||
}
|
||||
|
||||
override fun findByReadListId(readListId: String, userId: String, pageable: Pageable): Page<BookDto> {
|
||||
val conditions = rlb.READLIST_ID.eq(readListId)
|
||||
|
||||
return findAll(conditions, userId, pageable, selectReadListNumber = true)
|
||||
}
|
||||
|
||||
private fun findAll(conditions: Condition, userId: String, pageable: Pageable, selectReadListNumber: Boolean = false): Page<BookDto> {
|
||||
val count = dsl.selectDistinct(b.ID)
|
||||
.from(b)
|
||||
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
|
||||
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
|
||||
.leftJoin(r).on(b.ID.eq(r.BOOK_ID))
|
||||
.and(readProgressCondition(userId))
|
||||
.leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID))
|
||||
.where(conditions)
|
||||
.fetchOne(0, Long::class.java)
|
||||
.groupBy(b.ID)
|
||||
.fetch()
|
||||
.size
|
||||
|
||||
val orderBy = pageable.sort.toOrderBy(sorts)
|
||||
|
||||
val dtos = selectBase(userId)
|
||||
val dtos = selectBase(userId, selectReadListNumber)
|
||||
.where(conditions)
|
||||
.orderBy(orderBy)
|
||||
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
|
||||
|
|
@ -79,7 +94,7 @@ class BookDtoDao(
|
|||
return PageImpl(
|
||||
dtos,
|
||||
if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort)
|
||||
else PageRequest.of(0, maxOf(count.toInt(), 20), pageSort),
|
||||
else PageRequest.of(0, maxOf(count, 20), pageSort),
|
||||
count.toLong()
|
||||
)
|
||||
}
|
||||
|
|
@ -151,17 +166,19 @@ class BookDtoDao(
|
|||
.firstOrNull()
|
||||
}
|
||||
|
||||
private fun selectBase(userId: String) =
|
||||
dsl.select(
|
||||
private fun selectBase(userId: String, selectReadListNumber: Boolean = false) =
|
||||
dsl.selectDistinct(
|
||||
*b.fields(),
|
||||
*m.fields(),
|
||||
*d.fields(),
|
||||
*r.fields()
|
||||
).from(b)
|
||||
).apply { if (selectReadListNumber) select(rlb.NUMBER) }
|
||||
.from(b)
|
||||
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
|
||||
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
|
||||
.leftJoin(r).on(b.ID.eq(r.BOOK_ID))
|
||||
.and(readProgressCondition(userId))
|
||||
.leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID))
|
||||
|
||||
private fun ResultQuery<Record>.fetchAndMap() =
|
||||
fetch()
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ class LibraryDao(
|
|||
.set(l.IMPORT_COMICINFO_BOOK, library.importComicInfoBook)
|
||||
.set(l.IMPORT_COMICINFO_SERIES, library.importComicInfoSeries)
|
||||
.set(l.IMPORT_COMICINFO_COLLECTION, library.importComicInfoCollection)
|
||||
.set(l.IMPORT_COMICINFO_READLIST, library.importComicInfoReadList)
|
||||
.set(l.IMPORT_EPUB_BOOK, library.importEpubBook)
|
||||
.set(l.IMPORT_EPUB_SERIES, library.importEpubSeries)
|
||||
.set(l.IMPORT_LOCAL_ARTWORK, library.importLocalArtwork)
|
||||
|
|
@ -85,6 +86,7 @@ class LibraryDao(
|
|||
.set(l.IMPORT_COMICINFO_BOOK, library.importComicInfoBook)
|
||||
.set(l.IMPORT_COMICINFO_SERIES, library.importComicInfoSeries)
|
||||
.set(l.IMPORT_COMICINFO_COLLECTION, library.importComicInfoCollection)
|
||||
.set(l.IMPORT_COMICINFO_READLIST, library.importComicInfoReadList)
|
||||
.set(l.IMPORT_EPUB_BOOK, library.importEpubBook)
|
||||
.set(l.IMPORT_EPUB_SERIES, library.importEpubSeries)
|
||||
.set(l.IMPORT_LOCAL_ARTWORK, library.importLocalArtwork)
|
||||
|
|
@ -105,6 +107,7 @@ class LibraryDao(
|
|||
importComicInfoBook = importComicinfoBook,
|
||||
importComicInfoSeries = importComicinfoSeries,
|
||||
importComicInfoCollection = importComicinfoCollection,
|
||||
importComicInfoReadList = importComicinfoReadlist,
|
||||
importEpubBook = importEpubBook,
|
||||
importEpubSeries = importEpubSeries,
|
||||
importLocalArtwork = importLocalArtwork,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
package org.gotson.komga.infrastructure.jooq
|
||||
|
||||
import org.gotson.komga.domain.model.ReadList
|
||||
import org.gotson.komga.domain.persistence.ReadListRepository
|
||||
import org.gotson.komga.jooq.Tables
|
||||
import org.gotson.komga.jooq.tables.records.ReadlistRecord
|
||||
import org.jooq.DSLContext
|
||||
import org.jooq.Record
|
||||
import org.jooq.ResultQuery
|
||||
import org.jooq.impl.DSL
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.SortedMap
|
||||
|
||||
@Component
|
||||
class ReadListDao(
|
||||
private val dsl: DSLContext
|
||||
) : ReadListRepository {
|
||||
|
||||
private val rl = Tables.READLIST
|
||||
private val rlb = Tables.READLIST_BOOK
|
||||
private val b = Tables.BOOK
|
||||
|
||||
private val sorts = mapOf(
|
||||
"name" to DSL.lower(rl.NAME)
|
||||
)
|
||||
|
||||
|
||||
override fun findByIdOrNull(readListId: String): ReadList? =
|
||||
selectBase()
|
||||
.where(rl.ID.eq(readListId))
|
||||
.fetchAndMap(null)
|
||||
.firstOrNull()
|
||||
|
||||
override fun findByIdOrNull(readListId: String, filterOnLibraryIds: Collection<String>?): ReadList? =
|
||||
selectBase()
|
||||
.where(rl.ID.eq(readListId))
|
||||
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
|
||||
.fetchAndMap(filterOnLibraryIds)
|
||||
.firstOrNull()
|
||||
|
||||
override fun findAll(search: String?, pageable: Pageable): Page<ReadList> {
|
||||
val conditions = search?.let { rl.NAME.containsIgnoreCase(it) }
|
||||
?: DSL.trueCondition()
|
||||
|
||||
val count = dsl.selectCount()
|
||||
.from(rl)
|
||||
.where(conditions)
|
||||
.fetchOne(0, Long::class.java)
|
||||
|
||||
val orderBy = pageable.sort.toOrderBy(sorts)
|
||||
|
||||
val items = selectBase()
|
||||
.where(conditions)
|
||||
.orderBy(orderBy)
|
||||
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
|
||||
.fetchAndMap(null)
|
||||
|
||||
val pageSort = if (orderBy.size > 1) pageable.sort else Sort.unsorted()
|
||||
return PageImpl(
|
||||
items,
|
||||
if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort)
|
||||
else PageRequest.of(0, maxOf(count.toInt(), 20), pageSort),
|
||||
count.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
override fun findAllByLibraries(belongsToLibraryIds: Collection<String>, filterOnLibraryIds: Collection<String>?, search: String?, pageable: Pageable): Page<ReadList> {
|
||||
val ids = dsl.selectDistinct(rl.ID)
|
||||
.from(rl)
|
||||
.leftJoin(rlb).on(rl.ID.eq(rlb.READLIST_ID))
|
||||
.leftJoin(b).on(rlb.BOOK_ID.eq(b.ID))
|
||||
.where(b.LIBRARY_ID.`in`(belongsToLibraryIds))
|
||||
.apply { search?.let { and(rl.NAME.containsIgnoreCase(it)) } }
|
||||
.fetch(0, String::class.java)
|
||||
|
||||
val count = ids.size
|
||||
|
||||
val orderBy = pageable.sort.toOrderBy(sorts)
|
||||
|
||||
val items = selectBase()
|
||||
.where(rl.ID.`in`(ids))
|
||||
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
|
||||
.apply { search?.let { and(rl.NAME.containsIgnoreCase(it)) } }
|
||||
.orderBy(orderBy)
|
||||
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
|
||||
.fetchAndMap(filterOnLibraryIds)
|
||||
|
||||
val pageSort = if (orderBy.size > 1) pageable.sort else Sort.unsorted()
|
||||
return PageImpl(
|
||||
items,
|
||||
if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort)
|
||||
else PageRequest.of(0, maxOf(count, 20), pageSort),
|
||||
count.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
override fun findAllByBook(containsBookId: String, filterOnLibraryIds: Collection<String>?): Collection<ReadList> {
|
||||
val ids = dsl.select(rl.ID)
|
||||
.from(rl)
|
||||
.leftJoin(rlb).on(rl.ID.eq(rlb.READLIST_ID))
|
||||
.where(rlb.BOOK_ID.eq(containsBookId))
|
||||
.fetch(0, String::class.java)
|
||||
|
||||
return selectBase()
|
||||
.where(rl.ID.`in`(ids))
|
||||
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
|
||||
.fetchAndMap(filterOnLibraryIds)
|
||||
}
|
||||
|
||||
override fun findByNameOrNull(name: String): ReadList? =
|
||||
selectBase()
|
||||
.where(rl.NAME.equalIgnoreCase(name))
|
||||
.fetchAndMap(null)
|
||||
.firstOrNull()
|
||||
|
||||
private fun selectBase() =
|
||||
dsl.selectDistinct(*rl.fields())
|
||||
.from(rl)
|
||||
.leftJoin(rlb).on(rl.ID.eq(rlb.READLIST_ID))
|
||||
.leftJoin(b).on(rlb.BOOK_ID.eq(b.ID))
|
||||
|
||||
private fun ResultQuery<Record>.fetchAndMap(filterOnLibraryIds: Collection<String>?): List<ReadList> =
|
||||
fetchInto(rl)
|
||||
.map { rr ->
|
||||
val bookIds = dsl.select(*rlb.fields())
|
||||
.from(rlb)
|
||||
.leftJoin(b).on(rlb.BOOK_ID.eq(b.ID))
|
||||
.where(rlb.READLIST_ID.eq(rr.id))
|
||||
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
|
||||
.orderBy(rlb.NUMBER.asc())
|
||||
.fetchInto(rlb)
|
||||
.mapNotNull { it.number to it.bookId }
|
||||
.toMap().toSortedMap()
|
||||
rr.toDomain(bookIds)
|
||||
}
|
||||
|
||||
|
||||
override fun insert(readList: ReadList) {
|
||||
dsl.transaction { config ->
|
||||
config.dsl().insertInto(rl)
|
||||
.set(rl.ID, readList.id)
|
||||
.set(rl.NAME, readList.name)
|
||||
.set(rl.BOOK_COUNT, readList.bookIds.size)
|
||||
.execute()
|
||||
|
||||
insertBooks(config.dsl(), readList)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun insertBooks(dsl: DSLContext, readList: ReadList) {
|
||||
readList.bookIds.map { (index, id) ->
|
||||
dsl.insertInto(rlb)
|
||||
.set(rlb.READLIST_ID, readList.id)
|
||||
.set(rlb.BOOK_ID, id)
|
||||
.set(rlb.NUMBER, index)
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(readList: ReadList) {
|
||||
dsl.transaction { config ->
|
||||
with(config.dsl()) {
|
||||
update(rl)
|
||||
.set(rl.NAME, readList.name)
|
||||
.set(rl.BOOK_COUNT, readList.bookIds.size)
|
||||
.set(rl.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z")))
|
||||
.where(rl.ID.eq(readList.id))
|
||||
.execute()
|
||||
|
||||
deleteFrom(rlb).where(rlb.READLIST_ID.eq(readList.id)).execute()
|
||||
|
||||
insertBooks(config.dsl(), readList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeBookFromAll(bookId: String) {
|
||||
dsl.deleteFrom(rlb)
|
||||
.where(rlb.BOOK_ID.eq(bookId))
|
||||
.execute()
|
||||
}
|
||||
|
||||
override fun removeBookFromAll(bookIds: Collection<String>) {
|
||||
dsl.deleteFrom(rlb)
|
||||
.where(rlb.BOOK_ID.`in`(bookIds))
|
||||
.execute()
|
||||
}
|
||||
|
||||
override fun delete(readListId: String) {
|
||||
dsl.transaction { config ->
|
||||
with(config.dsl())
|
||||
{
|
||||
deleteFrom(rlb).where(rlb.READLIST_ID.eq(readListId)).execute()
|
||||
deleteFrom(rl).where(rl.ID.eq(readListId)).execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteAll() {
|
||||
dsl.transaction { config ->
|
||||
with(config.dsl())
|
||||
{
|
||||
deleteFrom(rlb).execute()
|
||||
deleteFrom(rl).execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun existsByName(name: String): Boolean =
|
||||
dsl.fetchExists(
|
||||
dsl.selectFrom(rl)
|
||||
.where(rl.NAME.equalIgnoreCase(name))
|
||||
)
|
||||
|
||||
|
||||
private fun ReadlistRecord.toDomain(bookIds: SortedMap<Int, String>) =
|
||||
ReadList(
|
||||
name = name,
|
||||
bookIds = bookIds,
|
||||
id = id,
|
||||
createdDate = createdDate.toCurrentTimeZone(),
|
||||
lastModifiedDate = lastModifiedDate.toCurrentTimeZone(),
|
||||
filtered = bookCount != bookIds.size
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package org.gotson.komga.infrastructure.language
|
||||
|
||||
import java.util.SortedMap
|
||||
|
||||
fun <T> List<T>.toIndexedMap(): SortedMap<Int, T> =
|
||||
mapIndexed { i, e -> i to e }.toMap().toSortedMap()
|
||||
|
|
@ -49,15 +49,17 @@ class ComicInfoProvider(
|
|||
}
|
||||
|
||||
return BookMetadataPatch(
|
||||
comicInfo.title,
|
||||
comicInfo.summary,
|
||||
comicInfo.number,
|
||||
comicInfo.number?.toFloatOrNull(),
|
||||
readingDirection,
|
||||
comicInfo.publisher,
|
||||
comicInfo.ageRating?.ageRating,
|
||||
releaseDate,
|
||||
authors.ifEmpty { null }
|
||||
title = comicInfo.title,
|
||||
summary = comicInfo.summary,
|
||||
number = comicInfo.number,
|
||||
numberSort = comicInfo.number?.toFloatOrNull(),
|
||||
readingDirection = readingDirection,
|
||||
publisher = comicInfo.publisher,
|
||||
ageRating = comicInfo.ageRating?.ageRating,
|
||||
releaseDate = releaseDate,
|
||||
authors = authors.ifEmpty { null },
|
||||
readList = comicInfo.alternateSeries ?: comicInfo.storyArc,
|
||||
readListNumber = comicInfo.alternateNumber?.toIntOrNull() ?: comicInfo.storyArcNumber?.toIntOrNull()
|
||||
)
|
||||
}
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -111,6 +111,9 @@ class ComicInfo {
|
|||
@JsonProperty(value = "StoryArc")
|
||||
var storyArc: String? = null
|
||||
|
||||
@JsonProperty(value = "StoryArcNumber")
|
||||
var storyArcNumber: String? = null
|
||||
|
||||
@JsonProperty(value = "SeriesGroup")
|
||||
var seriesGroup: String? = null
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,9 @@ class EpubMetadataProvider(
|
|||
publisher = publisher,
|
||||
ageRating = null,
|
||||
releaseDate = date,
|
||||
authors = authors
|
||||
authors = authors,
|
||||
readList = null,
|
||||
readListNumber = null
|
||||
)
|
||||
}
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import org.gotson.komga.domain.model.BookMetadata
|
|||
import org.gotson.komga.domain.model.BookSearch
|
||||
import org.gotson.komga.domain.model.Library
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.ReadList
|
||||
import org.gotson.komga.domain.model.Series
|
||||
import org.gotson.komga.domain.model.SeriesCollection
|
||||
import org.gotson.komga.domain.model.SeriesMetadata
|
||||
|
|
@ -14,6 +15,7 @@ import org.gotson.komga.domain.persistence.BookMetadataRepository
|
|||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||
import org.gotson.komga.domain.persistence.MediaRepository
|
||||
import org.gotson.komga.domain.persistence.ReadListRepository
|
||||
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
|
||||
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
|
||||
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||
|
|
@ -59,12 +61,14 @@ private const val ROUTE_SERIES_ALL = "series"
|
|||
private const val ROUTE_SERIES_LATEST = "series/latest"
|
||||
private const val ROUTE_LIBRARIES_ALL = "libraries"
|
||||
private const val ROUTE_COLLECTIONS_ALL = "collections"
|
||||
private const val ROUTE_READLISTS_ALL = "readlists"
|
||||
private const val ROUTE_SEARCH = "search"
|
||||
|
||||
private const val ID_SERIES_ALL = "allSeries"
|
||||
private const val ID_SERIES_LATEST = "latestSeries"
|
||||
private const val ID_LIBRARIES_ALL = "allLibraries"
|
||||
private const val ID_COLLECTIONS_ALL = "allCollections"
|
||||
private const val ID_READLISTS_ALL = "allReadLists"
|
||||
|
||||
@RestController
|
||||
@RequestMapping(value = [ROUTE_BASE], produces = [MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE])
|
||||
|
|
@ -72,6 +76,7 @@ class OpdsController(
|
|||
servletContext: ServletContext,
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val collectionRepository: SeriesCollectionRepository,
|
||||
private val readListRepository: ReadListRepository,
|
||||
private val seriesRepository: SeriesRepository,
|
||||
private val seriesMetadataRepository: SeriesMetadataRepository,
|
||||
private val bookRepository: BookRepository,
|
||||
|
|
@ -127,6 +132,13 @@ class OpdsController(
|
|||
id = ID_COLLECTIONS_ALL,
|
||||
content = "Browse by collection",
|
||||
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "$routeBase$ROUTE_COLLECTIONS_ALL")
|
||||
),
|
||||
OpdsEntryNavigation(
|
||||
title = "All read lists",
|
||||
updated = ZonedDateTime.now(),
|
||||
id = ID_READLISTS_ALL,
|
||||
content = "Browse by read lists",
|
||||
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "$routeBase$ROUTE_READLISTS_ALL")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -245,6 +257,30 @@ class OpdsController(
|
|||
)
|
||||
}
|
||||
|
||||
@GetMapping(ROUTE_READLISTS_ALL)
|
||||
fun getReadLists(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal
|
||||
): OpdsFeed {
|
||||
val pageRequest = UnpagedSorted(Sort.by(Sort.Order.asc("name")))
|
||||
val readLists =
|
||||
if (principal.user.sharedAllLibraries) {
|
||||
readListRepository.findAll(pageable = pageRequest)
|
||||
} else {
|
||||
readListRepository.findAllByLibraries(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds, pageable = pageRequest)
|
||||
}
|
||||
return OpdsFeedNavigation(
|
||||
id = ID_READLISTS_ALL,
|
||||
title = "All read lists",
|
||||
updated = ZonedDateTime.now(),
|
||||
author = komgaAuthor,
|
||||
links = listOf(
|
||||
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_READLISTS_ALL"),
|
||||
linkStart
|
||||
),
|
||||
entries = readLists.content.map { it.toOpdsEntry() }
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("series/{id}")
|
||||
fun getOneSeries(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
|
|
@ -266,7 +302,7 @@ class OpdsController(
|
|||
.map { it.toOpdsEntry(shouldPrependBookNumbers(userAgent)) }
|
||||
|
||||
OpdsFeedAcquisition(
|
||||
id = series.id.toString(),
|
||||
id = series.id,
|
||||
title = metadata.title,
|
||||
updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||
author = komgaAuthor,
|
||||
|
|
@ -294,7 +330,7 @@ class OpdsController(
|
|||
.map { it.toOpdsEntry() }
|
||||
|
||||
OpdsFeedNavigation(
|
||||
id = library.id.toString(),
|
||||
id = library.id,
|
||||
title = library.name,
|
||||
updated = library.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||
author = komgaAuthor,
|
||||
|
|
@ -324,7 +360,7 @@ class OpdsController(
|
|||
}
|
||||
|
||||
OpdsFeedNavigation(
|
||||
id = collection.id.toString(),
|
||||
id = collection.id,
|
||||
title = collection.name,
|
||||
updated = collection.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||
author = komgaAuthor,
|
||||
|
|
@ -337,19 +373,46 @@ class OpdsController(
|
|||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
@GetMapping("readlists/{id}")
|
||||
fun getOneReadList(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@PathVariable id: String
|
||||
): OpdsFeed {
|
||||
return readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList ->
|
||||
val books = readList.bookIds.values.mapNotNull { bookRepository.findByIdOrNull(it) }
|
||||
.map { BookWithInfo(it, mediaRepository.findById(it.id), bookMetadataRepository.findById(it.id)) }
|
||||
|
||||
val entries = books.mapIndexed { index, it ->
|
||||
it.toOpdsEntry(prependNumber = false, prepend = index + 1)
|
||||
}
|
||||
|
||||
OpdsFeedAcquisition(
|
||||
id = readList.id,
|
||||
title = readList.name,
|
||||
updated = readList.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||
author = komgaAuthor,
|
||||
links = listOf(
|
||||
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}readlists/$id"),
|
||||
linkStart
|
||||
),
|
||||
entries = entries
|
||||
)
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
|
||||
private fun SeriesWithInfo.toOpdsEntry(prepend: Int? = null): OpdsEntryNavigation {
|
||||
val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: ""
|
||||
return OpdsEntryNavigation(
|
||||
title = pre + metadata.title,
|
||||
updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||
id = series.id.toString(),
|
||||
id = series.id,
|
||||
content = "",
|
||||
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}series/${series.id}")
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookWithInfo.toOpdsEntry(prependNumber: Boolean): OpdsEntryAcquisition {
|
||||
private fun BookWithInfo.toOpdsEntry(prependNumber: Boolean, prepend: Int? = null): OpdsEntryAcquisition {
|
||||
val mediaTypes = media.pages.map { it.mediaType }.distinct()
|
||||
|
||||
val opdsLinkPageStreaming = if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) {
|
||||
|
|
@ -358,10 +421,11 @@ class OpdsController(
|
|||
OpdsLinkPageStreaming("image/jpeg", "${routeBase}books/${book.id}/pages/{pageNumber}?convert=jpeg&zero_based=true", media.pages.size)
|
||||
}
|
||||
|
||||
val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: ""
|
||||
return OpdsEntryAcquisition(
|
||||
title = "${if (prependNumber) "${decimalFormat.format(metadata.numberSort)} - " else ""}${metadata.title}",
|
||||
title = "$pre${if (prependNumber) "${decimalFormat.format(metadata.numberSort)} - " else ""}${metadata.title}",
|
||||
updated = book.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||
id = book.id.toString(),
|
||||
id = book.id,
|
||||
content = run {
|
||||
var content = "${book.fileExtension().toUpperCase()} - ${book.fileSizeHumanReadable()}"
|
||||
if (metadata.summary.isNotBlank())
|
||||
|
|
@ -378,25 +442,32 @@ class OpdsController(
|
|||
)
|
||||
}
|
||||
|
||||
private fun Library.toOpdsEntry(): OpdsEntryNavigation {
|
||||
return OpdsEntryNavigation(
|
||||
private fun Library.toOpdsEntry(): OpdsEntryNavigation =
|
||||
OpdsEntryNavigation(
|
||||
title = name,
|
||||
updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||
id = id.toString(),
|
||||
id = id,
|
||||
content = "",
|
||||
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}libraries/$id")
|
||||
)
|
||||
}
|
||||
|
||||
private fun SeriesCollection.toOpdsEntry(): OpdsEntryNavigation {
|
||||
return OpdsEntryNavigation(
|
||||
private fun SeriesCollection.toOpdsEntry(): OpdsEntryNavigation =
|
||||
OpdsEntryNavigation(
|
||||
title = name,
|
||||
updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||
id = id.toString(),
|
||||
id = id,
|
||||
content = "",
|
||||
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}collections/$id")
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReadList.toOpdsEntry(): OpdsEntryNavigation =
|
||||
OpdsEntryNavigation(
|
||||
title = name,
|
||||
updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||
id = id,
|
||||
content = "",
|
||||
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}readlists/$id")
|
||||
)
|
||||
|
||||
private fun shouldPrependBookNumbers(userAgent: String) =
|
||||
userAgent.contains("chunky", ignoreCase = true)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import org.gotson.komga.domain.model.ReadStatus
|
|||
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.ReadListRepository
|
||||
import org.gotson.komga.domain.service.BookLifecycle
|
||||
import org.gotson.komga.infrastructure.image.ImageType
|
||||
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
|
||||
|
|
@ -30,8 +31,10 @@ import org.gotson.komga.infrastructure.web.setCachePrivate
|
|||
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.ReadListDto
|
||||
import org.gotson.komga.interfaces.rest.dto.ReadProgressUpdateDto
|
||||
import org.gotson.komga.interfaces.rest.dto.restrictUrl
|
||||
import org.gotson.komga.interfaces.rest.dto.toDto
|
||||
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
|
||||
import org.springframework.core.io.FileSystemResource
|
||||
import org.springframework.data.domain.Page
|
||||
|
|
@ -74,7 +77,8 @@ class BookController(
|
|||
private val bookRepository: BookRepository,
|
||||
private val bookMetadataRepository: BookMetadataRepository,
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val bookDtoRepository: BookDtoRepository
|
||||
private val bookDtoRepository: BookDtoRepository,
|
||||
private val readListRepository: ReadListRepository
|
||||
) {
|
||||
|
||||
@PageableAsQueryParam
|
||||
|
|
@ -195,6 +199,20 @@ class BookController(
|
|||
}
|
||||
|
||||
|
||||
@GetMapping("api/v1/books/{bookId}/readlists")
|
||||
fun getAllReadListsByBook(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@PathVariable(name = "bookId") bookId: String
|
||||
): List<ReadListDto> {
|
||||
bookRepository.getLibraryId(bookId)?.let {
|
||||
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
|
||||
return readListRepository.findAllByBook(bookId, principal.user.getAuthorizedLibraryIds(null))
|
||||
.map { it.toDto() }
|
||||
}
|
||||
|
||||
|
||||
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
|
||||
@GetMapping(value = [
|
||||
"api/v1/books/{bookId}/thumbnail",
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ class LibraryController(
|
|||
importComicInfoBook = library.importComicInfoBook,
|
||||
importComicInfoSeries = library.importComicInfoSeries,
|
||||
importComicInfoCollection = library.importComicInfoCollection,
|
||||
importComicInfoReadList = library.importComicInfoReadList,
|
||||
importEpubBook = library.importEpubBook,
|
||||
importEpubSeries = library.importEpubSeries,
|
||||
importLocalArtwork = library.importLocalArtwork,
|
||||
|
|
@ -109,6 +110,7 @@ class LibraryController(
|
|||
importComicInfoBook = library.importComicInfoBook,
|
||||
importComicInfoSeries = library.importComicInfoSeries,
|
||||
importComicInfoCollection = library.importComicInfoCollection,
|
||||
importComicInfoReadList = library.importComicInfoReadList,
|
||||
importEpubBook = library.importEpubBook,
|
||||
importEpubSeries = library.importEpubSeries,
|
||||
importLocalArtwork = library.importLocalArtwork,
|
||||
|
|
@ -162,6 +164,7 @@ data class LibraryCreationDto(
|
|||
val importComicInfoBook: Boolean = true,
|
||||
val importComicInfoSeries: Boolean = true,
|
||||
val importComicInfoCollection: Boolean = true,
|
||||
val importComicInfoReadList: Boolean = true,
|
||||
val importEpubBook: Boolean = true,
|
||||
val importEpubSeries: Boolean = true,
|
||||
val importLocalArtwork: Boolean = true,
|
||||
|
|
@ -176,6 +179,7 @@ data class LibraryDto(
|
|||
val importComicInfoBook: Boolean,
|
||||
val importComicInfoSeries: Boolean,
|
||||
val importComicInfoCollection: Boolean,
|
||||
val importComicInfoReadList: Boolean,
|
||||
val importEpubBook: Boolean,
|
||||
val importEpubSeries: Boolean,
|
||||
val importLocalArtwork: Boolean,
|
||||
|
|
@ -189,6 +193,7 @@ data class LibraryUpdateDto(
|
|||
val importComicInfoBook: Boolean,
|
||||
val importComicInfoSeries: Boolean,
|
||||
val importComicInfoCollection: Boolean,
|
||||
val importComicInfoReadList: Boolean,
|
||||
val importEpubBook: Boolean,
|
||||
val importEpubSeries: Boolean,
|
||||
val importLocalArtwork: Boolean,
|
||||
|
|
@ -203,6 +208,7 @@ fun Library.toDto(includeRoot: Boolean) = LibraryDto(
|
|||
importComicInfoBook = importComicInfoBook,
|
||||
importComicInfoSeries = importComicInfoSeries,
|
||||
importComicInfoCollection = importComicInfoCollection,
|
||||
importComicInfoReadList = importComicInfoReadList,
|
||||
importEpubBook = importEpubBook,
|
||||
importEpubSeries = importEpubSeries,
|
||||
importLocalArtwork = importLocalArtwork,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
package org.gotson.komga.interfaces.rest
|
||||
|
||||
import io.swagger.v3.oas.annotations.Parameter
|
||||
import io.swagger.v3.oas.annotations.media.Content
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||
import mu.KotlinLogging
|
||||
import org.gotson.komga.domain.model.DuplicateNameException
|
||||
import org.gotson.komga.domain.model.ROLE_ADMIN
|
||||
import org.gotson.komga.domain.model.ReadList
|
||||
import org.gotson.komga.domain.persistence.ReadListRepository
|
||||
import org.gotson.komga.domain.service.ReadListLifecycle
|
||||
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
|
||||
import org.gotson.komga.infrastructure.language.toIndexedMap
|
||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
|
||||
import org.gotson.komga.interfaces.rest.dto.BookDto
|
||||
import org.gotson.komga.interfaces.rest.dto.ReadListCreationDto
|
||||
import org.gotson.komga.interfaces.rest.dto.ReadListDto
|
||||
import org.gotson.komga.interfaces.rest.dto.ReadListUpdateDto
|
||||
import org.gotson.komga.interfaces.rest.dto.restrictUrl
|
||||
import org.gotson.komga.interfaces.rest.dto.toDto
|
||||
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.http.CacheControl
|
||||
import org.springframework.http.HttpStatus
|
||||
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
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.validation.Valid
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@RestController
|
||||
@RequestMapping("api/v1/readlists", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||
class ReadListController(
|
||||
private val readListRepository: ReadListRepository,
|
||||
private val readListLifecycle: ReadListLifecycle,
|
||||
private val bookDtoRepository: BookDtoRepository
|
||||
) {
|
||||
|
||||
@PageableWithoutSortAsQueryParam
|
||||
@GetMapping
|
||||
fun getAll(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@RequestParam(name = "search", required = false) searchTerm: String?,
|
||||
@RequestParam(name = "library_id", required = false) libraryIds: List<String>?,
|
||||
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
|
||||
@Parameter(hidden = true) page: Pageable
|
||||
): Page<ReadListDto> {
|
||||
val pageRequest =
|
||||
if (unpaged) UnpagedSorted(Sort.by(Sort.Order.asc("name")))
|
||||
else PageRequest.of(
|
||||
page.pageNumber,
|
||||
page.pageSize,
|
||||
Sort.by(Sort.Order.asc("name"))
|
||||
)
|
||||
|
||||
return when {
|
||||
principal.user.sharedAllLibraries && libraryIds == null -> readListRepository.findAll(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() }
|
||||
}
|
||||
|
||||
@GetMapping("{id}")
|
||||
fun getOne(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@PathVariable id: String
|
||||
): ReadListDto =
|
||||
readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))
|
||||
?.toDto()
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
|
||||
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
|
||||
@GetMapping(value = ["{id}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE])
|
||||
fun getReadListThumbnail(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@PathVariable id: String
|
||||
): ResponseEntity<ByteArray> {
|
||||
readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let {
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePrivate())
|
||||
.body(readListLifecycle.getThumbnailBytes(it))
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('$ROLE_ADMIN')")
|
||||
fun addOne(
|
||||
@Valid @RequestBody readList: ReadListCreationDto
|
||||
): ReadListDto =
|
||||
try {
|
||||
readListLifecycle.addReadList(ReadList(
|
||||
name = readList.name,
|
||||
bookIds = readList.bookIds.toIndexedMap()
|
||||
)).toDto()
|
||||
} catch (e: DuplicateNameException) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
|
||||
}
|
||||
|
||||
@PatchMapping("{id}")
|
||||
@PreAuthorize("hasRole('$ROLE_ADMIN')")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
fun updateOne(
|
||||
@PathVariable id: String,
|
||||
@Valid @RequestBody readList: ReadListUpdateDto
|
||||
) {
|
||||
readListRepository.findByIdOrNull(id)?.let { existing ->
|
||||
val updated = existing.copy(
|
||||
name = readList.name ?: existing.name,
|
||||
bookIds = readList.bookIds?.toIndexedMap() ?: existing.bookIds
|
||||
)
|
||||
try {
|
||||
readListLifecycle.updateReadList(updated)
|
||||
} catch (e: DuplicateNameException) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
|
||||
}
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
@DeleteMapping("{id}")
|
||||
@PreAuthorize("hasRole('$ROLE_ADMIN')")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
fun deleteOne(
|
||||
@PathVariable id: String
|
||||
) {
|
||||
readListRepository.findByIdOrNull(id)?.let {
|
||||
readListLifecycle.deleteReadList(it.id)
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
@PageableWithoutSortAsQueryParam
|
||||
@GetMapping("{id}/books")
|
||||
fun getBooksForReadList(
|
||||
@PathVariable id: String,
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
|
||||
@Parameter(hidden = true) page: Pageable
|
||||
): Page<BookDto> =
|
||||
readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList ->
|
||||
val sort = Sort.by(Sort.Order.asc("readList.number"))
|
||||
|
||||
val pageRequest =
|
||||
if (unpaged) UnpagedSorted(sort)
|
||||
else PageRequest.of(
|
||||
page.pageNumber,
|
||||
page.pageSize,
|
||||
sort
|
||||
)
|
||||
|
||||
bookDtoRepository.findByReadListId(readList.id, principal.user.id, pageRequest)
|
||||
.map { it.restrictUrl(!principal.user.roleAdmin) }
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
|
|
@ -10,7 +10,6 @@ import org.gotson.komga.domain.model.ROLE_ADMIN
|
|||
import org.gotson.komga.domain.model.SeriesCollection
|
||||
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
|
||||
import org.gotson.komga.domain.service.SeriesCollectionLifecycle
|
||||
import org.gotson.komga.infrastructure.image.MosaicGenerator
|
||||
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
|
||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
|
||||
|
|
@ -52,9 +51,7 @@ private val logger = KotlinLogging.logger {}
|
|||
class SeriesCollectionController(
|
||||
private val collectionRepository: SeriesCollectionRepository,
|
||||
private val collectionLifecycle: SeriesCollectionLifecycle,
|
||||
private val seriesDtoRepository: SeriesDtoRepository,
|
||||
private val seriesController: SeriesController,
|
||||
private val mosaicGenerator: MosaicGenerator
|
||||
private val seriesDtoRepository: SeriesDtoRepository
|
||||
) {
|
||||
|
||||
@PageableWithoutSortAsQueryParam
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package org.gotson.komga.interfaces.rest.dto
|
||||
|
||||
import org.gotson.komga.infrastructure.validation.Unique
|
||||
import javax.validation.constraints.NotBlank
|
||||
import javax.validation.constraints.NotEmpty
|
||||
|
||||
data class ReadListCreationDto(
|
||||
@get:NotBlank val name: String,
|
||||
@get:NotEmpty @get:Unique val bookIds: List<String>
|
||||
)
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package org.gotson.komga.interfaces.rest.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat
|
||||
import org.gotson.komga.domain.model.ReadList
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class ReadListDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
|
||||
val bookIds: List<String>,
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
val createdDate: LocalDateTime,
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
val lastModifiedDate: LocalDateTime,
|
||||
|
||||
val filtered: Boolean
|
||||
)
|
||||
|
||||
fun ReadList.toDto() =
|
||||
ReadListDto(
|
||||
id = id,
|
||||
name = name,
|
||||
bookIds = bookIds.values.toList(),
|
||||
createdDate = createdDate.toUTC(),
|
||||
lastModifiedDate = lastModifiedDate.toUTC(),
|
||||
filtered = filtered
|
||||
)
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package org.gotson.komga.interfaces.rest.dto
|
||||
|
||||
import org.gotson.komga.infrastructure.validation.NullOrNotBlank
|
||||
import org.gotson.komga.infrastructure.validation.NullOrNotEmpty
|
||||
import org.gotson.komga.infrastructure.validation.Unique
|
||||
|
||||
data class ReadListUpdateDto(
|
||||
@get:NullOrNotBlank val name: String?,
|
||||
@get:NullOrNotEmpty @get:Unique val bookIds: List<String>?
|
||||
)
|
||||
|
|
@ -7,6 +7,7 @@ import org.springframework.data.domain.Pageable
|
|||
|
||||
interface BookDtoRepository {
|
||||
fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable): Page<BookDto>
|
||||
fun findByReadListId(readListId: String, userId: String, pageable: Pageable): Page<BookDto>
|
||||
fun findByIdOrNull(bookId: String, userId: String): BookDto?
|
||||
fun findPreviousInSeries(bookId: String, userId: String): BookDto?
|
||||
fun findNextInSeries(bookId: String, userId: String): BookDto?
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
package org.gotson.komga.infrastructure.jooq
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.gotson.komga.domain.model.ReadList
|
||||
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.LibraryRepository
|
||||
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||
import org.gotson.komga.infrastructure.language.toIndexedMap
|
||||
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.context.SpringBootTest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@ExtendWith(SpringExtension::class)
|
||||
@SpringBootTest
|
||||
class ReadListDaoTest(
|
||||
@Autowired private val readListDao: ReadListDao,
|
||||
@Autowired private val bookRepository: BookRepository,
|
||||
@Autowired private val seriesRepository: SeriesRepository,
|
||||
@Autowired private val libraryRepository: LibraryRepository
|
||||
) {
|
||||
|
||||
private val library = makeLibrary()
|
||||
private val library2 = makeLibrary("library2")
|
||||
|
||||
@BeforeAll
|
||||
fun setup() {
|
||||
libraryRepository.insert(library)
|
||||
libraryRepository.insert(library2)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun deleteSeries() {
|
||||
readListDao.deleteAll()
|
||||
bookRepository.deleteAll()
|
||||
seriesRepository.deleteAll()
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
fun tearDown() {
|
||||
libraryRepository.deleteAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given read list with books when inserting then it is persisted`() {
|
||||
// given
|
||||
val series = makeSeries("Series", library.id)
|
||||
seriesRepository.insert(series)
|
||||
val books = (1..10).map { makeBook("Book $it", libraryId = library.id, seriesId = series.id) }
|
||||
books.forEach { bookRepository.insert(it) }
|
||||
|
||||
val readList = ReadList(
|
||||
name = "MyReadList",
|
||||
bookIds = books.map { it.id }.toIndexedMap()
|
||||
)
|
||||
|
||||
// when
|
||||
val now = LocalDateTime.now()
|
||||
|
||||
readListDao.insert(readList)
|
||||
val created = readListDao.findByIdOrNull(readList.id)!!
|
||||
|
||||
// then
|
||||
assertThat(created.name).isEqualTo(readList.name)
|
||||
assertThat(created.createdDate)
|
||||
.isEqualTo(created.lastModifiedDate)
|
||||
.isCloseTo(now, offset)
|
||||
assertThat(created.bookIds.values).containsExactlyElementsOf(books.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given read list with updated books when updating then it is persisted`() {
|
||||
// given
|
||||
val series = makeSeries("Series", library.id)
|
||||
seriesRepository.insert(series)
|
||||
val books = (1..10).map { makeBook("Book $it", libraryId = library.id, seriesId = series.id) }
|
||||
books.forEach { bookRepository.insert(it) }
|
||||
|
||||
val readList = ReadList(
|
||||
name = "MyReadList",
|
||||
bookIds = books.map { it.id }.toIndexedMap()
|
||||
)
|
||||
|
||||
readListDao.insert(readList)
|
||||
|
||||
// when
|
||||
val updatedReadList = readList.copy(
|
||||
name = "UpdatedReadList",
|
||||
bookIds = readList.bookIds.values.take(5).toIndexedMap()
|
||||
)
|
||||
|
||||
val now = LocalDateTime.now()
|
||||
readListDao.update(updatedReadList)
|
||||
val updated = readListDao.findByIdOrNull(updatedReadList.id)!!
|
||||
|
||||
// then
|
||||
assertThat(updated.name).isEqualTo(updatedReadList.name)
|
||||
assertThat(updated.createdDate).isNotEqualTo(updated.lastModifiedDate)
|
||||
assertThat(updated.lastModifiedDate).isCloseTo(now, offset)
|
||||
assertThat(updated.bookIds.values)
|
||||
.hasSize(5)
|
||||
.containsExactlyElementsOf(books.map { it.id }.take(5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given read lists with books when removing one book from all then it is removed from all`() {
|
||||
// given
|
||||
val series = makeSeries("Series", library.id)
|
||||
seriesRepository.insert(series)
|
||||
val books = (1..10).map { makeBook("Book $it", libraryId = library.id, seriesId = series.id) }
|
||||
books.forEach { bookRepository.insert(it) }
|
||||
|
||||
val readList1 = ReadList(
|
||||
name = "MyReadList",
|
||||
bookIds = books.map { it.id }.toIndexedMap()
|
||||
)
|
||||
readListDao.insert(readList1)
|
||||
|
||||
val readList2 = ReadList(
|
||||
name = "MyReadList",
|
||||
bookIds = books.map { it.id }.take(5).toIndexedMap()
|
||||
)
|
||||
readListDao.insert(readList2)
|
||||
|
||||
|
||||
// when
|
||||
readListDao.removeBookFromAll(books.first().id)
|
||||
|
||||
// then
|
||||
val rl1 = readListDao.findByIdOrNull(readList1.id)!!
|
||||
assertThat(rl1.bookIds.values)
|
||||
.hasSize(9)
|
||||
.doesNotContain(books.first().id)
|
||||
|
||||
val col2 = readListDao.findByIdOrNull(readList2.id)!!
|
||||
assertThat(col2.bookIds.values)
|
||||
.hasSize(4)
|
||||
.doesNotContain(books.first().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given read lists spanning different libraries when finding by library then only matching collections are returned`() {
|
||||
// given
|
||||
val seriesLibrary1 = makeSeries("Series1", library.id).also { seriesRepository.insert(it) }
|
||||
val bookLibrary1 = makeBook("Book1", libraryId = library.id, seriesId = seriesLibrary1.id).also { bookRepository.insert(it) }
|
||||
val seriesLibrary2 = makeSeries("Series2", library2.id).also { seriesRepository.insert(it) }
|
||||
val bookLibrary2 = makeBook("Book2", libraryId = library2.id, seriesId = seriesLibrary2.id).also { bookRepository.insert(it) }
|
||||
|
||||
readListDao.insert(ReadList(
|
||||
name = "readListLibrary1",
|
||||
bookIds = listOf(bookLibrary1.id).toIndexedMap()
|
||||
))
|
||||
|
||||
readListDao.insert(ReadList(
|
||||
name = "readListLibrary2",
|
||||
bookIds = listOf(bookLibrary2.id).toIndexedMap()
|
||||
))
|
||||
|
||||
readListDao.insert(ReadList(
|
||||
name = "readListLibraryBoth",
|
||||
bookIds = listOf(bookLibrary1.id, bookLibrary2.id).toIndexedMap()
|
||||
))
|
||||
|
||||
// when
|
||||
val foundLibrary1Filtered = readListDao.findAllByLibraries(listOf(library.id), listOf(library.id), pageable = Pageable.unpaged()).content
|
||||
val foundLibrary1Unfiltered = readListDao.findAllByLibraries(listOf(library.id), null, pageable = Pageable.unpaged()).content
|
||||
val foundLibrary2Filtered = readListDao.findAllByLibraries(listOf(library2.id), listOf(library2.id), pageable = Pageable.unpaged()).content
|
||||
val foundLibrary2Unfiltered = readListDao.findAllByLibraries(listOf(library2.id), null, pageable = Pageable.unpaged()).content
|
||||
val foundBothUnfiltered = readListDao.findAllByLibraries(listOf(library.id, library2.id), null, pageable = Pageable.unpaged()).content
|
||||
|
||||
// then
|
||||
assertThat(foundLibrary1Filtered).hasSize(2)
|
||||
assertThat(foundLibrary1Filtered.map { it.name }).containsExactly("readListLibrary1", "readListLibraryBoth")
|
||||
with(foundLibrary1Filtered.find { it.name == "readListLibraryBoth" }!!) {
|
||||
assertThat(bookIds.values)
|
||||
.hasSize(1)
|
||||
.containsExactly(bookLibrary1.id)
|
||||
assertThat(filtered).isTrue()
|
||||
}
|
||||
|
||||
assertThat(foundLibrary1Unfiltered).hasSize(2)
|
||||
assertThat(foundLibrary1Unfiltered.map { it.name }).containsExactly("readListLibrary1", "readListLibraryBoth")
|
||||
with(foundLibrary1Unfiltered.find { it.name == "readListLibraryBoth" }!!) {
|
||||
assertThat(bookIds.values)
|
||||
.hasSize(2)
|
||||
.containsExactly(bookLibrary1.id, bookLibrary2.id)
|
||||
assertThat(filtered).isFalse()
|
||||
}
|
||||
|
||||
assertThat(foundLibrary2Filtered).hasSize(2)
|
||||
assertThat(foundLibrary2Filtered.map { it.name }).containsExactly("readListLibrary2", "readListLibraryBoth")
|
||||
with(foundLibrary2Filtered.find { it.name == "readListLibraryBoth" }!!) {
|
||||
assertThat(bookIds.values)
|
||||
.hasSize(1)
|
||||
.containsExactly(bookLibrary2.id)
|
||||
assertThat(filtered).isTrue()
|
||||
}
|
||||
|
||||
assertThat(foundLibrary2Unfiltered).hasSize(2)
|
||||
assertThat(foundLibrary2Unfiltered.map { it.name }).containsExactly("readListLibrary2", "readListLibraryBoth")
|
||||
with(foundLibrary2Unfiltered.find { it.name == "readListLibraryBoth" }!!) {
|
||||
assertThat(bookIds.values)
|
||||
.hasSize(2)
|
||||
.containsExactly(bookLibrary1.id, bookLibrary2.id)
|
||||
assertThat(filtered).isFalse()
|
||||
}
|
||||
|
||||
assertThat(foundBothUnfiltered).hasSize(3)
|
||||
assertThat(foundBothUnfiltered.map { it.name }).containsExactly("readListLibrary1", "readListLibrary2", "readListLibraryBoth")
|
||||
with(foundBothUnfiltered.find { it.name == "readListLibraryBoth" }!!) {
|
||||
assertThat(bookIds.values)
|
||||
.hasSize(2)
|
||||
.containsExactly(bookLibrary1.id, bookLibrary2.id)
|
||||
assertThat(filtered).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
package org.gotson.komga.interfaces.rest
|
||||
|
||||
import org.gotson.komga.domain.model.Book
|
||||
import org.gotson.komga.domain.model.ROLE_ADMIN
|
||||
import org.gotson.komga.domain.model.ReadList
|
||||
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.LibraryRepository
|
||||
import org.gotson.komga.domain.persistence.ReadListRepository
|
||||
import org.gotson.komga.domain.service.LibraryLifecycle
|
||||
import org.gotson.komga.domain.service.ReadListLifecycle
|
||||
import org.gotson.komga.domain.service.SeriesLifecycle
|
||||
import org.gotson.komga.infrastructure.language.toIndexedMap
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.junit.jupiter.api.Nested
|
||||
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.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.delete
|
||||
import org.springframework.test.web.servlet.get
|
||||
import org.springframework.test.web.servlet.patch
|
||||
import org.springframework.test.web.servlet.post
|
||||
|
||||
@ExtendWith(SpringExtension::class)
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc(printOnlyOnFailure = false)
|
||||
class ReadListControllerTest(
|
||||
@Autowired private val mockMvc: MockMvc,
|
||||
@Autowired private val readListLifecycle: ReadListLifecycle,
|
||||
@Autowired private val readListRepository: ReadListRepository,
|
||||
@Autowired private val libraryLifecycle: LibraryLifecycle,
|
||||
@Autowired private val libraryRepository: LibraryRepository,
|
||||
@Autowired private val seriesLifecycle: SeriesLifecycle
|
||||
) {
|
||||
|
||||
private val library1 = makeLibrary("Library1", id = "1")
|
||||
private val library2 = makeLibrary("Library2", id = "2")
|
||||
private val seriesLib1 = makeSeries("Series1", library1.id)
|
||||
private val seriesLib2 = makeSeries("Series2", library2.id)
|
||||
private lateinit var booksLibrary1: List<Book>
|
||||
private lateinit var booksLibrary2: List<Book>
|
||||
private lateinit var rlLib1: ReadList
|
||||
private lateinit var rlLib2: ReadList
|
||||
private lateinit var rlLibBoth: ReadList
|
||||
|
||||
@BeforeAll
|
||||
fun setup() {
|
||||
libraryRepository.insert(library1)
|
||||
libraryRepository.insert(library2)
|
||||
|
||||
seriesLifecycle.createSeries(seriesLib1)
|
||||
seriesLifecycle.createSeries(seriesLib2)
|
||||
|
||||
booksLibrary1 = (1..5).map { makeBook("Book_$it", libraryId = library1.id, seriesId = seriesLib1.id) }
|
||||
seriesLifecycle.addBooks(seriesLib1, booksLibrary1)
|
||||
|
||||
booksLibrary2 = (6..10).map { makeBook("Book_$it", libraryId = library2.id, seriesId = seriesLib2.id) }
|
||||
seriesLifecycle.addBooks(seriesLib2, booksLibrary2)
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
fun teardown() {
|
||||
libraryRepository.findAll().forEach {
|
||||
libraryLifecycle.deleteLibrary(it)
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun clear() {
|
||||
readListRepository.deleteAll()
|
||||
}
|
||||
|
||||
private fun makeReadLists() {
|
||||
rlLib1 = readListLifecycle.addReadList(ReadList(
|
||||
name = "Lib1",
|
||||
bookIds = booksLibrary1.map { it.id }.toIndexedMap()
|
||||
))
|
||||
|
||||
rlLib2 = readListLifecycle.addReadList(ReadList(
|
||||
name = "Lib2",
|
||||
bookIds = booksLibrary2.map { it.id }.toIndexedMap()
|
||||
))
|
||||
|
||||
rlLibBoth = readListLifecycle.addReadList(ReadList(
|
||||
name = "Lib1+2",
|
||||
bookIds = (booksLibrary1 + booksLibrary2).map { it.id }.toIndexedMap()
|
||||
))
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class GetAndFilter {
|
||||
@Test
|
||||
@WithMockCustomUser
|
||||
fun `given user with access to all libraries when getting read lists then get all read lists`() {
|
||||
makeReadLists()
|
||||
|
||||
mockMvc.get("/api/v1/readlists")
|
||||
.andExpect {
|
||||
status { isOk }
|
||||
jsonPath("$.totalElements") { value(3) }
|
||||
jsonPath("$.content[?(@.name == 'Lib1')].filtered") { value(false) }
|
||||
jsonPath("$.content[?(@.name == 'Lib2')].filtered") { value(false) }
|
||||
jsonPath("$.content[?(@.name == 'Lib1+2')].filtered") { value(false) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"])
|
||||
fun `given user with access to a single library when getting read lists then only get read lists from this library`() {
|
||||
makeReadLists()
|
||||
|
||||
mockMvc.get("/api/v1/readlists")
|
||||
.andExpect {
|
||||
status { isOk }
|
||||
jsonPath("$.totalElements") { value(2) }
|
||||
jsonPath("$.content[?(@.name == 'Lib1')].filtered") { value(false) }
|
||||
jsonPath("$.content[?(@.name == 'Lib1+2')].filtered") { value(true) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser
|
||||
fun `given user with access to all libraries when getting single read list then it is not filtered`() {
|
||||
makeReadLists()
|
||||
|
||||
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}")
|
||||
.andExpect {
|
||||
status { isOk }
|
||||
jsonPath("$.bookIds.length()") { value(10) }
|
||||
jsonPath("$.filtered") { value(false) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"])
|
||||
fun `given user with access to a single library when getting single read list with items from 2 libraries then it is filtered`() {
|
||||
makeReadLists()
|
||||
|
||||
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}")
|
||||
.andExpect {
|
||||
status { isOk }
|
||||
jsonPath("$.bookIds.length()") { value(5) }
|
||||
jsonPath("$.filtered") { value(true) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"])
|
||||
fun `given user with access to a single library when getting single read list from another library then return not found`() {
|
||||
makeReadLists()
|
||||
|
||||
mockMvc.get("/api/v1/readlists/${rlLib2.id}")
|
||||
.andExpect {
|
||||
status { isNotFound }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Creation {
|
||||
@Test
|
||||
@WithMockCustomUser
|
||||
fun `given non-admin user when creating read list then return forbidden`() {
|
||||
val jsonString = """
|
||||
{"name":"readlist","bookIds":[3]}
|
||||
""".trimIndent()
|
||||
|
||||
mockMvc.post("/api/v1/readlists") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = jsonString
|
||||
}.andExpect {
|
||||
status { isForbidden }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(roles = [ROLE_ADMIN])
|
||||
fun `given admin user when creating read list then return ok`() {
|
||||
val jsonString = """
|
||||
{"name":"readlist","bookIds":["${booksLibrary1.first().id}"]}
|
||||
""".trimIndent()
|
||||
|
||||
mockMvc.post("/api/v1/readlists") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = jsonString
|
||||
}.andExpect {
|
||||
status { isOk }
|
||||
jsonPath("$.bookIds.length()") { value(1) }
|
||||
jsonPath("$.name") { value("readlist") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(roles = [ROLE_ADMIN])
|
||||
fun `given existing read lists when creating read list with existing name then return bad request`() {
|
||||
makeReadLists()
|
||||
|
||||
val jsonString = """
|
||||
{"name":"Lib1","bookIds":[${booksLibrary1.first().id}]}
|
||||
""".trimIndent()
|
||||
|
||||
mockMvc.post("/api/v1/readlists") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = jsonString
|
||||
}.andExpect {
|
||||
status { isBadRequest }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(roles = [ROLE_ADMIN])
|
||||
fun `given read list with duplicate bookIds when creating read list then return bad request`() {
|
||||
makeReadLists()
|
||||
|
||||
val jsonString = """
|
||||
{"name":"Lib1","bookIds":[${booksLibrary1.first().id},${booksLibrary1.first().id}]}
|
||||
""".trimIndent()
|
||||
|
||||
mockMvc.post("/api/v1/readlists") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = jsonString
|
||||
}.andExpect {
|
||||
status { isBadRequest }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Update {
|
||||
@Test
|
||||
@WithMockCustomUser
|
||||
fun `given non-admin user when updating read list then return forbidden`() {
|
||||
val jsonString = """
|
||||
{"name":"readlist","bookIds":[3]}
|
||||
""".trimIndent()
|
||||
|
||||
mockMvc.patch("/api/v1/readlists/5") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = jsonString
|
||||
}.andExpect {
|
||||
status { isForbidden }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(roles = [ROLE_ADMIN])
|
||||
fun `given admin user when updating read list then return no content`() {
|
||||
makeReadLists()
|
||||
|
||||
val jsonString = """
|
||||
{"name":"updated","bookIds":["${booksLibrary1.first().id}"]}
|
||||
""".trimIndent()
|
||||
|
||||
mockMvc.patch("/api/v1/readlists/${rlLib1.id}") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = jsonString
|
||||
}.andExpect {
|
||||
status { isNoContent }
|
||||
}
|
||||
|
||||
mockMvc.get("/api/v1/readlists/${rlLib1.id}")
|
||||
.andExpect {
|
||||
status { isOk }
|
||||
jsonPath("$.name") { value("updated") }
|
||||
jsonPath("$.bookIds.length()") { value(1) }
|
||||
jsonPath("$.filtered") { value(false) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(roles = [ROLE_ADMIN])
|
||||
fun `given existing read lists when updating read list with existing name then return bad request`() {
|
||||
makeReadLists()
|
||||
|
||||
val jsonString = """{"name":"Lib2"}"""
|
||||
|
||||
mockMvc.patch("/api/v1/readlists/${rlLib1.id}") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = jsonString
|
||||
}.andExpect {
|
||||
status { isBadRequest }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(roles = [ROLE_ADMIN])
|
||||
fun `given existing read list when updating read list with duplicate bookIds then return bad request`() {
|
||||
makeReadLists()
|
||||
|
||||
val jsonString = """{"bookIds":[${booksLibrary1.first().id},${booksLibrary1.first().id}]}"""
|
||||
|
||||
mockMvc.patch("/api/v1/readlists/${rlLib1.id}") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = jsonString
|
||||
}.andExpect {
|
||||
status { isBadRequest }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(roles = [ROLE_ADMIN])
|
||||
fun `given admin user when updating read list then only updated fields are modified`() {
|
||||
makeReadLists()
|
||||
|
||||
mockMvc.patch("/api/v1/readlists/${rlLib2.id}") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = """{"name":"newName"}"""
|
||||
}
|
||||
|
||||
mockMvc.get("/api/v1/readlists/${rlLib2.id}")
|
||||
.andExpect {
|
||||
status { isOk }
|
||||
jsonPath("$.name") { value("newName") }
|
||||
jsonPath("$.bookIds.length()") { value(5) }
|
||||
}
|
||||
|
||||
|
||||
mockMvc.patch("/api/v1/readlists/${rlLibBoth.id}") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = """{"bookIds":["${booksLibrary1.first().id}"]}"""
|
||||
}
|
||||
|
||||
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}")
|
||||
.andExpect {
|
||||
status { isOk }
|
||||
jsonPath("$.name") { value("Lib1+2") }
|
||||
jsonPath("$.bookIds.length()") { value(1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Delete {
|
||||
@Test
|
||||
@WithMockCustomUser
|
||||
fun `given non-admin user when deleting read list then return forbidden`() {
|
||||
mockMvc.delete("/api/v1/readlists/5")
|
||||
.andExpect {
|
||||
status { isForbidden }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(roles = [ROLE_ADMIN])
|
||||
fun `given admin user when deleting read list then return no content`() {
|
||||
makeReadLists()
|
||||
|
||||
mockMvc.delete("/api/v1/readlists/${rlLib1.id}")
|
||||
.andExpect {
|
||||
status { isNoContent }
|
||||
}
|
||||
|
||||
mockMvc.get("/api/v1/readlists/${rlLib1.id}")
|
||||
.andExpect {
|
||||
status { isNotFound }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue