diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt
index cc3a920c..c11a99d9 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt
@@ -6,13 +6,10 @@ import okhttp3.HttpUrl
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.model.search.*
import org.koitharu.kotatsu.parsers.network.OkHttpWebClient
import org.koitharu.kotatsu.parsers.network.WebClient
-import org.koitharu.kotatsu.parsers.util.FaviconParser
-import org.koitharu.kotatsu.parsers.util.LinkResolver
-import org.koitharu.kotatsu.parsers.util.RelatedMangaFinder
-import org.koitharu.kotatsu.parsers.util.domain
-import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
+import org.koitharu.kotatsu.parsers.util.*
import java.util.*
public abstract class MangaParser @InternalParsersApi constructor(
@@ -27,8 +24,12 @@ public abstract class MangaParser @InternalParsersApi constructor(
*/
public abstract val availableSortOrders: Set
+ @Deprecated("Please check searchQueryCapabilities")
public abstract val filterCapabilities: MangaListFilterCapabilities
+ public open val searchQueryCapabilities: MangaSearchQueryCapabilities
+ get() = filterCapabilities.toMangaSearchQueryCapabilities()
+
public val config: MangaSourceConfig by lazy { context.getConfig(source) }
public open val sourceLocale: Locale
@@ -62,6 +63,30 @@ public abstract class MangaParser @InternalParsersApi constructor(
@JvmField
protected val webClient: WebClient = OkHttpWebClient(context.httpClient, source)
+ /**
+ * Search list of manga by specified searchQuery
+ *
+ * @param searchQuery searchQuery
+ */
+ public suspend fun queryManga(searchQuery: MangaSearchQuery): List {
+ if (!searchQuery.skipValidation) {
+ searchQueryCapabilities.validate(searchQuery)
+ }
+
+ return getList(searchQuery)
+ }
+
+ /**
+ * Search list of manga by specified searchQuery
+ *
+ * @param query searchQuery
+ */
+ protected open suspend fun getList(query: MangaSearchQuery): List = getList(
+ offset = query.offset,
+ order = query.order ?: defaultSortOrder,
+ filter = convertToMangaListFilter(query),
+ )
+
/**
* Parse list of manga by specified criteria
*
@@ -69,7 +94,10 @@ public abstract class MangaParser @InternalParsersApi constructor(
* Note than passed value may not be divisible by internal page size, so you should adjust it manually.
* @param order one of [availableSortOrders] or [defaultSortOrder] for default value
* @param filter is a set of filter rules
+ *
+ * @deprecated New [getList] should be preferred.
*/
+ @Deprecated("New getList(query: MangaSearchQuery) method should be preferred")
public abstract suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List
/**
@@ -113,4 +141,5 @@ public abstract class MangaParser @InternalParsersApi constructor(
* @see [Manga.publicUrl]
*/
internal open suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? = null
+
}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/PagedMangaParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/PagedMangaParser.kt
index 02492f43..e80a07cc 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/PagedMangaParser.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/PagedMangaParser.kt
@@ -5,7 +5,10 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.SortOrder
+import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
+import org.koitharu.kotatsu.parsers.model.search.SearchableField
import org.koitharu.kotatsu.parsers.util.Paginator
+import org.koitharu.kotatsu.parsers.util.convertToMangaListFilter
@InternalParsersApi
public abstract class PagedMangaParser(
@@ -21,6 +24,33 @@ public abstract class PagedMangaParser(
@JvmField
protected val searchPaginator: Paginator = Paginator(searchPageSize)
+ final override suspend fun getList(query: MangaSearchQuery): List {
+ var containTitleNameCriteria = false
+ query.criteria.forEach {
+ if (it.field == SearchableField.TITLE_NAME) {
+ containTitleNameCriteria = true
+ }
+ }
+
+ return searchManga(
+ paginator = if (containTitleNameCriteria) {
+ paginator
+ } else {
+ searchPaginator
+ },
+ query = query,
+ )
+ }
+
+ public open suspend fun getListPage(query: MangaSearchQuery, page: Int): List {
+ return getListPage(
+ page = page,
+ order = query.order ?: defaultSortOrder,
+ filter = convertToMangaListFilter(query),
+ )
+ }
+
+ @Deprecated("New searchManga method should be preferred")
final override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List {
return getList(
paginator = if (filter.query.isNullOrEmpty()) {
@@ -34,6 +64,7 @@ public abstract class PagedMangaParser(
)
}
+ @Deprecated("New searchManga method should be preferred")
public abstract suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List
private suspend fun getList(
@@ -47,4 +78,15 @@ public abstract class PagedMangaParser(
paginator.onListReceived(offset, page, list.size)
return list
}
+
+ private suspend fun searchManga(
+ paginator: Paginator,
+ query: MangaSearchQuery,
+ ): List {
+ val offset: Int = query.offset
+ val page = paginator.getPage(offset)
+ val list = getListPage(query, page)
+ paginator.onListReceived(offset, page, list.size)
+ return list
+ }
}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/SinglePageMangaParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/SinglePageMangaParser.kt
index 6e2425af..406f2181 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/SinglePageMangaParser.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/SinglePageMangaParser.kt
@@ -4,6 +4,8 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.SortOrder
+import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
+import org.koitharu.kotatsu.parsers.util.convertToMangaListFilter
@InternalParsersApi
public abstract class SinglePageMangaParser(
@@ -11,6 +13,23 @@ public abstract class SinglePageMangaParser(
source: MangaParserSource,
) : MangaParser(context, source) {
+
+ final override suspend fun getList(query: MangaSearchQuery): List {
+ if (query.offset > 0) {
+ return emptyList()
+ }
+ return searchSinglePageManga(query)
+ }
+
+ public open suspend fun searchSinglePageManga(searchQuery: MangaSearchQuery): List {
+ return getList(
+ searchQuery.offset,
+ searchQuery.order ?: defaultSortOrder,
+ convertToMangaListFilter(searchQuery),
+ )
+ }
+
+ @Deprecated("New searchManga method should be preferred")
final override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List {
if (offset > 0) {
return emptyList()
@@ -18,5 +37,6 @@ public abstract class SinglePageMangaParser(
return getList(order, filter)
}
+ @Deprecated("New searchManga method should be preferred")
public abstract suspend fun getList(order: SortOrder, filter: MangaListFilter): List
}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/Manga.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/Manga.kt
index d7a9b8dd..53326d8b 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/Manga.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/Manga.kt
@@ -52,7 +52,12 @@ public data class Manga private constructor(
/**
* Author of the manga, may be null
*/
+ @Deprecated("Please use authors")
@JvmField public val author: String?,
+ /**
+ * Authors of the manga
+ */
+ @JvmField public val authors: Set,
/**
* Large cover url (absolute), null if is no large cover
* @see coverUrl
@@ -189,6 +194,7 @@ public data class Manga private constructor(
source = source,
)
+ @Deprecated("")
public operator fun invoke(
/**
* Unique identifier for manga
@@ -271,5 +277,89 @@ public data class Manga private constructor(
chapters = chapters,
source = source,
)
+
+ public operator fun invoke(
+ /**
+ * Unique identifier for manga
+ */
+ id: Long,
+ /**
+ * Manga title, human-readable
+ */
+ title: String,
+ /**
+ * Alternative title (for example on other language), may be null
+ */
+ altTitle: String?,
+ /**
+ * Relative url to manga (**without** a domain) or any other uri.
+ * Used principally in parsers
+ */
+ url: String,
+ /**
+ * Absolute url to manga, must be ready to open in browser
+ */
+ publicUrl: String,
+ /**
+ * Normalized manga rating, must be in range of 0..1 or [RATING_UNKNOWN] if rating s unknown
+ * @see hasRating
+ */
+ rating: Float,
+ /**
+ * Indicates that manga may contain sensitive information (18+, NSFW)
+ */
+ contentRating: ContentRating?,
+ /**
+ * Absolute link to the cover
+ * @see largeCoverUrl
+ */
+ coverUrl: String?,
+ /**
+ * Tags (genres) of the manga
+ */
+ tags: Set,
+ /**
+ * Manga status (ongoing, finished) or null if unknown
+ */
+ state: MangaState?,
+ /**
+ * Authors of the manga
+ */
+ authors: Set,
+ /**
+ * Large cover url (absolute), null if is no large cover
+ * @see coverUrl
+ */
+ largeCoverUrl: String? = null,
+ /**
+ * Manga description, may be html or null
+ */
+ description: String? = null,
+ /**
+ * List of chapters
+ */
+ chapters: List? = null,
+ /**
+ * Manga source
+ */
+ source: MangaSource,
+ ): Manga = Manga(
+ id = id,
+ title = title,
+ altTitle = altTitle?.nullIfEmpty(),
+ url = url,
+ publicUrl = publicUrl,
+ rating = rating,
+ contentRating = contentRating,
+ coverUrl = coverUrl?.nullIfEmpty(),
+ tags = tags,
+ state = state,
+ authors = authors,
+ largeCoverUrl = largeCoverUrl?.nullIfEmpty(),
+ description = description?.nullIfEmpty(),
+ chapters = chapters,
+ source = source,
+ author = authors?.firstOrNull()?.title
+ )
}
}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilter.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilter.kt
index e996a588..771f2287 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilter.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilter.kt
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.parsers.model
import java.util.*
+@Deprecated("Please check new searchManga method and MangaSearchQuery class")
public data class MangaListFilter(
@JvmField val query: String? = null,
@JvmField val tags: Set = emptySet(),
@@ -42,4 +43,47 @@ public data class MangaListFilter(
@JvmStatic
public val EMPTY: MangaListFilter = MangaListFilter()
}
+
+ internal class Builder {
+ private var query: String? = null
+ private val tags: MutableSet = mutableSetOf()
+ private val tagsExclude: MutableSet = mutableSetOf()
+ private var locale: Locale? = null
+ private var originalLocale: Locale? = null
+ private val states: MutableSet = mutableSetOf()
+ private val contentRating: MutableSet = mutableSetOf()
+ private val types: MutableSet = mutableSetOf()
+ private val demographics: MutableSet = mutableSetOf()
+ private var year: Int = YEAR_UNKNOWN
+ private var yearFrom: Int = YEAR_UNKNOWN
+ private var yearTo: Int = YEAR_UNKNOWN
+
+ fun query(query: String?): Builder = apply { this.query = query }
+ fun addTag(tag: MangaTag): Builder = apply { tags.add(tag) }
+ fun addTags(tags: Collection): Builder = apply { this.tags.addAll(tags) }
+ fun excludeTag(tag: MangaTag): Builder = apply { tagsExclude.add(tag) }
+ fun excludeTags(tags: Collection): Builder = apply { this.tagsExclude.addAll(tags) }
+ fun locale(locale: Locale?): Builder = apply { this.locale = locale }
+ fun originalLocale(locale: Locale?): Builder = apply { this.originalLocale = locale }
+ fun addState(state: MangaState): Builder = apply { states.add(state) }
+ fun addStates(states: Collection): Builder = apply { this.states.addAll(states) }
+ fun addContentRating(rating: ContentRating): Builder = apply { contentRating.add(rating) }
+ fun addContentRatings(ratings: Collection): Builder =
+ apply { this.contentRating.addAll(ratings) }
+
+ fun addType(type: ContentType): Builder = apply { types.add(type) }
+ fun addTypes(types: Collection): Builder = apply { this.types.addAll(types) }
+ fun addDemographic(demographic: Demographic): Builder = apply { demographics.add(demographic) }
+ fun addDemographics(demographics: Collection): Builder =
+ apply { this.demographics.addAll(demographics) }
+
+ fun year(year: Int): Builder = apply { this.year = year }
+ fun yearFrom(year: Int): Builder = apply { this.yearFrom = year }
+ fun yearTo(year: Int): Builder = apply { this.yearTo = year }
+
+ fun build(): MangaListFilter = MangaListFilter(
+ query, tags, tagsExclude, locale, originalLocale, states,
+ contentRating, types, demographics, year, yearFrom, yearTo,
+ )
+ }
}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilterCapabilities.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilterCapabilities.kt
index d0daa06d..00a479cd 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilterCapabilities.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilterCapabilities.kt
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.parsers.model
import org.koitharu.kotatsu.parsers.InternalParsersApi
+@Deprecated("Please check new MangaSearchQueryCapabilities class")
public data class MangaListFilterCapabilities @InternalParsersApi constructor(
/**
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQuery.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQuery.kt
new file mode 100644
index 00000000..628b13f3
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQuery.kt
@@ -0,0 +1,84 @@
+package org.koitharu.kotatsu.parsers.model.search
+
+import androidx.collection.ArrayMap
+import androidx.collection.ArraySet
+import org.koitharu.kotatsu.parsers.model.SortOrder
+
+/**
+ * Represents a search query for filtering and sorting manga search results.
+ * This class is immutable and must be constructed using the [Builder].
+ *
+ * @property criteria The set of search criteria applied to the query.
+ * @property order The sorting order for the results (optional).
+ * @property offset The offset number for paginated search results (optional).
+ */
+
+@ConsistentCopyVisibility
+public data class MangaSearchQuery private constructor(
+ @JvmField public val criteria: Set>,
+ @JvmField public val order: SortOrder?,
+ @JvmField public val offset: Int,
+ @JvmField public val skipValidation: Boolean,
+) {
+
+ public fun newBuilder(): Builder = Builder(this)
+
+ public class Builder {
+
+ private val criteria = ArraySet>()
+ private var order: SortOrder? = null
+ private var offset: Int = 0
+ private var skipValidation: Boolean = false
+
+ public constructor()
+
+ public constructor(query: MangaSearchQuery) : this() {
+ criteria.addAll(query.criteria)
+ order = query.order
+ offset = query.offset
+ }
+
+ public fun criterion(criterion: QueryCriteria<*>): Builder = apply { criteria.add(criterion) }
+
+ public fun order(order: SortOrder?): Builder = apply { this.order = order }
+
+ public fun offset(offset: Int): Builder = apply { this.offset = offset }
+
+ public fun skipValidation(skip: Boolean): Builder = apply { this.skipValidation = skip }
+
+ @Throws(IllegalArgumentException::class)
+ public fun build(): MangaSearchQuery {
+ return MangaSearchQuery(deduplicateCriteria(criteria), order, offset, skipValidation)
+ }
+
+ private fun deduplicateCriteria(criteria: Set>): Set> {
+ val uniqueCriteria =
+ ArrayMap>>, QueryCriteria<*>>(criteria.size)
+
+ for (criterion in criteria) {
+ val key = criterion.field to criterion::class.java
+ val existing = uniqueCriteria[key]
+
+ when {
+ existing == null -> uniqueCriteria[key] = criterion
+
+ existing is QueryCriteria.Include<*> && criterion is QueryCriteria.Include<*> -> {
+ uniqueCriteria[key] =
+ QueryCriteria.Include(criterion.field, existing.values union criterion.values)
+ }
+
+ existing is QueryCriteria.Exclude<*> && criterion is QueryCriteria.Exclude<*> -> {
+ uniqueCriteria[key] =
+ QueryCriteria.Exclude(criterion.field, existing.values union criterion.values)
+ }
+
+ else -> throw IllegalArgumentException(
+ "Match and Range have only one criterion per type, but found duplicates for: ${criterion.field} in ${criterion::class.simpleName}",
+ )
+ }
+ }
+
+ return uniqueCriteria.values.toSet()
+ }
+ }
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQueryCapabilities.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQueryCapabilities.kt
new file mode 100644
index 00000000..8295d18b
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQueryCapabilities.kt
@@ -0,0 +1,51 @@
+package org.koitharu.kotatsu.parsers.model.search
+
+import androidx.collection.ArraySet
+import org.koitharu.kotatsu.parsers.InternalParsersApi
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
+import org.koitharu.kotatsu.parsers.util.mapToSet
+
+@ExposedCopyVisibility
+public data class MangaSearchQueryCapabilities internal constructor(
+ val capabilities: Set,
+) {
+
+ public constructor(vararg capabilities: SearchCapability) : this(ArraySet(capabilities))
+
+ @InternalParsersApi
+ public fun validate(query: MangaSearchQuery) {
+ val strictFields = capabilities.filter { !it.otherCriteria }.mapToSet { it.field }
+ val usedStrictFields = query.criteria.mapToSet { it.field }.intersect(strictFields)
+
+ if (usedStrictFields.isNotEmpty() && query.criteria.size > 1) {
+ throw IllegalArgumentException(
+ "Query contains multiple criteria, but at least one field (${usedStrictFields.joinToString()}) does not support multiple criteria.",
+ )
+ }
+
+ for (criterion in query.criteria) {
+ val capability = capabilities.find { it.field == criterion.field }
+ ?: throw IllegalArgumentException("Unsupported search field: ${criterion.field}")
+
+ if (criterion::class !in capability.criteriaTypes) {
+ throw IllegalArgumentException(
+ "Unsupported search criterion: ${criterion::class.simpleName} for field ${criterion.field}",
+ )
+ }
+
+ // Ensure single value per criterion if supportMultiValue is false
+ if (!capability.multiValue) {
+ when (criterion) {
+ is Include<*> -> if (criterion.values.size > 1)
+ throw IllegalArgumentException("Multiple values are not allowed for field ${criterion.field}")
+
+ is Exclude<*> -> if (criterion.values.size > 1)
+ throw IllegalArgumentException("Multiple values are not allowed for field ${criterion.field}")
+
+ is Range<*> -> {} // Range is always valid (from, to)
+ is Match<*> -> {} // Match always has a single value
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/QueryCriteria.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/QueryCriteria.kt
new file mode 100644
index 00000000..ffaf5759
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/QueryCriteria.kt
@@ -0,0 +1,59 @@
+package org.koitharu.kotatsu.parsers.model.search
+
+/**
+ * Represents a generic search criterion used for filtering manga search results.
+ * Each criterion applies a specific condition to a [SearchableField] and operates on values of type [T].
+ *
+ * @param T The type of value associated with the search criterion.
+ * @property field The field to which this search criterion applies.
+ */
+public sealed interface QueryCriteria {
+
+ public val field: SearchableField
+
+ override fun equals(other: Any?): Boolean
+
+ override fun hashCode(): Int
+
+ public data class Include(
+ public override val field: SearchableField,
+ @JvmField public val values: Set,
+ ) : QueryCriteria {
+
+ init {
+ check(values.all { x -> field.type.isInstance(x) })
+ }
+ }
+
+ public data class Exclude(
+ public override val field: SearchableField,
+ @JvmField public val values: Set,
+ ) : QueryCriteria {
+
+ init {
+ check(values.all { x -> field.type.isInstance(x) })
+ }
+ }
+
+ public data class Range>(
+ public override val field: SearchableField,
+ @JvmField public val from: T,
+ @JvmField public val to: T,
+ ) : QueryCriteria {
+
+ init {
+ check(field.type.isInstance(from))
+ check(field.type.isInstance(to))
+ }
+ }
+
+ public data class Match(
+ public override val field: SearchableField,
+ @JvmField public val value: T,
+ ) : QueryCriteria {
+
+ init {
+ check(field.type.isInstance(value))
+ }
+ }
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/SearchCapability.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/SearchCapability.kt
new file mode 100644
index 00000000..097986fa
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/SearchCapability.kt
@@ -0,0 +1,10 @@
+package org.koitharu.kotatsu.parsers.model.search
+
+import kotlin.reflect.KClass
+
+public data class SearchCapability (
+ @JvmField public val field: SearchableField,
+ @JvmField public val criteriaTypes: Set>>,
+ @JvmField public val multiValue: Boolean,
+ @JvmField public val otherCriteria: Boolean,
+)
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/SearchableField.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/SearchableField.kt
new file mode 100644
index 00000000..d14e5c16
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/SearchableField.kt
@@ -0,0 +1,23 @@
+package org.koitharu.kotatsu.parsers.model.search
+
+import org.koitharu.kotatsu.parsers.model.*
+import java.util.*
+
+/**
+ * Represents the various fields that can be used for searching manga.
+ * Each field is associated with a specific data type that defines its expected values.
+ *
+ * @property type The Java class representing the expected type of values for this field.
+ */
+public enum class SearchableField(public val type: Class<*>) {
+ TITLE_NAME(String::class.java),
+ TAG(MangaTag::class.java),
+ AUTHOR(MangaTag::class.java),
+ LANGUAGE(Locale::class.java),
+ ORIGINAL_LANGUAGE(Locale::class.java),
+ STATE(MangaState::class.java),
+ CONTENT_TYPE(ContentType::class.java),
+ CONTENT_RATING(ContentRating::class.java),
+ DEMOGRAPHIC(Demographic::class.java),
+ PUBLICATION_YEAR(Int::class.java);
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/HitomiLaParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/HitomiLaParser.kt
index 6d5b4193..24dd603e 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/HitomiLaParser.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/HitomiLaParser.kt
@@ -516,14 +516,14 @@ internal class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context
title = doc.selectFirstOrThrow("h1").text(),
url = id.toString(),
coverUrl =
- "https:" +
- doc.selectFirstOrThrow("picture > source")
- .attr("data-srcset")
- .substringBefore(" "),
+ "https:" +
+ doc.selectFirstOrThrow("picture > source")
+ .attr("data-srcset")
+ .substringBefore(" "),
publicUrl =
- doc.selectFirstOrThrow("h1 > a")
- .attrAsRelativeUrl("href")
- .toAbsoluteUrl(domain),
+ doc.selectFirstOrThrow("h1 > a")
+ .attrAsRelativeUrl("href")
+ .toAbsoluteUrl(domain),
author = null,
tags = emptySet(),
isNsfw = true,
@@ -546,37 +546,37 @@ internal class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context
return manga.copy(
title = json.getString("title"),
largeCoverUrl =
- json.getJSONArray("files").getJSONObject(0).let {
- val hash = it.getString("hash")
- val commonId = commonImageId()
- val imageId = imageIdFromHash(hash)
- val subDomain = 'a' + subdomainOffset(imageId)
-
- "https://${getDomain("${subDomain}a")}/webp/$commonId$imageId/$hash.webp"
- },
+ json.getJSONArray("files").getJSONObject(0).let {
+ val hash = it.getString("hash")
+ val commonId = commonImageId()
+ val imageId = imageIdFromHash(hash)
+ val subDomain = 'a' + subdomainOffset(imageId)
+
+ "https://${getDomain("${subDomain}a")}/webp/$commonId$imageId/$hash.webp"
+ },
author =
- json.optJSONArray("artists")
- ?.mapJSON { it.getString("artist").toCamelCase() }
- ?.joinToString(),
+ json.optJSONArray("artists")
+ ?.mapJSON { it.getString("artist").toCamelCase() }
+ ?.joinToString(),
publicUrl = json.getString("galleryurl").toAbsoluteUrl(domain),
tags =
- buildSet {
- json.optJSONArray("characters")
- ?.mapToTags("character")
- ?.let(::addAll)
- json.optJSONArray("tags")
- ?.mapToTags("tag")
- ?.let(::addAll)
- json.optJSONArray("artists")
- ?.mapToTags("artist")
- ?.let(::addAll)
- json.optJSONArray("parodys")
- ?.mapToTags("parody")
- ?.let(::addAll)
- json.optJSONArray("groups")
- ?.mapToTags("group")
- ?.let(::addAll)
- },
+ buildSet {
+ json.optJSONArray("characters")
+ ?.mapToTags("character")
+ ?.let(::addAll)
+ json.optJSONArray("tags")
+ ?.mapToTags("tag")
+ ?.let(::addAll)
+ json.optJSONArray("artists")
+ ?.mapToTags("artist")
+ ?.let(::addAll)
+ json.optJSONArray("parodys")
+ ?.mapToTags("parody")
+ ?.let(::addAll)
+ json.optJSONArray("groups")
+ ?.mapToTags("group")
+ ?.let(::addAll)
+ },
chapters = listOf(
MangaChapter(
id = generateUid(manga.url),
@@ -600,15 +600,15 @@ internal class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context
mapJSON {
MangaTag(
title =
- it.getString(key).toCamelCase().let { title ->
- if (it.getStringOrNull("female")?.toIntOrNull() == 1) {
- "$title ♀"
- } else if (it.getStringOrNull("male")?.toIntOrNull() == 1) {
- "$title ♂"
- } else {
- title
- }
- },
+ it.getString(key).toCamelCase().let { title ->
+ if (it.getStringOrNull("female")?.toIntOrNull() == 1) {
+ "$title ♀"
+ } else if (it.getStringOrNull("male")?.toIntOrNull() == 1) {
+ "$title ♂"
+ } else {
+ title
+ }
+ },
key = it.getString("url").tagUrlToTag(),
source = source,
).let(tags::add)
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaDexParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaDexParser.kt
index 9f57ba58..1305e384 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaDexParser.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaDexParser.kt
@@ -11,9 +11,14 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
-import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
+import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
+import org.koitharu.kotatsu.parsers.model.search.SearchCapability
+import org.koitharu.kotatsu.parsers.model.search.SearchableField
+import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.*
import java.text.SimpleDateFormat
@@ -74,6 +79,70 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
isAuthorSearchSupported = true,
)
+ override val searchQueryCapabilities: MangaSearchQueryCapabilities
+ get() = MangaSearchQueryCapabilities(
+ SearchCapability(
+ field = TAG,
+ criteriaTypes = setOf(Include::class, Exclude::class),
+ multiValue = true,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = TITLE_NAME,
+ criteriaTypes = setOf(Match::class),
+ multiValue = false,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = STATE,
+ criteriaTypes = setOf(Include::class),
+ multiValue = true,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = AUTHOR,
+ criteriaTypes = setOf(Include::class),
+ multiValue = true,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = CONTENT_TYPE,
+ criteriaTypes = setOf(Include::class),
+ multiValue = true,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = CONTENT_RATING,
+ criteriaTypes = setOf(Include::class),
+ multiValue = true,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = DEMOGRAPHIC,
+ criteriaTypes = setOf(Include::class),
+ multiValue = true,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = ORIGINAL_LANGUAGE,
+ criteriaTypes = setOf(Include::class),
+ multiValue = true,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = LANGUAGE,
+ criteriaTypes = setOf(Include::class),
+ multiValue = true,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = PUBLICATION_YEAR,
+ criteriaTypes = setOf(Match::class),
+ multiValue = false,
+ otherCriteria = true,
+ ),
+ )
+
override suspend fun getFilterOptions(): MangaListFilterOptions = coroutineScope {
val localesDeferred = async { fetchAvailableLocales() }
val tagsDeferred = async { fetchAvailableTags() }
@@ -97,121 +166,122 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
)
}
- override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List {
- val domain = domain
- val url = buildString {
- append("https://api.")
- append(domain)
- append("/manga?limit=")
- append(PAGE_SIZE)
- append("&offset=")
- append(offset)
- append("&includes[]=cover_art&includes[]=author&includes[]=artist")
+ private fun SearchableField.toParamName(): String = when (this) {
+ TITLE_NAME -> "title"
+ TAG -> "includedTags[]"
+ AUTHOR -> "authors[]"
+ STATE -> "status[]"
+ CONTENT_TYPE -> "contentType[]"
+ CONTENT_RATING -> "contentRating[]"
+ DEMOGRAPHIC -> "publicationDemographic[]"
+ ORIGINAL_LANGUAGE -> "originalLanguage[]"
+ LANGUAGE -> "availableTranslatedLanguage[]"
+ PUBLICATION_YEAR -> "year"
+ }
- filter.query?.let {
- append("&title=")
- append(filter.query.urlEncoded())
- }
+ private fun Any?.toQueryParam(): String = when (this) {
+ is String -> urlEncoded()
+ is Locale -> if (language == "in") "id" else language
+ is MangaTag -> key
+ is MangaState -> when (this) {
+ MangaState.ONGOING -> "ongoing"
+ MangaState.FINISHED -> "completed"
+ MangaState.ABANDONED -> "cancelled"
+ MangaState.PAUSED -> "hiatus"
+ else -> ""
+ }
- filter.tags.forEach {
- append("&includedTags[]=")
- append(it.key)
- }
+ is ContentRating -> when (this) {
+ ContentRating.SAFE -> "safe"
+ // quick fix for double value
+ ContentRating.SUGGESTIVE -> "suggestive&contentRating[]=erotica"
+ ContentRating.ADULT -> "pornographic"
+ }
- filter.tagsExclude.forEach {
- append("&excludedTags[]=")
- append(it.key)
- }
+ is Demographic -> when (this) {
+ Demographic.SHOUNEN -> "shounen"
+ Demographic.SHOUJO -> "shoujo"
+ Demographic.SEINEN -> "seinen"
+ Demographic.JOSEI -> "josei"
+ Demographic.NONE -> "none"
+ else -> ""
+ }
- if (filter.contentRating.isNotEmpty()) {
- filter.contentRating.forEach {
- when (it) {
- ContentRating.SAFE -> append("&contentRating[]=safe")
- ContentRating.SUGGESTIVE -> append("&contentRating[]=suggestive&contentRating[]=erotica")
- ContentRating.ADULT -> append("&contentRating[]=pornographic")
+ is SortOrder -> when (this) {
+ SortOrder.UPDATED -> "[latestUploadedChapter]=desc"
+ SortOrder.UPDATED_ASC -> "[latestUploadedChapter]=asc"
+ SortOrder.RATING -> "[rating]=desc"
+ SortOrder.RATING_ASC -> "[rating]=asc"
+ SortOrder.ALPHABETICAL -> "[title]=asc"
+ SortOrder.ALPHABETICAL_DESC -> "[title]=desc"
+ SortOrder.NEWEST -> "[year]=desc"
+ SortOrder.NEWEST_ASC -> "[year]=asc"
+ SortOrder.POPULARITY -> "[followedCount]=desc"
+ SortOrder.POPULARITY_ASC -> "[followedCount]=asc"
+ SortOrder.ADDED -> "[createdAt]=desc"
+ SortOrder.ADDED_ASC -> "[createdAt]=asc"
+ SortOrder.RELEVANCE -> "&order[relevance]=desc"
+ else -> "[latestUploadedChapter]=desc"
+ }
- }
- }
- } else {
- append("&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic")
- }
+ else -> this.toString().urlEncoded()
+ }
- if (!filter.author.isNullOrEmpty()) {
- append("&authorOrArtist=").append(getAuthorId(filter.author))
- }
+ private fun StringBuilder.appendCriterion(field: SearchableField, value: Any?, paramName: String? = null) {
+ val param = paramName ?: field.toParamName()
+ if (param.isNotBlank()) {
+ append("&$param=")
+ append(value.toQueryParam())
+ }
+ }
- append("&order")
- append(
- when (order) {
- SortOrder.UPDATED -> "[latestUploadedChapter]=desc"
- SortOrder.UPDATED_ASC -> "[latestUploadedChapter]=asc"
- SortOrder.RATING -> "[rating]=desc"
- SortOrder.RATING_ASC -> "[rating]=asc"
- SortOrder.ALPHABETICAL -> "[title]=asc"
- SortOrder.ALPHABETICAL_DESC -> "[title]=desc"
- SortOrder.NEWEST -> "[year]=desc"
- SortOrder.NEWEST_ASC -> "[year]=asc"
- SortOrder.POPULARITY -> "[followedCount]=desc"
- SortOrder.POPULARITY_ASC -> "[followedCount]=asc"
- SortOrder.ADDED -> "[createdAt]=desc"
- SortOrder.ADDED_ASC -> "[createdAt]=asc"
- SortOrder.RELEVANCE -> "&order[relevance]=desc"
- else -> "[latestUploadedChapter]=desc"
- },
- )
+ override suspend fun getList(query: MangaSearchQuery): List {
+ val url = buildString {
+ append("https://api.$domain/manga?limit=$PAGE_SIZE&offset=${query.offset}")
+ .append("&includes[]=cover_art&includes[]=author&includes[]=artist&includedTagsMode=AND&excludedTagsMode=OR")
+
+ var hasContentRating = false
+
+ query.criteria.forEach { criterion ->
+ when (criterion) {
+ is Include<*> -> {
+ if (criterion.field == CONTENT_RATING) {
+ hasContentRating = true
+ }
+ criterion.values.forEach { appendCriterion(criterion.field, it) }
+ }
- filter.states.forEach {
- append("&status[]=")
- when (it) {
- MangaState.ONGOING -> append("ongoing")
- MangaState.FINISHED -> append("completed")
- MangaState.ABANDONED -> append("cancelled")
- MangaState.PAUSED -> append("hiatus")
- else -> append("")
- }
- }
+ is Exclude<*> -> {
+ criterion.values.forEach { appendCriterion(criterion.field, it, "excludedTags[]") }
+ }
- filter.demographics.forEach {
- append("&publicationDemographic[]=")
- append(
- when (it) {
- Demographic.SHOUNEN -> "shounen"
- Demographic.SHOUJO -> "shoujo"
- Demographic.SEINEN -> "seinen"
- Demographic.JOSEI -> "josei"
- Demographic.NONE -> "none"
- else -> ""
- },
- )
- }
+ is Match<*> -> {
+ appendCriterion(criterion.field, criterion.value)
+ }
- filter.locale?.let {
- append("&availableTranslatedLanguage[]=")
- if (it.language == "in") {
- append("id")
- } else {
- append(it.language)
+ else -> {
+ // Not supported
+ }
}
}
- filter.originalLocale?.let {
- append("&originalLanguage[]=")
- if (it.language == "in") {
- append("id")
- } else {
- append(it.language)
- }
+ // If contentRating is not provided, add default values
+ if (!hasContentRating) {
+ append("&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic")
}
- if (filter.year != 0) {
- append("&year=")
- append(filter.year)
- }
+ append("&order")
+ append((query.order ?: defaultSortOrder).toQueryParam())
}
+
val json = webClient.httpGet(url).parseJson().getJSONArray("data")
return json.mapJSON { jo -> jo.fetchManga(null) }
}
+ override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List {
+ return queryManga(convertToMangaSearchQuery(offset, order, filter))
+ }
+
override suspend fun getDetails(manga: Manga): Manga {
val mangaId = manga.url.removePrefix("/")
return getDetails(mangaId)
@@ -282,11 +352,22 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
val attrs = getJSONObject("attributes")
val relations = getJSONArray("relationships").associateByKey("type")
val cover = relations["cover_art"]
+ ?.firstOrNull()
?.getJSONObject("attributes")
?.getString("fileName")
?.let {
"https://uploads.$domain/covers/$id/$it"
}
+ val authors: Set = (relations["author"] ?: relations["artist"])
+ ?.mapNotNullToSet {
+ val key = it.getStringOrNull("id")
+ val title = it.getJSONObject("attributes")?.getStringOrNull("name")
+
+ key?.let { k ->
+ title?.let { t -> MangaTag(key = k, title = t, source = source) }
+ }
+ }.orEmpty()
+
return Manga(
id = generateUid(id),
title = requireNotNull(attrs.getJSONObject("title").selectByLocale()) {
@@ -322,9 +403,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
"cancelled" -> MangaState.ABANDONED
else -> null
},
- author = (relations["author"] ?: relations["artist"])
- ?.getJSONObject("attributes")
- ?.getStringOrNull("name"),
+ authors = authors,
chapters = chapters,
source = source,
)
@@ -408,22 +487,6 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
}
}
- private suspend fun getAuthorId(name: String): String {
- val url = urlBuilder("api")
- .addPathSegment("author")
- .addQueryParameter("name", name)
- .addQueryParameter("limit", "1")
- .build()
- val json = webClient.httpGet(url).parseJson()
- .getJSONArray("data")
- .getJSONObject(0)
- if (json.getJSONObject("attributes").getString("name").equals(name, ignoreCase = true)) {
- return json.getString("id")
- } else {
- throw NotFoundException("Author $name not found", url.toString())
- }
- }
-
private fun mapChapters(list: List): List {
// 2022-01-02T00:27:11+00:00
val dateFormat = SimpleDateFormat(
@@ -443,7 +506,8 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
val locale = attrs.getStringOrNull("translatedLanguage")?.let { Locale.forLanguageTag(it) }
val lc = locale?.getDisplayName(locale)?.toTitleCase(locale)
val relations = jo.getJSONArray("relationships").associateByKey("type")
- val team = relations["scanlation_group"]?.optJSONObject("attributes")?.getStringOrNull("name")
+ val team =
+ relations["scanlation_group"]?.firstOrNull()?.optJSONObject("attributes")?.getStringOrNull("name")
val branch = (list.indices).firstNotNullOf { i ->
val b = if (i == 0) lc else "$lc ($i)"
if (branchedChapters[b]?.get(volume to number) == null) b else null
@@ -469,12 +533,12 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
return chaptersBuilder.toList()
}
- private fun JSONArray.associateByKey(key: String): Map {
- val destination = LinkedHashMap(length())
+ private fun JSONArray.associateByKey(key: String): Map> {
+ val destination = LinkedHashMap>(length())
repeat(length()) { i ->
val item = getJSONObject(i)
val keyValue = item.getString(key)
- destination[keyValue] = item
+ destination.computeIfAbsent(keyValue) { mutableListOf() }.add(item)
}
return destination
}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/WeebCentral.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/WeebCentral.kt
index 26a8dcb3..d354fa55 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/WeebCentral.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/WeebCentral.kt
@@ -64,27 +64,27 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
MangaTag(
title = it.selectFirstOrThrow(".label-text").text(),
key = it.selectFirstOrThrow("input[id$=value]").attr("value"),
- source = source
+ source = source,
)
}
val states = EnumSet.of(
- ONGOING, FINISHED, ABANDONED, PAUSED
+ ONGOING, FINISHED, ABANDONED, PAUSED,
)
val types = EnumSet.of(
- MANGA, MANHWA, MANHUA, COMICS
+ MANGA, MANHWA, MANHUA, COMICS,
)
val rating = EnumSet.of(
- SAFE, SUGGESTIVE
+ SAFE, SUGGESTIVE,
)
return MangaListFilterOptions(
availableTags = tags,
availableStates = states,
availableContentTypes = types,
- availableContentRating = rating
+ availableContentRating = rating,
)
}
@@ -109,7 +109,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
ADDED, ADDED_ASC -> "Recently Added"
UPDATED, UPDATED_ASC -> "Latest Updates"
else -> throw UnsupportedOperationException("unsupported order: $order")
- }
+ },
)
addQueryParameter(
name = "order",
@@ -117,11 +117,11 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
RELEVANCE, ALPHABETICAL, POPULARITY_ASC, RATING_ASC, ADDED_ASC, UPDATED_ASC -> "Ascending"
ALPHABETICAL_DESC, POPULARITY, RATING, ADDED, UPDATED -> "Descending"
else -> throw UnsupportedOperationException("unsupported order: $order")
- }
+ },
)
addQueryParameter("official", "Any")
addQueryParameter("anime", "Any")
- with (filter.contentRating) {
+ with(filter.contentRating) {
addQueryParameter(
name = "adult",
value = when {
@@ -130,7 +130,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
SAFE in this -> "False"
SUGGESTIVE in this -> "True"
else -> throw UnsupportedOperationException("unsupported content rating: $this")
- }
+ },
)
}
filter.states.forEach { state ->
@@ -142,7 +142,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
ABANDONED -> "Canceled"
PAUSED -> "Hiatus"
else -> throw UnsupportedOperationException("unsupported state: $state")
- }
+ },
)
}
filter.types.forEach { type ->
@@ -154,7 +154,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
MANHUA -> "Manhua"
COMICS -> "OEL"
else -> throw UnsupportedOperationException("unsupported type: $type")
- }
+ },
)
}
filter.tags.forEach { tag ->
@@ -193,11 +193,11 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
MangaTag(
title = it,
key = it,
- source = source
+ source = source,
)
}
.orEmpty(),
- state = when(document.selectFirst("div:contains(status) span")?.text()) {
+ state = when (document.selectFirst("div:contains(status) span")?.text()) {
"Ongoing" -> ONGOING
"Complete" -> FINISHED
"Canceled" -> ABANDONED
@@ -207,7 +207,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
author = document.select("div:contains(author) a").eachText().joinToString(),
largeCoverUrl = null,
chapters = null,
- source = source
+ source = source,
)
}
}
@@ -237,7 +237,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
MangaTag(
title = it.text(),
key = it.text(),
- source = source
+ source = source,
)
},
state = when (sectionLeft.selectFirst("ul > li:has(strong:contains(Status)) > a")?.text()) {
@@ -259,12 +259,12 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
abbr.selectFirst("a")?.attr("href")?.let { url ->
val a = Element("a")
.text(
- abbr.attr("title")
+ abbr.attr("title"),
)
.attr("href", url)
ul.appendChild(
- Element("li").appendChild(a)
+ Element("li").appendChild(a),
)
}
}
@@ -276,7 +276,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
}.outerHtml(),
chapters = chapters.await(),
- source = source
+ source = source,
)
}
@@ -308,10 +308,10 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
else -> null
},
uploadDate = dateFormat.tryParse(
- element.selectFirst("time[datetime]")?.attr("datetime")
+ element.selectFirst("time[datetime]")?.attr("datetime"),
),
branch = null,
- source = source
+ source = source,
)
}
}
@@ -336,7 +336,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
id = generateUid(pageUrl),
url = pageUrl,
preview = null,
- source = source
+ source = source,
)
}
}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/MangaboxParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/MangaboxParser.kt
index b008e266..d5ad8753 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/MangaboxParser.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/MangaboxParser.kt
@@ -7,6 +7,12 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
+import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
+import org.koitharu.kotatsu.parsers.model.search.SearchCapability
+import org.koitharu.kotatsu.parsers.model.search.SearchableField
+import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.DateFormat
import java.text.SimpleDateFormat
@@ -38,6 +44,34 @@ internal abstract class MangaboxParser(
isSearchWithFiltersSupported = true,
)
+ override val searchQueryCapabilities: MangaSearchQueryCapabilities
+ get() = MangaSearchQueryCapabilities(
+ SearchCapability(
+ field = TAG,
+ criteriaTypes = setOf(Include::class, Exclude::class),
+ multiValue = true,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = TITLE_NAME,
+ criteriaTypes = setOf(Match::class),
+ multiValue = false,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = STATE,
+ criteriaTypes = setOf(Include::class),
+ multiValue = true,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = AUTHOR,
+ criteriaTypes = setOf(Include::class),
+ multiValue = false,
+ otherCriteria = false,
+ ),
+ )
+
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = fetchAvailableTags(),
availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED),
@@ -59,64 +93,88 @@ internal abstract class MangaboxParser(
)
protected open val listUrl = "/advanced_search"
+ protected open val authorUrl = "/search/author"
protected open val searchUrl = "/search/story/"
protected open val datePattern = "MMM dd,yy"
- override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
- val url = buildString {
- append("https://")
- append(domain)
- append(listUrl)
- append("/?s=all")
-
- filter.query?.let {
- append("&keyw=")
- append(filter.query.replace(" ", "_").urlEncoded())
- }
+ private fun SearchableField.toParamName(): String = when (this) {
+ TITLE_NAME, AUTHOR -> "keyw"
+ TAG -> "g_i"
+ STATE -> "sts"
+ else -> ""
+ }
- if (filter.tags.isNotEmpty()) {
- append("&g_i=")
- filter.tags.forEach {
- append("_")
- append(it.key)
- append("_")
- }
- }
+ private fun Any?.toQueryParam(): String = when (this) {
+ is String -> replace(" ", "_").urlEncoded()
+ is MangaTag -> key
+ is MangaState -> when (this) {
+ MangaState.ONGOING -> "ongoing"
+ MangaState.FINISHED -> "completed"
+ else -> ""
+ }
- if (filter.tagsExclude.isNotEmpty()) {
- append("&g_e=")
- filter.tagsExclude.forEach {
- append("_")
- append(it.key)
- append("_")
- }
- }
+ is SortOrder -> when (this) {
+ SortOrder.ALPHABETICAL -> "az"
+ SortOrder.NEWEST -> "newest"
+ SortOrder.POPULARITY -> "topview"
+ else -> ""
+ }
- filter.states.oneOrThrowIfMany()?.let {
- append("&sts=")
- append(
- when (it) {
- MangaState.ONGOING -> "ongoing"
- MangaState.FINISHED -> "completed"
- else -> ""
- },
- )
- }
+ else -> this.toString().replace(" ", "_").urlEncoded()
+ }
- append("&orby=")
- when (order) {
- SortOrder.POPULARITY -> append("topview")
- SortOrder.UPDATED -> append("")
- SortOrder.NEWEST -> append("newest")
- SortOrder.ALPHABETICAL -> append("az")
- else -> append("")
+ private fun StringBuilder.appendCriterion(field: SearchableField, value: Any?, paramName: String? = null) {
+ val param = paramName ?: field.toParamName()
+ if (param.isNotBlank()) {
+ append("&$param=")
+ append(value.toQueryParam())
+ }
+ }
+
+ private val authorKeyRegex by lazy {
+ Regex("[^/]+\$", RegexOption.IGNORE_CASE)
+ }
+
+ override suspend fun getListPage(query: MangaSearchQuery, page: Int): List {
+ var authorSearchUrl: String? = null
+ val url = buildString {
+ val pageQueryParameter = "page=$page"
+ append("https://${domain}${listUrl}/?s=all")
+
+ query.criteria.forEach { criterion ->
+ when (criterion) {
+ is Include<*> -> {
+ if (criterion.field == AUTHOR) {
+ criterion.values.firstOrNull()?.toQueryParam()?.takeIf { it.isNotBlank() }
+ ?.let { authorKey ->
+ authorSearchUrl = "https://${domain}${authorUrl}/${authorKey}/?$pageQueryParameter"
+ }
+ }
+
+ criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param ->
+ append("&$param=${criterion.values.joinToString("_") { it.toQueryParam() }}")
+ }
+ }
+
+ is Exclude<*> -> {
+ append("&g_e=${criterion.values.joinToString("_") { it.toQueryParam() }}")
+ }
+
+ is Match<*> -> {
+ appendCriterion(criterion.field, criterion.value)
+ }
+
+ else -> {
+ // Not supported
+ }
+ }
}
- append("&page=")
- append(page.toString())
+ append("&${pageQueryParameter}")
+ append("&orby=${(query.order ?: defaultSortOrder).toQueryParam()}")
}
- val doc = webClient.httpGet(url).parseHtml()
+ val doc = webClient.httpGet(authorSearchUrl ?: url).parseHtml()
return doc.select("div.content-genres-item, div.list-story-item").ifEmpty {
doc.select("div.search-story-item")
@@ -131,14 +189,18 @@ internal abstract class MangaboxParser(
altTitle = null,
rating = RATING_UNKNOWN,
tags = emptySet(),
- author = null,
+ authors = emptySet(),
state = null,
source = source,
- isNsfw = isNsfwSource,
+ contentRating = if (isNsfwSource) ContentRating.ADULT else ContentRating.SAFE,
)
}
}
+ override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ return queryManga(convertToMangaSearchQuery(page, order, filter))
+ }
+
protected open val selectTagMap = "div.panel-genres-list a:not(.genres-select)"
protected open suspend fun fetchAvailableTags(): Set {
@@ -175,7 +237,14 @@ internal abstract class MangaboxParser(
}
}
val alt = doc.body().select(selectAlt).text().replace("Alternative : ", "").nullIfEmpty()
- val aut = doc.body().select(selectAut).eachText().joinToString().nullIfEmpty()
+ val authors = doc.body().select(selectAut).mapToSet {
+ MangaTag(
+ key = it.attribute("href").value.find(authorKeyRegex) ?: it.text(),
+ title = it.text(),
+ source = source,
+ )
+ }
+
manga.copy(
tags = doc.body().select(selectTag).mapToSet { a ->
MangaTag(
@@ -186,7 +255,8 @@ internal abstract class MangaboxParser(
},
description = desc,
altTitle = alt,
- author = aut,
+ authors = authors,
+ author = authors.firstOrNull()?.title,
state = state,
chapters = chaptersDeferred.await(),
)
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangairo.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangairo.kt
index fb19b4c4..7fb73556 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangairo.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangairo.kt
@@ -6,6 +6,12 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
+import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.Include
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.Match
+import org.koitharu.kotatsu.parsers.model.search.SearchCapability
+import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import org.koitharu.kotatsu.parsers.site.mangabox.MangaboxParser
import org.koitharu.kotatsu.parsers.util.*
import java.util.*
@@ -38,6 +44,106 @@ internal class Mangairo(context: MangaLoaderContext) :
isSearchWithFiltersSupported = false,
)
+ override val searchQueryCapabilities: MangaSearchQueryCapabilities
+ get() = MangaSearchQueryCapabilities(
+ SearchCapability(
+ field = TAG,
+ criteriaTypes = setOf(Include::class),
+ multiValue = false,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = TITLE_NAME,
+ criteriaTypes = setOf(Match::class),
+ multiValue = false,
+ otherCriteria = false,
+ ),
+ SearchCapability(
+ field = STATE,
+ criteriaTypes = setOf(Include::class),
+ multiValue = false,
+ otherCriteria = true,
+ ),
+ )
+
+ private fun Any?.toQueryParam(): String = when (this) {
+ is String -> replace(" ", "_").urlEncoded()
+ is MangaTag -> key
+ is MangaState -> when (this) {
+ MangaState.ONGOING -> "ongoing"
+ MangaState.FINISHED -> "completed"
+ else -> "all"
+ }
+
+ is SortOrder -> when (this) {
+ SortOrder.POPULARITY -> "topview"
+ SortOrder.UPDATED -> "latest"
+ SortOrder.NEWEST -> "newest"
+ else -> "latest"
+ }
+
+ else -> this.toString().urlEncoded()
+ }
+
+ override suspend fun getListPage(query: MangaSearchQuery, page: Int): List {
+ var titleSearchUrl: String? = null
+ var category = "all"
+ var state = "all"
+
+ val url = buildString {
+ append("https://${domain}${listUrl}")
+ append("/type-${(query.order ?: defaultSortOrder).toQueryParam()}")
+
+ query.criteria.forEach { criterion ->
+ when (criterion) {
+ is Include<*> -> {
+ when (criterion.field) {
+ TAG -> category = criterion.values.first().toQueryParam()
+ STATE -> state = criterion.values.first().toQueryParam()
+ else -> Unit
+ }
+ }
+
+ is Match<*> -> {
+ if (criterion.field == TITLE_NAME) {
+ criterion.value.toQueryParam().takeIf { it.isNotBlank() }?.let { titleName ->
+ titleSearchUrl = "https://${domain}${searchUrl}${titleName}/" +
+ "?page=${query.offset}"
+ }
+ }
+ }
+
+ else -> {
+ // Not supported
+ }
+ }
+ }
+ append("/ctg-$category")
+ append("/state-$state")
+ append("/page-$page")
+ }
+
+ val doc = webClient.httpGet(titleSearchUrl ?: url).parseHtml()
+
+ return doc.select("div.story-item").map { div ->
+ val href = div.selectFirstOrThrow("a").attrAsRelativeUrl("href")
+ Manga(
+ id = generateUid(href),
+ url = href,
+ publicUrl = href.toAbsoluteUrl(div.host ?: domain),
+ coverUrl = div.selectFirst("img")?.src().orEmpty(),
+ title = (div.selectFirst("h2")?.text() ?: div.selectFirst("h3")?.text()).orEmpty(),
+ altTitle = null,
+ rating = RATING_UNKNOWN,
+ tags = emptySet(),
+ authors = emptySet(),
+ state = null,
+ source = source,
+ contentRating = if (isNsfwSource) ContentRating.ADULT else ContentRating.SAFE,
+ )
+ }
+ }
+
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
val url = buildString {
append("https://")
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangakakalot.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangakakalot.kt
index 8c77b79a..81300ba2 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangakakalot.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangakakalot.kt
@@ -5,6 +5,9 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.model.search.*
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
+import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import org.koitharu.kotatsu.parsers.site.mangabox.MangaboxParser
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
@@ -25,57 +28,109 @@ internal class Mangakakalot(context: MangaLoaderContext) :
isMultipleTagsSupported = false,
isSearchWithFiltersSupported = false,
)
+
+ override val searchQueryCapabilities: MangaSearchQueryCapabilities
+ get() = MangaSearchQueryCapabilities(
+ SearchCapability(
+ field = TAG,
+ criteriaTypes = setOf(Include::class),
+ multiValue = false,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = TITLE_NAME,
+ criteriaTypes = setOf(Match::class),
+ multiValue = false,
+ otherCriteria = false,
+ ),
+ SearchCapability(
+ field = STATE,
+ criteriaTypes = setOf(Include::class),
+ multiValue = false,
+ otherCriteria = true,
+ ),
+ )
+
override val otherDomain = "chapmanganato.com"
override val listUrl = "/manga_list"
- override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ private fun SearchableField.toParamName(): String = when (this) {
+ TAG -> "category"
+ STATE -> "state"
+ else -> ""
+ }
+
+ private fun Any?.toQueryParam(): String = when (this) {
+ is String -> {
+ sanitizeTitleNameRegex.replace(this, "")
+ .replace(" ", "_")
+ .urlEncoded()
+ }
+
+ is MangaTag -> key
+ is MangaState -> when (this) {
+ MangaState.ONGOING -> "ongoing"
+ MangaState.FINISHED -> "completed"
+ else -> "all"
+ }
+
+ is SortOrder -> when (this) {
+ SortOrder.POPULARITY -> "topview"
+ SortOrder.UPDATED -> "latest"
+ SortOrder.NEWEST -> "newest"
+ else -> ""
+ }
+
+ else -> this.toString().replace(" ", "_").urlEncoded()
+ }
+
+ private fun StringBuilder.appendCriterion(field: SearchableField, value: Any?, paramName: String? = null) {
+ val param = paramName ?: field.toParamName()
+ if (param.isNotBlank()) {
+ append("&$param=")
+ append(value.toQueryParam())
+ }
+ }
+
+ private val sanitizeTitleNameRegex by lazy {
+ Regex("[^A-Za-z0-9 ]")
+ }
+
+ override suspend fun getListPage(query: MangaSearchQuery, page: Int): List {
+ var titleSearchUrl: String? = null
val url = buildString {
- append("https://")
- append(domain)
- when {
-
- !filter.query.isNullOrEmpty() -> {
- append(searchUrl)
- val regex = Regex("[^A-Za-z0-9 ]")
- val q = regex.replace(filter.query, "")
- append(q.replace(" ", "_"))
- append("?page=")
- }
+ val pageQueryParameter = "page=$page"
+ append("https://$domain/?")
- else -> {
- append(listUrl)
- append("?type=")
- when (order) {
- SortOrder.POPULARITY -> append("topview")
- SortOrder.UPDATED -> append("latest")
- SortOrder.NEWEST -> append("newest")
- else -> append("latest")
- }
- if (filter.tags.isNotEmpty()) {
- append("&category=")
- filter.tags.oneOrThrowIfMany()?.let {
- append(it.key)
+ query.criteria.forEach { criterion ->
+ when (criterion) {
+ is Include<*> -> {
+ criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param ->
+ append("&$param=${criterion.values.first().toQueryParam()}")
}
}
- filter.states.oneOrThrowIfMany()?.let {
- append("&state=")
- append(
- when (it) {
- MangaState.ONGOING -> "ongoing"
- MangaState.FINISHED -> "completed"
- else -> "all"
- },
- )
+ is Match<*> -> {
+ if (criterion.field == TITLE_NAME) {
+ criterion.value.toQueryParam().takeIf { it.isNotBlank() }?.let { titleName ->
+ titleSearchUrl = "https://${domain}${searchUrl}${titleName}/" +
+ "?$pageQueryParameter"
+ }
+ }
+ appendCriterion(criterion.field, criterion.value)
}
- append("&page=")
+ else -> {
+ // Not supported
+ }
}
}
- append(page.toString())
+
+ append("&$pageQueryParameter")
+ append("&type=${(query.order ?: defaultSortOrder).toQueryParam()}")
}
- val doc = webClient.httpGet(url).parseHtml()
+ val doc = webClient.httpGet(titleSearchUrl ?: url).parseHtml()
return doc.select("div.list-truyen-item-wrap").ifEmpty {
doc.select("div.story_item")
@@ -90,10 +145,10 @@ internal class Mangakakalot(context: MangaLoaderContext) :
altTitle = null,
rating = RATING_UNKNOWN,
tags = emptySet(),
- author = null,
+ authors = emptySet(),
state = null,
source = source,
- isNsfw = isNsfwSource,
+ contentRating = if (isNsfwSource) ContentRating.ADULT else ContentRating.SAFE,
)
}
}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/MangakakalotTv.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/MangakakalotTv.kt
index 3ec07a94..7cfda584 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/MangakakalotTv.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/MangakakalotTv.kt
@@ -6,6 +6,9 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.model.search.*
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
+import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import org.koitharu.kotatsu.parsers.site.mangabox.MangaboxParser
import org.koitharu.kotatsu.parsers.util.*
import java.util.*
@@ -29,51 +32,98 @@ internal class MangakakalotTv(context: MangaLoaderContext) :
isSearchWithFiltersSupported = false,
)
- override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+
+ override val searchQueryCapabilities: MangaSearchQueryCapabilities
+ get() = MangaSearchQueryCapabilities(
+ SearchCapability(
+ field = TAG,
+ criteriaTypes = setOf(Include::class),
+ multiValue = false,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = TITLE_NAME,
+ criteriaTypes = setOf(Match::class),
+ multiValue = false,
+ otherCriteria = false,
+ ),
+ SearchCapability(
+ field = STATE,
+ criteriaTypes = setOf(Include::class),
+ multiValue = false,
+ otherCriteria = true,
+ ),
+ )
+
+ private fun SearchableField.toParamName(): String = when (this) {
+ TAG -> "category"
+ STATE -> "state"
+ else -> ""
+ }
+
+ private fun Any?.toQueryParam(): String = when (this) {
+ is String -> urlEncoded()
+ is MangaTag -> key
+ is MangaState -> when (this) {
+ MangaState.ONGOING -> "ongoing"
+ MangaState.FINISHED -> "completed"
+ else -> "all"
+ }
+
+ is SortOrder -> when (this) {
+ SortOrder.POPULARITY -> "topview"
+ SortOrder.UPDATED -> "latest"
+ SortOrder.NEWEST -> "newest"
+ else -> ""
+ }
+
+ else -> this.toString().urlEncoded()
+ }
+
+ private fun StringBuilder.appendCriterion(field: SearchableField, value: Any?, paramName: String? = null) {
+ val param = paramName ?: field.toParamName()
+ if (param.isNotBlank()) {
+ append("&$param=")
+ append(value.toQueryParam())
+ }
+ }
+
+ override suspend fun getListPage(query: MangaSearchQuery, page: Int): List {
+ var titleSearchUrl: String? = null
val url = buildString {
- append("https://")
- append(domain)
- when {
-
- !filter.query.isNullOrEmpty() -> {
- append(searchUrl)
- append(filter.query.urlEncoded())
- append("?page=")
- }
+ val pageQueryParameter = "page=$page"
+ append("https://$domain/?")
- else -> {
- append(listUrl)
- append("?type=")
- when (order) {
- SortOrder.POPULARITY -> append("topview")
- SortOrder.UPDATED -> append("latest")
- SortOrder.NEWEST -> append("newest")
- else -> append("latest")
- }
- if (filter.tags.isNotEmpty()) {
- append("&category=")
- filter.tags.oneOrThrowIfMany()?.let {
- append(it.key)
+ query.criteria.forEach { criterion ->
+ when (criterion) {
+ is Include<*> -> {
+ criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param ->
+ append("&$param=${criterion.values.first().toQueryParam()}")
}
}
- filter.states.oneOrThrowIfMany()?.let {
- append("&state=")
- append(
- when (it) {
- MangaState.ONGOING -> "Ongoing"
- MangaState.FINISHED -> "Completed"
- else -> "all"
- },
- )
+ is Match<*> -> {
+ if (criterion.field == TITLE_NAME) {
+ criterion.value.toQueryParam().takeIf { it.isNotBlank() }?.let { titleName ->
+ titleSearchUrl = "https://${domain}${searchUrl}${titleName}/" +
+ "?$pageQueryParameter"
+ }
+ }
+ appendCriterion(criterion.field, criterion.value)
}
- append("&page=")
+ else -> {
+ // Not supported
+ }
}
}
- append(page.toString())
+
+ append("&$pageQueryParameter")
+ append("&type=${(query.order ?: defaultSortOrder).toQueryParam()}")
}
- val doc = webClient.httpGet(url).parseHtml()
+
+ val doc = webClient.httpGet(titleSearchUrl ?: url).parseHtml()
+
return doc.select("div.list-truyen-item-wrap").ifEmpty {
doc.select("div.story_item")
}.map { div ->
@@ -83,14 +133,14 @@ internal class MangakakalotTv(context: MangaLoaderContext) :
url = href,
publicUrl = href.toAbsoluteUrl(div.host ?: domain),
coverUrl = div.selectFirst("img")?.src().orEmpty(),
- title = div.selectFirstOrThrow("h3").text().orEmpty(),
+ title = div.selectFirst("h3")?.text().orEmpty(),
altTitle = null,
rating = RATING_UNKNOWN,
tags = emptySet(),
- author = null,
+ authors = emptySet(),
state = null,
source = source,
- isNsfw = isNsfwSource,
+ contentRating = if (isNsfwSource) ContentRating.ADULT else ContentRating.SAFE,
)
}
}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Manganato.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Manganato.kt
index a41005c3..e885d9c3 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Manganato.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Manganato.kt
@@ -16,5 +16,6 @@ internal class Manganato(context: MangaLoaderContext) :
)
override val otherDomain = "chapmanganato.to"
+ override val authorUrl = "/author/story"
override val selectPage = ".container-chapter-reader > img"
}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/SearchQueryConverter.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/SearchQueryConverter.kt
new file mode 100644
index 00000000..5fe41fc6
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/SearchQueryConverter.kt
@@ -0,0 +1,244 @@
+package org.koitharu.kotatsu.parsers.util
+
+import org.koitharu.kotatsu.parsers.InternalParsersApi
+import org.koitharu.kotatsu.parsers.model.MangaListFilter
+import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
+import org.koitharu.kotatsu.parsers.model.SortOrder
+import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
+import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
+import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
+import org.koitharu.kotatsu.parsers.model.search.SearchCapability
+import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
+
+/**
+ * Converts a [MangaListFilter] into a [MangaSearchQuery].
+ *
+ * This function iterates through the filter attributes in [MangaListFilter] and creates corresponding
+ * search criteria in a [MangaSearchQuery.Builder].
+ *
+ * @param filter The [MangaListFilter] to convert.
+ * @return A [MangaSearchQuery] constructed based on the given [filter].
+ */
+@InternalParsersApi
+public fun convertToMangaSearchQuery(offset: Int, sortOrder: SortOrder, filter: MangaListFilter): MangaSearchQuery {
+ return MangaSearchQuery.Builder().apply {
+ offset(offset)
+ order(sortOrder)
+ if (filter.tags.isNotEmpty()) criterion(Include(TAG, filter.tags))
+ if (filter.tagsExclude.isNotEmpty()) criterion(Exclude(TAG, filter.tagsExclude))
+ if (filter.states.isNotEmpty()) criterion(Include(STATE, filter.states))
+ if (filter.types.isNotEmpty()) criterion(Include(CONTENT_TYPE, filter.types))
+ if (filter.contentRating.isNotEmpty()) criterion(Include(CONTENT_RATING, filter.contentRating))
+ if (filter.demographics.isNotEmpty()) criterion(Include(DEMOGRAPHIC, filter.demographics))
+ if (validateYear(filter.yearFrom) || validateYear(filter.yearTo)) {
+ criterion(QueryCriteria.Range(PUBLICATION_YEAR, filter.yearFrom, filter.yearTo))
+ }
+ if (validateYear(filter.year)) {
+ criterion(Match(PUBLICATION_YEAR, filter.year))
+ }
+ filter.locale?.let {
+ criterion(Include(LANGUAGE, setOf(it)))
+ }
+ filter.originalLocale?.let {
+ criterion(Include(ORIGINAL_LANGUAGE, setOf(it)))
+ }
+ filter.query?.takeIf { it.isNotBlank() }?.let {
+ criterion(Match(TITLE_NAME, it))
+ }
+ }.build()
+}
+
+/**
+ * Converts a {@link MangaSearchQuery} into a {@link MangaListFilter}.
+ *
+ * This method iterates through the search criteria defined in the provided {@code searchQuery}
+ * and applies them to a {@link MangaListFilter.Builder}. The criteria are processed based on
+ * their types, such as inclusion, exclusion, equality checks, range filtering, and pattern matching.
+ *
+ *
+ * Supported criteria:
+ *
+ * - {@link QueryCriteria.Include} - Adds tags, states, content types, content ratings, demographics, and languages.
+ * - {@link QueryCriteria.Exclude} - Excludes tags.
+ * - {@link QueryCriteria.Equals} - Sets specific values like publication year.
+ * - {@link QueryCriteria.Between} - Sets a range of values like publication year range.
+ * - {@link QueryCriteria.Match} - Adds a search pattern for the title name.
+ *
+ *
+ *
+ * If an unsupported field is encountered, an {@link UnsupportedOperationException} is thrown.
+ *
+ *
+ * @param searchQuery The {@link MangaSearchQuery} to convert.
+ * @return A {@link MangaListFilter} constructed based on the given {@code searchQuery}.
+ * @throws UnsupportedOperationException If the search criteria contain unsupported fields.
+ */
+@InternalParsersApi
+public fun convertToMangaListFilter(searchQuery: MangaSearchQuery): MangaListFilter {
+ return MangaListFilter.Builder().apply {
+ for (criterion in searchQuery.criteria) {
+ when (criterion) {
+ is Include<*> -> handleInclude(this, criterion)
+ is Exclude<*> -> handleExclude(this, criterion)
+ is Range<*> -> handleBetween(this, criterion)
+ is Match<*> -> handleMatch(this, criterion)
+ }
+ }
+ }.build()
+}
+
+internal fun MangaListFilterCapabilities.toMangaSearchQueryCapabilities(): MangaSearchQueryCapabilities =
+ MangaSearchQueryCapabilities(
+ capabilities = setOfNotNull(
+ isMultipleTagsSupported.takeIf { it }?.let {
+ SearchCapability(
+ field = TAG, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true,
+ )
+ },
+ isTagsExclusionSupported.takeIf { it }?.let {
+ SearchCapability(
+ field = TAG, criteriaTypes = setOf(Exclude::class), multiValue = true, otherCriteria = true,
+ )
+ },
+ isSearchSupported.takeIf { it }?.let {
+ SearchCapability(
+ field = TITLE_NAME,
+ criteriaTypes = setOf(Match::class),
+ multiValue = false,
+ otherCriteria = false,
+ )
+ },
+ isSearchWithFiltersSupported.takeIf { it }?.let {
+ SearchCapability(
+ field = TITLE_NAME,
+ criteriaTypes = setOf(Match::class),
+ multiValue = false,
+ otherCriteria = true,
+ )
+ },
+ isYearSupported.takeIf { it }?.let {
+ SearchCapability(
+ field = PUBLICATION_YEAR,
+ criteriaTypes = setOf(Match::class),
+ multiValue = false,
+ otherCriteria = true,
+ )
+ },
+ isYearRangeSupported.takeIf { it }?.let {
+ SearchCapability(
+ field = PUBLICATION_YEAR,
+ criteriaTypes = setOf(Range::class),
+ multiValue = false,
+ otherCriteria = true,
+ )
+ },
+ isOriginalLocaleSupported.takeIf { it }?.let {
+ SearchCapability(
+ field = ORIGINAL_LANGUAGE,
+ criteriaTypes = setOf(Include::class),
+ multiValue = true,
+ otherCriteria = true,
+ )
+ },
+ SearchCapability(
+ field = LANGUAGE,
+ criteriaTypes = setOf(Include::class),
+ multiValue = true,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = STATE, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true,
+ ),
+ SearchCapability(
+ field = CONTENT_TYPE,
+ criteriaTypes = setOf(Include::class),
+ multiValue = true,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = CONTENT_RATING,
+ criteriaTypes = setOf(Include::class),
+ multiValue = true,
+ otherCriteria = true,
+ ),
+ SearchCapability(
+ field = DEMOGRAPHIC,
+ criteriaTypes = setOf(Include::class),
+ multiValue = true,
+ otherCriteria = true,
+ ),
+ ),
+ )
+
+private fun handleInclude(builder: MangaListFilter.Builder, criterion: Include<*>) {
+ val type = criterion.field.type
+
+ when (criterion.field) {
+ TAG -> builder.addTags(filterValues(criterion, type))
+ STATE -> builder.addStates(filterValues(criterion, type))
+ CONTENT_TYPE -> builder.addTypes(filterValues(criterion, type))
+ CONTENT_RATING -> builder.addContentRatings(filterValues(criterion, type))
+ DEMOGRAPHIC -> builder.addDemographics(filterValues(criterion, type))
+ LANGUAGE -> builder.locale(getFirstValue(criterion, type))
+ ORIGINAL_LANGUAGE -> builder.originalLocale(getFirstValue(criterion, type))
+ else -> throw IllegalArgumentException("Unsupported field for Include criterion: ${criterion.field}")
+ }
+}
+
+private fun handleExclude(builder: MangaListFilter.Builder, criterion: Exclude<*>) {
+ val type = criterion.field.type
+
+ when (criterion.field) {
+ TAG -> builder.excludeTags(filterValues(criterion, type))
+ else -> throw IllegalArgumentException("Unsupported field for Exclude criterion: ${criterion.field}")
+ }
+}
+
+private fun handleBetween(builder: MangaListFilter.Builder, criterion: Range<*>) {
+ val type = criterion.field.type
+
+ when (criterion.field) {
+ PUBLICATION_YEAR -> {
+ builder.yearFrom(getValue(criterion.from, type, YEAR_UNKNOWN))
+ builder.yearTo(getValue(criterion.to, type, YEAR_UNKNOWN))
+ }
+
+ else -> throw IllegalArgumentException("Unsupported field for Between criterion: ${criterion.field}")
+ }
+}
+
+private fun handleMatch(builder: MangaListFilter.Builder, criterion: Match<*>) {
+ val type = criterion.field.type
+
+ when (criterion.field) {
+ TITLE_NAME -> builder.query(getValue(criterion.value, type, ""))
+ PUBLICATION_YEAR -> builder.year(getValue(criterion.value, type, YEAR_UNKNOWN))
+ else -> throw IllegalArgumentException("Unsupported field for Match criterion: ${criterion.field}")
+ }
+}
+
+@Suppress("UNCHECKED_CAST")
+private fun filterValues(criterion: Include<*>, type: Class<*>): List {
+ return criterion.values.filter { type.isInstance(it) } as List
+}
+
+@Suppress("UNCHECKED_CAST")
+private fun filterValues(criterion: Exclude<*>, type: Class<*>): List {
+ return criterion.values.filter { type.isInstance(it) } as List
+}
+
+@Suppress("UNCHECKED_CAST")
+private fun getFirstValue(criterion: Include<*>, type: Class<*>): T? {
+ return criterion.values.firstOrNull { type.isInstance(it) } as? T
+}
+
+@Suppress("UNCHECKED_CAST")
+private fun getValue(value: Any?, type: Class<*>, default: T): T {
+ val isCompatibleIntType = (type == Int::class.java && Integer::class.isInstance(value))
+
+ return if (type.isInstance(value) || isCompatibleIntType) value as T else default
+}
+
+private fun validateYear(year: Int) = year != YEAR_UNKNOWN
diff --git a/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt b/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt
index 96950060..8fad3243 100644
--- a/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt
+++ b/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt
@@ -6,6 +6,10 @@ import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.params.ParameterizedTest
import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.Include
+import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.medianOrNull
import org.koitharu.kotatsu.parsers.util.mimeType
@@ -34,8 +38,9 @@ internal class MangaParserTest {
if (parser is SinglePageMangaParser) {
return@runTest
}
- val page1 = parser.getList(0, parser.defaultSortOrder, MangaListFilter.EMPTY)
- val page2 = parser.getList(page1.size, parser.defaultSortOrder, MangaListFilter.EMPTY)
+ val page1 = parser.queryManga(MangaSearchQuery.Builder().offset(0).order(parser.defaultSortOrder).build())
+ val page2 =
+ parser.queryManga(MangaSearchQuery.Builder().offset(page1.size).order(parser.defaultSortOrder).build())
if (parser is PagedMangaParser) {
assert(parser.pageSize >= page1.size) {
"Page size is ${page1.size} but ${parser.pageSize} expected"
@@ -52,18 +57,25 @@ internal class MangaParserTest {
@ParameterizedTest(name = "{index}|search|{0}")
@MangaSources
- fun search(source: MangaParserSource) = runTest(timeout = timeout) {
+ fun searchByTitleName(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source)
- val subject = parser.getList(
- offset = 0,
- order = SortOrder.POPULARITY,
- filter = MangaListFilter.EMPTY,
+ val subject = parser.queryManga(
+ MangaSearchQuery.Builder()
+ .offset(0)
+ .order(parser.defaultSortOrder)
+ .build(),
).minByOrNull {
it.title.length
} ?: error("No manga found")
+
val query = subject.title
check(query.isNotBlank()) { "Manga title '$query' is blank" }
- val list = parser.getList(0, SortOrder.RELEVANCE, MangaListFilter(query = query))
+ val list = parser.queryManga(
+ MangaSearchQuery.Builder()
+ .order(SortOrder.RELEVANCE)
+ .criterion(QueryCriteria.Match(TITLE_NAME, query))
+ .build(),
+ )
assert(list.isNotEmpty()) { "Empty search results by \"$query\"" }
assert(list.singleOrNull { it.url == subject.url && it.id == subject.id } != null) {
"Single subject '${subject.title} (${subject.publicUrl})' not found in search results"
@@ -91,10 +103,12 @@ internal class MangaParserTest {
assert(tags.all { it.source == source })
val tag = tags.last()
- val list = parser.getList(
- offset = 0,
- order = parser.defaultSortOrder,
- filter = MangaListFilter(tags = setOf(tag)),
+ val list = parser.queryManga(
+ MangaSearchQuery.Builder()
+ .offset(0)
+ .order(parser.defaultSortOrder)
+ .criterion(Include(TAG, setOf(tag)))
+ .build(),
)
checkMangaList(list, "${tag.title} (${tag.key})")
assert(list.all { it.source == source })
@@ -107,8 +121,14 @@ internal class MangaParserTest {
if (!parser.filterCapabilities.isMultipleTagsSupported) return@runTest
val tags = parser.getFilterOptions().availableTags.shuffled().take(2).toSet()
- val filter = MangaListFilter(tags = tags)
- val list = parser.getList(0, parser.defaultSortOrder, filter)
+ val list = parser.queryManga(
+ MangaSearchQuery.Builder()
+ .offset(0)
+ .order(parser.defaultSortOrder)
+ .criterion(Include(TAG, tags))
+ .build(),
+ )
+
checkMangaList(list, "${tags.joinToString { it.title }} (${tags.joinToString { it.key }})")
assert(list.all { it.source == source })
}
@@ -121,12 +141,17 @@ internal class MangaParserTest {
if (locales.isEmpty()) {
return@runTest
}
- val filter = MangaListFilter(
- locale = locales.random(),
- originalLocale = locales.random(),
+ val locale = locales.random()
+ val list = parser.queryManga(
+ MangaSearchQuery.Builder()
+ .offset(0)
+ .order(parser.defaultSortOrder)
+ .criterion(Include(LANGUAGE, setOf(locale)))
+ .criterion(Include(LANGUAGE, setOf(locale)))
+ .criterion(Include(ORIGINAL_LANGUAGE, setOf(locales.random())))
+ .build(),
)
- val list = parser.getList(offset = 0, order = parser.defaultSortOrder, filter)
- checkMangaList(list, filter.locale.toString())
+ checkMangaList(list, locale.toString())
assert(list.all { it.source == source })
}
@@ -135,8 +160,9 @@ internal class MangaParserTest {
@MangaSources
fun details(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source)
- val list = parser.getList(0, parser.defaultSortOrder, MangaListFilter.EMPTY)
- val manga = list[3]
+ val list = parser.queryManga(MangaSearchQuery.Builder().offset(0).order(parser.defaultSortOrder).build())
+
+ val manga = list[0]
parser.getDetails(manga).apply {
assert(!chapters.isNullOrEmpty()) { "Chapters are null or empty" }
assert(publicUrl.isUrlAbsolute()) { "Manga public url is not absolute: '$publicUrl'" }
@@ -165,7 +191,7 @@ internal class MangaParserTest {
@MangaSources
fun pages(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source)
- val list = parser.getList(0, parser.defaultSortOrder, MangaListFilter.EMPTY)
+ val list = parser.queryManga(MangaSearchQuery.Builder().offset(0).order(parser.defaultSortOrder).build())
val manga = list.first()
val chapter = parser.getDetails(manga).chapters?.firstOrNull() ?: error("Chapter is null at ${manga.publicUrl}")
val pages = parser.getPages(chapter)
@@ -220,7 +246,8 @@ internal class MangaParserTest {
@MangaSources
fun link(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source)
- val manga = parser.getList(0, parser.defaultSortOrder, MangaListFilter.EMPTY).first()
+ val manga =
+ parser.queryManga(MangaSearchQuery.Builder().offset(0).order(parser.defaultSortOrder).build()).first()
val resolved = context.newLinkResolver(manga.publicUrl).getManga()
Assertions.assertNotNull(resolved)
resolved ?: return@runTest
diff --git a/src/test/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQueryTest.kt b/src/test/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQueryTest.kt
new file mode 100644
index 00000000..b7be3832
--- /dev/null
+++ b/src/test/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQueryTest.kt
@@ -0,0 +1,72 @@
+package org.koitharu.kotatsu.parsers.model.search
+
+import org.junit.jupiter.api.Assertions.assertDoesNotThrow
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+import org.koitharu.kotatsu.parsers.model.MangaParserSource
+import org.koitharu.kotatsu.parsers.model.MangaState
+import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
+import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
+import java.util.*
+
+class MangaSearchQueryCapabilitiesTest {
+
+ private val capabilities = MangaSearchQueryCapabilities(
+ capabilities = setOf(
+ SearchCapability(TITLE_NAME, setOf(Match::class), multiValue = false, otherCriteria = false),
+ SearchCapability(TAG, setOf(Include::class, Exclude::class), multiValue = true, otherCriteria = true),
+ SearchCapability(PUBLICATION_YEAR, setOf(Range::class), multiValue = false, otherCriteria = true),
+ SearchCapability(STATE, setOf(Include::class), multiValue = false, otherCriteria = true),
+ ),
+ )
+
+ @Test
+ fun validateValidSingleCriterionQuery() {
+ val query = MangaSearchQuery.Builder()
+ .criterion(Match(TITLE_NAME, "title"))
+ .build()
+
+ assertDoesNotThrow { capabilities.validate(query) }
+ }
+
+ @Test
+ fun validateUnsupportedFieldThrowsException() {
+ val query = MangaSearchQuery.Builder()
+ .criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.ENGLISH)))
+ .build()
+
+ assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) }
+ }
+
+ @Test
+ fun validateUnsupportedMultiValueThrowsException() {
+ val query = MangaSearchQuery.Builder()
+ .criterion(Include(STATE, setOf(MangaState.ONGOING, MangaState.FINISHED)))
+ .build()
+
+ assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) }
+ }
+
+ @Test
+ fun validateMultipleCriteriaWithOtherCriteriaAllowed() {
+ val query = MangaSearchQuery.Builder()
+ .criterion(Include(TAG, setOf(buildTag("tag1"), buildTag("tag2"))))
+ .criterion(Exclude(TAG, setOf(buildTag("tag3"))))
+ .build()
+
+ assertDoesNotThrow { capabilities.validate(query) }
+ }
+
+ @Test
+ fun validateMultipleCriteriaWithStrictCapabilityThrowsException() {
+ val query = MangaSearchQuery.Builder()
+ .criterion(Match(TITLE_NAME, "title"))
+ .criterion(Range(PUBLICATION_YEAR, 1990, 2000))
+ .build()
+
+ assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) }
+ }
+
+ private fun buildTag(name: String) = MangaTag(title = name, key = "${name}Key", source = MangaParserSource.DUMMY)
+}
diff --git a/src/test/kotlin/org/koitharu/kotatsu/parsers/util/ListFilterToSearchQueryConverterTest.kt b/src/test/kotlin/org/koitharu/kotatsu/parsers/util/ListFilterToSearchQueryConverterTest.kt
new file mode 100644
index 00000000..04530bec
--- /dev/null
+++ b/src/test/kotlin/org/koitharu/kotatsu/parsers/util/ListFilterToSearchQueryConverterTest.kt
@@ -0,0 +1,77 @@
+package org.koitharu.kotatsu.parsers.util
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.model.ContentType.MANGA
+import org.koitharu.kotatsu.parsers.model.ContentType.MANHUA
+import org.koitharu.kotatsu.parsers.model.Demographic.SEINEN
+import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
+import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
+import java.util.*
+
+class ListFilterToSearchQueryConverterTest {
+
+ @Test
+ fun convertToMangaSearchQueryTest() {
+ val tags = setOf(buildMangaTag("tag1"), buildMangaTag("tag2"))
+ val excludedTags = setOf(buildMangaTag("exclude_tag"))
+ val states = setOf(MangaState.ONGOING)
+ val contentRatings = setOf(ContentRating.SAFE)
+ val contentTypes = setOf(MANGA, MANHUA)
+ val demographics = setOf(SEINEN)
+
+ val filter = MangaListFilter(
+ query = "title_name",
+ tags = tags,
+ tagsExclude = excludedTags,
+ locale = Locale.ENGLISH,
+ originalLocale = Locale.JAPANESE,
+ states = states,
+ contentRating = contentRatings,
+ types = contentTypes,
+ demographics = demographics,
+ year = 2020,
+ yearFrom = 1997,
+ yearTo = 2024,
+ )
+
+ val searchQuery = convertToMangaSearchQuery(0, SortOrder.NEWEST, filter)
+
+ val expectedQuery = MangaSearchQuery.Builder()
+ .offset(0)
+ .order(SortOrder.NEWEST)
+ .criterion(Match(TITLE_NAME, "title_name"))
+ .criterion(Include(TAG, tags))
+ .criterion(Exclude(TAG, excludedTags))
+ .criterion(Include(LANGUAGE, setOf(Locale.ENGLISH)))
+ .criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.JAPANESE)))
+ .criterion(Include(STATE, states))
+ .criterion(Include(CONTENT_RATING, contentRatings))
+ .criterion(Include(CONTENT_TYPE, contentTypes))
+ .criterion(Include(DEMOGRAPHIC, demographics))
+ .criterion(Range(PUBLICATION_YEAR, 1997, 2024))
+ .criterion(Match(PUBLICATION_YEAR, 2020))
+ .build()
+
+ assertEquals(expectedQuery, searchQuery)
+ }
+
+ @Test
+ fun convertToMangaSearchQueryWithEmptyFieldsTest() {
+ val filter = MangaListFilter()
+
+ val searchQuery = convertToMangaSearchQuery(0, SortOrder.NEWEST, filter)
+
+ assertEquals(MangaSearchQuery.Builder().offset(0).order(SortOrder.NEWEST).build(), searchQuery)
+ }
+
+ private fun buildMangaTag(name: String): MangaTag {
+ return MangaTag(
+ key = "${name}Key",
+ title = name,
+ source = MangaParserSource.DUMMY,
+ )
+ }
+}
diff --git a/src/test/kotlin/org/koitharu/kotatsu/parsers/util/SearchQueryToListFilterConverterTest.kt b/src/test/kotlin/org/koitharu/kotatsu/parsers/util/SearchQueryToListFilterConverterTest.kt
new file mode 100644
index 00000000..ef282378
--- /dev/null
+++ b/src/test/kotlin/org/koitharu/kotatsu/parsers/util/SearchQueryToListFilterConverterTest.kt
@@ -0,0 +1,94 @@
+package org.koitharu.kotatsu.parsers.util
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import org.koitharu.kotatsu.parsers.model.ContentRating
+import org.koitharu.kotatsu.parsers.model.ContentType.MANGA
+import org.koitharu.kotatsu.parsers.model.ContentType.MANHUA
+import org.koitharu.kotatsu.parsers.model.Demographic.SEINEN
+import org.koitharu.kotatsu.parsers.model.MangaParserSource
+import org.koitharu.kotatsu.parsers.model.MangaState
+import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
+import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
+import java.util.*
+
+class ConvertToMangaListFilterTest {
+
+ @Test
+ fun convertToMangaListFilterTest() {
+ val tags = setOf(buildMangaTag("tag1"), buildMangaTag("tag2"))
+ val excludedTags = setOf(buildMangaTag("exclude_tag"))
+ val states = setOf(MangaState.ONGOING)
+ val contentRatings = setOf(ContentRating.SAFE)
+ val contentTypes = setOf(MANGA, MANHUA)
+ val demographics = setOf(SEINEN)
+
+ val query = MangaSearchQuery.Builder()
+ .criterion(Match(TITLE_NAME, "title_name"))
+ .criterion(Include(TAG, tags))
+ .criterion(Exclude(TAG, excludedTags))
+ .criterion(Include(LANGUAGE, setOf(Locale.ENGLISH)))
+ .criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.JAPANESE)))
+ .criterion(Include(STATE, states))
+ .criterion(Include(CONTENT_RATING, contentRatings))
+ .criterion(Include(CONTENT_TYPE, contentTypes))
+ .criterion(Include(DEMOGRAPHIC, demographics))
+ .criterion(Range(PUBLICATION_YEAR, 1997, 2024))
+ .criterion(Match(PUBLICATION_YEAR, 2020))
+ .build()
+
+ val listFilter = convertToMangaListFilter(query)
+
+ assertEquals(listFilter.query, "title_name")
+ assertEquals(listFilter.tags, tags)
+ assertEquals(listFilter.tagsExclude, excludedTags)
+ assertEquals(listFilter.locale, Locale.ENGLISH)
+ assertEquals(listFilter.originalLocale, Locale.JAPANESE)
+ assertEquals(listFilter.states, states)
+ assertEquals(listFilter.contentRating, contentRatings)
+ assertEquals(listFilter.types, contentTypes)
+ assertEquals(listFilter.demographics, demographics)
+ assertEquals(listFilter.year, 2020)
+ assertEquals(listFilter.yearFrom, 1997)
+ assertEquals(listFilter.yearTo, 2024)
+ }
+
+ @Test
+ fun convertToMangaListFilterWithMultipleTagsIncludeTest() {
+ val tags1 = setOf(buildMangaTag("tag1"), buildMangaTag("tag2"))
+ val tags2 = setOf(buildMangaTag("tag3"), buildMangaTag("tag4"))
+
+ val query = MangaSearchQuery.Builder()
+ .criterion(Include(TAG, tags1))
+ .criterion(Include(TAG, tags2))
+ .build()
+
+ val listFilter = convertToMangaListFilter(query)
+
+ assertEquals(listFilter.tags, tags1 union tags2)
+ }
+
+ @Test
+ fun convertToMangaListFilterWithUnsupportedFieldTest() {
+ val query = MangaSearchQuery.Builder()
+ .criterion(Include(AUTHOR, setOf(buildMangaTag("author"))))
+ .build()
+
+ val exception = assertThrows {
+ convertToMangaListFilter(query)
+ }
+
+ assert(exception.message!!.contains("Unsupported field for Include criterion: AUTHOR"))
+ }
+
+ private fun buildMangaTag(name: String): MangaTag {
+ return MangaTag(
+ key = "${name}Key",
+ title = name,
+ source = MangaParserSource.DUMMY,
+ )
+ }
+}