diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt
index cc3a920c..3621d202 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() = MangaSearchQueryCapabilities.from(filterCapabilities)
+
public val config: MangaSourceConfig by lazy { context.getConfig(source) }
public open val sourceLocale: Locale
@@ -62,6 +63,32 @@ 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 searchManga(searchQuery: MangaSearchQuery, validateQuery: Boolean = true): List {
+ if (validateQuery) {
+ searchQueryCapabilities.validate(searchQuery)
+ }
+
+ return validatedMangaSearch(searchQuery)
+ }
+
+ /**
+ * Search list of manga by specified searchQuery
+ *
+ * @param searchQuery searchQuery
+ */
+ protected open suspend fun validatedMangaSearch(searchQuery: MangaSearchQuery): List {
+ return getList(
+ searchQuery.offset ?: 0,
+ searchQuery.order ?: defaultSortOrder,
+ convertToMangaListFilter(searchQuery),
+ )
+ }
+
/**
* Parse list of manga by specified criteria
*
@@ -69,7 +96,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 [searchManga] should be preferred.
*/
+ @Deprecated("New searchManga method should be preferred")
public abstract suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List
/**
@@ -113,4 +143,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..23179207 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 validatedMangaSearch(searchQuery: MangaSearchQuery): List {
+ var containTitleNameCriteria = false
+ searchQuery.criteria.forEach {
+ if (it.field == SearchableField.TITLE_NAME) {
+ containTitleNameCriteria = true
+ }
+ }
+
+ return searchManga(
+ paginator = if (containTitleNameCriteria) {
+ paginator
+ } else {
+ searchPaginator
+ },
+ searchQuery = searchQuery,
+ )
+ }
+
+ public open suspend fun searchPageManga(searchQuery: MangaSearchQuery): List {
+ return getList(
+ searchQuery.offset ?: 0,
+ searchQuery.order ?: defaultSortOrder,
+ convertToMangaListFilter(searchQuery),
+ )
+ }
+
+ @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,20 @@ public abstract class PagedMangaParser(
paginator.onListReceived(offset, page, list.size)
return list
}
+
+ private suspend fun searchManga(
+ paginator: Paginator,
+ searchQuery: MangaSearchQuery,
+ ): List {
+ val offset: Int = searchQuery.offset ?: 0
+ val page = paginator.getPage(offset)
+ val list = searchPageManga(
+ MangaSearchQuery.builder()
+ .copy(searchQuery)
+ .offset(page)
+ .build(),
+ )
+ 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..a4e32f5e 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 validatedMangaSearch(searchQuery: MangaSearchQuery): List {
+ if (searchQuery.offset != null && searchQuery.offset > 0) {
+ return emptyList()
+ }
+ return searchSinglePageManga(searchQuery)
+ }
+
+ public open suspend fun searchSinglePageManga(searchQuery: MangaSearchQuery): List {
+ return getList(
+ searchQuery.offset ?: 0,
+ 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..febf96ee 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,43 @@ public data class MangaListFilter(
@JvmStatic
public val EMPTY: MangaListFilter = MangaListFilter()
}
+
+ public 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
+
+ public fun query(query: String?): Builder = apply { this.query = query }
+ public fun addTag(tag: MangaTag): Builder = apply { tags.add(tag) }
+ public fun addTags(tags: Collection): Builder = apply { this.tags.addAll(tags) }
+ public fun excludeTag(tag: MangaTag): Builder = apply { tagsExclude.add(tag) }
+ public fun excludeTags(tags: Collection): Builder = apply { this.tagsExclude.addAll(tags) }
+ public fun locale(locale: Locale?): Builder = apply { this.locale = locale }
+ public fun originalLocale(locale: Locale?): Builder = apply { this.originalLocale = locale }
+ public fun addState(state: MangaState): Builder = apply { states.add(state) }
+ public fun addStates(states: Collection): Builder = apply { this.states.addAll(states) }
+ public fun addContentRating(rating: ContentRating): Builder = apply { contentRating.add(rating) }
+ public fun addContentRatings(ratings: Collection): Builder = apply { this.contentRating.addAll(ratings) }
+ public fun addType(type: ContentType): Builder = apply { types.add(type) }
+ public fun addTypes(types: Collection): Builder = apply { this.types.addAll(types) }
+ public fun addDemographic(demographic: Demographic): Builder = apply { demographics.add(demographic) }
+ public fun addDemographics(demographics: Collection): Builder = apply { this.demographics.addAll(demographics) }
+ public fun year(year: Int): Builder = apply { this.year = year }
+ public fun yearFrom(year: Int): Builder = apply { this.yearFrom = year }
+ public fun yearTo(year: Int): Builder = apply { this.yearTo = year }
+
+ public 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..dde23d10
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQuery.kt
@@ -0,0 +1,130 @@
+package org.koitharu.kotatsu.parsers.model.search
+
+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).
+ */
+public class MangaSearchQuery private constructor(
+ @JvmField public val criteria: Set> = emptySet(),
+ @JvmField public val order: SortOrder? = null,
+ @JvmField public val offset: Int? = null,
+) {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is MangaSearchQuery) return false
+
+ return criteria == other.criteria &&
+ order == other.order &&
+ offset == other.offset
+ }
+
+ override fun hashCode(): Int {
+ var result = criteria.hashCode()
+ result = 31 * result + (order?.hashCode() ?: 0)
+ result = 31 * result + (offset ?: 0)
+ return result
+ }
+
+ public companion object {
+ public fun builder(): Builder = Builder()
+ }
+
+ public class Builder {
+ private var criteria: MutableSet> = mutableSetOf()
+ private var order: SortOrder? = null
+ private var offset: Int? = null
+
+ public fun copy(searchQuery: MangaSearchQuery): Builder = apply {
+ this.criteria = searchQuery.criteria as MutableSet>
+ this.order = searchQuery.order
+ this.offset = searchQuery.offset
+ }
+
+ @Throws(IllegalArgumentException::class)
+ public fun criterion(criterion: QueryCriteria<*>): Builder = apply {
+ validateCriterion(criterion)
+ this.criteria.add(criterion)
+ }
+
+ public fun order(order: SortOrder?): Builder = apply { this.order = order }
+
+ public fun offset(offset: Int?): Builder = apply { this.offset = offset }
+
+ @Throws(IllegalArgumentException::class)
+ public fun build(): MangaSearchQuery {
+ return MangaSearchQuery(deduplicateCriteria(criteria), order, offset)
+ }
+
+ /**
+ * Validates the provided [QueryCriteria] to ensure type correctness.
+ *
+ * @param criterion The search criterion to validate.
+ * @throws IllegalArgumentException If the criterion type does not match the expected type.
+ */
+ private fun validateCriterion(criterion: QueryCriteria<*>) {
+ try {
+ val expectedType = criterion.field.type
+ val actualType: Class<*>? = when (criterion) {
+ is QueryCriteria.Include<*> -> criterion.values.first().javaClass
+ is QueryCriteria.Exclude<*> -> criterion.values.first().javaClass
+ is QueryCriteria.Match<*> -> criterion.value.javaClass
+ is QueryCriteria.Range<*> -> {
+ if (criterion.from.javaClass != criterion.to.javaClass) {
+ throw IllegalArgumentException(
+ "Mismatched types for field '${criterion.field}'. 'from' and 'to' should have same types"
+ )
+ }
+ criterion.from.javaClass
+ }
+ }
+
+ val isCompatibleIntType = (expectedType == Int::class.java && actualType == Integer::class.java) ||
+ (expectedType == Integer::class.java && actualType == Int::class.java)
+
+ if (actualType != null && !expectedType.isAssignableFrom(actualType) && !isCompatibleIntType) {
+ throw IllegalArgumentException(
+ "Invalid type for ${criterion.field}. Expected: ${expectedType.simpleName}, but got: ${actualType.simpleName}"
+ )
+ }
+ } catch (e: NoSuchElementException) {
+ throw IllegalArgumentException(
+ "QueryCriteria values should not be empty"
+ )
+ }
+ }
+
+ private fun deduplicateCriteria(criteria: Set>): Set> {
+ val uniqueCriteria = mutableMapOf>>, QueryCriteria<*>>()
+
+ 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..b7152b39
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQueryCapabilities.kt
@@ -0,0 +1,105 @@
+package org.koitharu.kotatsu.parsers.model.search
+
+import org.koitharu.kotatsu.parsers.InternalParsersApi
+import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
+import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
+
+public data class MangaSearchQueryCapabilities(
+ val capabilities: Set = emptySet(),
+) {
+ public companion object {
+ @InternalParsersApi
+ public fun from(filterCapabilities: MangaListFilterCapabilities): MangaSearchQueryCapabilities {
+ return MangaSearchQueryCapabilities(
+ capabilities = setOfNotNull(
+ filterCapabilities.isMultipleTagsSupported.takeIf { it }?.let {
+ SearchCapability(
+ field = TAG, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true
+ )
+ },
+ filterCapabilities.isTagsExclusionSupported.takeIf { it }?.let {
+ SearchCapability(
+ field = TAG, criteriaTypes = setOf(Exclude::class), multiValue = true, otherCriteria = true
+ )
+ },
+ filterCapabilities.isSearchSupported.takeIf { it }?.let {
+ SearchCapability(
+ field = TITLE_NAME, criteriaTypes = setOf(Match::class), multiValue = false, otherCriteria = false
+ )
+ },
+ filterCapabilities.isSearchWithFiltersSupported.takeIf { it }?.let {
+ SearchCapability(
+ field = TITLE_NAME, criteriaTypes = setOf(Match::class), multiValue = false, otherCriteria = true
+ )
+ },
+ filterCapabilities.isYearSupported.takeIf { it }?.let {
+ SearchCapability(
+ field = PUBLICATION_YEAR, criteriaTypes = setOf(Match::class), multiValue = false, otherCriteria = true
+ )
+ },
+ filterCapabilities.isYearRangeSupported.takeIf { it }?.let {
+ SearchCapability(
+ field = PUBLICATION_YEAR, criteriaTypes = setOf(Range::class), multiValue = false, otherCriteria = true
+ )
+ },
+ filterCapabilities.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
+ ),
+ ),
+ )
+ }
+ }
+
+ @InternalParsersApi
+ public fun validate(query: MangaSearchQuery) {
+ val strictFields = capabilities.filter { !it.otherCriteria }.map { it.field }.toSet()
+ val usedStrictFields = query.criteria.map { it.field }.toSet().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..8294844d
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/QueryCriteria.kt
@@ -0,0 +1,63 @@
+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 class QueryCriteria(
+ @JvmField public val field: SearchableField
+) {
+ public class Include(field: SearchableField, @JvmField public val values: Set) : QueryCriteria(field) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is Include<*>) return false
+ return field == other.field && values == other.values
+ }
+
+ override fun hashCode(): Int {
+ return 31 * field.hashCode() + values.hashCode()
+ }
+ }
+
+ public class Exclude(field: SearchableField, @JvmField public val values: Set) : QueryCriteria(field) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is Exclude<*>) return false
+ return field == other.field && values == other.values
+ }
+
+ override fun hashCode(): Int {
+ return 31 * field.hashCode() + values.hashCode()
+ }
+ }
+
+ public class Range>(field: SearchableField, @JvmField public val from: T, @JvmField public val to: T) : QueryCriteria(field) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is Range<*>) return false
+ return field == other.field && from == other.from && to == other.to
+ }
+
+ override fun hashCode(): Int {
+ var result = field.hashCode()
+ result = 31 * result + from.hashCode()
+ result = 31 * result + to.hashCode()
+ return result
+ }
+ }
+
+ public class Match(field: SearchableField, @JvmField public val value: T) : QueryCriteria(field) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is Match<*>) return false
+ return field == other.field && value == other.value
+ }
+
+ override fun hashCode(): Int {
+ return 31 * field.hashCode() + value.hashCode()
+ }
+ }
+}
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..e906713a
--- /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.Locale
+
+/**
+ * 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/MangaDexParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaDexParser.kt
index 9f57ba58..99b5de22 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
@@ -14,6 +14,9 @@ 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.*
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
+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 +77,22 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
isAuthorSearchSupported = true,
)
+ override val searchQueryCapabilities: MangaSearchQueryCapabilities
+ get() = MangaSearchQueryCapabilities(
+ capabilities = setOf(
+ 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 +116,117 @@ 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")
-
- filter.query?.let {
- append("&title=")
- append(filter.query.urlEncoded())
- }
-
- filter.tags.forEach {
- append("&includedTags[]=")
- append(it.key)
- }
+ 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"
+ else -> ""
+ }
- filter.tagsExclude.forEach {
- append("&excludedTags[]=")
- append(it.key)
- }
+ 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 -> ""
+ }
+ is ContentRating -> when (this) {
+ ContentRating.SAFE -> "safe"
+ // quick fix for double value
+ ContentRating.SUGGESTIVE -> "suggestive&contentRating[]=erotica"
+ ContentRating.ADULT -> "pornographic"
+ else -> ""
+ }
+ is Demographic -> when (this) {
+ Demographic.SHOUNEN -> "shounen"
+ Demographic.SHOUJO -> "shoujo"
+ Demographic.SEINEN -> "seinen"
+ Demographic.JOSEI -> "josei"
+ Demographic.NONE -> "none"
+ else -> ""
+ }
+ 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 -> this.toString().urlEncoded()
+ }
- 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")
+ 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 validatedMangaSearch(searchQuery: MangaSearchQuery): List {
+ val url = buildString {
+ append("https://api.$domain/manga?limit=$PAGE_SIZE&offset=${searchQuery.offset ?: 0}")
+ .append("&includes[]=cover_art&includes[]=author&includes[]=artist&includedTagsMode=AND&excludedTagsMode=OR")
+
+ var hasContentRating = false
+
+ searchQuery.criteria.forEach { criterion ->
+ when (criterion) {
+ is Include<*> -> {
+ if (criterion.field == CONTENT_RATING) {
+ hasContentRating = true
+ }
+ criterion.values.forEach { appendCriterion(criterion.field, it) }
+ }
+ is Exclude<*> -> {
+ criterion.values.forEach { appendCriterion(criterion.field, it, "excludedTags[]") }
+ }
+ is Match<*> -> {
+ appendCriterion(criterion.field, criterion.value)
+ }
+ else -> {
+ // Not supported
}
}
- } else {
- append("&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic")
}
- if (!filter.author.isNullOrEmpty()) {
- append("&authorOrArtist=").append(getAuthorId(filter.author))
+ // If contentRating is not provided, add default values
+ if (!hasContentRating) {
+ append("&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic")
}
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"
- },
- )
-
- 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("")
- }
- }
-
- filter.demographics.forEach {
- append("&publicationDemographic[]=")
- append(
- when (it) {
- Demographic.SHOUNEN -> "shounen"
- Demographic.SHOUJO -> "shoujo"
- Demographic.SEINEN -> "seinen"
- Demographic.JOSEI -> "josei"
- Demographic.NONE -> "none"
- else -> ""
- },
- )
- }
-
- filter.locale?.let {
- append("&availableTranslatedLanguage[]=")
- if (it.language == "in") {
- append("id")
- } else {
- append(it.language)
- }
- }
-
- filter.originalLocale?.let {
- append("&originalLanguage[]=")
- if (it.language == "in") {
- append("id")
- } else {
- append(it.language)
- }
- }
-
- if (filter.year != 0) {
- append("&year=")
- append(filter.year)
- }
+ append((searchQuery.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 searchManga(convertToMangaSearchQuery(offset, order, filter))
+ }
+
override suspend fun getDetails(manga: Manga): Manga {
val mangaId = manga.url.removePrefix("/")
return getDetails(mangaId)
@@ -282,11 +297,23 @@ 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"])
+ ?.mapNotNull {
+ 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) }
+ }
+ }
+ ?.toSet() ?: emptySet()
+
return Manga(
id = generateUid(id),
title = requireNotNull(attrs.getJSONObject("title").selectByLocale()) {
@@ -322,9 +349,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 +433,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 +452,7 @@ 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 +478,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/mangabox/MangaboxParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/MangaboxParser.kt
index b008e266..e74d5e60 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,16 @@ internal abstract class MangaboxParser(
isSearchWithFiltersSupported = true,
)
+ override val searchQueryCapabilities: MangaSearchQueryCapabilities
+ get() = MangaSearchQueryCapabilities(
+ capabilities = setOf(
+ 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 +75,82 @@ 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 -> ""
+ }
+ is SortOrder -> when (this) {
+ SortOrder.ALPHABETICAL -> "az"
+ SortOrder.NEWEST -> "newest"
+ SortOrder.POPULARITY -> "topview"
+ else -> ""
+ }
+ else -> this.toString().replace(" ", "_").urlEncoded()
+ }
- if (filter.tagsExclude.isNotEmpty()) {
- append("&g_e=")
- filter.tagsExclude.forEach {
- append("_")
- append(it.key)
- 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())
+ }
+ }
- filter.states.oneOrThrowIfMany()?.let {
- append("&sts=")
- append(
- when (it) {
- MangaState.ONGOING -> "ongoing"
- MangaState.FINISHED -> "completed"
- else -> ""
- },
- )
- }
+ private val authorKeyRegex by lazy {
+ Regex("[^/]+\$", RegexOption.IGNORE_CASE)
+ }
- append("&orby=")
- when (order) {
- SortOrder.POPULARITY -> append("topview")
- SortOrder.UPDATED -> append("")
- SortOrder.NEWEST -> append("newest")
- SortOrder.ALPHABETICAL -> append("az")
- else -> append("")
+ override suspend fun searchPageManga(searchQuery: MangaSearchQuery): List {
+ var authorSearchUrl: String? = null
+ val url = buildString {
+ val pageQueryParameter = "page=${searchQuery.offset ?: 0}"
+ append("https://${domain}${listUrl}/?s=all")
+
+ searchQuery.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=${(searchQuery.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 +165,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 searchManga(convertToMangaSearchQuery(page, order, filter))
+ }
+
protected open val selectTagMap = "div.panel-genres-list a:not(.genres-select)"
protected open suspend fun fetchAvailableTags(): Set {
@@ -175,7 +213,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 +231,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..895ef473 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,10 @@ 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.Include
+import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.Match
+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 +42,89 @@ internal class Mangairo(context: MangaLoaderContext) :
isSearchWithFiltersSupported = false,
)
+ override val searchQueryCapabilities: MangaSearchQueryCapabilities
+ get() = MangaSearchQueryCapabilities(
+ capabilities = setOf(
+ 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 searchPageManga(searchQuery: MangaSearchQuery): List {
+ var titleSearchUrl: String? = null
+ var category = "all"
+ var state = "all"
+
+ val url = buildString {
+ append("https://${domain}${listUrl}")
+ append("/type-${(searchQuery.order ?: defaultSortOrder).toQueryParam()}")
+
+ searchQuery.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=${searchQuery.offset ?: 0}"
+ }
+ }
+ }
+ else -> {
+ // Not supported
+ }
+ }
+ }
+ append("/ctg-$category")
+ append("/state-$state")
+ append("/page-${searchQuery.offset ?: 0}")
+ }
+
+ 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..4ed13a69 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,91 @@ internal class Mangakakalot(context: MangaLoaderContext) :
isMultipleTagsSupported = false,
isSearchWithFiltersSupported = false,
)
+
+ override val searchQueryCapabilities: MangaSearchQueryCapabilities
+ get() = MangaSearchQueryCapabilities(
+ capabilities = setOf(
+ 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 searchPageManga(searchQuery: MangaSearchQuery): 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=${searchQuery.offset ?: 0}"
+ 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")
+ searchQuery.criteria.forEach { criterion ->
+ when (criterion) {
+ is Include<*> -> {
+ criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param ->
+ append("&$param=${criterion.values.first().toQueryParam()}")
+ }
}
- if (filter.tags.isNotEmpty()) {
- append("&category=")
- filter.tags.oneOrThrowIfMany()?.let {
- append(it.key)
+ 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)
}
-
- filter.states.oneOrThrowIfMany()?.let {
- append("&state=")
- append(
- when (it) {
- MangaState.ONGOING -> "ongoing"
- MangaState.FINISHED -> "completed"
- else -> "all"
- },
- )
+ else -> {
+ // Not supported
}
-
- append("&page=")
}
}
- append(page.toString())
+
+ append("&$pageQueryParameter")
+ append("&type=${(searchQuery.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 +127,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..418f1e29 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,81 @@ internal class MangakakalotTv(context: MangaLoaderContext) :
isSearchWithFiltersSupported = false,
)
- override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+
+ override val searchQueryCapabilities: MangaSearchQueryCapabilities
+ get() = MangaSearchQueryCapabilities(
+ capabilities = setOf(
+ 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 searchPageManga(searchQuery: MangaSearchQuery): 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=${searchQuery.offset ?: 0}"
+ 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")
+ searchQuery.criteria.forEach { criterion ->
+ when (criterion) {
+ is Include<*> -> {
+ criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param ->
+ append("&$param=${criterion.values.first().toQueryParam()}")
+ }
}
- if (filter.tags.isNotEmpty()) {
- append("&category=")
- filter.tags.oneOrThrowIfMany()?.let {
- append(it.key)
+ 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)
}
-
- filter.states.oneOrThrowIfMany()?.let {
- append("&state=")
- append(
- when (it) {
- MangaState.ONGOING -> "Ongoing"
- MangaState.FINISHED -> "Completed"
- else -> "all"
- },
- )
+ else -> {
+ // Not supported
}
-
- append("&page=")
}
}
- append(page.toString())
+
+ append("&$pageQueryParameter")
+ append("&type=${(searchQuery.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 +116,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..74680b6a
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/SearchQueryConverter.kt
@@ -0,0 +1,154 @@
+package org.koitharu.kotatsu.parsers.util
+
+import org.koitharu.kotatsu.parsers.InternalParsersApi
+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.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(QueryCriteria.Include(TAG, filter.tags))
+ if (filter.tagsExclude.isNotEmpty()) criterion(QueryCriteria.Exclude(TAG, filter.tagsExclude))
+ if (filter.states.isNotEmpty()) criterion(QueryCriteria.Include(STATE, filter.states))
+ if (filter.types.isNotEmpty()) criterion(QueryCriteria.Include(CONTENT_TYPE, filter.types))
+ if (filter.contentRating.isNotEmpty()) criterion(QueryCriteria.Include(CONTENT_RATING, filter.contentRating))
+ if (filter.demographics.isNotEmpty()) criterion(QueryCriteria.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(QueryCriteria.Match(PUBLICATION_YEAR, filter.year))
+ }
+ filter.locale?.takeIf { it != null }?.let {
+ criterion(QueryCriteria.Include(LANGUAGE, setOf(it)))
+ }
+ filter.originalLocale?.takeIf { it != null }?.let {
+ criterion(QueryCriteria.Include(ORIGINAL_LANGUAGE, setOf(it)))
+ }
+ filter.query?.takeIf { it.isNotBlank() }?.let {
+ criterion(QueryCriteria.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 QueryCriteria.Include<*> -> handleInclude(this, criterion)
+ is QueryCriteria.Exclude<*> -> handleExclude(this, criterion)
+ is QueryCriteria.Range<*> -> handleBetween(this, criterion)
+ is QueryCriteria.Match<*> -> handleMatch(this, criterion)
+ }
+ }
+ }.build()
+}
+
+private fun handleInclude(builder: MangaListFilter.Builder, criterion: QueryCriteria.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 UnsupportedOperationException("Unsupported field for Include criterion: ${criterion.field}")
+ }
+}
+
+private fun handleExclude(builder: MangaListFilter.Builder, criterion: QueryCriteria.Exclude<*>) {
+ val type = criterion.field.type
+
+ when (criterion.field) {
+ TAG -> builder.excludeTags(filterValues(criterion, type))
+ else -> throw UnsupportedOperationException("Unsupported field for Exclude criterion: ${criterion.field}")
+ }
+}
+
+private fun handleBetween(builder: MangaListFilter.Builder, criterion: QueryCriteria.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 UnsupportedOperationException("Unsupported field for Between criterion: ${criterion.field}")
+ }
+}
+
+private fun handleMatch(builder: MangaListFilter.Builder, criterion: QueryCriteria.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 UnsupportedOperationException("Unsupported field for Match criterion: ${criterion.field}")
+ }
+}
+
+@Suppress("UNCHECKED_CAST")
+private fun filterValues(criterion: QueryCriteria.Include<*>, type: Class<*>): List {
+ return criterion.values.filter { type.isInstance(it) } as List
+}
+
+@Suppress("UNCHECKED_CAST")
+private fun filterValues(criterion: QueryCriteria.Exclude<*>, type: Class<*>): List {
+ return criterion.values.filter { type.isInstance(it) } as List
+}
+
+@Suppress("UNCHECKED_CAST")
+private fun getFirstValue(criterion: QueryCriteria.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 != null && 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..d667cf90 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,8 @@ 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.searchManga(MangaSearchQuery.builder().offset(0).order(parser.defaultSortOrder).build())
+ val page2 = parser.searchManga(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 +56,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.searchManga(
+ 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.searchManga(
+ 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 +102,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.searchManga(
+ 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 +120,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.searchManga(
+ 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 +140,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.searchManga(
+ 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 +159,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.searchManga(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 +190,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.searchManga(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 +245,7 @@ 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.searchManga(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..9aaddec6
--- /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.Locale
+
+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..d43db48e
--- /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..1d7dfa7a
--- /dev/null
+++ b/src/test/kotlin/org/koitharu/kotatsu/parsers/util/SearchQueryToListFilterConverterTest.kt
@@ -0,0 +1,90 @@
+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.*
+import org.koitharu.kotatsu.parsers.model.ContentType.*
+import org.koitharu.kotatsu.parsers.model.Demographic.SEINEN
+import org.koitharu.kotatsu.parsers.model.search.*
+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,
+ )
+ }
+}