mirror of
https://github.com/gotson/komga.git
synced 2025-12-22 00:13:30 +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 publisher: String?,
|
||||||
val ageRating: Int?,
|
val ageRating: Int?,
|
||||||
val releaseDate: LocalDate?,
|
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 importComicInfoBook: Boolean = true,
|
||||||
val importComicInfoSeries: Boolean = true,
|
val importComicInfoSeries: Boolean = true,
|
||||||
val importComicInfoCollection: Boolean = true,
|
val importComicInfoCollection: Boolean = true,
|
||||||
|
val importComicInfoReadList: Boolean = true,
|
||||||
val importEpubBook: Boolean = true,
|
val importEpubBook: Boolean = true,
|
||||||
val importEpubSeries: Boolean = true,
|
val importEpubSeries: Boolean = true,
|
||||||
val importLocalArtwork: 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.BookMetadataRepository
|
||||||
import org.gotson.komga.domain.persistence.BookRepository
|
import org.gotson.komga.domain.persistence.BookRepository
|
||||||
import org.gotson.komga.domain.persistence.MediaRepository
|
import org.gotson.komga.domain.persistence.MediaRepository
|
||||||
|
import org.gotson.komga.domain.persistence.ReadListRepository
|
||||||
import org.gotson.komga.domain.persistence.ReadProgressRepository
|
import org.gotson.komga.domain.persistence.ReadProgressRepository
|
||||||
import org.gotson.komga.domain.persistence.ThumbnailBookRepository
|
import org.gotson.komga.domain.persistence.ThumbnailBookRepository
|
||||||
import org.gotson.komga.infrastructure.image.ImageConverter
|
import org.gotson.komga.infrastructure.image.ImageConverter
|
||||||
|
|
@ -30,6 +31,7 @@ class BookLifecycle(
|
||||||
private val bookMetadataRepository: BookMetadataRepository,
|
private val bookMetadataRepository: BookMetadataRepository,
|
||||||
private val readProgressRepository: ReadProgressRepository,
|
private val readProgressRepository: ReadProgressRepository,
|
||||||
private val thumbnailBookRepository: ThumbnailBookRepository,
|
private val thumbnailBookRepository: ThumbnailBookRepository,
|
||||||
|
private val readListRepository: ReadListRepository,
|
||||||
private val bookAnalyzer: BookAnalyzer,
|
private val bookAnalyzer: BookAnalyzer,
|
||||||
private val imageConverter: ImageConverter
|
private val imageConverter: ImageConverter
|
||||||
) {
|
) {
|
||||||
|
|
@ -195,6 +197,8 @@ class BookLifecycle(
|
||||||
logger.info { "Delete book id: $bookId" }
|
logger.info { "Delete book id: $bookId" }
|
||||||
|
|
||||||
readProgressRepository.deleteByBookId(bookId)
|
readProgressRepository.deleteByBookId(bookId)
|
||||||
|
readListRepository.removeBookFromAll(bookId)
|
||||||
|
|
||||||
mediaRepository.delete(bookId)
|
mediaRepository.delete(bookId)
|
||||||
thumbnailBookRepository.deleteByBookId(bookId)
|
thumbnailBookRepository.deleteByBookId(bookId)
|
||||||
bookMetadataRepository.delete(bookId)
|
bookMetadataRepository.delete(bookId)
|
||||||
|
|
@ -206,6 +210,8 @@ class BookLifecycle(
|
||||||
logger.info { "Delete all books: $bookIds" }
|
logger.info { "Delete all books: $bookIds" }
|
||||||
|
|
||||||
readProgressRepository.deleteByBookIds(bookIds)
|
readProgressRepository.deleteByBookIds(bookIds)
|
||||||
|
readListRepository.removeBookFromAll(bookIds)
|
||||||
|
|
||||||
mediaRepository.deleteByBookIds(bookIds)
|
mediaRepository.deleteByBookIds(bookIds)
|
||||||
thumbnailBookRepository.deleteByBookIds(bookIds)
|
thumbnailBookRepository.deleteByBookIds(bookIds)
|
||||||
bookMetadataRepository.deleteByBookIds(bookIds)
|
bookMetadataRepository.deleteByBookIds(bookIds)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package org.gotson.komga.domain.service
|
||||||
|
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import org.gotson.komga.domain.model.Book
|
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.Series
|
||||||
import org.gotson.komga.domain.model.SeriesCollection
|
import org.gotson.komga.domain.model.SeriesCollection
|
||||||
import org.gotson.komga.domain.model.SeriesMetadataPatch
|
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.BookRepository
|
||||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||||
import org.gotson.komga.domain.persistence.MediaRepository
|
import org.gotson.komga.domain.persistence.MediaRepository
|
||||||
|
import org.gotson.komga.domain.persistence.ReadListRepository
|
||||||
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
|
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
|
||||||
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
|
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
|
||||||
import org.gotson.komga.infrastructure.metadata.BookMetadataProvider
|
import org.gotson.komga.infrastructure.metadata.BookMetadataProvider
|
||||||
|
|
@ -34,6 +36,8 @@ class MetadataLifecycle(
|
||||||
private val seriesLifecycle: SeriesLifecycle,
|
private val seriesLifecycle: SeriesLifecycle,
|
||||||
private val collectionRepository: SeriesCollectionRepository,
|
private val collectionRepository: SeriesCollectionRepository,
|
||||||
private val collectionLifecycle: SeriesCollectionLifecycle,
|
private val collectionLifecycle: SeriesCollectionLifecycle,
|
||||||
|
private val readListRepository: ReadListRepository,
|
||||||
|
private val readListLifecycle: ReadListLifecycle,
|
||||||
private val localArtworkProvider: LocalArtworkProvider
|
private val localArtworkProvider: LocalArtworkProvider
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|
@ -45,20 +49,64 @@ class MetadataLifecycle(
|
||||||
|
|
||||||
bookMetadataProviders.forEach { provider ->
|
bookMetadataProviders.forEach { provider ->
|
||||||
when {
|
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" }
|
provider is EpubMetadataProvider && !library.importEpubBook -> logger.info { "Library is not set to import book metadata from Epub, skipping" }
|
||||||
else -> {
|
else -> {
|
||||||
logger.debug { "Provider: $provider" }
|
logger.debug { "Provider: $provider" }
|
||||||
provider.getBookMetadataFromBook(book, media)?.let { bPatch ->
|
val patch = provider.getBookMetadataFromBook(book, media)
|
||||||
|
|
||||||
bookMetadataRepository.findById(book.id).let {
|
// handle book metadata
|
||||||
logger.debug { "Original metadata: $it" }
|
if ((provider is ComicInfoProvider && library.importComicInfoBook) ||
|
||||||
val patched = metadataApplier.apply(bPatch, it)
|
(provider is EpubMetadataProvider && library.importEpubBook)) {
|
||||||
logger.debug { "Patched metadata: $patched" }
|
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
|
// handle series metadata
|
||||||
if ((provider is ComicInfoProvider && library.importComicInfoSeries) ||
|
if ((provider is ComicInfoProvider && library.importComicInfoSeries) ||
|
||||||
(provider is EpubMetadataProvider && !library.importEpubSeries)) {
|
(provider is EpubMetadataProvider && library.importEpubSeries)) {
|
||||||
val title = patches.uniqueOrNull { it.title }
|
val title = patches.uniqueOrNull { it.title }
|
||||||
val titleSort = patches.uniqueOrNull { it.titleSort }
|
val titleSort = patches.uniqueOrNull { it.titleSort }
|
||||||
val status = patches.uniqueOrNull { it.status }
|
val status = patches.uniqueOrNull { it.status }
|
||||||
|
|
@ -113,15 +161,15 @@ class MetadataLifecycle(
|
||||||
collectionRepository.findByNameOrNull(collection).let { existing ->
|
collectionRepository.findByNameOrNull(collection).let { existing ->
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
if (existing.seriesIds.contains(series.id))
|
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 {
|
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(
|
collectionLifecycle.updateCollection(
|
||||||
existing.copy(seriesIds = existing.seriesIds + series.id)
|
existing.copy(seriesIds = existing.seriesIds + series.id)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.debug { "Adding series ${series.name} to new collection $collection" }
|
logger.debug { "Adding series '${series.name}' to new collection '$collection'" }
|
||||||
collectionLifecycle.addCollection(SeriesCollection(
|
collectionLifecycle.addCollection(SeriesCollection(
|
||||||
name = collection,
|
name = collection,
|
||||||
seriesIds = listOf(series.id)
|
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 r = Tables.READ_PROGRESS
|
||||||
private val a = Tables.BOOK_METADATA_AUTHOR
|
private val a = Tables.BOOK_METADATA_AUTHOR
|
||||||
private val s = Tables.SERIES
|
private val s = Tables.SERIES
|
||||||
|
private val rlb = Tables.READLIST_BOOK
|
||||||
|
|
||||||
private val sorts = mapOf(
|
private val sorts = mapOf(
|
||||||
"name" to DSL.lower(b.NAME),
|
"name" to DSL.lower(b.NAME),
|
||||||
|
|
@ -52,24 +53,38 @@ class BookDtoDao(
|
||||||
"media.comment" to DSL.lower(m.COMMENT),
|
"media.comment" to DSL.lower(m.COMMENT),
|
||||||
"media.mediaType" to DSL.lower(m.MEDIA_TYPE),
|
"media.mediaType" to DSL.lower(m.MEDIA_TYPE),
|
||||||
"metadata.numberSort" to d.NUMBER_SORT,
|
"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> {
|
override fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable): Page<BookDto> {
|
||||||
val conditions = search.toCondition()
|
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)
|
.from(b)
|
||||||
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
|
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
|
||||||
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
|
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
|
||||||
.leftJoin(r).on(b.ID.eq(r.BOOK_ID))
|
.leftJoin(r).on(b.ID.eq(r.BOOK_ID))
|
||||||
.and(readProgressCondition(userId))
|
.and(readProgressCondition(userId))
|
||||||
|
.leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID))
|
||||||
.where(conditions)
|
.where(conditions)
|
||||||
.fetchOne(0, Long::class.java)
|
.groupBy(b.ID)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
|
||||||
val orderBy = pageable.sort.toOrderBy(sorts)
|
val orderBy = pageable.sort.toOrderBy(sorts)
|
||||||
|
|
||||||
val dtos = selectBase(userId)
|
val dtos = selectBase(userId, selectReadListNumber)
|
||||||
.where(conditions)
|
.where(conditions)
|
||||||
.orderBy(orderBy)
|
.orderBy(orderBy)
|
||||||
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
|
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
|
||||||
|
|
@ -79,7 +94,7 @@ class BookDtoDao(
|
||||||
return PageImpl(
|
return PageImpl(
|
||||||
dtos,
|
dtos,
|
||||||
if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort)
|
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()
|
count.toLong()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -151,17 +166,19 @@ class BookDtoDao(
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectBase(userId: String) =
|
private fun selectBase(userId: String, selectReadListNumber: Boolean = false) =
|
||||||
dsl.select(
|
dsl.selectDistinct(
|
||||||
*b.fields(),
|
*b.fields(),
|
||||||
*m.fields(),
|
*m.fields(),
|
||||||
*d.fields(),
|
*d.fields(),
|
||||||
*r.fields()
|
*r.fields()
|
||||||
).from(b)
|
).apply { if (selectReadListNumber) select(rlb.NUMBER) }
|
||||||
|
.from(b)
|
||||||
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
|
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
|
||||||
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
|
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
|
||||||
.leftJoin(r).on(b.ID.eq(r.BOOK_ID))
|
.leftJoin(r).on(b.ID.eq(r.BOOK_ID))
|
||||||
.and(readProgressCondition(userId))
|
.and(readProgressCondition(userId))
|
||||||
|
.leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID))
|
||||||
|
|
||||||
private fun ResultQuery<Record>.fetchAndMap() =
|
private fun ResultQuery<Record>.fetchAndMap() =
|
||||||
fetch()
|
fetch()
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ class LibraryDao(
|
||||||
.set(l.IMPORT_COMICINFO_BOOK, library.importComicInfoBook)
|
.set(l.IMPORT_COMICINFO_BOOK, library.importComicInfoBook)
|
||||||
.set(l.IMPORT_COMICINFO_SERIES, library.importComicInfoSeries)
|
.set(l.IMPORT_COMICINFO_SERIES, library.importComicInfoSeries)
|
||||||
.set(l.IMPORT_COMICINFO_COLLECTION, library.importComicInfoCollection)
|
.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_BOOK, library.importEpubBook)
|
||||||
.set(l.IMPORT_EPUB_SERIES, library.importEpubSeries)
|
.set(l.IMPORT_EPUB_SERIES, library.importEpubSeries)
|
||||||
.set(l.IMPORT_LOCAL_ARTWORK, library.importLocalArtwork)
|
.set(l.IMPORT_LOCAL_ARTWORK, library.importLocalArtwork)
|
||||||
|
|
@ -85,6 +86,7 @@ class LibraryDao(
|
||||||
.set(l.IMPORT_COMICINFO_BOOK, library.importComicInfoBook)
|
.set(l.IMPORT_COMICINFO_BOOK, library.importComicInfoBook)
|
||||||
.set(l.IMPORT_COMICINFO_SERIES, library.importComicInfoSeries)
|
.set(l.IMPORT_COMICINFO_SERIES, library.importComicInfoSeries)
|
||||||
.set(l.IMPORT_COMICINFO_COLLECTION, library.importComicInfoCollection)
|
.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_BOOK, library.importEpubBook)
|
||||||
.set(l.IMPORT_EPUB_SERIES, library.importEpubSeries)
|
.set(l.IMPORT_EPUB_SERIES, library.importEpubSeries)
|
||||||
.set(l.IMPORT_LOCAL_ARTWORK, library.importLocalArtwork)
|
.set(l.IMPORT_LOCAL_ARTWORK, library.importLocalArtwork)
|
||||||
|
|
@ -105,6 +107,7 @@ class LibraryDao(
|
||||||
importComicInfoBook = importComicinfoBook,
|
importComicInfoBook = importComicinfoBook,
|
||||||
importComicInfoSeries = importComicinfoSeries,
|
importComicInfoSeries = importComicinfoSeries,
|
||||||
importComicInfoCollection = importComicinfoCollection,
|
importComicInfoCollection = importComicinfoCollection,
|
||||||
|
importComicInfoReadList = importComicinfoReadlist,
|
||||||
importEpubBook = importEpubBook,
|
importEpubBook = importEpubBook,
|
||||||
importEpubSeries = importEpubSeries,
|
importEpubSeries = importEpubSeries,
|
||||||
importLocalArtwork = importLocalArtwork,
|
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(
|
return BookMetadataPatch(
|
||||||
comicInfo.title,
|
title = comicInfo.title,
|
||||||
comicInfo.summary,
|
summary = comicInfo.summary,
|
||||||
comicInfo.number,
|
number = comicInfo.number,
|
||||||
comicInfo.number?.toFloatOrNull(),
|
numberSort = comicInfo.number?.toFloatOrNull(),
|
||||||
readingDirection,
|
readingDirection = readingDirection,
|
||||||
comicInfo.publisher,
|
publisher = comicInfo.publisher,
|
||||||
comicInfo.ageRating?.ageRating,
|
ageRating = comicInfo.ageRating?.ageRating,
|
||||||
releaseDate,
|
releaseDate = releaseDate,
|
||||||
authors.ifEmpty { null }
|
authors = authors.ifEmpty { null },
|
||||||
|
readList = comicInfo.alternateSeries ?: comicInfo.storyArc,
|
||||||
|
readListNumber = comicInfo.alternateNumber?.toIntOrNull() ?: comicInfo.storyArcNumber?.toIntOrNull()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,9 @@ class ComicInfo {
|
||||||
@JsonProperty(value = "StoryArc")
|
@JsonProperty(value = "StoryArc")
|
||||||
var storyArc: String? = null
|
var storyArc: String? = null
|
||||||
|
|
||||||
|
@JsonProperty(value = "StoryArcNumber")
|
||||||
|
var storyArcNumber: String? = null
|
||||||
|
|
||||||
@JsonProperty(value = "SeriesGroup")
|
@JsonProperty(value = "SeriesGroup")
|
||||||
var seriesGroup: String? = null
|
var seriesGroup: String? = null
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,9 @@ class EpubMetadataProvider(
|
||||||
publisher = publisher,
|
publisher = publisher,
|
||||||
ageRating = null,
|
ageRating = null,
|
||||||
releaseDate = date,
|
releaseDate = date,
|
||||||
authors = authors
|
authors = authors,
|
||||||
|
readList = null,
|
||||||
|
readListNumber = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return 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.BookSearch
|
||||||
import org.gotson.komga.domain.model.Library
|
import org.gotson.komga.domain.model.Library
|
||||||
import org.gotson.komga.domain.model.Media
|
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.Series
|
||||||
import org.gotson.komga.domain.model.SeriesCollection
|
import org.gotson.komga.domain.model.SeriesCollection
|
||||||
import org.gotson.komga.domain.model.SeriesMetadata
|
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.BookRepository
|
||||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||||
import org.gotson.komga.domain.persistence.MediaRepository
|
import org.gotson.komga.domain.persistence.MediaRepository
|
||||||
|
import org.gotson.komga.domain.persistence.ReadListRepository
|
||||||
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
|
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
|
||||||
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
|
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
|
||||||
import org.gotson.komga.domain.persistence.SeriesRepository
|
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_SERIES_LATEST = "series/latest"
|
||||||
private const val ROUTE_LIBRARIES_ALL = "libraries"
|
private const val ROUTE_LIBRARIES_ALL = "libraries"
|
||||||
private const val ROUTE_COLLECTIONS_ALL = "collections"
|
private const val ROUTE_COLLECTIONS_ALL = "collections"
|
||||||
|
private const val ROUTE_READLISTS_ALL = "readlists"
|
||||||
private const val ROUTE_SEARCH = "search"
|
private const val ROUTE_SEARCH = "search"
|
||||||
|
|
||||||
private const val ID_SERIES_ALL = "allSeries"
|
private const val ID_SERIES_ALL = "allSeries"
|
||||||
private const val ID_SERIES_LATEST = "latestSeries"
|
private const val ID_SERIES_LATEST = "latestSeries"
|
||||||
private const val ID_LIBRARIES_ALL = "allLibraries"
|
private const val ID_LIBRARIES_ALL = "allLibraries"
|
||||||
private const val ID_COLLECTIONS_ALL = "allCollections"
|
private const val ID_COLLECTIONS_ALL = "allCollections"
|
||||||
|
private const val ID_READLISTS_ALL = "allReadLists"
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping(value = [ROUTE_BASE], produces = [MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE])
|
@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,
|
servletContext: ServletContext,
|
||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
private val collectionRepository: SeriesCollectionRepository,
|
private val collectionRepository: SeriesCollectionRepository,
|
||||||
|
private val readListRepository: ReadListRepository,
|
||||||
private val seriesRepository: SeriesRepository,
|
private val seriesRepository: SeriesRepository,
|
||||||
private val seriesMetadataRepository: SeriesMetadataRepository,
|
private val seriesMetadataRepository: SeriesMetadataRepository,
|
||||||
private val bookRepository: BookRepository,
|
private val bookRepository: BookRepository,
|
||||||
|
|
@ -127,6 +132,13 @@ class OpdsController(
|
||||||
id = ID_COLLECTIONS_ALL,
|
id = ID_COLLECTIONS_ALL,
|
||||||
content = "Browse by collection",
|
content = "Browse by collection",
|
||||||
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "$routeBase$ROUTE_COLLECTIONS_ALL")
|
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}")
|
@GetMapping("series/{id}")
|
||||||
fun getOneSeries(
|
fun getOneSeries(
|
||||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||||
|
|
@ -266,7 +302,7 @@ class OpdsController(
|
||||||
.map { it.toOpdsEntry(shouldPrependBookNumbers(userAgent)) }
|
.map { it.toOpdsEntry(shouldPrependBookNumbers(userAgent)) }
|
||||||
|
|
||||||
OpdsFeedAcquisition(
|
OpdsFeedAcquisition(
|
||||||
id = series.id.toString(),
|
id = series.id,
|
||||||
title = metadata.title,
|
title = metadata.title,
|
||||||
updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||||
author = komgaAuthor,
|
author = komgaAuthor,
|
||||||
|
|
@ -294,7 +330,7 @@ class OpdsController(
|
||||||
.map { it.toOpdsEntry() }
|
.map { it.toOpdsEntry() }
|
||||||
|
|
||||||
OpdsFeedNavigation(
|
OpdsFeedNavigation(
|
||||||
id = library.id.toString(),
|
id = library.id,
|
||||||
title = library.name,
|
title = library.name,
|
||||||
updated = library.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
updated = library.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||||
author = komgaAuthor,
|
author = komgaAuthor,
|
||||||
|
|
@ -324,7 +360,7 @@ class OpdsController(
|
||||||
}
|
}
|
||||||
|
|
||||||
OpdsFeedNavigation(
|
OpdsFeedNavigation(
|
||||||
id = collection.id.toString(),
|
id = collection.id,
|
||||||
title = collection.name,
|
title = collection.name,
|
||||||
updated = collection.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
updated = collection.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||||
author = komgaAuthor,
|
author = komgaAuthor,
|
||||||
|
|
@ -337,19 +373,46 @@ class OpdsController(
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: 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 {
|
private fun SeriesWithInfo.toOpdsEntry(prepend: Int? = null): OpdsEntryNavigation {
|
||||||
val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: ""
|
val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: ""
|
||||||
return OpdsEntryNavigation(
|
return OpdsEntryNavigation(
|
||||||
title = pre + metadata.title,
|
title = pre + metadata.title,
|
||||||
updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||||
id = series.id.toString(),
|
id = series.id,
|
||||||
content = "",
|
content = "",
|
||||||
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}series/${series.id}")
|
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 mediaTypes = media.pages.map { it.mediaType }.distinct()
|
||||||
|
|
||||||
val opdsLinkPageStreaming = if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) {
|
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)
|
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(
|
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(),
|
updated = book.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||||
id = book.id.toString(),
|
id = book.id,
|
||||||
content = run {
|
content = run {
|
||||||
var content = "${book.fileExtension().toUpperCase()} - ${book.fileSizeHumanReadable()}"
|
var content = "${book.fileExtension().toUpperCase()} - ${book.fileSizeHumanReadable()}"
|
||||||
if (metadata.summary.isNotBlank())
|
if (metadata.summary.isNotBlank())
|
||||||
|
|
@ -378,25 +442,32 @@ class OpdsController(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Library.toOpdsEntry(): OpdsEntryNavigation {
|
private fun Library.toOpdsEntry(): OpdsEntryNavigation =
|
||||||
return OpdsEntryNavigation(
|
OpdsEntryNavigation(
|
||||||
title = name,
|
title = name,
|
||||||
updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||||
id = id.toString(),
|
id = id,
|
||||||
content = "",
|
content = "",
|
||||||
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}libraries/$id")
|
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}libraries/$id")
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
private fun SeriesCollection.toOpdsEntry(): OpdsEntryNavigation {
|
private fun SeriesCollection.toOpdsEntry(): OpdsEntryNavigation =
|
||||||
return OpdsEntryNavigation(
|
OpdsEntryNavigation(
|
||||||
title = name,
|
title = name,
|
||||||
updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||||
id = id.toString(),
|
id = id,
|
||||||
content = "",
|
content = "",
|
||||||
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}collections/$id")
|
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) =
|
private fun shouldPrependBookNumbers(userAgent: String) =
|
||||||
userAgent.contains("chunky", ignoreCase = true)
|
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.BookMetadataRepository
|
||||||
import org.gotson.komga.domain.persistence.BookRepository
|
import org.gotson.komga.domain.persistence.BookRepository
|
||||||
import org.gotson.komga.domain.persistence.MediaRepository
|
import org.gotson.komga.domain.persistence.MediaRepository
|
||||||
|
import org.gotson.komga.domain.persistence.ReadListRepository
|
||||||
import org.gotson.komga.domain.service.BookLifecycle
|
import org.gotson.komga.domain.service.BookLifecycle
|
||||||
import org.gotson.komga.infrastructure.image.ImageType
|
import org.gotson.komga.infrastructure.image.ImageType
|
||||||
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
|
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.BookDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto
|
import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.PageDto
|
import org.gotson.komga.interfaces.rest.dto.PageDto
|
||||||
|
import org.gotson.komga.interfaces.rest.dto.ReadListDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.ReadProgressUpdateDto
|
import org.gotson.komga.interfaces.rest.dto.ReadProgressUpdateDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.restrictUrl
|
import org.gotson.komga.interfaces.rest.dto.restrictUrl
|
||||||
|
import org.gotson.komga.interfaces.rest.dto.toDto
|
||||||
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
|
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
|
||||||
import org.springframework.core.io.FileSystemResource
|
import org.springframework.core.io.FileSystemResource
|
||||||
import org.springframework.data.domain.Page
|
import org.springframework.data.domain.Page
|
||||||
|
|
@ -74,7 +77,8 @@ class BookController(
|
||||||
private val bookRepository: BookRepository,
|
private val bookRepository: BookRepository,
|
||||||
private val bookMetadataRepository: BookMetadataRepository,
|
private val bookMetadataRepository: BookMetadataRepository,
|
||||||
private val mediaRepository: MediaRepository,
|
private val mediaRepository: MediaRepository,
|
||||||
private val bookDtoRepository: BookDtoRepository
|
private val bookDtoRepository: BookDtoRepository,
|
||||||
|
private val readListRepository: ReadListRepository
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@PageableAsQueryParam
|
@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"))])
|
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
|
||||||
@GetMapping(value = [
|
@GetMapping(value = [
|
||||||
"api/v1/books/{bookId}/thumbnail",
|
"api/v1/books/{bookId}/thumbnail",
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ class LibraryController(
|
||||||
importComicInfoBook = library.importComicInfoBook,
|
importComicInfoBook = library.importComicInfoBook,
|
||||||
importComicInfoSeries = library.importComicInfoSeries,
|
importComicInfoSeries = library.importComicInfoSeries,
|
||||||
importComicInfoCollection = library.importComicInfoCollection,
|
importComicInfoCollection = library.importComicInfoCollection,
|
||||||
|
importComicInfoReadList = library.importComicInfoReadList,
|
||||||
importEpubBook = library.importEpubBook,
|
importEpubBook = library.importEpubBook,
|
||||||
importEpubSeries = library.importEpubSeries,
|
importEpubSeries = library.importEpubSeries,
|
||||||
importLocalArtwork = library.importLocalArtwork,
|
importLocalArtwork = library.importLocalArtwork,
|
||||||
|
|
@ -109,6 +110,7 @@ class LibraryController(
|
||||||
importComicInfoBook = library.importComicInfoBook,
|
importComicInfoBook = library.importComicInfoBook,
|
||||||
importComicInfoSeries = library.importComicInfoSeries,
|
importComicInfoSeries = library.importComicInfoSeries,
|
||||||
importComicInfoCollection = library.importComicInfoCollection,
|
importComicInfoCollection = library.importComicInfoCollection,
|
||||||
|
importComicInfoReadList = library.importComicInfoReadList,
|
||||||
importEpubBook = library.importEpubBook,
|
importEpubBook = library.importEpubBook,
|
||||||
importEpubSeries = library.importEpubSeries,
|
importEpubSeries = library.importEpubSeries,
|
||||||
importLocalArtwork = library.importLocalArtwork,
|
importLocalArtwork = library.importLocalArtwork,
|
||||||
|
|
@ -162,6 +164,7 @@ data class LibraryCreationDto(
|
||||||
val importComicInfoBook: Boolean = true,
|
val importComicInfoBook: Boolean = true,
|
||||||
val importComicInfoSeries: Boolean = true,
|
val importComicInfoSeries: Boolean = true,
|
||||||
val importComicInfoCollection: Boolean = true,
|
val importComicInfoCollection: Boolean = true,
|
||||||
|
val importComicInfoReadList: Boolean = true,
|
||||||
val importEpubBook: Boolean = true,
|
val importEpubBook: Boolean = true,
|
||||||
val importEpubSeries: Boolean = true,
|
val importEpubSeries: Boolean = true,
|
||||||
val importLocalArtwork: Boolean = true,
|
val importLocalArtwork: Boolean = true,
|
||||||
|
|
@ -176,6 +179,7 @@ data class LibraryDto(
|
||||||
val importComicInfoBook: Boolean,
|
val importComicInfoBook: Boolean,
|
||||||
val importComicInfoSeries: Boolean,
|
val importComicInfoSeries: Boolean,
|
||||||
val importComicInfoCollection: Boolean,
|
val importComicInfoCollection: Boolean,
|
||||||
|
val importComicInfoReadList: Boolean,
|
||||||
val importEpubBook: Boolean,
|
val importEpubBook: Boolean,
|
||||||
val importEpubSeries: Boolean,
|
val importEpubSeries: Boolean,
|
||||||
val importLocalArtwork: Boolean,
|
val importLocalArtwork: Boolean,
|
||||||
|
|
@ -189,6 +193,7 @@ data class LibraryUpdateDto(
|
||||||
val importComicInfoBook: Boolean,
|
val importComicInfoBook: Boolean,
|
||||||
val importComicInfoSeries: Boolean,
|
val importComicInfoSeries: Boolean,
|
||||||
val importComicInfoCollection: Boolean,
|
val importComicInfoCollection: Boolean,
|
||||||
|
val importComicInfoReadList: Boolean,
|
||||||
val importEpubBook: Boolean,
|
val importEpubBook: Boolean,
|
||||||
val importEpubSeries: Boolean,
|
val importEpubSeries: Boolean,
|
||||||
val importLocalArtwork: Boolean,
|
val importLocalArtwork: Boolean,
|
||||||
|
|
@ -203,6 +208,7 @@ fun Library.toDto(includeRoot: Boolean) = LibraryDto(
|
||||||
importComicInfoBook = importComicInfoBook,
|
importComicInfoBook = importComicInfoBook,
|
||||||
importComicInfoSeries = importComicInfoSeries,
|
importComicInfoSeries = importComicInfoSeries,
|
||||||
importComicInfoCollection = importComicInfoCollection,
|
importComicInfoCollection = importComicInfoCollection,
|
||||||
|
importComicInfoReadList = importComicInfoReadList,
|
||||||
importEpubBook = importEpubBook,
|
importEpubBook = importEpubBook,
|
||||||
importEpubSeries = importEpubSeries,
|
importEpubSeries = importEpubSeries,
|
||||||
importLocalArtwork = importLocalArtwork,
|
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.model.SeriesCollection
|
||||||
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
|
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
|
||||||
import org.gotson.komga.domain.service.SeriesCollectionLifecycle
|
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.jooq.UnpagedSorted
|
||||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||||
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
|
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
|
||||||
|
|
@ -52,9 +51,7 @@ private val logger = KotlinLogging.logger {}
|
||||||
class SeriesCollectionController(
|
class SeriesCollectionController(
|
||||||
private val collectionRepository: SeriesCollectionRepository,
|
private val collectionRepository: SeriesCollectionRepository,
|
||||||
private val collectionLifecycle: SeriesCollectionLifecycle,
|
private val collectionLifecycle: SeriesCollectionLifecycle,
|
||||||
private val seriesDtoRepository: SeriesDtoRepository,
|
private val seriesDtoRepository: SeriesDtoRepository
|
||||||
private val seriesController: SeriesController,
|
|
||||||
private val mosaicGenerator: MosaicGenerator
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@PageableWithoutSortAsQueryParam
|
@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 {
|
interface BookDtoRepository {
|
||||||
fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable): Page<BookDto>
|
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 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?
|
||||||
|
|
|
||||||
|
|
@ -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