Refactoring

master
Koitharu 1 year ago
parent c0ea9cadd7
commit f107e11528
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -28,7 +28,7 @@ public abstract class MangaParser @InternalParsersApi constructor(
public abstract val filterCapabilities: MangaListFilterCapabilities public abstract val filterCapabilities: MangaListFilterCapabilities
public open val searchQueryCapabilities: MangaSearchQueryCapabilities public open val searchQueryCapabilities: MangaSearchQueryCapabilities
get() = MangaSearchQueryCapabilities.from(filterCapabilities) get() = filterCapabilities.toMangaSearchQueryCapabilities()
public val config: MangaSourceConfig by lazy { context.getConfig(source) } public val config: MangaSourceConfig by lazy { context.getConfig(source) }
@ -68,26 +68,24 @@ public abstract class MangaParser @InternalParsersApi constructor(
* *
* @param searchQuery searchQuery * @param searchQuery searchQuery
*/ */
public suspend fun searchManga(searchQuery: MangaSearchQuery, validateQuery: Boolean = true): List<Manga> { public suspend fun queryManga(searchQuery: MangaSearchQuery): List<Manga> {
if (validateQuery) { if (!searchQuery.skipValidation) {
searchQueryCapabilities.validate(searchQuery) searchQueryCapabilities.validate(searchQuery)
} }
return validatedMangaSearch(searchQuery) return getList(searchQuery)
} }
/** /**
* Search list of manga by specified searchQuery * Search list of manga by specified searchQuery
* *
* @param searchQuery searchQuery * @param query searchQuery
*/ */
protected open suspend fun validatedMangaSearch(searchQuery: MangaSearchQuery): List<Manga> { protected open suspend fun getList(query: MangaSearchQuery): List<Manga> = getList(
return getList( offset = query.offset,
searchQuery.offset ?: 0, order = query.order ?: defaultSortOrder,
searchQuery.order ?: defaultSortOrder, filter = convertToMangaListFilter(query),
convertToMangaListFilter(searchQuery), )
)
}
/** /**
* Parse list of manga by specified criteria * Parse list of manga by specified criteria
@ -97,9 +95,9 @@ public abstract class MangaParser @InternalParsersApi constructor(
* @param order one of [availableSortOrders] or [defaultSortOrder] for default value * @param order one of [availableSortOrders] or [defaultSortOrder] for default value
* @param filter is a set of filter rules * @param filter is a set of filter rules
* *
* @deprecated New [searchManga] should be preferred. * @deprecated New [getList] should be preferred.
*/ */
@Deprecated("New searchManga method should be preferred") @Deprecated("New getList(query: MangaSearchQuery) method should be preferred")
public abstract suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> public abstract suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga>
/** /**

@ -24,9 +24,9 @@ public abstract class PagedMangaParser(
@JvmField @JvmField
protected val searchPaginator: Paginator = Paginator(searchPageSize) protected val searchPaginator: Paginator = Paginator(searchPageSize)
final override suspend fun validatedMangaSearch(searchQuery: MangaSearchQuery): List<Manga> { final override suspend fun getList(query: MangaSearchQuery): List<Manga> {
var containTitleNameCriteria = false var containTitleNameCriteria = false
searchQuery.criteria.forEach { query.criteria.forEach {
if (it.field == SearchableField.TITLE_NAME) { if (it.field == SearchableField.TITLE_NAME) {
containTitleNameCriteria = true containTitleNameCriteria = true
} }
@ -38,15 +38,15 @@ public abstract class PagedMangaParser(
} else { } else {
searchPaginator searchPaginator
}, },
searchQuery = searchQuery, query = query,
) )
} }
public open suspend fun searchPageManga(searchQuery: MangaSearchQuery): List<Manga> { public open suspend fun getListPage(query: MangaSearchQuery, page: Int): List<Manga> {
return getList( return getListPage(
searchQuery.offset ?: 0, page = page,
searchQuery.order ?: defaultSortOrder, order = query.order ?: defaultSortOrder,
convertToMangaListFilter(searchQuery), filter = convertToMangaListFilter(query),
) )
} }
@ -81,16 +81,11 @@ public abstract class PagedMangaParser(
private suspend fun searchManga( private suspend fun searchManga(
paginator: Paginator, paginator: Paginator,
searchQuery: MangaSearchQuery, query: MangaSearchQuery,
): List<Manga> { ): List<Manga> {
val offset: Int = searchQuery.offset ?: 0 val offset: Int = query.offset
val page = paginator.getPage(offset) val page = paginator.getPage(offset)
val list = searchPageManga( val list = getListPage(query, page)
MangaSearchQuery.builder()
.copy(searchQuery)
.offset(page)
.build(),
)
paginator.onListReceived(offset, page, list.size) paginator.onListReceived(offset, page, list.size)
return list return list
} }

@ -14,16 +14,16 @@ public abstract class SinglePageMangaParser(
) : MangaParser(context, source) { ) : MangaParser(context, source) {
final override suspend fun validatedMangaSearch(searchQuery: MangaSearchQuery): List<Manga> { final override suspend fun getList(query: MangaSearchQuery): List<Manga> {
if (searchQuery.offset != null && searchQuery.offset > 0) { if (query.offset > 0) {
return emptyList() return emptyList()
} }
return searchSinglePageManga(searchQuery) return searchSinglePageManga(query)
} }
public open suspend fun searchSinglePageManga(searchQuery: MangaSearchQuery): List<Manga> { public open suspend fun searchSinglePageManga(searchQuery: MangaSearchQuery): List<Manga> {
return getList( return getList(
searchQuery.offset ?: 0, searchQuery.offset,
searchQuery.order ?: defaultSortOrder, searchQuery.order ?: defaultSortOrder,
convertToMangaListFilter(searchQuery), convertToMangaListFilter(searchQuery),
) )

@ -44,7 +44,7 @@ public data class MangaListFilter(
public val EMPTY: MangaListFilter = MangaListFilter() public val EMPTY: MangaListFilter = MangaListFilter()
} }
public class Builder { internal class Builder {
private var query: String? = null private var query: String? = null
private val tags: MutableSet<MangaTag> = mutableSetOf() private val tags: MutableSet<MangaTag> = mutableSetOf()
private val tagsExclude: MutableSet<MangaTag> = mutableSetOf() private val tagsExclude: MutableSet<MangaTag> = mutableSetOf()
@ -58,28 +58,32 @@ public data class MangaListFilter(
private var yearFrom: Int = YEAR_UNKNOWN private var yearFrom: Int = YEAR_UNKNOWN
private var yearTo: Int = YEAR_UNKNOWN private var yearTo: Int = YEAR_UNKNOWN
public fun query(query: String?): Builder = apply { this.query = query } fun query(query: String?): Builder = apply { this.query = query }
public fun addTag(tag: MangaTag): Builder = apply { tags.add(tag) } fun addTag(tag: MangaTag): Builder = apply { tags.add(tag) }
public fun addTags(tags: Collection<MangaTag>): Builder = apply { this.tags.addAll(tags) } fun addTags(tags: Collection<MangaTag>): Builder = apply { this.tags.addAll(tags) }
public fun excludeTag(tag: MangaTag): Builder = apply { tagsExclude.add(tag) } fun excludeTag(tag: MangaTag): Builder = apply { tagsExclude.add(tag) }
public fun excludeTags(tags: Collection<MangaTag>): Builder = apply { this.tagsExclude.addAll(tags) } fun excludeTags(tags: Collection<MangaTag>): Builder = apply { this.tagsExclude.addAll(tags) }
public fun locale(locale: Locale?): Builder = apply { this.locale = locale } fun locale(locale: Locale?): Builder = apply { this.locale = locale }
public fun originalLocale(locale: Locale?): Builder = apply { this.originalLocale = locale } fun originalLocale(locale: Locale?): Builder = apply { this.originalLocale = locale }
public fun addState(state: MangaState): Builder = apply { states.add(state) } fun addState(state: MangaState): Builder = apply { states.add(state) }
public fun addStates(states: Collection<MangaState>): Builder = apply { this.states.addAll(states) } fun addStates(states: Collection<MangaState>): Builder = apply { this.states.addAll(states) }
public fun addContentRating(rating: ContentRating): Builder = apply { contentRating.add(rating) } fun addContentRating(rating: ContentRating): Builder = apply { contentRating.add(rating) }
public fun addContentRatings(ratings: Collection<ContentRating>): Builder = apply { this.contentRating.addAll(ratings) } fun addContentRatings(ratings: Collection<ContentRating>): Builder =
public fun addType(type: ContentType): Builder = apply { types.add(type) } apply { this.contentRating.addAll(ratings) }
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( fun addType(type: ContentType): Builder = apply { types.add(type) }
fun addTypes(types: Collection<ContentType>): Builder = apply { this.types.addAll(types) }
fun addDemographic(demographic: Demographic): Builder = apply { demographics.add(demographic) }
fun addDemographics(demographics: Collection<Demographic>): Builder =
apply { this.demographics.addAll(demographics) }
fun year(year: Int): Builder = apply { this.year = year }
fun yearFrom(year: Int): Builder = apply { this.yearFrom = year }
fun yearTo(year: Int): Builder = apply { this.yearTo = year }
fun build(): MangaListFilter = MangaListFilter(
query, tags, tagsExclude, locale, originalLocale, states, query, tags, tagsExclude, locale, originalLocale, states,
contentRating, types, demographics, year, yearFrom, yearTo contentRating, types, demographics, year, yearFrom, yearTo,
) )
} }
} }

@ -1,5 +1,7 @@
package org.koitharu.kotatsu.parsers.model.search package org.koitharu.kotatsu.parsers.model.search
import androidx.collection.ArrayMap
import androidx.collection.ArraySet
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
/** /**
@ -10,98 +12,48 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
* @property order The sorting order for the results (optional). * @property order The sorting order for the results (optional).
* @property offset The offset number for paginated search 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 && @ConsistentCopyVisibility
order == other.order && public data class MangaSearchQuery private constructor(
offset == other.offset @JvmField public val criteria: Set<QueryCriteria<*>>,
} @JvmField public val order: SortOrder?,
@JvmField public val offset: Int,
@JvmField public val skipValidation: Boolean,
) {
override fun hashCode(): Int { public fun newBuilder(): Builder = Builder(this)
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 { public class Builder {
private var criteria: MutableSet<QueryCriteria<*>> = mutableSetOf()
private val criteria = ArraySet<QueryCriteria<*>>()
private var order: SortOrder? = null private var order: SortOrder? = null
private var offset: Int? = null private var offset: Int = 0
private var skipValidation: Boolean = false
public fun copy(searchQuery: MangaSearchQuery): Builder = apply { public constructor()
this.criteria = searchQuery.criteria as MutableSet<QueryCriteria<*>>
this.order = searchQuery.order
this.offset = searchQuery.offset
}
@Throws(IllegalArgumentException::class) public constructor(query: MangaSearchQuery) : this() {
public fun criterion(criterion: QueryCriteria<*>): Builder = apply { criteria.addAll(query.criteria)
validateCriterion(criterion) order = query.order
this.criteria.add(criterion) offset = query.offset
} }
public fun criterion(criterion: QueryCriteria<*>): Builder = apply { criteria.add(criterion) }
public fun order(order: SortOrder?): Builder = apply { this.order = order } public fun order(order: SortOrder?): Builder = apply { this.order = order }
public fun offset(offset: Int?): Builder = apply { this.offset = offset } public fun offset(offset: Int): Builder = apply { this.offset = offset }
public fun skipValidation(skip: Boolean): Builder = apply { this.skipValidation = skip }
@Throws(IllegalArgumentException::class) @Throws(IllegalArgumentException::class)
public fun build(): MangaSearchQuery { public fun build(): MangaSearchQuery {
return MangaSearchQuery(deduplicateCriteria(criteria), order, offset) return MangaSearchQuery(deduplicateCriteria(criteria), order, offset, skipValidation)
}
/**
* 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<*>> { private fun deduplicateCriteria(criteria: Set<QueryCriteria<*>>): Set<QueryCriteria<*>> {
val uniqueCriteria = mutableMapOf<Pair<SearchableField, Class<out QueryCriteria<*>>>, QueryCriteria<*>>() val uniqueCriteria =
ArrayMap<Pair<SearchableField, Class<out QueryCriteria<*>>>, QueryCriteria<*>>(criteria.size)
for (criterion in criteria) { for (criterion in criteria) {
val key = criterion.field to criterion::class.java val key = criterion.field to criterion::class.java
@ -111,15 +63,17 @@ public class MangaSearchQuery private constructor(
existing == null -> uniqueCriteria[key] = criterion existing == null -> uniqueCriteria[key] = criterion
existing is QueryCriteria.Include<*> && criterion is QueryCriteria.Include<*> -> { existing is QueryCriteria.Include<*> && criterion is QueryCriteria.Include<*> -> {
uniqueCriteria[key] = QueryCriteria.Include(criterion.field,existing.values union criterion.values) uniqueCriteria[key] =
QueryCriteria.Include(criterion.field, existing.values union criterion.values)
} }
existing is QueryCriteria.Exclude<*> && criterion is QueryCriteria.Exclude<*> -> { existing is QueryCriteria.Exclude<*> && criterion is QueryCriteria.Exclude<*> -> {
uniqueCriteria[key] = QueryCriteria.Exclude(criterion.field,existing.values union criterion.values) uniqueCriteria[key] =
QueryCriteria.Exclude(criterion.field, existing.values union criterion.values)
} }
else -> throw IllegalArgumentException( else -> throw IllegalArgumentException(
"Match and Range have only one criterion per type, but found duplicates for: ${criterion.field} in ${criterion::class.simpleName}" "Match and Range have only one criterion per type, but found duplicates for: ${criterion.field} in ${criterion::class.simpleName}",
) )
} }
} }

@ -1,81 +1,25 @@
package org.koitharu.kotatsu.parsers.model.search package org.koitharu.kotatsu.parsers.model.search
import androidx.collection.ArraySet
import org.koitharu.kotatsu.parsers.InternalParsersApi 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.QueryCriteria.*
import org.koitharu.kotatsu.parsers.model.search.SearchableField.* import org.koitharu.kotatsu.parsers.util.mapToSet
public data class MangaSearchQueryCapabilities( @ExposedCopyVisibility
val capabilities: Set<SearchCapability> = emptySet(), public data class MangaSearchQueryCapabilities internal constructor(
val capabilities: Set<SearchCapability>,
) { ) {
public companion object {
@InternalParsersApi public constructor(vararg capabilities: SearchCapability) : this(ArraySet(capabilities))
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 @InternalParsersApi
public fun validate(query: MangaSearchQuery) { public fun validate(query: MangaSearchQuery) {
val strictFields = capabilities.filter { !it.otherCriteria }.map { it.field }.toSet() val strictFields = capabilities.filter { !it.otherCriteria }.mapToSet { it.field }
val usedStrictFields = query.criteria.map { it.field }.toSet().intersect(strictFields) val usedStrictFields = query.criteria.mapToSet { it.field }.intersect(strictFields)
if (usedStrictFields.isNotEmpty() && query.criteria.size > 1) { if (usedStrictFields.isNotEmpty() && query.criteria.size > 1) {
throw IllegalArgumentException( throw IllegalArgumentException(
"Query contains multiple criteria, but at least one field (${usedStrictFields.joinToString()}) does not support multiple criteria." "Query contains multiple criteria, but at least one field (${usedStrictFields.joinToString()}) does not support multiple criteria.",
) )
} }
@ -85,7 +29,7 @@ public data class MangaSearchQueryCapabilities(
if (criterion::class !in capability.criteriaTypes) { if (criterion::class !in capability.criteriaTypes) {
throw IllegalArgumentException( throw IllegalArgumentException(
"Unsupported search criterion: ${criterion::class.simpleName} for field ${criterion.field}" "Unsupported search criterion: ${criterion::class.simpleName} for field ${criterion.field}",
) )
} }
@ -94,8 +38,10 @@ public data class MangaSearchQueryCapabilities(
when (criterion) { when (criterion) {
is Include<*> -> if (criterion.values.size > 1) is Include<*> -> if (criterion.values.size > 1)
throw IllegalArgumentException("Multiple values are not allowed for field ${criterion.field}") throw IllegalArgumentException("Multiple values are not allowed for field ${criterion.field}")
is Exclude<*> -> if (criterion.values.size > 1) is Exclude<*> -> if (criterion.values.size > 1)
throw IllegalArgumentException("Multiple values are not allowed for field ${criterion.field}") throw IllegalArgumentException("Multiple values are not allowed for field ${criterion.field}")
is Range<*> -> {} // Range is always valid (from, to) is Range<*> -> {} // Range is always valid (from, to)
is Match<*> -> {} // Match always has a single value is Match<*> -> {} // Match always has a single value
} }

@ -7,57 +7,53 @@ package org.koitharu.kotatsu.parsers.model.search
* @param T The type of value associated with the search criterion. * @param T The type of value associated with the search criterion.
* @property field The field to which this search criterion applies. * @property field The field to which this search criterion applies.
*/ */
public sealed class QueryCriteria<T>( public sealed interface QueryCriteria<T> {
@JvmField public val field: SearchableField
) { 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 { override fun equals(other: Any?): Boolean
if (this === other) return true
if (other !is Include<*>) return false override fun hashCode(): Int
return field == other.field && values == other.values
}
override fun hashCode(): Int { public data class Include<T : Any>(
return 31 * field.hashCode() + values.hashCode() public override val field: SearchableField,
@JvmField public val values: Set<T>,
) : QueryCriteria<T> {
init {
check(values.all { x -> field.type.isInstance(x) })
} }
} }
public class Exclude<T : Any>(field: SearchableField, @JvmField public val values: Set<T>) : QueryCriteria<T>(field) { public data class Exclude<T : Any>(
override fun equals(other: Any?): Boolean { public override val field: SearchableField,
if (this === other) return true @JvmField public val values: Set<T>,
if (other !is Exclude<*>) return false ) : QueryCriteria<T> {
return field == other.field && values == other.values
}
override fun hashCode(): Int { init {
return 31 * field.hashCode() + values.hashCode() check(values.all { x -> field.type.isInstance(x) })
} }
} }
public class Range<T : Comparable<T>>(field: SearchableField, @JvmField public val from: T, @JvmField public val to: T) : QueryCriteria<T>(field) { public data class Range<T : Comparable<T>>(
override fun equals(other: Any?): Boolean { public override val field: SearchableField,
if (this === other) return true @JvmField public val from: T,
if (other !is Range<*>) return false @JvmField public val to: T,
return field == other.field && from == other.from && to == other.to ) : QueryCriteria<T> {
}
override fun hashCode(): Int { init {
var result = field.hashCode() check(field.type.isInstance(from))
result = 31 * result + from.hashCode() check(field.type.isInstance(to))
result = 31 * result + to.hashCode()
return result
} }
} }
public class Match<T : Any>(field: SearchableField, @JvmField public val value: T) : QueryCriteria<T>(field) { public data class Match<T : Any>(
override fun equals(other: Any?): Boolean { public override val field: SearchableField,
if (this === other) return true @JvmField public val value: T,
if (other !is Match<*>) return false ) : QueryCriteria<T> {
return field == other.field && value == other.value
}
override fun hashCode(): Int { init {
return 31 * field.hashCode() + value.hashCode() check(field.type.isInstance(value))
} }
} }
} }

@ -1,7 +1,7 @@
package org.koitharu.kotatsu.parsers.model.search package org.koitharu.kotatsu.parsers.model.search
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import java.util.Locale import java.util.*
/** /**
* Represents the various fields that can be used for searching manga. * Represents the various fields that can be used for searching manga.

@ -516,14 +516,14 @@ internal class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context
title = doc.selectFirstOrThrow("h1").text(), title = doc.selectFirstOrThrow("h1").text(),
url = id.toString(), url = id.toString(),
coverUrl = coverUrl =
"https:" + "https:" +
doc.selectFirstOrThrow("picture > source") doc.selectFirstOrThrow("picture > source")
.attr("data-srcset") .attr("data-srcset")
.substringBefore(" "), .substringBefore(" "),
publicUrl = publicUrl =
doc.selectFirstOrThrow("h1 > a") doc.selectFirstOrThrow("h1 > a")
.attrAsRelativeUrl("href") .attrAsRelativeUrl("href")
.toAbsoluteUrl(domain), .toAbsoluteUrl(domain),
author = null, author = null,
tags = emptySet(), tags = emptySet(),
isNsfw = true, isNsfw = true,
@ -546,37 +546,37 @@ internal class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context
return manga.copy( return manga.copy(
title = json.getString("title"), title = json.getString("title"),
largeCoverUrl = largeCoverUrl =
json.getJSONArray("files").getJSONObject(0).let { json.getJSONArray("files").getJSONObject(0).let {
val hash = it.getString("hash") val hash = it.getString("hash")
val commonId = commonImageId() val commonId = commonImageId()
val imageId = imageIdFromHash(hash) val imageId = imageIdFromHash(hash)
val subDomain = 'a' + subdomainOffset(imageId) val subDomain = 'a' + subdomainOffset(imageId)
"https://${getDomain("${subDomain}a")}/webp/$commonId$imageId/$hash.webp" "https://${getDomain("${subDomain}a")}/webp/$commonId$imageId/$hash.webp"
}, },
author = author =
json.optJSONArray("artists") json.optJSONArray("artists")
?.mapJSON { it.getString("artist").toCamelCase() } ?.mapJSON { it.getString("artist").toCamelCase() }
?.joinToString(), ?.joinToString(),
publicUrl = json.getString("galleryurl").toAbsoluteUrl(domain), publicUrl = json.getString("galleryurl").toAbsoluteUrl(domain),
tags = tags =
buildSet { buildSet {
json.optJSONArray("characters") json.optJSONArray("characters")
?.mapToTags("character") ?.mapToTags("character")
?.let(::addAll) ?.let(::addAll)
json.optJSONArray("tags") json.optJSONArray("tags")
?.mapToTags("tag") ?.mapToTags("tag")
?.let(::addAll) ?.let(::addAll)
json.optJSONArray("artists") json.optJSONArray("artists")
?.mapToTags("artist") ?.mapToTags("artist")
?.let(::addAll) ?.let(::addAll)
json.optJSONArray("parodys") json.optJSONArray("parodys")
?.mapToTags("parody") ?.mapToTags("parody")
?.let(::addAll) ?.let(::addAll)
json.optJSONArray("groups") json.optJSONArray("groups")
?.mapToTags("group") ?.mapToTags("group")
?.let(::addAll) ?.let(::addAll)
}, },
chapters = listOf( chapters = listOf(
MangaChapter( MangaChapter(
id = generateUid(manga.url), id = generateUid(manga.url),
@ -600,15 +600,15 @@ internal class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context
mapJSON { mapJSON {
MangaTag( MangaTag(
title = title =
it.getString(key).toCamelCase().let { title -> it.getString(key).toCamelCase().let { title ->
if (it.getStringOrNull("female")?.toIntOrNull() == 1) { if (it.getStringOrNull("female")?.toIntOrNull() == 1) {
"$title" "$title"
} else if (it.getStringOrNull("male")?.toIntOrNull() == 1) { } else if (it.getStringOrNull("male")?.toIntOrNull() == 1) {
"$title" "$title"
} else { } else {
title title
} }
}, },
key = it.getString("url").tagUrlToTag(), key = it.getString("url").tagUrlToTag(),
source = source, source = source,
).let(tags::add) ).let(tags::add)

@ -11,11 +11,13 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey 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.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.model.search.* import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.* import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
import org.koitharu.kotatsu.parsers.model.search.SearchCapability
import org.koitharu.kotatsu.parsers.model.search.SearchableField
import org.koitharu.kotatsu.parsers.model.search.SearchableField.* import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.* import org.koitharu.kotatsu.parsers.util.json.*
@ -79,17 +81,65 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
override val searchQueryCapabilities: MangaSearchQueryCapabilities override val searchQueryCapabilities: MangaSearchQueryCapabilities
get() = MangaSearchQueryCapabilities( get() = MangaSearchQueryCapabilities(
capabilities = setOf( SearchCapability(
SearchCapability(field = TAG, criteriaTypes = setOf(Include::class, Exclude::class), multiValue = true, otherCriteria = true), field = TAG,
SearchCapability(field = TITLE_NAME, criteriaTypes = setOf(Match::class), multiValue = false, otherCriteria = true), criteriaTypes = setOf(Include::class, Exclude::class),
SearchCapability(field = STATE, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true), multiValue = true,
SearchCapability(field = AUTHOR, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = 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(
SearchCapability(field = DEMOGRAPHIC, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true), field = TITLE_NAME,
SearchCapability(field = ORIGINAL_LANGUAGE, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true), criteriaTypes = setOf(Match::class),
SearchCapability(field = LANGUAGE, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true), multiValue = false,
SearchCapability(field = PUBLICATION_YEAR, criteriaTypes = setOf(Match::class), multiValue = false, otherCriteria = true), 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,
), ),
) )
@ -127,7 +177,6 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
ORIGINAL_LANGUAGE -> "originalLanguage[]" ORIGINAL_LANGUAGE -> "originalLanguage[]"
LANGUAGE -> "availableTranslatedLanguage[]" LANGUAGE -> "availableTranslatedLanguage[]"
PUBLICATION_YEAR -> "year" PUBLICATION_YEAR -> "year"
else -> ""
} }
private fun Any?.toQueryParam(): String = when (this) { private fun Any?.toQueryParam(): String = when (this) {
@ -141,13 +190,14 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
MangaState.PAUSED -> "hiatus" MangaState.PAUSED -> "hiatus"
else -> "" else -> ""
} }
is ContentRating -> when (this) { is ContentRating -> when (this) {
ContentRating.SAFE -> "safe" ContentRating.SAFE -> "safe"
// quick fix for double value // quick fix for double value
ContentRating.SUGGESTIVE -> "suggestive&contentRating[]=erotica" ContentRating.SUGGESTIVE -> "suggestive&contentRating[]=erotica"
ContentRating.ADULT -> "pornographic" ContentRating.ADULT -> "pornographic"
else -> ""
} }
is Demographic -> when (this) { is Demographic -> when (this) {
Demographic.SHOUNEN -> "shounen" Demographic.SHOUNEN -> "shounen"
Demographic.SHOUJO -> "shoujo" Demographic.SHOUJO -> "shoujo"
@ -156,6 +206,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
Demographic.NONE -> "none" Demographic.NONE -> "none"
else -> "" else -> ""
} }
is SortOrder -> when (this) { is SortOrder -> when (this) {
SortOrder.UPDATED -> "[latestUploadedChapter]=desc" SortOrder.UPDATED -> "[latestUploadedChapter]=desc"
SortOrder.UPDATED_ASC -> "[latestUploadedChapter]=asc" SortOrder.UPDATED_ASC -> "[latestUploadedChapter]=asc"
@ -172,6 +223,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
SortOrder.RELEVANCE -> "&order[relevance]=desc" SortOrder.RELEVANCE -> "&order[relevance]=desc"
else -> "[latestUploadedChapter]=desc" else -> "[latestUploadedChapter]=desc"
} }
else -> this.toString().urlEncoded() else -> this.toString().urlEncoded()
} }
@ -183,14 +235,14 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
} }
} }
override suspend fun validatedMangaSearch(searchQuery: MangaSearchQuery): List<Manga> { override suspend fun getList(query: MangaSearchQuery): List<Manga> {
val url = buildString { val url = buildString {
append("https://api.$domain/manga?limit=$PAGE_SIZE&offset=${searchQuery.offset ?: 0}") append("https://api.$domain/manga?limit=$PAGE_SIZE&offset=${query.offset}")
.append("&includes[]=cover_art&includes[]=author&includes[]=artist&includedTagsMode=AND&excludedTagsMode=OR") .append("&includes[]=cover_art&includes[]=author&includes[]=artist&includedTagsMode=AND&excludedTagsMode=OR")
var hasContentRating = false var hasContentRating = false
searchQuery.criteria.forEach { criterion -> query.criteria.forEach { criterion ->
when (criterion) { when (criterion) {
is Include<*> -> { is Include<*> -> {
if (criterion.field == CONTENT_RATING) { if (criterion.field == CONTENT_RATING) {
@ -198,12 +250,15 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
} }
criterion.values.forEach { appendCriterion(criterion.field, it) } criterion.values.forEach { appendCriterion(criterion.field, it) }
} }
is Exclude<*> -> { is Exclude<*> -> {
criterion.values.forEach { appendCriterion(criterion.field, it, "excludedTags[]") } criterion.values.forEach { appendCriterion(criterion.field, it, "excludedTags[]") }
} }
is Match<*> -> { is Match<*> -> {
appendCriterion(criterion.field, criterion.value) appendCriterion(criterion.field, criterion.value)
} }
else -> { else -> {
// Not supported // Not supported
} }
@ -216,7 +271,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
} }
append("&order") append("&order")
append((searchQuery.order ?: defaultSortOrder).toQueryParam()) append((query.order ?: defaultSortOrder).toQueryParam())
} }
val json = webClient.httpGet(url).parseJson().getJSONArray("data") val json = webClient.httpGet(url).parseJson().getJSONArray("data")
@ -224,7 +279,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
} }
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> { override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
return searchManga(convertToMangaSearchQuery(offset, order, filter)) return queryManga(convertToMangaSearchQuery(offset, order, filter))
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
@ -304,15 +359,14 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
"https://uploads.$domain/covers/$id/$it" "https://uploads.$domain/covers/$id/$it"
} }
val authors: Set<MangaTag> = (relations["author"] ?: relations["artist"]) val authors: Set<MangaTag> = (relations["author"] ?: relations["artist"])
?.mapNotNull { ?.mapNotNullToSet {
val key = it.getStringOrNull("id") val key = it.getStringOrNull("id")
val title = it.getJSONObject("attributes")?.getStringOrNull("name") val title = it.getJSONObject("attributes")?.getStringOrNull("name")
key?.let { k -> key?.let { k ->
title?.let { t -> MangaTag(key = k, title = t, source = source) } title?.let { t -> MangaTag(key = k, title = t, source = source) }
} }
} }.orEmpty()
?.toSet() ?: emptySet()
return Manga( return Manga(
id = generateUid(id), id = generateUid(id),
@ -452,7 +506,8 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
val locale = attrs.getStringOrNull("translatedLanguage")?.let { Locale.forLanguageTag(it) } val locale = attrs.getStringOrNull("translatedLanguage")?.let { Locale.forLanguageTag(it) }
val lc = locale?.getDisplayName(locale)?.toTitleCase(locale) val lc = locale?.getDisplayName(locale)?.toTitleCase(locale)
val relations = jo.getJSONArray("relationships").associateByKey("type") val relations = jo.getJSONArray("relationships").associateByKey("type")
val team = relations["scanlation_group"]?.firstOrNull()?.optJSONObject("attributes")?.getStringOrNull("name") val team =
relations["scanlation_group"]?.firstOrNull()?.optJSONObject("attributes")?.getStringOrNull("name")
val branch = (list.indices).firstNotNullOf { i -> val branch = (list.indices).firstNotNullOf { i ->
val b = if (i == 0) lc else "$lc ($i)" val b = if (i == 0) lc else "$lc ($i)"
if (branchedChapters[b]?.get(volume to number) == null) b else null if (branchedChapters[b]?.get(volume to number) == null) b else null

@ -64,27 +64,27 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
MangaTag( MangaTag(
title = it.selectFirstOrThrow(".label-text").text(), title = it.selectFirstOrThrow(".label-text").text(),
key = it.selectFirstOrThrow("input[id$=value]").attr("value"), key = it.selectFirstOrThrow("input[id$=value]").attr("value"),
source = source source = source,
) )
} }
val states = EnumSet.of( val states = EnumSet.of(
ONGOING, FINISHED, ABANDONED, PAUSED ONGOING, FINISHED, ABANDONED, PAUSED,
) )
val types = EnumSet.of( val types = EnumSet.of(
MANGA, MANHWA, MANHUA, COMICS MANGA, MANHWA, MANHUA, COMICS,
) )
val rating = EnumSet.of( val rating = EnumSet.of(
SAFE, SUGGESTIVE SAFE, SUGGESTIVE,
) )
return MangaListFilterOptions( return MangaListFilterOptions(
availableTags = tags, availableTags = tags,
availableStates = states, availableStates = states,
availableContentTypes = types, availableContentTypes = types,
availableContentRating = rating availableContentRating = rating,
) )
} }
@ -109,7 +109,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
ADDED, ADDED_ASC -> "Recently Added" ADDED, ADDED_ASC -> "Recently Added"
UPDATED, UPDATED_ASC -> "Latest Updates" UPDATED, UPDATED_ASC -> "Latest Updates"
else -> throw UnsupportedOperationException("unsupported order: $order") else -> throw UnsupportedOperationException("unsupported order: $order")
} },
) )
addQueryParameter( addQueryParameter(
name = "order", name = "order",
@ -117,11 +117,11 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
RELEVANCE, ALPHABETICAL, POPULARITY_ASC, RATING_ASC, ADDED_ASC, UPDATED_ASC -> "Ascending" RELEVANCE, ALPHABETICAL, POPULARITY_ASC, RATING_ASC, ADDED_ASC, UPDATED_ASC -> "Ascending"
ALPHABETICAL_DESC, POPULARITY, RATING, ADDED, UPDATED -> "Descending" ALPHABETICAL_DESC, POPULARITY, RATING, ADDED, UPDATED -> "Descending"
else -> throw UnsupportedOperationException("unsupported order: $order") else -> throw UnsupportedOperationException("unsupported order: $order")
} },
) )
addQueryParameter("official", "Any") addQueryParameter("official", "Any")
addQueryParameter("anime", "Any") addQueryParameter("anime", "Any")
with (filter.contentRating) { with(filter.contentRating) {
addQueryParameter( addQueryParameter(
name = "adult", name = "adult",
value = when { value = when {
@ -130,7 +130,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
SAFE in this -> "False" SAFE in this -> "False"
SUGGESTIVE in this -> "True" SUGGESTIVE in this -> "True"
else -> throw UnsupportedOperationException("unsupported content rating: $this") else -> throw UnsupportedOperationException("unsupported content rating: $this")
} },
) )
} }
filter.states.forEach { state -> filter.states.forEach { state ->
@ -142,7 +142,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
ABANDONED -> "Canceled" ABANDONED -> "Canceled"
PAUSED -> "Hiatus" PAUSED -> "Hiatus"
else -> throw UnsupportedOperationException("unsupported state: $state") else -> throw UnsupportedOperationException("unsupported state: $state")
} },
) )
} }
filter.types.forEach { type -> filter.types.forEach { type ->
@ -154,7 +154,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
MANHUA -> "Manhua" MANHUA -> "Manhua"
COMICS -> "OEL" COMICS -> "OEL"
else -> throw UnsupportedOperationException("unsupported type: $type") else -> throw UnsupportedOperationException("unsupported type: $type")
} },
) )
} }
filter.tags.forEach { tag -> filter.tags.forEach { tag ->
@ -193,11 +193,11 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
MangaTag( MangaTag(
title = it, title = it,
key = it, key = it,
source = source source = source,
) )
} }
.orEmpty(), .orEmpty(),
state = when(document.selectFirst("div:contains(status) span")?.text()) { state = when (document.selectFirst("div:contains(status) span")?.text()) {
"Ongoing" -> ONGOING "Ongoing" -> ONGOING
"Complete" -> FINISHED "Complete" -> FINISHED
"Canceled" -> ABANDONED "Canceled" -> ABANDONED
@ -207,7 +207,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
author = document.select("div:contains(author) a").eachText().joinToString(), author = document.select("div:contains(author) a").eachText().joinToString(),
largeCoverUrl = null, largeCoverUrl = null,
chapters = null, chapters = null,
source = source source = source,
) )
} }
} }
@ -237,7 +237,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
MangaTag( MangaTag(
title = it.text(), title = it.text(),
key = it.text(), key = it.text(),
source = source source = source,
) )
}, },
state = when (sectionLeft.selectFirst("ul > li:has(strong:contains(Status)) > a")?.text()) { state = when (sectionLeft.selectFirst("ul > li:has(strong:contains(Status)) > a")?.text()) {
@ -259,12 +259,12 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
abbr.selectFirst("a")?.attr("href")?.let { url -> abbr.selectFirst("a")?.attr("href")?.let { url ->
val a = Element("a") val a = Element("a")
.text( .text(
abbr.attr("title") abbr.attr("title"),
) )
.attr("href", url) .attr("href", url)
ul.appendChild( ul.appendChild(
Element("li").appendChild(a) Element("li").appendChild(a),
) )
} }
} }
@ -276,7 +276,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
}.outerHtml(), }.outerHtml(),
chapters = chapters.await(), chapters = chapters.await(),
source = source source = source,
) )
} }
@ -308,10 +308,10 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
else -> null else -> null
}, },
uploadDate = dateFormat.tryParse( uploadDate = dateFormat.tryParse(
element.selectFirst("time[datetime]")?.attr("datetime") element.selectFirst("time[datetime]")?.attr("datetime"),
), ),
branch = null, branch = null,
source = source source = source,
) )
} }
} }
@ -336,7 +336,7 @@ internal class WeebCentral(context: MangaLoaderContext) : MangaParser(context, M
id = generateUid(pageUrl), id = generateUid(pageUrl),
url = pageUrl, url = pageUrl,
preview = null, preview = null,
source = source source = source,
) )
} }
} }

@ -46,11 +46,29 @@ internal abstract class MangaboxParser(
override val searchQueryCapabilities: MangaSearchQueryCapabilities override val searchQueryCapabilities: MangaSearchQueryCapabilities
get() = MangaSearchQueryCapabilities( get() = MangaSearchQueryCapabilities(
capabilities = setOf( SearchCapability(
SearchCapability(field = TAG, criteriaTypes = setOf(Include::class, Exclude::class), multiValue = true, otherCriteria = true), field = TAG,
SearchCapability(field = TITLE_NAME, criteriaTypes = setOf(Match::class), multiValue = false, otherCriteria = true), criteriaTypes = setOf(Include::class, Exclude::class),
SearchCapability(field = STATE, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true), multiValue = true,
SearchCapability(field = AUTHOR, criteriaTypes = setOf(Include::class), multiValue = false, otherCriteria = false), 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,
), ),
) )
@ -94,12 +112,14 @@ internal abstract class MangaboxParser(
MangaState.FINISHED -> "completed" MangaState.FINISHED -> "completed"
else -> "" else -> ""
} }
is SortOrder -> when (this) { is SortOrder -> when (this) {
SortOrder.ALPHABETICAL -> "az" SortOrder.ALPHABETICAL -> "az"
SortOrder.NEWEST -> "newest" SortOrder.NEWEST -> "newest"
SortOrder.POPULARITY -> "topview" SortOrder.POPULARITY -> "topview"
else -> "" else -> ""
} }
else -> this.toString().replace(" ", "_").urlEncoded() else -> this.toString().replace(" ", "_").urlEncoded()
} }
@ -115,31 +135,35 @@ internal abstract class MangaboxParser(
Regex("[^/]+\$", RegexOption.IGNORE_CASE) Regex("[^/]+\$", RegexOption.IGNORE_CASE)
} }
override suspend fun searchPageManga(searchQuery: MangaSearchQuery): List<Manga> { override suspend fun getListPage(query: MangaSearchQuery, page: Int): List<Manga> {
var authorSearchUrl: String? = null var authorSearchUrl: String? = null
val url = buildString { val url = buildString {
val pageQueryParameter = "page=${searchQuery.offset ?: 0}" val pageQueryParameter = "page=$page"
append("https://${domain}${listUrl}/?s=all") append("https://${domain}${listUrl}/?s=all")
searchQuery.criteria.forEach { criterion -> query.criteria.forEach { criterion ->
when (criterion) { when (criterion) {
is Include<*> -> { is Include<*> -> {
if (criterion.field == AUTHOR) { if (criterion.field == AUTHOR) {
criterion.values.firstOrNull()?.toQueryParam()?.takeIf { it.isNotBlank() }?.let { authorKey -> criterion.values.firstOrNull()?.toQueryParam()?.takeIf { it.isNotBlank() }
authorSearchUrl = "https://${domain}${authorUrl}/${authorKey}/?$pageQueryParameter" ?.let { authorKey ->
} authorSearchUrl = "https://${domain}${authorUrl}/${authorKey}/?$pageQueryParameter"
}
} }
criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param -> criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param ->
append("&$param=${criterion.values.joinToString("_") { it.toQueryParam() }}") append("&$param=${criterion.values.joinToString("_") { it.toQueryParam() }}")
} }
} }
is Exclude<*> -> { is Exclude<*> -> {
append("&g_e=${criterion.values.joinToString("_") { it.toQueryParam() }}") append("&g_e=${criterion.values.joinToString("_") { it.toQueryParam() }}")
} }
is Match<*> -> { is Match<*> -> {
appendCriterion(criterion.field, criterion.value) appendCriterion(criterion.field, criterion.value)
} }
else -> { else -> {
// Not supported // Not supported
} }
@ -147,7 +171,7 @@ internal abstract class MangaboxParser(
} }
append("&${pageQueryParameter}") append("&${pageQueryParameter}")
append("&orby=${(searchQuery.order ?: defaultSortOrder).toQueryParam()}") append("&orby=${(query.order ?: defaultSortOrder).toQueryParam()}")
} }
val doc = webClient.httpGet(authorSearchUrl ?: url).parseHtml() val doc = webClient.httpGet(authorSearchUrl ?: url).parseHtml()
@ -174,7 +198,7 @@ internal abstract class MangaboxParser(
} }
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> { override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
return searchManga(convertToMangaSearchQuery(page, order, filter)) return queryManga(convertToMangaSearchQuery(page, order, filter))
} }
protected open val selectTagMap = "div.panel-genres-list a:not(.genres-select)" protected open val selectTagMap = "div.panel-genres-list a:not(.genres-select)"
@ -215,7 +239,7 @@ internal abstract class MangaboxParser(
val alt = doc.body().select(selectAlt).text().replace("Alternative : ", "").nullIfEmpty() val alt = doc.body().select(selectAlt).text().replace("Alternative : ", "").nullIfEmpty()
val authors = doc.body().select(selectAut).mapToSet { val authors = doc.body().select(selectAut).mapToSet {
MangaTag( MangaTag(
key = it.attribute("href").value.find(authorKeyRegex)?: it.text(), key = it.attribute("href").value.find(authorKeyRegex) ?: it.text(),
title = it.text(), title = it.text(),
source = source, source = source,
) )

@ -6,9 +6,11 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.model.search.* import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.Include import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.Include
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.Match import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.Match
import org.koitharu.kotatsu.parsers.model.search.SearchCapability
import org.koitharu.kotatsu.parsers.model.search.SearchableField.* import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import org.koitharu.kotatsu.parsers.site.mangabox.MangaboxParser import org.koitharu.kotatsu.parsers.site.mangabox.MangaboxParser
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
@ -44,10 +46,23 @@ internal class Mangairo(context: MangaLoaderContext) :
override val searchQueryCapabilities: MangaSearchQueryCapabilities override val searchQueryCapabilities: MangaSearchQueryCapabilities
get() = MangaSearchQueryCapabilities( get() = MangaSearchQueryCapabilities(
capabilities = setOf( SearchCapability(
SearchCapability(field = TAG, criteriaTypes = setOf(Include::class), multiValue = false, otherCriteria = true), field = TAG,
SearchCapability(field = TITLE_NAME, criteriaTypes = setOf(Match::class), multiValue = false, otherCriteria = false), criteriaTypes = setOf(Include::class),
SearchCapability(field = STATE, criteriaTypes = setOf(Include::class), multiValue = false, otherCriteria = true), 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,
), ),
) )
@ -59,25 +74,27 @@ internal class Mangairo(context: MangaLoaderContext) :
MangaState.FINISHED -> "completed" MangaState.FINISHED -> "completed"
else -> "all" else -> "all"
} }
is SortOrder -> when (this) { is SortOrder -> when (this) {
SortOrder.POPULARITY -> "topview" SortOrder.POPULARITY -> "topview"
SortOrder.UPDATED -> "latest" SortOrder.UPDATED -> "latest"
SortOrder.NEWEST -> "newest" SortOrder.NEWEST -> "newest"
else -> "latest" else -> "latest"
} }
else -> this.toString().urlEncoded() else -> this.toString().urlEncoded()
} }
override suspend fun searchPageManga(searchQuery: MangaSearchQuery): List<Manga> { override suspend fun getListPage(query: MangaSearchQuery, page: Int): List<Manga> {
var titleSearchUrl: String? = null var titleSearchUrl: String? = null
var category = "all" var category = "all"
var state = "all" var state = "all"
val url = buildString { val url = buildString {
append("https://${domain}${listUrl}") append("https://${domain}${listUrl}")
append("/type-${(searchQuery.order ?: defaultSortOrder).toQueryParam()}") append("/type-${(query.order ?: defaultSortOrder).toQueryParam()}")
searchQuery.criteria.forEach { criterion -> query.criteria.forEach { criterion ->
when (criterion) { when (criterion) {
is Include<*> -> { is Include<*> -> {
when (criterion.field) { when (criterion.field) {
@ -86,14 +103,16 @@ internal class Mangairo(context: MangaLoaderContext) :
else -> Unit else -> Unit
} }
} }
is Match<*> -> { is Match<*> -> {
if (criterion.field == TITLE_NAME) { if (criterion.field == TITLE_NAME) {
criterion.value.toQueryParam().takeIf { it.isNotBlank() }?.let { titleName -> criterion.value.toQueryParam().takeIf { it.isNotBlank() }?.let { titleName ->
titleSearchUrl = "https://${domain}${searchUrl}${titleName}/" + titleSearchUrl = "https://${domain}${searchUrl}${titleName}/" +
"?page=${searchQuery.offset ?: 0}" "?page=${query.offset}"
} }
} }
} }
else -> { else -> {
// Not supported // Not supported
} }
@ -101,7 +120,7 @@ internal class Mangairo(context: MangaLoaderContext) :
} }
append("/ctg-$category") append("/ctg-$category")
append("/state-$state") append("/state-$state")
append("/page-${searchQuery.offset ?: 0}") append("/page-$page")
} }
val doc = webClient.httpGet(titleSearchUrl ?: url).parseHtml() val doc = webClient.httpGet(titleSearchUrl ?: url).parseHtml()

@ -31,10 +31,23 @@ internal class Mangakakalot(context: MangaLoaderContext) :
override val searchQueryCapabilities: MangaSearchQueryCapabilities override val searchQueryCapabilities: MangaSearchQueryCapabilities
get() = MangaSearchQueryCapabilities( get() = MangaSearchQueryCapabilities(
capabilities = setOf( SearchCapability(
SearchCapability(field = TAG, criteriaTypes = setOf(Include::class), multiValue = false, otherCriteria = true), field = TAG,
SearchCapability(field = TITLE_NAME, criteriaTypes = setOf(Match::class), multiValue = false, otherCriteria = false), criteriaTypes = setOf(Include::class),
SearchCapability(field = STATE, criteriaTypes = setOf(Include::class), multiValue = false, otherCriteria = true), 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,
), ),
) )
@ -53,18 +66,21 @@ internal class Mangakakalot(context: MangaLoaderContext) :
.replace(" ", "_") .replace(" ", "_")
.urlEncoded() .urlEncoded()
} }
is MangaTag -> key is MangaTag -> key
is MangaState -> when (this) { is MangaState -> when (this) {
MangaState.ONGOING -> "ongoing" MangaState.ONGOING -> "ongoing"
MangaState.FINISHED -> "completed" MangaState.FINISHED -> "completed"
else -> "all" else -> "all"
} }
is SortOrder -> when (this) { is SortOrder -> when (this) {
SortOrder.POPULARITY -> "topview" SortOrder.POPULARITY -> "topview"
SortOrder.UPDATED -> "latest" SortOrder.UPDATED -> "latest"
SortOrder.NEWEST -> "newest" SortOrder.NEWEST -> "newest"
else -> "" else -> ""
} }
else -> this.toString().replace(" ", "_").urlEncoded() else -> this.toString().replace(" ", "_").urlEncoded()
} }
@ -80,19 +96,20 @@ internal class Mangakakalot(context: MangaLoaderContext) :
Regex("[^A-Za-z0-9 ]") Regex("[^A-Za-z0-9 ]")
} }
override suspend fun searchPageManga(searchQuery: MangaSearchQuery): List<Manga> { override suspend fun getListPage(query: MangaSearchQuery, page: Int): List<Manga> {
var titleSearchUrl: String? = null var titleSearchUrl: String? = null
val url = buildString { val url = buildString {
val pageQueryParameter = "page=${searchQuery.offset ?: 0}" val pageQueryParameter = "page=$page"
append("https://$domain/?") append("https://$domain/?")
searchQuery.criteria.forEach { criterion -> query.criteria.forEach { criterion ->
when (criterion) { when (criterion) {
is Include<*> -> { is Include<*> -> {
criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param -> criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param ->
append("&$param=${criterion.values.first().toQueryParam()}") append("&$param=${criterion.values.first().toQueryParam()}")
} }
} }
is Match<*> -> { is Match<*> -> {
if (criterion.field == TITLE_NAME) { if (criterion.field == TITLE_NAME) {
criterion.value.toQueryParam().takeIf { it.isNotBlank() }?.let { titleName -> criterion.value.toQueryParam().takeIf { it.isNotBlank() }?.let { titleName ->
@ -102,6 +119,7 @@ internal class Mangakakalot(context: MangaLoaderContext) :
} }
appendCriterion(criterion.field, criterion.value) appendCriterion(criterion.field, criterion.value)
} }
else -> { else -> {
// Not supported // Not supported
} }
@ -109,7 +127,7 @@ internal class Mangakakalot(context: MangaLoaderContext) :
} }
append("&$pageQueryParameter") append("&$pageQueryParameter")
append("&type=${(searchQuery.order ?: defaultSortOrder).toQueryParam()}") append("&type=${(query.order ?: defaultSortOrder).toQueryParam()}")
} }
val doc = webClient.httpGet(titleSearchUrl ?: url).parseHtml() val doc = webClient.httpGet(titleSearchUrl ?: url).parseHtml()

@ -35,10 +35,23 @@ internal class MangakakalotTv(context: MangaLoaderContext) :
override val searchQueryCapabilities: MangaSearchQueryCapabilities override val searchQueryCapabilities: MangaSearchQueryCapabilities
get() = MangaSearchQueryCapabilities( get() = MangaSearchQueryCapabilities(
capabilities = setOf( SearchCapability(
SearchCapability(field = TAG, criteriaTypes = setOf(Include::class), multiValue = false, otherCriteria = true), field = TAG,
SearchCapability(field = TITLE_NAME, criteriaTypes = setOf(Match::class), multiValue = false, otherCriteria = false), criteriaTypes = setOf(Include::class),
SearchCapability(field = STATE, criteriaTypes = setOf(Include::class), multiValue = false, otherCriteria = true), 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,
), ),
) )
@ -56,12 +69,14 @@ internal class MangakakalotTv(context: MangaLoaderContext) :
MangaState.FINISHED -> "completed" MangaState.FINISHED -> "completed"
else -> "all" else -> "all"
} }
is SortOrder -> when (this) { is SortOrder -> when (this) {
SortOrder.POPULARITY -> "topview" SortOrder.POPULARITY -> "topview"
SortOrder.UPDATED -> "latest" SortOrder.UPDATED -> "latest"
SortOrder.NEWEST -> "newest" SortOrder.NEWEST -> "newest"
else -> "" else -> ""
} }
else -> this.toString().urlEncoded() else -> this.toString().urlEncoded()
} }
@ -73,19 +88,20 @@ internal class MangakakalotTv(context: MangaLoaderContext) :
} }
} }
override suspend fun searchPageManga(searchQuery: MangaSearchQuery): List<Manga> { override suspend fun getListPage(query: MangaSearchQuery, page: Int): List<Manga> {
var titleSearchUrl: String? = null var titleSearchUrl: String? = null
val url = buildString { val url = buildString {
val pageQueryParameter = "page=${searchQuery.offset ?: 0}" val pageQueryParameter = "page=$page"
append("https://$domain/?") append("https://$domain/?")
searchQuery.criteria.forEach { criterion -> query.criteria.forEach { criterion ->
when (criterion) { when (criterion) {
is Include<*> -> { is Include<*> -> {
criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param -> criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param ->
append("&$param=${criterion.values.first().toQueryParam()}") append("&$param=${criterion.values.first().toQueryParam()}")
} }
} }
is Match<*> -> { is Match<*> -> {
if (criterion.field == TITLE_NAME) { if (criterion.field == TITLE_NAME) {
criterion.value.toQueryParam().takeIf { it.isNotBlank() }?.let { titleName -> criterion.value.toQueryParam().takeIf { it.isNotBlank() }?.let { titleName ->
@ -95,6 +111,7 @@ internal class MangakakalotTv(context: MangaLoaderContext) :
} }
appendCriterion(criterion.field, criterion.value) appendCriterion(criterion.field, criterion.value)
} }
else -> { else -> {
// Not supported // Not supported
} }
@ -102,7 +119,7 @@ internal class MangakakalotTv(context: MangaLoaderContext) :
} }
append("&$pageQueryParameter") append("&$pageQueryParameter")
append("&type=${(searchQuery.order ?: defaultSortOrder).toQueryParam()}") append("&type=${(query.order ?: defaultSortOrder).toQueryParam()}")
} }
val doc = webClient.httpGet(titleSearchUrl ?: url).parseHtml() val doc = webClient.httpGet(titleSearchUrl ?: url).parseHtml()

@ -1,9 +1,15 @@
package org.koitharu.kotatsu.parsers.util package org.koitharu.kotatsu.parsers.util
import org.koitharu.kotatsu.parsers.InternalParsersApi import org.koitharu.kotatsu.parsers.InternalParsersApi
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria import org.koitharu.kotatsu.parsers.model.search.QueryCriteria
import org.koitharu.kotatsu.parsers.model.search.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.*
/** /**
@ -20,26 +26,26 @@ public fun convertToMangaSearchQuery(offset: Int, sortOrder: SortOrder, filter:
return MangaSearchQuery.Builder().apply { return MangaSearchQuery.Builder().apply {
offset(offset) offset(offset)
order(sortOrder) order(sortOrder)
if (filter.tags.isNotEmpty()) criterion(QueryCriteria.Include(TAG, filter.tags)) if (filter.tags.isNotEmpty()) criterion(Include(TAG, filter.tags))
if (filter.tagsExclude.isNotEmpty()) criterion(QueryCriteria.Exclude(TAG, filter.tagsExclude)) if (filter.tagsExclude.isNotEmpty()) criterion(Exclude(TAG, filter.tagsExclude))
if (filter.states.isNotEmpty()) criterion(QueryCriteria.Include(STATE, filter.states)) if (filter.states.isNotEmpty()) criterion(Include(STATE, filter.states))
if (filter.types.isNotEmpty()) criterion(QueryCriteria.Include(CONTENT_TYPE, filter.types)) if (filter.types.isNotEmpty()) criterion(Include(CONTENT_TYPE, filter.types))
if (filter.contentRating.isNotEmpty()) criterion(QueryCriteria.Include(CONTENT_RATING, filter.contentRating)) if (filter.contentRating.isNotEmpty()) criterion(Include(CONTENT_RATING, filter.contentRating))
if (filter.demographics.isNotEmpty()) criterion(QueryCriteria.Include(DEMOGRAPHIC, filter.demographics)) if (filter.demographics.isNotEmpty()) criterion(Include(DEMOGRAPHIC, filter.demographics))
if (validateYear(filter.yearFrom) || validateYear(filter.yearTo)) { if (validateYear(filter.yearFrom) || validateYear(filter.yearTo)) {
criterion(QueryCriteria.Range(PUBLICATION_YEAR, filter.yearFrom, filter.yearTo)) criterion(QueryCriteria.Range(PUBLICATION_YEAR, filter.yearFrom, filter.yearTo))
} }
if (validateYear(filter.year)) { if (validateYear(filter.year)) {
criterion(QueryCriteria.Match(PUBLICATION_YEAR, filter.year)) criterion(Match(PUBLICATION_YEAR, filter.year))
} }
filter.locale?.takeIf { it != null }?.let { filter.locale?.let {
criterion(QueryCriteria.Include(LANGUAGE, setOf(it))) criterion(Include(LANGUAGE, setOf(it)))
} }
filter.originalLocale?.takeIf { it != null }?.let { filter.originalLocale?.let {
criterion(QueryCriteria.Include(ORIGINAL_LANGUAGE, setOf(it))) criterion(Include(ORIGINAL_LANGUAGE, setOf(it)))
} }
filter.query?.takeIf { it.isNotBlank() }?.let { filter.query?.takeIf { it.isNotBlank() }?.let {
criterion(QueryCriteria.Match(TITLE_NAME, it)) criterion(Match(TITLE_NAME, it))
} }
}.build() }.build()
} }
@ -74,16 +80,99 @@ public fun convertToMangaListFilter(searchQuery: MangaSearchQuery): MangaListFil
return MangaListFilter.Builder().apply { return MangaListFilter.Builder().apply {
for (criterion in searchQuery.criteria) { for (criterion in searchQuery.criteria) {
when (criterion) { when (criterion) {
is QueryCriteria.Include<*> -> handleInclude(this, criterion) is Include<*> -> handleInclude(this, criterion)
is QueryCriteria.Exclude<*> -> handleExclude(this, criterion) is Exclude<*> -> handleExclude(this, criterion)
is QueryCriteria.Range<*> -> handleBetween(this, criterion) is Range<*> -> handleBetween(this, criterion)
is QueryCriteria.Match<*> -> handleMatch(this, criterion) is Match<*> -> handleMatch(this, criterion)
} }
} }
}.build() }.build()
} }
private fun handleInclude(builder: MangaListFilter.Builder, criterion: QueryCriteria.Include<*>) { internal fun MangaListFilterCapabilities.toMangaSearchQueryCapabilities(): MangaSearchQueryCapabilities =
MangaSearchQueryCapabilities(
capabilities = setOfNotNull(
isMultipleTagsSupported.takeIf { it }?.let {
SearchCapability(
field = TAG, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true,
)
},
isTagsExclusionSupported.takeIf { it }?.let {
SearchCapability(
field = TAG, criteriaTypes = setOf(Exclude::class), multiValue = true, otherCriteria = true,
)
},
isSearchSupported.takeIf { it }?.let {
SearchCapability(
field = TITLE_NAME,
criteriaTypes = setOf(Match::class),
multiValue = false,
otherCriteria = false,
)
},
isSearchWithFiltersSupported.takeIf { it }?.let {
SearchCapability(
field = TITLE_NAME,
criteriaTypes = setOf(Match::class),
multiValue = false,
otherCriteria = true,
)
},
isYearSupported.takeIf { it }?.let {
SearchCapability(
field = PUBLICATION_YEAR,
criteriaTypes = setOf(Match::class),
multiValue = false,
otherCriteria = true,
)
},
isYearRangeSupported.takeIf { it }?.let {
SearchCapability(
field = PUBLICATION_YEAR,
criteriaTypes = setOf(Range::class),
multiValue = false,
otherCriteria = true,
)
},
isOriginalLocaleSupported.takeIf { it }?.let {
SearchCapability(
field = ORIGINAL_LANGUAGE,
criteriaTypes = setOf(Include::class),
multiValue = true,
otherCriteria = true,
)
},
SearchCapability(
field = LANGUAGE,
criteriaTypes = setOf(Include::class),
multiValue = true,
otherCriteria = true,
),
SearchCapability(
field = STATE, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true,
),
SearchCapability(
field = CONTENT_TYPE,
criteriaTypes = setOf(Include::class),
multiValue = true,
otherCriteria = true,
),
SearchCapability(
field = CONTENT_RATING,
criteriaTypes = setOf(Include::class),
multiValue = true,
otherCriteria = true,
),
SearchCapability(
field = DEMOGRAPHIC,
criteriaTypes = setOf(Include::class),
multiValue = true,
otherCriteria = true,
),
),
)
private fun handleInclude(builder: MangaListFilter.Builder, criterion: Include<*>) {
val type = criterion.field.type val type = criterion.field.type
when (criterion.field) { when (criterion.field) {
@ -94,20 +183,20 @@ private fun handleInclude(builder: MangaListFilter.Builder, criterion: QueryCrit
DEMOGRAPHIC -> builder.addDemographics(filterValues(criterion, type)) DEMOGRAPHIC -> builder.addDemographics(filterValues(criterion, type))
LANGUAGE -> builder.locale(getFirstValue(criterion, type)) LANGUAGE -> builder.locale(getFirstValue(criterion, type))
ORIGINAL_LANGUAGE -> builder.originalLocale(getFirstValue(criterion, type)) ORIGINAL_LANGUAGE -> builder.originalLocale(getFirstValue(criterion, type))
else -> throw UnsupportedOperationException("Unsupported field for Include criterion: ${criterion.field}") else -> throw IllegalArgumentException("Unsupported field for Include criterion: ${criterion.field}")
} }
} }
private fun handleExclude(builder: MangaListFilter.Builder, criterion: QueryCriteria.Exclude<*>) { private fun handleExclude(builder: MangaListFilter.Builder, criterion: Exclude<*>) {
val type = criterion.field.type val type = criterion.field.type
when (criterion.field) { when (criterion.field) {
TAG -> builder.excludeTags(filterValues(criterion, type)) TAG -> builder.excludeTags(filterValues(criterion, type))
else -> throw UnsupportedOperationException("Unsupported field for Exclude criterion: ${criterion.field}") else -> throw IllegalArgumentException("Unsupported field for Exclude criterion: ${criterion.field}")
} }
} }
private fun handleBetween(builder: MangaListFilter.Builder, criterion: QueryCriteria.Range<*>) { private fun handleBetween(builder: MangaListFilter.Builder, criterion: Range<*>) {
val type = criterion.field.type val type = criterion.field.type
when (criterion.field) { when (criterion.field) {
@ -115,32 +204,33 @@ private fun handleBetween(builder: MangaListFilter.Builder, criterion: QueryCrit
builder.yearFrom(getValue(criterion.from, type, YEAR_UNKNOWN)) builder.yearFrom(getValue(criterion.from, type, YEAR_UNKNOWN))
builder.yearTo(getValue(criterion.to, type, YEAR_UNKNOWN)) builder.yearTo(getValue(criterion.to, type, YEAR_UNKNOWN))
} }
else -> throw UnsupportedOperationException("Unsupported field for Between criterion: ${criterion.field}")
else -> throw IllegalArgumentException("Unsupported field for Between criterion: ${criterion.field}")
} }
} }
private fun handleMatch(builder: MangaListFilter.Builder, criterion: QueryCriteria.Match<*>) { private fun handleMatch(builder: MangaListFilter.Builder, criterion: Match<*>) {
val type = criterion.field.type val type = criterion.field.type
when (criterion.field) { when (criterion.field) {
TITLE_NAME -> builder.query(getValue(criterion.value, type, "")) TITLE_NAME -> builder.query(getValue(criterion.value, type, ""))
PUBLICATION_YEAR -> builder.year(getValue(criterion.value, type, YEAR_UNKNOWN)) PUBLICATION_YEAR -> builder.year(getValue(criterion.value, type, YEAR_UNKNOWN))
else -> throw UnsupportedOperationException("Unsupported field for Match criterion: ${criterion.field}") else -> throw IllegalArgumentException("Unsupported field for Match criterion: ${criterion.field}")
} }
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun <T> filterValues(criterion: QueryCriteria.Include<*>, type: Class<*>): List<T> { private fun <T> filterValues(criterion: Include<*>, type: Class<*>): List<T> {
return criterion.values.filter { type.isInstance(it) } as List<T> return criterion.values.filter { type.isInstance(it) } as List<T>
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun <T> filterValues(criterion: QueryCriteria.Exclude<*>, type: Class<*>): List<T> { private fun <T> filterValues(criterion: Exclude<*>, type: Class<*>): List<T> {
return criterion.values.filter { type.isInstance(it) } as List<T> return criterion.values.filter { type.isInstance(it) } as List<T>
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun <T> getFirstValue(criterion: QueryCriteria.Include<*>, type: Class<*>): T? { private fun <T> getFirstValue(criterion: Include<*>, type: Class<*>): T? {
return criterion.values.firstOrNull { type.isInstance(it) } as? T return criterion.values.firstOrNull { type.isInstance(it) } as? T
} }
@ -151,4 +241,4 @@ private fun <T> getValue(value: Any?, type: Class<*>, default: T): T {
return if (type.isInstance(value) || isCompatibleIntType) value as T else default return if (type.isInstance(value) || isCompatibleIntType) value as T else default
} }
private fun validateYear(year: Int) = year != null && year != YEAR_UNKNOWN private fun validateYear(year: Int) = year != YEAR_UNKNOWN

@ -38,8 +38,9 @@ internal class MangaParserTest {
if (parser is SinglePageMangaParser) { if (parser is SinglePageMangaParser) {
return@runTest return@runTest
} }
val page1 = parser.searchManga(MangaSearchQuery.builder().offset(0).order(parser.defaultSortOrder).build()) val page1 = parser.queryManga(MangaSearchQuery.Builder().offset(0).order(parser.defaultSortOrder).build())
val page2 = parser.searchManga(MangaSearchQuery.builder().offset(page1.size).order(parser.defaultSortOrder).build()) val page2 =
parser.queryManga(MangaSearchQuery.Builder().offset(page1.size).order(parser.defaultSortOrder).build())
if (parser is PagedMangaParser) { if (parser is PagedMangaParser) {
assert(parser.pageSize >= page1.size) { assert(parser.pageSize >= page1.size) {
"Page size is ${page1.size} but ${parser.pageSize} expected" "Page size is ${page1.size} but ${parser.pageSize} expected"
@ -58,19 +59,19 @@ internal class MangaParserTest {
@MangaSources @MangaSources
fun searchByTitleName(source: MangaParserSource) = runTest(timeout = timeout) { fun searchByTitleName(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source) val parser = context.newParserInstance(source)
val subject = parser.searchManga( val subject = parser.queryManga(
MangaSearchQuery.builder() MangaSearchQuery.Builder()
.offset(0) .offset(0)
.order(parser.defaultSortOrder) .order(parser.defaultSortOrder)
.build() .build(),
).minByOrNull { ).minByOrNull {
it.title.length it.title.length
} ?: error("No manga found") } ?: error("No manga found")
val query = subject.title val query = subject.title
check(query.isNotBlank()) { "Manga title '$query' is blank" } check(query.isNotBlank()) { "Manga title '$query' is blank" }
val list = parser.searchManga( val list = parser.queryManga(
MangaSearchQuery.builder() MangaSearchQuery.Builder()
.order(SortOrder.RELEVANCE) .order(SortOrder.RELEVANCE)
.criterion(QueryCriteria.Match(TITLE_NAME, query)) .criterion(QueryCriteria.Match(TITLE_NAME, query))
.build(), .build(),
@ -102,8 +103,8 @@ internal class MangaParserTest {
assert(tags.all { it.source == source }) assert(tags.all { it.source == source })
val tag = tags.last() val tag = tags.last()
val list = parser.searchManga( val list = parser.queryManga(
MangaSearchQuery.builder() MangaSearchQuery.Builder()
.offset(0) .offset(0)
.order(parser.defaultSortOrder) .order(parser.defaultSortOrder)
.criterion(Include(TAG, setOf(tag))) .criterion(Include(TAG, setOf(tag)))
@ -120,8 +121,8 @@ internal class MangaParserTest {
if (!parser.filterCapabilities.isMultipleTagsSupported) return@runTest if (!parser.filterCapabilities.isMultipleTagsSupported) return@runTest
val tags = parser.getFilterOptions().availableTags.shuffled().take(2).toSet() val tags = parser.getFilterOptions().availableTags.shuffled().take(2).toSet()
val list = parser.searchManga( val list = parser.queryManga(
MangaSearchQuery.builder() MangaSearchQuery.Builder()
.offset(0) .offset(0)
.order(parser.defaultSortOrder) .order(parser.defaultSortOrder)
.criterion(Include(TAG, tags)) .criterion(Include(TAG, tags))
@ -141,8 +142,8 @@ internal class MangaParserTest {
return@runTest return@runTest
} }
val locale = locales.random() val locale = locales.random()
val list = parser.searchManga( val list = parser.queryManga(
MangaSearchQuery.builder() MangaSearchQuery.Builder()
.offset(0) .offset(0)
.order(parser.defaultSortOrder) .order(parser.defaultSortOrder)
.criterion(Include(LANGUAGE, setOf(locale))) .criterion(Include(LANGUAGE, setOf(locale)))
@ -159,7 +160,7 @@ internal class MangaParserTest {
@MangaSources @MangaSources
fun details(source: MangaParserSource) = runTest(timeout = timeout) { fun details(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source) val parser = context.newParserInstance(source)
val list = parser.searchManga(MangaSearchQuery.builder().offset(0).order(parser.defaultSortOrder).build()) val list = parser.queryManga(MangaSearchQuery.Builder().offset(0).order(parser.defaultSortOrder).build())
val manga = list[0] val manga = list[0]
parser.getDetails(manga).apply { parser.getDetails(manga).apply {
@ -190,7 +191,7 @@ internal class MangaParserTest {
@MangaSources @MangaSources
fun pages(source: MangaParserSource) = runTest(timeout = timeout) { fun pages(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source) val parser = context.newParserInstance(source)
val list = parser.searchManga(MangaSearchQuery.builder().offset(0).order(parser.defaultSortOrder).build()) val list = parser.queryManga(MangaSearchQuery.Builder().offset(0).order(parser.defaultSortOrder).build())
val manga = list.first() val manga = list.first()
val chapter = parser.getDetails(manga).chapters?.firstOrNull() ?: error("Chapter is null at ${manga.publicUrl}") val chapter = parser.getDetails(manga).chapters?.firstOrNull() ?: error("Chapter is null at ${manga.publicUrl}")
val pages = parser.getPages(chapter) val pages = parser.getPages(chapter)
@ -245,7 +246,8 @@ internal class MangaParserTest {
@MangaSources @MangaSources
fun link(source: MangaParserSource) = runTest(timeout = timeout) { fun link(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source) val parser = context.newParserInstance(source)
val manga = parser.searchManga(MangaSearchQuery.builder().offset(0).order(parser.defaultSortOrder).build()).first() val manga =
parser.queryManga(MangaSearchQuery.Builder().offset(0).order(parser.defaultSortOrder).build()).first()
val resolved = context.newLinkResolver(manga.publicUrl).getManga() val resolved = context.newLinkResolver(manga.publicUrl).getManga()
Assertions.assertNotNull(resolved) Assertions.assertNotNull(resolved)
resolved ?: return@runTest resolved ?: return@runTest

@ -8,7 +8,7 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.* import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
import org.koitharu.kotatsu.parsers.model.search.SearchableField.* import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import java.util.Locale import java.util.*
class MangaSearchQueryCapabilitiesTest { class MangaSearchQueryCapabilitiesTest {
@ -18,12 +18,12 @@ class MangaSearchQueryCapabilitiesTest {
SearchCapability(TAG, setOf(Include::class, Exclude::class), multiValue = true, otherCriteria = true), SearchCapability(TAG, setOf(Include::class, Exclude::class), multiValue = true, otherCriteria = true),
SearchCapability(PUBLICATION_YEAR, setOf(Range::class), multiValue = false, otherCriteria = true), SearchCapability(PUBLICATION_YEAR, setOf(Range::class), multiValue = false, otherCriteria = true),
SearchCapability(STATE, setOf(Include::class), multiValue = false, otherCriteria = true), SearchCapability(STATE, setOf(Include::class), multiValue = false, otherCriteria = true),
) ),
) )
@Test @Test
fun validateValidSingleCriterionQuery() { fun validateValidSingleCriterionQuery() {
val query = MangaSearchQuery.builder() val query = MangaSearchQuery.Builder()
.criterion(Match(TITLE_NAME, "title")) .criterion(Match(TITLE_NAME, "title"))
.build() .build()
@ -32,7 +32,7 @@ class MangaSearchQueryCapabilitiesTest {
@Test @Test
fun validateUnsupportedFieldThrowsException() { fun validateUnsupportedFieldThrowsException() {
val query = MangaSearchQuery.builder() val query = MangaSearchQuery.Builder()
.criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.ENGLISH))) .criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.ENGLISH)))
.build() .build()
@ -41,7 +41,7 @@ class MangaSearchQueryCapabilitiesTest {
@Test @Test
fun validateUnsupportedMultiValueThrowsException() { fun validateUnsupportedMultiValueThrowsException() {
val query = MangaSearchQuery.builder() val query = MangaSearchQuery.Builder()
.criterion(Include(STATE, setOf(MangaState.ONGOING, MangaState.FINISHED))) .criterion(Include(STATE, setOf(MangaState.ONGOING, MangaState.FINISHED)))
.build() .build()
@ -50,7 +50,7 @@ class MangaSearchQueryCapabilitiesTest {
@Test @Test
fun validateMultipleCriteriaWithOtherCriteriaAllowed() { fun validateMultipleCriteriaWithOtherCriteriaAllowed() {
val query = MangaSearchQuery.builder() val query = MangaSearchQuery.Builder()
.criterion(Include(TAG, setOf(buildTag("tag1"), buildTag("tag2")))) .criterion(Include(TAG, setOf(buildTag("tag1"), buildTag("tag2"))))
.criterion(Exclude(TAG, setOf(buildTag("tag3")))) .criterion(Exclude(TAG, setOf(buildTag("tag3"))))
.build() .build()
@ -60,7 +60,7 @@ class MangaSearchQueryCapabilitiesTest {
@Test @Test
fun validateMultipleCriteriaWithStrictCapabilityThrowsException() { fun validateMultipleCriteriaWithStrictCapabilityThrowsException() {
val query = MangaSearchQuery.builder() val query = MangaSearchQuery.Builder()
.criterion(Match(TITLE_NAME, "title")) .criterion(Match(TITLE_NAME, "title"))
.criterion(Range(PUBLICATION_YEAR, 1990, 2000)) .criterion(Range(PUBLICATION_YEAR, 1990, 2000))
.build() .build()
@ -68,5 +68,5 @@ class MangaSearchQueryCapabilitiesTest {
assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) } assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) }
} }
private fun buildTag(name: String) = MangaTag(title = name, key = "${name}Key", source = MangaParserSource.DUMMY) private fun buildTag(name: String) = MangaTag(title = name, key = "${name}Key", source = MangaParserSource.DUMMY)
} }

@ -34,12 +34,12 @@ class ListFilterToSearchQueryConverterTest {
demographics = demographics, demographics = demographics,
year = 2020, year = 2020,
yearFrom = 1997, yearFrom = 1997,
yearTo = 2024 yearTo = 2024,
) )
val searchQuery = convertToMangaSearchQuery(0, SortOrder.NEWEST, filter) val searchQuery = convertToMangaSearchQuery(0, SortOrder.NEWEST, filter)
val expectedQuery = MangaSearchQuery.builder() val expectedQuery = MangaSearchQuery.Builder()
.offset(0) .offset(0)
.order(SortOrder.NEWEST) .order(SortOrder.NEWEST)
.criterion(Match(TITLE_NAME, "title_name")) .criterion(Match(TITLE_NAME, "title_name"))
@ -64,14 +64,14 @@ class ListFilterToSearchQueryConverterTest {
val searchQuery = convertToMangaSearchQuery(0, SortOrder.NEWEST, filter) val searchQuery = convertToMangaSearchQuery(0, SortOrder.NEWEST, filter)
assertEquals(MangaSearchQuery.builder().offset(0).order(SortOrder.NEWEST).build(), searchQuery) assertEquals(MangaSearchQuery.Builder().offset(0).order(SortOrder.NEWEST).build(), searchQuery)
} }
private fun buildMangaTag(name: String): MangaTag { private fun buildMangaTag(name: String): MangaTag {
return MangaTag( return MangaTag(
key = "${name}Key", key = "${name}Key",
title = name, title = name,
source = MangaParserSource.DUMMY source = MangaParserSource.DUMMY,
) )
} }
} }

@ -3,10 +3,14 @@ package org.koitharu.kotatsu.parsers.util
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType.* 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.Demographic.SEINEN
import org.koitharu.kotatsu.parsers.model.search.* import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.* import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
import org.koitharu.kotatsu.parsers.model.search.SearchableField.* import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import java.util.* import java.util.*
@ -22,7 +26,7 @@ class ConvertToMangaListFilterTest {
val contentTypes = setOf(MANGA, MANHUA) val contentTypes = setOf(MANGA, MANHUA)
val demographics = setOf(SEINEN) val demographics = setOf(SEINEN)
val query = MangaSearchQuery.builder() val query = MangaSearchQuery.Builder()
.criterion(Match(TITLE_NAME, "title_name")) .criterion(Match(TITLE_NAME, "title_name"))
.criterion(Include(TAG, tags)) .criterion(Include(TAG, tags))
.criterion(Exclude(TAG, excludedTags)) .criterion(Exclude(TAG, excludedTags))
@ -57,7 +61,7 @@ class ConvertToMangaListFilterTest {
val tags1 = setOf(buildMangaTag("tag1"), buildMangaTag("tag2")) val tags1 = setOf(buildMangaTag("tag1"), buildMangaTag("tag2"))
val tags2 = setOf(buildMangaTag("tag3"), buildMangaTag("tag4")) val tags2 = setOf(buildMangaTag("tag3"), buildMangaTag("tag4"))
val query = MangaSearchQuery.builder() val query = MangaSearchQuery.Builder()
.criterion(Include(TAG, tags1)) .criterion(Include(TAG, tags1))
.criterion(Include(TAG, tags2)) .criterion(Include(TAG, tags2))
.build() .build()
@ -69,11 +73,11 @@ class ConvertToMangaListFilterTest {
@Test @Test
fun convertToMangaListFilterWithUnsupportedFieldTest() { fun convertToMangaListFilterWithUnsupportedFieldTest() {
val query = MangaSearchQuery.builder() val query = MangaSearchQuery.Builder()
.criterion(Include(AUTHOR, setOf(buildMangaTag("author")))) .criterion(Include(AUTHOR, setOf(buildMangaTag("author"))))
.build() .build()
val exception = assertThrows<UnsupportedOperationException> { val exception = assertThrows<IllegalArgumentException> {
convertToMangaListFilter(query) convertToMangaListFilter(query)
} }

Loading…
Cancel
Save