New MangaSearchQuery implementation proposal

master
palaks-1 1 year ago
parent 8c966c3e23
commit c0ea9cadd7

@ -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<SortOrder>
@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<Manga> {
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<Manga> {
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<Manga>
/**
@ -113,4 +143,5 @@ public abstract class MangaParser @InternalParsersApi constructor(
* @see [Manga.publicUrl]
*/
internal open suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? = null
}

@ -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<Manga> {
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<Manga> {
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<Manga> {
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<Manga>
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<Manga> {
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
}
}

@ -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<Manga> {
if (searchQuery.offset != null && searchQuery.offset > 0) {
return emptyList()
}
return searchSinglePageManga(searchQuery)
}
public open suspend fun searchSinglePageManga(searchQuery: MangaSearchQuery): List<Manga> {
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<Manga> {
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<Manga>
}

@ -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<MangaTag>,
/**
* 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<MangaTag>,
/**
* Manga status (ongoing, finished) or null if unknown
*/
state: MangaState?,
/**
* Authors of the manga
*/
authors: Set<MangaTag>,
/**
* 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<MangaChapter>? = 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
)
}
}

@ -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<MangaTag> = 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<MangaTag> = mutableSetOf()
private val tagsExclude: MutableSet<MangaTag> = mutableSetOf()
private var locale: Locale? = null
private var originalLocale: Locale? = null
private val states: MutableSet<MangaState> = mutableSetOf()
private val contentRating: MutableSet<ContentRating> = mutableSetOf()
private val types: MutableSet<ContentType> = mutableSetOf()
private val demographics: MutableSet<Demographic> = 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<MangaTag>): Builder = apply { this.tags.addAll(tags) }
public fun excludeTag(tag: MangaTag): Builder = apply { tagsExclude.add(tag) }
public fun excludeTags(tags: Collection<MangaTag>): 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<MangaState>): Builder = apply { this.states.addAll(states) }
public fun addContentRating(rating: ContentRating): Builder = apply { contentRating.add(rating) }
public fun addContentRatings(ratings: Collection<ContentRating>): Builder = apply { this.contentRating.addAll(ratings) }
public fun addType(type: ContentType): Builder = apply { types.add(type) }
public fun addTypes(types: Collection<ContentType>): Builder = apply { this.types.addAll(types) }
public fun addDemographic(demographic: Demographic): Builder = apply { demographics.add(demographic) }
public fun addDemographics(demographics: Collection<Demographic>): 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
)
}
}

@ -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(
/**

@ -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<QueryCriteria<*>> = 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<QueryCriteria<*>> = mutableSetOf()
private var order: SortOrder? = null
private var offset: Int? = null
public fun copy(searchQuery: MangaSearchQuery): Builder = apply {
this.criteria = searchQuery.criteria as MutableSet<QueryCriteria<*>>
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<QueryCriteria<*>>): Set<QueryCriteria<*>> {
val uniqueCriteria = mutableMapOf<Pair<SearchableField, Class<out QueryCriteria<*>>>, 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()
}
}
}

@ -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<SearchCapability> = 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
}
}
}
}
}

@ -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<T>(
@JvmField public val field: SearchableField
) {
public class Include<T : Any>(field: SearchableField, @JvmField public val values: Set<T>) : QueryCriteria<T>(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<T : Any>(field: SearchableField, @JvmField public val values: Set<T>) : QueryCriteria<T>(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<T : Comparable<T>>(field: SearchableField, @JvmField public val from: T, @JvmField public val to: T) : QueryCriteria<T>(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<T : Any>(field: SearchableField, @JvmField public val value: T) : QueryCriteria<T>(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()
}
}
}

@ -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<KClass<out QueryCriteria<*>>>,
@JvmField public val multiValue: Boolean,
@JvmField public val otherCriteria: Boolean,
)

@ -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);
}

@ -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,52 +116,47 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
)
}
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
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)
}
filter.tagsExclude.forEach {
append("&excludedTags[]=")
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 -> ""
}
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 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 -> ""
}
} else {
append("&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic")
is ContentRating -> when (this) {
ContentRating.SAFE -> "safe"
// quick fix for double value
ContentRating.SUGGESTIVE -> "suggestive&contentRating[]=erotica"
ContentRating.ADULT -> "pornographic"
else -> ""
}
if (!filter.author.isNullOrEmpty()) {
append("&authorOrArtist=").append(getAuthorId(filter.author))
is Demographic -> when (this) {
Demographic.SHOUNEN -> "shounen"
Demographic.SHOUJO -> "shoujo"
Demographic.SEINEN -> "seinen"
Demographic.JOSEI -> "josei"
Demographic.NONE -> "none"
else -> ""
}
append("&order")
append(
when (order) {
is SortOrder -> when (this) {
SortOrder.UPDATED -> "[latestUploadedChapter]=desc"
SortOrder.UPDATED_ASC -> "[latestUploadedChapter]=asc"
SortOrder.RATING -> "[rating]=desc"
@ -157,61 +171,62 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
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("")
}
else -> this.toString().urlEncoded()
}
filter.demographics.forEach {
append("&publicationDemographic[]=")
append(
when (it) {
Demographic.SHOUNEN -> "shounen"
Demographic.SHOUJO -> "shoujo"
Demographic.SEINEN -> "seinen"
Demographic.JOSEI -> "josei"
Demographic.NONE -> "none"
else -> ""
},
)
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.locale?.let {
append("&availableTranslatedLanguage[]=")
if (it.language == "in") {
append("id")
} else {
append(it.language)
override suspend fun validatedMangaSearch(searchQuery: MangaSearchQuery): List<Manga> {
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
}
filter.originalLocale?.let {
append("&originalLanguage[]=")
if (it.language == "in") {
append("id")
} else {
append(it.language)
}
}
if (filter.year != 0) {
append("&year=")
append(filter.year)
// If contentRating is not provided, add default values
if (!hasContentRating) {
append("&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic")
}
append("&order")
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<Manga> {
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<MangaTag> = (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<JSONObject>): List<MangaChapter> {
// 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<String, JSONObject> {
val destination = LinkedHashMap<String, JSONObject>(length())
private fun JSONArray.associateByKey(key: String): Map<String, List<JSONObject>> {
val destination = LinkedHashMap<String, MutableList<JSONObject>>(length())
repeat(length()) { i ->
val item = getJSONObject(i)
val keyValue = item.getString(key)
destination[keyValue] = item
destination.computeIfAbsent(keyValue) { mutableListOf() }.add(item)
}
return destination
}

@ -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<Manga> {
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<Manga> {
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<Manga> {
return searchManga(convertToMangaSearchQuery(page, order, filter))
}
protected open val selectTagMap = "div.panel-genres-list a:not(.genres-select)"
protected open suspend fun fetchAvailableTags(): Set<MangaTag> {
@ -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(),
)

@ -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<Manga> {
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<Manga> {
val url = buildString {
append("https://")

@ -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<Manga> {
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=")
private fun SearchableField.toParamName(): String = when (this) {
TAG -> "category"
STATE -> "state"
else -> ""
}
else -> {
append(listUrl)
append("?type=")
when (order) {
SortOrder.POPULARITY -> append("topview")
SortOrder.UPDATED -> append("latest")
SortOrder.NEWEST -> append("newest")
else -> append("latest")
private fun Any?.toQueryParam(): String = when (this) {
is String -> {
sanitizeTitleNameRegex.replace(this, "")
.replace(" ", "_")
.urlEncoded()
}
if (filter.tags.isNotEmpty()) {
append("&category=")
filter.tags.oneOrThrowIfMany()?.let {
append(it.key)
}
}
filter.states.oneOrThrowIfMany()?.let {
append("&state=")
append(
when (it) {
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 ]")
}
append("&page=")
override suspend fun searchPageManga(searchQuery: MangaSearchQuery): List<Manga> {
var titleSearchUrl: String? = null
val url = buildString {
val pageQueryParameter = "page=${searchQuery.offset ?: 0}"
append("https://$domain/?")
searchQuery.criteria.forEach { criterion ->
when (criterion) {
is Include<*> -> {
criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param ->
append("&$param=${criterion.values.first().toQueryParam()}")
}
}
is Match<*> -> {
if (criterion.field == TITLE_NAME) {
criterion.value.toQueryParam().takeIf { it.isNotBlank() }?.let { titleName ->
titleSearchUrl = "https://${domain}${searchUrl}${titleName}/" +
"?$pageQueryParameter"
}
append(page.toString())
}
appendCriterion(criterion.field, criterion.value)
}
else -> {
// Not supported
}
}
}
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,
)
}
}

@ -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<Manga> {
val url = buildString {
append("https://")
append(domain)
when {
!filter.query.isNullOrEmpty() -> {
append(searchUrl)
append(filter.query.urlEncoded())
append("?page=")
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 -> ""
}
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)
}
}
filter.states.oneOrThrowIfMany()?.let {
append("&state=")
append(
when (it) {
MangaState.ONGOING -> "Ongoing"
MangaState.FINISHED -> "Completed"
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()
}
append("&page=")
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<Manga> {
var titleSearchUrl: String? = null
val url = buildString {
val pageQueryParameter = "page=${searchQuery.offset ?: 0}"
append("https://$domain/?")
searchQuery.criteria.forEach { criterion ->
when (criterion) {
is Include<*> -> {
criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param ->
append("&$param=${criterion.values.first().toQueryParam()}")
}
append(page.toString())
}
val doc = webClient.httpGet(url).parseHtml()
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)
}
else -> {
// Not supported
}
}
}
append("&$pageQueryParameter")
append("&type=${(searchQuery.order ?: defaultSortOrder).toQueryParam()}")
}
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,
)
}
}

@ -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"
}

@ -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}.
* <p>
* 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.
* </p>
* <p>
* Supported criteria:
* <ul>
* <li>{@link QueryCriteria.Include} - Adds tags, states, content types, content ratings, demographics, and languages.</li>
* <li>{@link QueryCriteria.Exclude} - Excludes tags.</li>
* <li>{@link QueryCriteria.Equals} - Sets specific values like publication year.</li>
* <li>{@link QueryCriteria.Between} - Sets a range of values like publication year range.</li>
* <li>{@link QueryCriteria.Match} - Adds a search pattern for the title name.</li>
* </ul>
* </p>
* <p>
* If an unsupported field is encountered, an {@link UnsupportedOperationException} is thrown.
* </p>
*
* @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 <T> filterValues(criterion: QueryCriteria.Include<*>, type: Class<*>): List<T> {
return criterion.values.filter { type.isInstance(it) } as List<T>
}
@Suppress("UNCHECKED_CAST")
private fun <T> filterValues(criterion: QueryCriteria.Exclude<*>, type: Class<*>): List<T> {
return criterion.values.filter { type.isInstance(it) } as List<T>
}
@Suppress("UNCHECKED_CAST")
private fun <T> getFirstValue(criterion: QueryCriteria.Include<*>, type: Class<*>): T? {
return criterion.values.firstOrNull { type.isInstance(it) } as? T
}
@Suppress("UNCHECKED_CAST")
private fun <T> 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

@ -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

@ -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)
}

@ -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
)
}
}

@ -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<UnsupportedOperationException> {
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,
)
}
}
Loading…
Cancel
Save