mirror of
https://github.com/gotson/komga.git
synced 2025-12-20 15:34:17 +01:00
feat(api): search series by regex
This commit is contained in:
parent
c4a439276a
commit
1fe55809a1
14 changed files with 183 additions and 12 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -4,15 +4,21 @@ open class SeriesSearch(
|
|||
val libraryIds: Collection<String>? = null,
|
||||
val collectionIds: Collection<String>? = null,
|
||||
val searchTerm: String? = null,
|
||||
val searchRegex: Pair<String, SearchField>? = null,
|
||||
val metadataStatus: Collection<SeriesMetadata.Status>? = null,
|
||||
val publishers: Collection<String>? = null,
|
||||
val deleted: Boolean? = null,
|
||||
)
|
||||
) {
|
||||
enum class SearchField {
|
||||
NAME, TITLE, TITLE_SORT
|
||||
}
|
||||
}
|
||||
|
||||
class SeriesSearchWithReadProgress(
|
||||
libraryIds: Collection<String>? = null,
|
||||
collectionIds: Collection<String>? = null,
|
||||
searchTerm: String? = null,
|
||||
searchRegex: Pair<String, SearchField>? = null,
|
||||
metadataStatus: Collection<SeriesMetadata.Status>? = null,
|
||||
publishers: Collection<String>? = null,
|
||||
deleted: Boolean? = null,
|
||||
|
|
@ -27,6 +33,7 @@ class SeriesSearchWithReadProgress(
|
|||
libraryIds = libraryIds,
|
||||
collectionIds = collectionIds,
|
||||
searchTerm = searchTerm,
|
||||
searchRegex = searchRegex,
|
||||
metadataStatus = metadataStatus,
|
||||
publishers = publishers,
|
||||
deleted = deleted,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ package org.gotson.komga.infrastructure.web
|
|||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.VALUE_PARAMETER)
|
||||
annotation class Authors()
|
||||
annotation class Authors
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package org.gotson.komga.infrastructure.web
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.VALUE_PARAMETER)
|
||||
annotation class DelimitedPair(
|
||||
val parameterName: String
|
||||
)
|
||||
|
|
@ -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<String, String>? {
|
||||
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<String, String>? =
|
||||
if (!source.contains(delimiter)) null
|
||||
else Pair(source.substringBeforeLast(delimiter), source.substringAfterLast(delimiter))
|
||||
}
|
||||
|
|
@ -81,6 +81,7 @@ class WebMvcConfiguration : WebMvcConfigurer {
|
|||
|
||||
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
|
||||
resolvers.add(AuthorsHandlerMethodArgumentResolver())
|
||||
resolvers.add(DelimitedPairHandlerMethodArgumentResolver())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String, String>?,
|
||||
@RequestParam(name = "library_id", required = false) libraryIds: List<String>?,
|
||||
@RequestParam(name = "collection_id", required = false) collectionIds: List<String>?,
|
||||
@RequestParam(name = "status", required = false) metadataStatus: List<SeriesMetadata.Status>?,
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue