diff --git a/komga/build.gradle.kts b/komga/build.gradle.kts index 3cbab0b4b..f3b047b4b 100644 --- a/komga/build.gradle.kts +++ b/komga/build.gradle.kts @@ -87,7 +87,7 @@ dependencies { // While waiting for https://github.com/xerial/sqlite-jdbc/pull/491 and https://github.com/xerial/sqlite-jdbc/pull/494 // runtimeOnly("org.xerial:sqlite-jdbc:3.32.3.2") // jooqGenerator("org.xerial:sqlite-jdbc:3.32.3.2") - runtimeOnly("com.github.gotson:sqlite-jdbc:3.32.3.6") + implementation("com.github.gotson:sqlite-jdbc:3.32.3.6") jooqGenerator("com.github.gotson:sqlite-jdbc:3.32.3.6") testImplementation("org.springframework.boot:spring-boot-starter-test") { diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt index c36f85eb6..4f834ba8b 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt @@ -4,15 +4,21 @@ open class SeriesSearch( val libraryIds: Collection? = null, val collectionIds: Collection? = null, val searchTerm: String? = null, + val searchRegex: Pair? = null, val metadataStatus: Collection? = null, val publishers: Collection? = null, val deleted: Boolean? = null, -) +) { + enum class SearchField { + NAME, TITLE, TITLE_SORT + } +} class SeriesSearchWithReadProgress( libraryIds: Collection? = null, collectionIds: Collection? = null, searchTerm: String? = null, + searchRegex: Pair? = null, metadataStatus: Collection? = null, publishers: Collection? = null, deleted: Boolean? = null, @@ -27,6 +33,7 @@ class SeriesSearchWithReadProgress( libraryIds = libraryIds, collectionIds = collectionIds, searchTerm = searchTerm, + searchRegex = searchRegex, metadataStatus = metadataStatus, publishers = publishers, deleted = deleted, diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/DataSourcesConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/DataSourcesConfiguration.kt index 14704d228..53bfc92fc 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/DataSourcesConfiguration.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/DataSourcesConfiguration.kt @@ -1,5 +1,6 @@ package org.gotson.komga.infrastructure.datasource +import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariDataSource import org.gotson.komga.infrastructure.configuration.KomgaProperties import org.springframework.boot.jdbc.DataSourceBuilder @@ -15,12 +16,20 @@ class DataSourcesConfiguration( @Bean("sqliteDataSource") @Primary - fun sqliteDataSource(): DataSource = ( - DataSourceBuilder.create() + fun sqliteDataSource(): DataSource { + + val sqliteUdfDataSource = DataSourceBuilder.create() .driverClassName("org.sqlite.JDBC") .url("jdbc:sqlite:${komgaProperties.database.file}?foreign_keys=on;") - .type(HikariDataSource::class.java) - .build() as HikariDataSource + .type(SqliteUdfDataSource::class.java) + .build() + + return HikariDataSource( + HikariConfig().apply { + dataSource = sqliteUdfDataSource + poolName = "SqliteUdfPool" + maximumPoolSize = 1 + } ) - .apply { maximumPoolSize = 1 } + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/SqliteUdfDataSource.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/SqliteUdfDataSource.kt new file mode 100644 index 000000000..3ce2b7e7b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/SqliteUdfDataSource.kt @@ -0,0 +1,33 @@ +package org.gotson.komga.infrastructure.datasource + +import mu.KotlinLogging +import org.springframework.jdbc.datasource.SimpleDriverDataSource +import org.sqlite.Function +import org.sqlite.SQLiteConnection +import java.sql.Connection + +private val log = KotlinLogging.logger {} + +class SqliteUdfDataSource : SimpleDriverDataSource() { + + override fun getConnection(): Connection = + super.getConnection().also { createUdfRegexp(it as SQLiteConnection) } + + override fun getConnection(username: String, password: String): Connection = + super.getConnection(username, password).also { createUdfRegexp(it as SQLiteConnection) } + + private fun createUdfRegexp(connection: SQLiteConnection) { + log.debug { "Adding custom REGEXP function" } + Function.create( + connection, "REGEXP", + object : Function() { + override fun xFunc() { + val regexp = (value_text(0) ?: "").toRegex(RegexOption.IGNORE_CASE) + val text = value_text(1) ?: "" + + result(if (regexp.containsMatchIn(text)) 1 else 0) + } + } + ) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDao.kt index 3b4e04482..ce4d9b59c 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDao.kt @@ -127,6 +127,7 @@ class SeriesDao( if (!libraryIds.isNullOrEmpty()) c = c.and(s.LIBRARY_ID.`in`(libraryIds)) if (!collectionIds.isNullOrEmpty()) c = c.and(cs.COLLECTION_ID.`in`(collectionIds)) searchTerm?.let { c = c.and(d.TITLE.containsIgnoreCase(it)) } + searchRegex?.let { c = c.and((it.second.toColumn()).likeRegex(it.first)) } if (!metadataStatus.isNullOrEmpty()) c = c.and(d.STATUS.`in`(metadataStatus)) if (!publishers.isNullOrEmpty()) c = c.and(DSL.lower(d.PUBLISHER).`in`(publishers.map { it.lowercase() })) if (deleted == true) c = c.and(s.DELETED_DATE.isNotNull) @@ -135,6 +136,13 @@ class SeriesDao( return c } + private fun SeriesSearch.SearchField.toColumn() = + when (this) { + SeriesSearch.SearchField.NAME -> s.NAME + SeriesSearch.SearchField.TITLE -> d.TITLE + SeriesSearch.SearchField.TITLE_SORT -> d.TITLE_SORT + } + private fun SeriesRecord.toDomain() = Series( name = name, diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt index 2d9549f1b..acab7ca3c 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt @@ -1,6 +1,7 @@ package org.gotson.komga.infrastructure.jooq import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.SeriesSearch import org.gotson.komga.domain.model.SeriesSearchWithReadProgress import org.gotson.komga.infrastructure.web.toFilePath import org.gotson.komga.interfaces.rest.dto.AuthorDto @@ -205,6 +206,7 @@ class SeriesDtoDao( if (!libraryIds.isNullOrEmpty()) c = c.and(s.LIBRARY_ID.`in`(libraryIds)) if (!collectionIds.isNullOrEmpty()) c = c.and(cs.COLLECTION_ID.`in`(collectionIds)) searchTerm?.let { c = c.and(d.TITLE.containsIgnoreCase(it)) } + searchRegex?.let { c = c.and((it.second.toColumn()).likeRegex(it.first)) } if (!metadataStatus.isNullOrEmpty()) c = c.and(d.STATUS.`in`(metadataStatus)) if (!publishers.isNullOrEmpty()) c = c.and(lower(d.PUBLISHER).`in`(publishers.map { it.lowercase() })) if (deleted == true) c = c.and(s.DELETED_DATE.isNotNull) @@ -242,6 +244,13 @@ class SeriesDtoDao( return c } + private fun SeriesSearch.SearchField.toColumn() = + when (this) { + SeriesSearch.SearchField.NAME -> s.NAME + SeriesSearch.SearchField.TITLE -> d.TITLE + SeriesSearch.SearchField.TITLE_SORT -> d.TITLE_SORT + } + private fun SeriesSearchWithReadProgress.toJoinConditions() = JoinConditions( genre = !genres.isNullOrEmpty(), diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Authors.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Authors.kt index bfde49144..6d840dc07 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Authors.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Authors.kt @@ -2,4 +2,4 @@ package org.gotson.komga.infrastructure.web @Retention(AnnotationRetention.RUNTIME) @Target(AnnotationTarget.VALUE_PARAMETER) -annotation class Authors() +annotation class Authors diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/DelimitedPair.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/DelimitedPair.kt new file mode 100644 index 000000000..71e67171c --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/DelimitedPair.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.infrastructure.web + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class DelimitedPair( + val parameterName: String +) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/DelimitedPairHandlerMethodArgumentResolver.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/DelimitedPairHandlerMethodArgumentResolver.kt new file mode 100644 index 000000000..0b2ab6217 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/DelimitedPairHandlerMethodArgumentResolver.kt @@ -0,0 +1,31 @@ +package org.gotson.komga.infrastructure.web + +import org.springframework.core.MethodParameter +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class DelimitedPairHandlerMethodArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean = + parameter.getParameterAnnotation(DelimitedPair::class.java) != null + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory? + ): Pair? { + val paramName = parameter.getParameterAnnotation(DelimitedPair::class.java)?.parameterName ?: return null + val param = webRequest.getParameterValues(paramName) ?: return null + + // Single empty parameter, e.g "search=" + if (param.size == 1 && param[0].isNullOrBlank()) return null + + return parseParameterIntoPairs(param.first()) + } + + private fun parseParameterIntoPairs(source: String, delimiter: String = ","): Pair? = + if (!source.contains(delimiter)) null + else Pair(source.substringBeforeLast(delimiter), source.substringAfterLast(delimiter)) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/WebMvcConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/WebMvcConfiguration.kt index d60b0d225..48040758c 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/WebMvcConfiguration.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/WebMvcConfiguration.kt @@ -81,6 +81,7 @@ class WebMvcConfiguration : WebMvcConfigurer { override fun addArgumentResolvers(resolvers: MutableList) { resolvers.add(AuthorsHandlerMethodArgumentResolver()) + resolvers.add(DelimitedPairHandlerMethodArgumentResolver()) } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt index ba221eeb3..c07efccba 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt @@ -2,6 +2,8 @@ package org.gotson.komga.interfaces.rest import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.Parameters +import io.swagger.v3.oas.annotations.enums.ParameterIn import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse @@ -20,6 +22,7 @@ import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD import org.gotson.komga.domain.model.ReadStatus import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.SeriesSearch import org.gotson.komga.domain.model.SeriesSearchWithReadProgress import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.SeriesCollectionRepository @@ -33,6 +36,7 @@ import org.gotson.komga.infrastructure.swagger.AuthorsAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam import org.gotson.komga.infrastructure.web.Authors +import org.gotson.komga.infrastructure.web.DelimitedPair import org.gotson.komga.interfaces.rest.dto.BookDto import org.gotson.komga.interfaces.rest.dto.CollectionDto import org.gotson.komga.interfaces.rest.dto.SeriesDto @@ -93,10 +97,17 @@ class SeriesController( @PageableAsQueryParam @AuthorsAsQueryParam + @Parameters( + Parameter( + description = "Search by regex criteria, in the form: regex,field. Supported fields are TITLE and TITLE_SORT.", + `in` = ParameterIn.QUERY, name = "search_regex", schema = Schema(type = "string") + ) + ) @GetMapping fun getAllSeries( @AuthenticationPrincipal principal: KomgaPrincipal, @RequestParam(name = "search", required = false) searchTerm: String?, + @Parameter(hidden = true) @DelimitedPair("search_regex") searchRegex: Pair?, @RequestParam(name = "library_id", required = false) libraryIds: List?, @RequestParam(name = "collection_id", required = false) collectionIds: List?, @RequestParam(name = "status", required = false) metadataStatus: List?, @@ -128,6 +139,13 @@ class SeriesController( libraryIds = principal.user.getAuthorizedLibraryIds(libraryIds), collectionIds = collectionIds, searchTerm = searchTerm, + searchRegex = searchRegex?.let { + when (it.second.lowercase()) { + "title" -> Pair(it.first, SeriesSearch.SearchField.TITLE) + "title_sort" -> Pair(it.first, SeriesSearch.SearchField.TITLE_SORT) + else -> null + } + }, metadataStatus = metadataStatus, readStatus = readStatus, publishers = publishers, diff --git a/komga/src/main/resources/application-dev.yml b/komga/src/main/resources/application-dev.yml index f69bbed4d..1fb362db7 100644 --- a/komga/src/main/resources/application-dev.yml +++ b/komga/src/main/resources/application-dev.yml @@ -21,14 +21,15 @@ logging: name: komga-dev.log level: org.apache.activemq.audit.message: WARN + org.gotson.komga: DEBUG # org.jooq: DEBUG # web: DEBUG - org.gotson.komga: DEBUG +# com.zaxxer.hikari: DEBUG +# org.springframework.jms: DEBUG +# org.springframework.security.web.FilterChainProxy: DEBUG logback: rollingpolicy: max-history: 1 -# org.springframework.jms: DEBUG -# org.springframework.security.web.FilterChainProxy: DEBUG management.metrics.export.influx: # enabled: true diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDaoTest.kt index fbd873f94..51a22d0ca 100644 --- a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDaoTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDaoTest.kt @@ -195,7 +195,7 @@ class SeriesDaoTest( } @Test - fun `given existing series when searching then results is returned`() { + fun `given existing series when searching then result is returned`() { val series = Series( name = "Series", url = URL("file://series"), @@ -213,6 +213,21 @@ class SeriesDaoTest( assertThat(found).hasSize(1) } + @Test + fun `given existing series when searching by regex then result is returned`() { + val series = Series( + name = "my Series", + url = URL("file://series"), + fileLastModified = LocalDateTime.now(), + libraryId = library.id + ) + seriesDao.insert(series) + + assertThat(seriesDao.findAll(SeriesSearch(searchRegex = Pair("^my", SeriesSearch.SearchField.NAME)))).hasSize(1) + assertThat(seriesDao.findAll(SeriesSearch(searchRegex = Pair("ries$", SeriesSearch.SearchField.NAME)))).hasSize(1) + assertThat(seriesDao.findAll(SeriesSearch(searchRegex = Pair("series", SeriesSearch.SearchField.NAME)))).hasSize(1) + } + @Test fun `given existing series when finding by libraryId then series are returned`() { val series = Series( diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt index 86bfefdfd..26127766e 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt @@ -86,6 +86,38 @@ class SeriesControllerTest( seriesLifecycle.deleteMany(seriesRepository.findAll()) } + @Nested + inner class Search { + @Test + @WithMockCustomUser + fun `given series when searching by regex then series are found`() { + val alphaC = seriesLifecycle.createSeries(makeSeries("TheAlpha", libraryId = library.id)) + seriesMetadataRepository.findById(alphaC.id).let { + seriesMetadataRepository.update(it.copy(titleSort = "Alpha, The")) + } + seriesLifecycle.createSeries(makeSeries("TheBeta", libraryId = library.id)) + + mockMvc.get("/api/v1/series") { + param("search_regex", "a$,title_sort") + } + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(1) } + jsonPath("$.content[0].metadata.title") { value("TheBeta") } + } + + mockMvc.get("/api/v1/series") { + param("search_regex", "^the,title") + } + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].metadata.title") { value("TheAlpha") } + jsonPath("$.content[1].metadata.title") { value("TheBeta") } + } + } + } + @Nested inner class SeriesSort { @Test