From f0c864f4eba8c70bdac8f92171bfb03edabcea53 Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Thu, 20 Aug 2020 10:10:45 +0800 Subject: [PATCH] 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 --- .../sqlite/V20200817115957__readlists.sql | 20 + .../komga/domain/model/BookMetadataPatch.kt | 5 +- .../org/gotson/komga/domain/model/Library.kt | 1 + .../org/gotson/komga/domain/model/ReadList.kt | 21 + .../domain/persistence/ReadListRepository.kt | 41 ++ .../komga/domain/service/BookLifecycle.kt | 6 + .../komga/domain/service/MetadataLifecycle.kt | 70 +++- .../komga/domain/service/ReadListLifecycle.kt | 58 +++ .../komga/infrastructure/jooq/BookDtoDao.kt | 33 +- .../komga/infrastructure/jooq/LibraryDao.kt | 3 + .../komga/infrastructure/jooq/ReadListDao.kt | 233 +++++++++++ .../komga/infrastructure/language/Utils.kt | 6 + .../metadata/comicinfo/ComicInfoProvider.kt | 20 +- .../metadata/comicinfo/dto/ComicInfo.kt | 3 + .../metadata/epub/EpubMetadataProvider.kt | 4 +- .../komga/interfaces/opds/OpdsController.kt | 101 ++++- .../komga/interfaces/rest/BookController.kt | 20 +- .../interfaces/rest/LibraryController.kt | 6 + .../interfaces/rest/ReadListController.kt | 173 +++++++++ .../rest/SeriesCollectionController.kt | 5 +- .../rest/dto/ReadListCreationDto.kt | 10 + .../komga/interfaces/rest/dto/ReadListDto.kt | 29 ++ .../interfaces/rest/dto/ReadListUpdateDto.kt | 10 + .../rest/persistence/BookDtoRepository.kt | 1 + .../infrastructure/jooq/ReadListDaoTest.kt | 226 +++++++++++ .../interfaces/rest/ReadListControllerTest.kt | 367 ++++++++++++++++++ 26 files changed, 1422 insertions(+), 50 deletions(-) create mode 100644 komga/src/flyway/resources/db/migration/sqlite/V20200817115957__readlists.sql create mode 100644 komga/src/main/kotlin/org/gotson/komga/domain/model/ReadList.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadListRepository.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListLifecycle.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDao.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/infrastructure/language/Utils.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/rest/ReadListController.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListCreationDto.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListDto.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListUpdateDto.kt create mode 100644 komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDaoTest.kt create mode 100644 komga/src/test/kotlin/org/gotson/komga/interfaces/rest/ReadListControllerTest.kt diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20200817115957__readlists.sql b/komga/src/flyway/resources/db/migration/sqlite/V20200817115957__readlists.sql new file mode 100644 index 000000000..f49f5dc4c --- /dev/null +++ b/komga/src/flyway/resources/db/migration/sqlite/V20200817115957__readlists.sql @@ -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; diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt index c01d7d052..ce37a9f5f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt @@ -11,5 +11,8 @@ data class BookMetadataPatch( val publisher: String?, val ageRating: Int?, val releaseDate: LocalDate?, - val authors: List? + val authors: List?, + + val readList: String?, + val readListNumber: Int? ) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt index faa0423d7..dbd5f12ed 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt @@ -12,6 +12,7 @@ data class Library( val importComicInfoBook: Boolean = true, val importComicInfoSeries: Boolean = true, val importComicInfoCollection: Boolean = true, + val importComicInfoReadList: Boolean = true, val importEpubBook: Boolean = true, val importEpubSeries: Boolean = true, val importLocalArtwork: Boolean = true, diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadList.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadList.kt new file mode 100644 index 000000000..eb8874e1b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadList.kt @@ -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 = 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() diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadListRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadListRepository.kt new file mode 100644 index 000000000..13be3026d --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadListRepository.kt @@ -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 + + /** + * Find one ReadList by readListId, + * optionally with only bookIds filtered by the provided filterOnLibraryIds. + */ + fun findByIdOrNull(readListId: String, filterOnLibraryIds: Collection?): 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, filterOnLibraryIds: Collection?, search: String? = null, pageable: Pageable): Page + + /** + * Find all ReadList that contains the provided containsBookId, + * optionally with only bookIds filtered by the provided filterOnLibraryIds. + */ + fun findAllByBook(containsBookId: String, filterOnLibraryIds: Collection?): Collection + + fun findByNameOrNull(name: String): ReadList? + + fun insert(readList: ReadList) + fun update(readList: ReadList) + + fun removeBookFromAll(bookId: String) + fun removeBookFromAll(bookIds: Collection) + + fun delete(readListId: String) + + fun deleteAll() + fun existsByName(name: String): Boolean +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt index 2fd95ba4a..d1b052a79 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt @@ -12,6 +12,7 @@ import org.gotson.komga.domain.model.ThumbnailBook import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadListRepository import org.gotson.komga.domain.persistence.ReadProgressRepository import org.gotson.komga.domain.persistence.ThumbnailBookRepository import org.gotson.komga.infrastructure.image.ImageConverter @@ -30,6 +31,7 @@ class BookLifecycle( private val bookMetadataRepository: BookMetadataRepository, private val readProgressRepository: ReadProgressRepository, private val thumbnailBookRepository: ThumbnailBookRepository, + private val readListRepository: ReadListRepository, private val bookAnalyzer: BookAnalyzer, private val imageConverter: ImageConverter ) { @@ -195,6 +197,8 @@ class BookLifecycle( logger.info { "Delete book id: $bookId" } readProgressRepository.deleteByBookId(bookId) + readListRepository.removeBookFromAll(bookId) + mediaRepository.delete(bookId) thumbnailBookRepository.deleteByBookId(bookId) bookMetadataRepository.delete(bookId) @@ -206,6 +210,8 @@ class BookLifecycle( logger.info { "Delete all books: $bookIds" } readProgressRepository.deleteByBookIds(bookIds) + readListRepository.removeBookFromAll(bookIds) + mediaRepository.deleteByBookIds(bookIds) thumbnailBookRepository.deleteByBookIds(bookIds) bookMetadataRepository.deleteByBookIds(bookIds) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt index 0254ce222..6efc29769 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt @@ -2,6 +2,7 @@ package org.gotson.komga.domain.service import mu.KotlinLogging import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.ReadList import org.gotson.komga.domain.model.Series import org.gotson.komga.domain.model.SeriesCollection import org.gotson.komga.domain.model.SeriesMetadataPatch @@ -9,6 +10,7 @@ import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadListRepository import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.infrastructure.metadata.BookMetadataProvider @@ -34,6 +36,8 @@ class MetadataLifecycle( private val seriesLifecycle: SeriesLifecycle, private val collectionRepository: SeriesCollectionRepository, private val collectionLifecycle: SeriesCollectionLifecycle, + private val readListRepository: ReadListRepository, + private val readListLifecycle: ReadListLifecycle, private val localArtworkProvider: LocalArtworkProvider ) { @@ -45,20 +49,64 @@ class MetadataLifecycle( bookMetadataProviders.forEach { provider -> when { - provider is ComicInfoProvider && !library.importComicInfoBook -> logger.info { "Library is not set to import book metadata from ComicInfo, skipping" } + provider is ComicInfoProvider && !library.importComicInfoBook && !library.importComicInfoReadList -> logger.info { "Library is not set to import book and read lists metadata from ComicInfo, skipping" } provider is EpubMetadataProvider && !library.importEpubBook -> logger.info { "Library is not set to import book metadata from Epub, skipping" } else -> { logger.debug { "Provider: $provider" } - provider.getBookMetadataFromBook(book, media)?.let { bPatch -> + val patch = provider.getBookMetadataFromBook(book, media) - bookMetadataRepository.findById(book.id).let { - logger.debug { "Original metadata: $it" } - val patched = metadataApplier.apply(bPatch, it) - logger.debug { "Patched metadata: $patched" } + // handle book metadata + if ((provider is ComicInfoProvider && library.importComicInfoBook) || + (provider is EpubMetadataProvider && library.importEpubBook)) { + patch?.let { bPatch -> + bookMetadataRepository.findById(book.id).let { + logger.debug { "Original metadata: $it" } + val patched = metadataApplier.apply(bPatch, it) + logger.debug { "Patched metadata: $patched" } - bookMetadataRepository.update(patched) + bookMetadataRepository.update(patched) + } } } + + // handle read lists + if (provider is ComicInfoProvider && library.importComicInfoReadList) { + patch?.let { bPatch -> + val readList = bPatch.readList + val readListNumber = bPatch.readListNumber + + + if (readList != null) { + readListRepository.findByNameOrNull(readList).let { existing -> + if (existing != null) { + if (existing.bookIds.containsValue(book.id)) + logger.debug { "Book is already in existing readlist '${existing.name}'" } + else { + val map = existing.bookIds.toSortedMap() + val key = if (readListNumber != null && existing.bookIds.containsKey(readListNumber)) { + logger.debug { "Existing readlist '${existing.name}' already contains a book at position $readListNumber, adding book '${book.name}' at the end" } + existing.bookIds.lastKey() + 1 + } else { + logger.debug { "Adding book '${book.name}' to existing readlist '${existing.name}'" } + readListNumber ?: existing.bookIds.lastKey() + 1 + } + map[key] = book.id + readListLifecycle.updateReadList( + existing.copy(bookIds = map) + ) + } + } else { + logger.debug { "Adding book '${book.name}' to new readlist '$readList'" } + readListLifecycle.addReadList(ReadList( + name = readList, + bookIds = mapOf((readListNumber ?: 0) to book.id).toSortedMap() + )) + } + } + } + } + } + } } } @@ -85,7 +133,7 @@ class MetadataLifecycle( // handle series metadata if ((provider is ComicInfoProvider && library.importComicInfoSeries) || - (provider is EpubMetadataProvider && !library.importEpubSeries)) { + (provider is EpubMetadataProvider && library.importEpubSeries)) { val title = patches.uniqueOrNull { it.title } val titleSort = patches.uniqueOrNull { it.titleSort } val status = patches.uniqueOrNull { it.status } @@ -113,15 +161,15 @@ class MetadataLifecycle( collectionRepository.findByNameOrNull(collection).let { existing -> if (existing != null) { if (existing.seriesIds.contains(series.id)) - logger.debug { "Series is already in existing collection ${existing.name}" } + logger.debug { "Series is already in existing collection '${existing.name}'" } else { - logger.debug { "Adding series ${series.name} to existing collection ${existing.name}" } + logger.debug { "Adding series '${series.name}' to existing collection '${existing.name}'" } collectionLifecycle.updateCollection( existing.copy(seriesIds = existing.seriesIds + series.id) ) } } else { - logger.debug { "Adding series ${series.name} to new collection $collection" } + logger.debug { "Adding series '${series.name}' to new collection '$collection'" } collectionLifecycle.addCollection(SeriesCollection( name = collection, seriesIds = listOf(series.id) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListLifecycle.kt new file mode 100644 index 000000000..8b3c61fe5 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListLifecycle.kt @@ -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()) { + while (size < 4) { + this += readList.bookIds.values.take(4) + } + this.take(4) + } + + val images = ids.mapNotNull { bookLifecycle.getThumbnailBytes(it) } + return mosaicGenerator.createMosaic(images) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt index ed7baf704..de4bd10f9 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt @@ -39,6 +39,7 @@ class BookDtoDao( private val r = Tables.READ_PROGRESS private val a = Tables.BOOK_METADATA_AUTHOR private val s = Tables.SERIES + private val rlb = Tables.READLIST_BOOK private val sorts = mapOf( "name" to DSL.lower(b.NAME), @@ -52,24 +53,38 @@ class BookDtoDao( "media.comment" to DSL.lower(m.COMMENT), "media.mediaType" to DSL.lower(m.MEDIA_TYPE), "metadata.numberSort" to d.NUMBER_SORT, - "readProgress.lastModified" to r.LAST_MODIFIED_DATE + "readProgress.lastModified" to r.LAST_MODIFIED_DATE, + "readList.number" to rlb.NUMBER ) override fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable): Page { val conditions = search.toCondition() - val count = dsl.selectCount() + return findAll(conditions, userId, pageable) + } + + override fun findByReadListId(readListId: String, userId: String, pageable: Pageable): Page { + 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 { + val count = dsl.selectDistinct(b.ID) .from(b) .leftJoin(m).on(b.ID.eq(m.BOOK_ID)) .leftJoin(d).on(b.ID.eq(d.BOOK_ID)) .leftJoin(r).on(b.ID.eq(r.BOOK_ID)) .and(readProgressCondition(userId)) + .leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID)) .where(conditions) - .fetchOne(0, Long::class.java) + .groupBy(b.ID) + .fetch() + .size val orderBy = pageable.sort.toOrderBy(sorts) - val dtos = selectBase(userId) + val dtos = selectBase(userId, selectReadListNumber) .where(conditions) .orderBy(orderBy) .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } @@ -79,7 +94,7 @@ class BookDtoDao( return PageImpl( dtos, if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) - else PageRequest.of(0, maxOf(count.toInt(), 20), pageSort), + else PageRequest.of(0, maxOf(count, 20), pageSort), count.toLong() ) } @@ -151,17 +166,19 @@ class BookDtoDao( .firstOrNull() } - private fun selectBase(userId: String) = - dsl.select( + private fun selectBase(userId: String, selectReadListNumber: Boolean = false) = + dsl.selectDistinct( *b.fields(), *m.fields(), *d.fields(), *r.fields() - ).from(b) + ).apply { if (selectReadListNumber) select(rlb.NUMBER) } + .from(b) .leftJoin(m).on(b.ID.eq(m.BOOK_ID)) .leftJoin(d).on(b.ID.eq(d.BOOK_ID)) .leftJoin(r).on(b.ID.eq(r.BOOK_ID)) .and(readProgressCondition(userId)) + .leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID)) private fun ResultQuery.fetchAndMap() = fetch() diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDao.kt index 8c3f275e7..034e6b75b 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDao.kt @@ -70,6 +70,7 @@ class LibraryDao( .set(l.IMPORT_COMICINFO_BOOK, library.importComicInfoBook) .set(l.IMPORT_COMICINFO_SERIES, library.importComicInfoSeries) .set(l.IMPORT_COMICINFO_COLLECTION, library.importComicInfoCollection) + .set(l.IMPORT_COMICINFO_READLIST, library.importComicInfoReadList) .set(l.IMPORT_EPUB_BOOK, library.importEpubBook) .set(l.IMPORT_EPUB_SERIES, library.importEpubSeries) .set(l.IMPORT_LOCAL_ARTWORK, library.importLocalArtwork) @@ -85,6 +86,7 @@ class LibraryDao( .set(l.IMPORT_COMICINFO_BOOK, library.importComicInfoBook) .set(l.IMPORT_COMICINFO_SERIES, library.importComicInfoSeries) .set(l.IMPORT_COMICINFO_COLLECTION, library.importComicInfoCollection) + .set(l.IMPORT_COMICINFO_READLIST, library.importComicInfoReadList) .set(l.IMPORT_EPUB_BOOK, library.importEpubBook) .set(l.IMPORT_EPUB_SERIES, library.importEpubSeries) .set(l.IMPORT_LOCAL_ARTWORK, library.importLocalArtwork) @@ -105,6 +107,7 @@ class LibraryDao( importComicInfoBook = importComicinfoBook, importComicInfoSeries = importComicinfoSeries, importComicInfoCollection = importComicinfoCollection, + importComicInfoReadList = importComicinfoReadlist, importEpubBook = importEpubBook, importEpubSeries = importEpubSeries, importLocalArtwork = importLocalArtwork, diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDao.kt new file mode 100644 index 000000000..de213b74a --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDao.kt @@ -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?): 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 { + 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, filterOnLibraryIds: Collection?, search: String?, pageable: Pageable): Page { + 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?): Collection { + 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.fetchAndMap(filterOnLibraryIds: Collection?): List = + 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) { + 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) = + ReadList( + name = name, + bookIds = bookIds, + id = id, + createdDate = createdDate.toCurrentTimeZone(), + lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), + filtered = bookCount != bookIds.size + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/language/Utils.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/language/Utils.kt new file mode 100644 index 000000000..8f8310387 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/language/Utils.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.infrastructure.language + +import java.util.SortedMap + +fun List.toIndexedMap(): SortedMap = + mapIndexed { i, e -> i to e }.toMap().toSortedMap() diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProvider.kt index 67a3f7d8e..229c3e619 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProvider.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProvider.kt @@ -49,15 +49,17 @@ class ComicInfoProvider( } return BookMetadataPatch( - comicInfo.title, - comicInfo.summary, - comicInfo.number, - comicInfo.number?.toFloatOrNull(), - readingDirection, - comicInfo.publisher, - comicInfo.ageRating?.ageRating, - releaseDate, - authors.ifEmpty { null } + title = comicInfo.title, + summary = comicInfo.summary, + number = comicInfo.number, + numberSort = comicInfo.number?.toFloatOrNull(), + readingDirection = readingDirection, + publisher = comicInfo.publisher, + ageRating = comicInfo.ageRating?.ageRating, + releaseDate = releaseDate, + authors = authors.ifEmpty { null }, + readList = comicInfo.alternateSeries ?: comicInfo.storyArc, + readListNumber = comicInfo.alternateNumber?.toIntOrNull() ?: comicInfo.storyArcNumber?.toIntOrNull() ) } return null diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/ComicInfo.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/ComicInfo.kt index 21cd52ebd..acd889dfc 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/ComicInfo.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/ComicInfo.kt @@ -111,6 +111,9 @@ class ComicInfo { @JsonProperty(value = "StoryArc") var storyArc: String? = null + @JsonProperty(value = "StoryArcNumber") + var storyArcNumber: String? = null + @JsonProperty(value = "SeriesGroup") var seriesGroup: String? = null diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProvider.kt index 0522f4e75..484a87f5b 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProvider.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProvider.kt @@ -67,7 +67,9 @@ class EpubMetadataProvider( publisher = publisher, ageRating = null, releaseDate = date, - authors = authors + authors = authors, + readList = null, + readListNumber = null ) } return null diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt index 0e3821124..92485cd0f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt @@ -6,6 +6,7 @@ import org.gotson.komga.domain.model.BookMetadata import org.gotson.komga.domain.model.BookSearch import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.ReadList import org.gotson.komga.domain.model.Series import org.gotson.komga.domain.model.SeriesCollection import org.gotson.komga.domain.model.SeriesMetadata @@ -14,6 +15,7 @@ import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadListRepository import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.persistence.SeriesRepository @@ -59,12 +61,14 @@ private const val ROUTE_SERIES_ALL = "series" private const val ROUTE_SERIES_LATEST = "series/latest" private const val ROUTE_LIBRARIES_ALL = "libraries" private const val ROUTE_COLLECTIONS_ALL = "collections" +private const val ROUTE_READLISTS_ALL = "readlists" private const val ROUTE_SEARCH = "search" private const val ID_SERIES_ALL = "allSeries" private const val ID_SERIES_LATEST = "latestSeries" private const val ID_LIBRARIES_ALL = "allLibraries" private const val ID_COLLECTIONS_ALL = "allCollections" +private const val ID_READLISTS_ALL = "allReadLists" @RestController @RequestMapping(value = [ROUTE_BASE], produces = [MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE]) @@ -72,6 +76,7 @@ class OpdsController( servletContext: ServletContext, private val libraryRepository: LibraryRepository, private val collectionRepository: SeriesCollectionRepository, + private val readListRepository: ReadListRepository, private val seriesRepository: SeriesRepository, private val seriesMetadataRepository: SeriesMetadataRepository, private val bookRepository: BookRepository, @@ -127,6 +132,13 @@ class OpdsController( id = ID_COLLECTIONS_ALL, content = "Browse by collection", link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "$routeBase$ROUTE_COLLECTIONS_ALL") + ), + OpdsEntryNavigation( + title = "All read lists", + updated = ZonedDateTime.now(), + id = ID_READLISTS_ALL, + content = "Browse by read lists", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "$routeBase$ROUTE_READLISTS_ALL") ) ) ) @@ -245,6 +257,30 @@ class OpdsController( ) } + @GetMapping(ROUTE_READLISTS_ALL) + fun getReadLists( + @AuthenticationPrincipal principal: KomgaPrincipal + ): OpdsFeed { + val pageRequest = UnpagedSorted(Sort.by(Sort.Order.asc("name"))) + val readLists = + if (principal.user.sharedAllLibraries) { + readListRepository.findAll(pageable = pageRequest) + } else { + readListRepository.findAllByLibraries(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds, pageable = pageRequest) + } + return OpdsFeedNavigation( + id = ID_READLISTS_ALL, + title = "All read lists", + updated = ZonedDateTime.now(), + author = komgaAuthor, + links = listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_READLISTS_ALL"), + linkStart + ), + entries = readLists.content.map { it.toOpdsEntry() } + ) + } + @GetMapping("series/{id}") fun getOneSeries( @AuthenticationPrincipal principal: KomgaPrincipal, @@ -266,7 +302,7 @@ class OpdsController( .map { it.toOpdsEntry(shouldPrependBookNumbers(userAgent)) } OpdsFeedAcquisition( - id = series.id.toString(), + id = series.id, title = metadata.title, updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), author = komgaAuthor, @@ -294,7 +330,7 @@ class OpdsController( .map { it.toOpdsEntry() } OpdsFeedNavigation( - id = library.id.toString(), + id = library.id, title = library.name, updated = library.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), author = komgaAuthor, @@ -324,7 +360,7 @@ class OpdsController( } OpdsFeedNavigation( - id = collection.id.toString(), + id = collection.id, title = collection.name, updated = collection.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), author = komgaAuthor, @@ -337,19 +373,46 @@ class OpdsController( } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } + @GetMapping("readlists/{id}") + fun getOneReadList( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: String + ): OpdsFeed { + return readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList -> + val books = readList.bookIds.values.mapNotNull { bookRepository.findByIdOrNull(it) } + .map { BookWithInfo(it, mediaRepository.findById(it.id), bookMetadataRepository.findById(it.id)) } + + val entries = books.mapIndexed { index, it -> + it.toOpdsEntry(prependNumber = false, prepend = index + 1) + } + + OpdsFeedAcquisition( + id = readList.id, + title = readList.name, + updated = readList.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + author = komgaAuthor, + links = listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}readlists/$id"), + linkStart + ), + entries = entries + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + private fun SeriesWithInfo.toOpdsEntry(prepend: Int? = null): OpdsEntryNavigation { val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: "" return OpdsEntryNavigation( title = pre + metadata.title, updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), - id = series.id.toString(), + id = series.id, content = "", link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}series/${series.id}") ) } - private fun BookWithInfo.toOpdsEntry(prependNumber: Boolean): OpdsEntryAcquisition { + private fun BookWithInfo.toOpdsEntry(prependNumber: Boolean, prepend: Int? = null): OpdsEntryAcquisition { val mediaTypes = media.pages.map { it.mediaType }.distinct() val opdsLinkPageStreaming = if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) { @@ -358,10 +421,11 @@ class OpdsController( OpdsLinkPageStreaming("image/jpeg", "${routeBase}books/${book.id}/pages/{pageNumber}?convert=jpeg&zero_based=true", media.pages.size) } + val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: "" return OpdsEntryAcquisition( - title = "${if (prependNumber) "${decimalFormat.format(metadata.numberSort)} - " else ""}${metadata.title}", + title = "$pre${if (prependNumber) "${decimalFormat.format(metadata.numberSort)} - " else ""}${metadata.title}", updated = book.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), - id = book.id.toString(), + id = book.id, content = run { var content = "${book.fileExtension().toUpperCase()} - ${book.fileSizeHumanReadable()}" if (metadata.summary.isNotBlank()) @@ -378,25 +442,32 @@ class OpdsController( ) } - private fun Library.toOpdsEntry(): OpdsEntryNavigation { - return OpdsEntryNavigation( + private fun Library.toOpdsEntry(): OpdsEntryNavigation = + OpdsEntryNavigation( title = name, updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), - id = id.toString(), + id = id, content = "", link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}libraries/$id") ) - } - private fun SeriesCollection.toOpdsEntry(): OpdsEntryNavigation { - return OpdsEntryNavigation( + private fun SeriesCollection.toOpdsEntry(): OpdsEntryNavigation = + OpdsEntryNavigation( title = name, updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), - id = id.toString(), + id = id, content = "", link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}collections/$id") ) - } + + private fun ReadList.toOpdsEntry(): OpdsEntryNavigation = + OpdsEntryNavigation( + title = name, + updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + id = id, + content = "", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}readlists/$id") + ) private fun shouldPrependBookNumbers(userAgent: String) = userAgent.contains("chunky", ignoreCase = true) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt index 7974805f7..8600a856f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt @@ -20,6 +20,7 @@ import org.gotson.komga.domain.model.ReadStatus import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadListRepository import org.gotson.komga.domain.service.BookLifecycle import org.gotson.komga.infrastructure.image.ImageType import org.gotson.komga.infrastructure.jooq.UnpagedSorted @@ -30,8 +31,10 @@ import org.gotson.komga.infrastructure.web.setCachePrivate import org.gotson.komga.interfaces.rest.dto.BookDto import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto import org.gotson.komga.interfaces.rest.dto.PageDto +import org.gotson.komga.interfaces.rest.dto.ReadListDto import org.gotson.komga.interfaces.rest.dto.ReadProgressUpdateDto import org.gotson.komga.interfaces.rest.dto.restrictUrl +import org.gotson.komga.interfaces.rest.dto.toDto import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository import org.springframework.core.io.FileSystemResource import org.springframework.data.domain.Page @@ -74,7 +77,8 @@ class BookController( private val bookRepository: BookRepository, private val bookMetadataRepository: BookMetadataRepository, private val mediaRepository: MediaRepository, - private val bookDtoRepository: BookDtoRepository + private val bookDtoRepository: BookDtoRepository, + private val readListRepository: ReadListRepository ) { @PageableAsQueryParam @@ -195,6 +199,20 @@ class BookController( } + @GetMapping("api/v1/books/{bookId}/readlists") + fun getAllReadListsByBook( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "bookId") bookId: String + ): List { + bookRepository.getLibraryId(bookId)?.let { + if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + return readListRepository.findAllByBook(bookId, principal.user.getAuthorizedLibraryIds(null)) + .map { it.toDto() } + } + + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) @GetMapping(value = [ "api/v1/books/{bookId}/thumbnail", diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt index ffed62ad9..79445b741 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt @@ -76,6 +76,7 @@ class LibraryController( importComicInfoBook = library.importComicInfoBook, importComicInfoSeries = library.importComicInfoSeries, importComicInfoCollection = library.importComicInfoCollection, + importComicInfoReadList = library.importComicInfoReadList, importEpubBook = library.importEpubBook, importEpubSeries = library.importEpubSeries, importLocalArtwork = library.importLocalArtwork, @@ -109,6 +110,7 @@ class LibraryController( importComicInfoBook = library.importComicInfoBook, importComicInfoSeries = library.importComicInfoSeries, importComicInfoCollection = library.importComicInfoCollection, + importComicInfoReadList = library.importComicInfoReadList, importEpubBook = library.importEpubBook, importEpubSeries = library.importEpubSeries, importLocalArtwork = library.importLocalArtwork, @@ -162,6 +164,7 @@ data class LibraryCreationDto( val importComicInfoBook: Boolean = true, val importComicInfoSeries: Boolean = true, val importComicInfoCollection: Boolean = true, + val importComicInfoReadList: Boolean = true, val importEpubBook: Boolean = true, val importEpubSeries: Boolean = true, val importLocalArtwork: Boolean = true, @@ -176,6 +179,7 @@ data class LibraryDto( val importComicInfoBook: Boolean, val importComicInfoSeries: Boolean, val importComicInfoCollection: Boolean, + val importComicInfoReadList: Boolean, val importEpubBook: Boolean, val importEpubSeries: Boolean, val importLocalArtwork: Boolean, @@ -189,6 +193,7 @@ data class LibraryUpdateDto( val importComicInfoBook: Boolean, val importComicInfoSeries: Boolean, val importComicInfoCollection: Boolean, + val importComicInfoReadList: Boolean, val importEpubBook: Boolean, val importEpubSeries: Boolean, val importLocalArtwork: Boolean, @@ -203,6 +208,7 @@ fun Library.toDto(includeRoot: Boolean) = LibraryDto( importComicInfoBook = importComicInfoBook, importComicInfoSeries = importComicInfoSeries, importComicInfoCollection = importComicInfoCollection, + importComicInfoReadList = importComicInfoReadList, importEpubBook = importEpubBook, importEpubSeries = importEpubSeries, importLocalArtwork = importLocalArtwork, diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/ReadListController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/ReadListController.kt new file mode 100644 index 000000000..3d36c1c4b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/ReadListController.kt @@ -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?, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable + ): Page { + 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 { + 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 = + 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) +} + diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionController.kt index fa60d50db..a2d873a2a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionController.kt @@ -10,7 +10,6 @@ import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.SeriesCollection import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.domain.service.SeriesCollectionLifecycle -import org.gotson.komga.infrastructure.image.MosaicGenerator import org.gotson.komga.infrastructure.jooq.UnpagedSorted import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam @@ -52,9 +51,7 @@ private val logger = KotlinLogging.logger {} class SeriesCollectionController( private val collectionRepository: SeriesCollectionRepository, private val collectionLifecycle: SeriesCollectionLifecycle, - private val seriesDtoRepository: SeriesDtoRepository, - private val seriesController: SeriesController, - private val mosaicGenerator: MosaicGenerator + private val seriesDtoRepository: SeriesDtoRepository ) { @PageableWithoutSortAsQueryParam diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListCreationDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListCreationDto.kt new file mode 100644 index 000000000..ebb2ee0d9 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListCreationDto.kt @@ -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 +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListDto.kt new file mode 100644 index 000000000..a74bea91f --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListDto.kt @@ -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, + + @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 + ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListUpdateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListUpdateDto.kt new file mode 100644 index 000000000..dd2b7527e --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListUpdateDto.kt @@ -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? +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/BookDtoRepository.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/BookDtoRepository.kt index 4b1b54fbc..f916327c0 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/BookDtoRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/BookDtoRepository.kt @@ -7,6 +7,7 @@ import org.springframework.data.domain.Pageable interface BookDtoRepository { fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable): Page + fun findByReadListId(readListId: String, userId: String, pageable: Pageable): Page fun findByIdOrNull(bookId: String, userId: String): BookDto? fun findPreviousInSeries(bookId: String, userId: String): BookDto? fun findNextInSeries(bookId: String, userId: String): BookDto? diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDaoTest.kt new file mode 100644 index 000000000..c1b94f3fd --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDaoTest.kt @@ -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() + } + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/ReadListControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/ReadListControllerTest.kt new file mode 100644 index 000000000..43038a098 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/ReadListControllerTest.kt @@ -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 + private lateinit var booksLibrary2: List + 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 } + } + } + } +}