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 open val searchQueryCapabilities: MangaSearchQueryCapabilities
get() = MangaSearchQueryCapabilities.from(filterCapabilities)
get() = filterCapabilities.toMangaSearchQueryCapabilities()
public val config: MangaSourceConfig by lazy { context.getConfig(source) }
@ -68,26 +68,24 @@ public abstract class MangaParser @InternalParsersApi constructor(
*
* @param searchQuery searchQuery
*/
public suspend fun searchManga(searchQuery: MangaSearchQuery, validateQuery: Boolean = true): List<Manga> {
if (validateQuery) {
public suspend fun queryManga(searchQuery: MangaSearchQuery): List<Manga> {
if (!searchQuery.skipValidation) {
searchQueryCapabilities.validate(searchQuery)
}
return validatedMangaSearch(searchQuery)
return getList(searchQuery)
}
/**
* Search list of manga by specified searchQuery
*
* @param searchQuery searchQuery
* @param query searchQuery
*/
protected open suspend fun validatedMangaSearch(searchQuery: MangaSearchQuery): List<Manga> {
return getList(
searchQuery.offset ?: 0,
searchQuery.order ?: defaultSortOrder,
convertToMangaListFilter(searchQuery),
protected open suspend fun getList(query: MangaSearchQuery): List<Manga> = getList(
offset = query.offset,
order = query.order ?: defaultSortOrder,
filter = convertToMangaListFilter(query),
)
}
/**
* 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 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>
/**

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

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

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

@ -1,5 +1,7 @@
package org.koitharu.kotatsu.parsers.model.search
import androidx.collection.ArrayMap
import androidx.collection.ArraySet
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 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
}
@ConsistentCopyVisibility
public data class MangaSearchQuery private constructor(
@JvmField public val criteria: Set<QueryCriteria<*>>,
@JvmField public val order: SortOrder?,
@JvmField public val offset: Int,
@JvmField public val skipValidation: Boolean,
) {
public companion object {
public fun builder(): Builder = Builder()
}
public fun newBuilder(): Builder = Builder(this)
public class Builder {
private var criteria: MutableSet<QueryCriteria<*>> = mutableSetOf()
private val criteria = ArraySet<QueryCriteria<*>>()
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 {
this.criteria = searchQuery.criteria as MutableSet<QueryCriteria<*>>
this.order = searchQuery.order
this.offset = searchQuery.offset
}
public constructor()
@Throws(IllegalArgumentException::class)
public fun criterion(criterion: QueryCriteria<*>): Builder = apply {
validateCriterion(criterion)
this.criteria.add(criterion)
public constructor(query: MangaSearchQuery) : this() {
criteria.addAll(query.criteria)
order = query.order
offset = query.offset
}
public fun criterion(criterion: QueryCriteria<*>): Builder = apply { criteria.add(criterion) }
public fun order(order: SortOrder?): Builder = apply { this.order = order }
public fun offset(offset: Int?): Builder = apply { this.offset = offset }
public fun offset(offset: Int): Builder = apply { this.offset = offset }
public fun skipValidation(skip: Boolean): Builder = apply { this.skipValidation = skip }
@Throws(IllegalArgumentException::class)
public fun build(): MangaSearchQuery {
return MangaSearchQuery(deduplicateCriteria(criteria), order, offset)
}
/**
* 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"
)
}
return MangaSearchQuery(deduplicateCriteria(criteria), order, offset, skipValidation)
}
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) {
val key = criterion.field to criterion::class.java
@ -111,15 +63,17 @@ public class MangaSearchQuery private constructor(
existing == null -> uniqueCriteria[key] = criterion
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<*> -> {
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(
"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
import androidx.collection.ArraySet
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.*
import org.koitharu.kotatsu.parsers.util.mapToSet
public data class MangaSearchQueryCapabilities(
val capabilities: Set<SearchCapability> = emptySet(),
@ExposedCopyVisibility
public data class MangaSearchQueryCapabilities internal constructor(
val capabilities: Set<SearchCapability>,
) {
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
),
),
)
}
}
public constructor(vararg capabilities: SearchCapability) : this(ArraySet(capabilities))
@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)
val strictFields = capabilities.filter { !it.otherCriteria }.mapToSet { it.field }
val usedStrictFields = query.criteria.mapToSet { it.field }.intersect(strictFields)
if (usedStrictFields.isNotEmpty() && query.criteria.size > 1) {
throw IllegalArgumentException(
"Query contains multiple criteria, but at least one field (${usedStrictFields.joinToString()}) does not support multiple criteria."
"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) {
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) {
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
}

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

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

@ -11,11 +11,13 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.model.search.*
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
import org.koitharu.kotatsu.parsers.model.search.SearchCapability
import org.koitharu.kotatsu.parsers.model.search.SearchableField
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.*
@ -79,17 +81,65 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
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),
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,
),
)
@ -127,7 +177,6 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
ORIGINAL_LANGUAGE -> "originalLanguage[]"
LANGUAGE -> "availableTranslatedLanguage[]"
PUBLICATION_YEAR -> "year"
else -> ""
}
private fun Any?.toQueryParam(): String = when (this) {
@ -141,13 +190,14 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
MangaState.PAUSED -> "hiatus"
else -> ""
}
is ContentRating -> when (this) {
ContentRating.SAFE -> "safe"
// quick fix for double value
ContentRating.SUGGESTIVE -> "suggestive&contentRating[]=erotica"
ContentRating.ADULT -> "pornographic"
else -> ""
}
is Demographic -> when (this) {
Demographic.SHOUNEN -> "shounen"
Demographic.SHOUJO -> "shoujo"
@ -156,6 +206,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
Demographic.NONE -> "none"
else -> ""
}
is SortOrder -> when (this) {
SortOrder.UPDATED -> "[latestUploadedChapter]=desc"
SortOrder.UPDATED_ASC -> "[latestUploadedChapter]=asc"
@ -172,6 +223,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
SortOrder.RELEVANCE -> "&order[relevance]=desc"
else -> "[latestUploadedChapter]=desc"
}
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 {
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")
var hasContentRating = false
searchQuery.criteria.forEach { criterion ->
query.criteria.forEach { criterion ->
when (criterion) {
is Include<*> -> {
if (criterion.field == CONTENT_RATING) {
@ -198,12 +250,15 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
}
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
}
@ -216,7 +271,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
}
append("&order")
append((searchQuery.order ?: defaultSortOrder).toQueryParam())
append((query.order ?: defaultSortOrder).toQueryParam())
}
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> {
return searchManga(convertToMangaSearchQuery(offset, order, filter))
return queryManga(convertToMangaSearchQuery(offset, order, filter))
}
override suspend fun getDetails(manga: Manga): Manga {
@ -304,15 +359,14 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
"https://uploads.$domain/covers/$id/$it"
}
val authors: Set<MangaTag> = (relations["author"] ?: relations["artist"])
?.mapNotNull {
?.mapNotNullToSet {
val key = it.getStringOrNull("id")
val title = it.getJSONObject("attributes")?.getStringOrNull("name")
key?.let { k ->
title?.let { t -> MangaTag(key = k, title = t, source = source) }
}
}
?.toSet() ?: emptySet()
}.orEmpty()
return Manga(
id = generateUid(id),
@ -452,7 +506,8 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
val locale = attrs.getStringOrNull("translatedLanguage")?.let { Locale.forLanguageTag(it) }
val lc = locale?.getDisplayName(locale)?.toTitleCase(locale)
val relations = jo.getJSONArray("relationships").associateByKey("type")
val team = relations["scanlation_group"]?.firstOrNull()?.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

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

@ -46,11 +46,29 @@ internal abstract class MangaboxParser(
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),
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,
),
)
@ -94,12 +112,14 @@ internal abstract class MangaboxParser(
MangaState.FINISHED -> "completed"
else -> ""
}
is SortOrder -> when (this) {
SortOrder.ALPHABETICAL -> "az"
SortOrder.NEWEST -> "newest"
SortOrder.POPULARITY -> "topview"
else -> ""
}
else -> this.toString().replace(" ", "_").urlEncoded()
}
@ -115,17 +135,18 @@ internal abstract class MangaboxParser(
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
val url = buildString {
val pageQueryParameter = "page=${searchQuery.offset ?: 0}"
val pageQueryParameter = "page=$page"
append("https://${domain}${listUrl}/?s=all")
searchQuery.criteria.forEach { criterion ->
query.criteria.forEach { criterion ->
when (criterion) {
is Include<*> -> {
if (criterion.field == AUTHOR) {
criterion.values.firstOrNull()?.toQueryParam()?.takeIf { it.isNotBlank() }?.let { authorKey ->
criterion.values.firstOrNull()?.toQueryParam()?.takeIf { it.isNotBlank() }
?.let { authorKey ->
authorSearchUrl = "https://${domain}${authorUrl}/${authorKey}/?$pageQueryParameter"
}
}
@ -134,12 +155,15 @@ internal abstract class MangaboxParser(
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
}
@ -147,7 +171,7 @@ internal abstract class MangaboxParser(
}
append("&${pageQueryParameter}")
append("&orby=${(searchQuery.order ?: defaultSortOrder).toQueryParam()}")
append("&orby=${(query.order ?: defaultSortOrder).toQueryParam()}")
}
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> {
return searchManga(convertToMangaSearchQuery(page, order, filter))
return queryManga(convertToMangaSearchQuery(page, order, filter))
}
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 authors = doc.body().select(selectAut).mapToSet {
MangaTag(
key = it.attribute("href").value.find(authorKeyRegex)?: it.text(),
key = it.attribute("href").value.find(authorKeyRegex) ?: it.text(),
title = it.text(),
source = source,
)

@ -6,9 +6,11 @@ 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.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.Include
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.Match
import org.koitharu.kotatsu.parsers.model.search.SearchCapability
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import org.koitharu.kotatsu.parsers.site.mangabox.MangaboxParser
import org.koitharu.kotatsu.parsers.util.*
@ -44,10 +46,23 @@ internal class Mangairo(context: MangaLoaderContext) :
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),
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,
),
)
@ -59,25 +74,27 @@ internal class Mangairo(context: MangaLoaderContext) :
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> {
override suspend fun getListPage(query: MangaSearchQuery, page: Int): 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()}")
append("/type-${(query.order ?: defaultSortOrder).toQueryParam()}")
searchQuery.criteria.forEach { criterion ->
query.criteria.forEach { criterion ->
when (criterion) {
is Include<*> -> {
when (criterion.field) {
@ -86,14 +103,16 @@ internal class Mangairo(context: MangaLoaderContext) :
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}"
"?page=${query.offset}"
}
}
}
else -> {
// Not supported
}
@ -101,7 +120,7 @@ internal class Mangairo(context: MangaLoaderContext) :
}
append("/ctg-$category")
append("/state-$state")
append("/page-${searchQuery.offset ?: 0}")
append("/page-$page")
}
val doc = webClient.httpGet(titleSearchUrl ?: url).parseHtml()

@ -31,10 +31,23 @@ internal class Mangakakalot(context: MangaLoaderContext) :
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),
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,
),
)
@ -53,18 +66,21 @@ internal class Mangakakalot(context: MangaLoaderContext) :
.replace(" ", "_")
.urlEncoded()
}
is MangaTag -> key
is MangaState -> when (this) {
MangaState.ONGOING -> "ongoing"
MangaState.FINISHED -> "completed"
else -> "all"
}
is SortOrder -> when (this) {
SortOrder.POPULARITY -> "topview"
SortOrder.UPDATED -> "latest"
SortOrder.NEWEST -> "newest"
else -> ""
}
else -> this.toString().replace(" ", "_").urlEncoded()
}
@ -80,19 +96,20 @@ internal class Mangakakalot(context: MangaLoaderContext) :
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
val url = buildString {
val pageQueryParameter = "page=${searchQuery.offset ?: 0}"
val pageQueryParameter = "page=$page"
append("https://$domain/?")
searchQuery.criteria.forEach { criterion ->
query.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 ->
@ -102,6 +119,7 @@ internal class Mangakakalot(context: MangaLoaderContext) :
}
appendCriterion(criterion.field, criterion.value)
}
else -> {
// Not supported
}
@ -109,7 +127,7 @@ internal class Mangakakalot(context: MangaLoaderContext) :
}
append("&$pageQueryParameter")
append("&type=${(searchQuery.order ?: defaultSortOrder).toQueryParam()}")
append("&type=${(query.order ?: defaultSortOrder).toQueryParam()}")
}
val doc = webClient.httpGet(titleSearchUrl ?: url).parseHtml()

@ -35,10 +35,23 @@ internal class MangakakalotTv(context: MangaLoaderContext) :
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),
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,
),
)
@ -56,12 +69,14 @@ internal class MangakakalotTv(context: MangaLoaderContext) :
MangaState.FINISHED -> "completed"
else -> "all"
}
is SortOrder -> when (this) {
SortOrder.POPULARITY -> "topview"
SortOrder.UPDATED -> "latest"
SortOrder.NEWEST -> "newest"
else -> ""
}
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
val url = buildString {
val pageQueryParameter = "page=${searchQuery.offset ?: 0}"
val pageQueryParameter = "page=$page"
append("https://$domain/?")
searchQuery.criteria.forEach { criterion ->
query.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 ->
@ -95,6 +111,7 @@ internal class MangakakalotTv(context: MangaLoaderContext) :
}
appendCriterion(criterion.field, criterion.value)
}
else -> {
// Not supported
}
@ -102,7 +119,7 @@ internal class MangakakalotTv(context: MangaLoaderContext) :
}
append("&$pageQueryParameter")
append("&type=${(searchQuery.order ?: defaultSortOrder).toQueryParam()}")
append("&type=${(query.order ?: defaultSortOrder).toQueryParam()}")
}
val doc = webClient.httpGet(titleSearchUrl ?: url).parseHtml()

@ -1,9 +1,15 @@
package org.koitharu.kotatsu.parsers.util
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.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.*
/**
@ -20,26 +26,26 @@ public fun convertToMangaSearchQuery(offset: Int, sortOrder: SortOrder, filter:
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 (filter.tags.isNotEmpty()) criterion(Include(TAG, filter.tags))
if (filter.tagsExclude.isNotEmpty()) criterion(Exclude(TAG, filter.tagsExclude))
if (filter.states.isNotEmpty()) criterion(Include(STATE, filter.states))
if (filter.types.isNotEmpty()) criterion(Include(CONTENT_TYPE, filter.types))
if (filter.contentRating.isNotEmpty()) criterion(Include(CONTENT_RATING, filter.contentRating))
if (filter.demographics.isNotEmpty()) criterion(Include(DEMOGRAPHIC, filter.demographics))
if (validateYear(filter.yearFrom) || validateYear(filter.yearTo)) {
criterion(QueryCriteria.Range(PUBLICATION_YEAR, filter.yearFrom, filter.yearTo))
}
if (validateYear(filter.year)) {
criterion(QueryCriteria.Match(PUBLICATION_YEAR, filter.year))
criterion(Match(PUBLICATION_YEAR, filter.year))
}
filter.locale?.takeIf { it != null }?.let {
criterion(QueryCriteria.Include(LANGUAGE, setOf(it)))
filter.locale?.let {
criterion(Include(LANGUAGE, setOf(it)))
}
filter.originalLocale?.takeIf { it != null }?.let {
criterion(QueryCriteria.Include(ORIGINAL_LANGUAGE, setOf(it)))
filter.originalLocale?.let {
criterion(Include(ORIGINAL_LANGUAGE, setOf(it)))
}
filter.query?.takeIf { it.isNotBlank() }?.let {
criterion(QueryCriteria.Match(TITLE_NAME, it))
criterion(Match(TITLE_NAME, it))
}
}.build()
}
@ -74,16 +80,99 @@ public fun convertToMangaListFilter(searchQuery: MangaSearchQuery): MangaListFil
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)
is Include<*> -> handleInclude(this, criterion)
is Exclude<*> -> handleExclude(this, criterion)
is Range<*> -> handleBetween(this, criterion)
is Match<*> -> handleMatch(this, criterion)
}
}
}.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
when (criterion.field) {
@ -94,20 +183,20 @@ private fun handleInclude(builder: MangaListFilter.Builder, criterion: QueryCrit
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}")
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
when (criterion.field) {
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
when (criterion.field) {
@ -115,32 +204,33 @@ private fun handleBetween(builder: MangaListFilter.Builder, criterion: QueryCrit
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}")
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
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}")
else -> throw IllegalArgumentException("Unsupported field for Match criterion: ${criterion.field}")
}
}
@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>
}
@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>
}
@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
}
@ -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
}
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) {
return@runTest
}
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())
val page1 = parser.queryManga(MangaSearchQuery.Builder().offset(0).order(parser.defaultSortOrder).build())
val page2 =
parser.queryManga(MangaSearchQuery.Builder().offset(page1.size).order(parser.defaultSortOrder).build())
if (parser is PagedMangaParser) {
assert(parser.pageSize >= page1.size) {
"Page size is ${page1.size} but ${parser.pageSize} expected"
@ -58,19 +59,19 @@ internal class MangaParserTest {
@MangaSources
fun searchByTitleName(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source)
val subject = parser.searchManga(
MangaSearchQuery.builder()
val subject = parser.queryManga(
MangaSearchQuery.Builder()
.offset(0)
.order(parser.defaultSortOrder)
.build()
.build(),
).minByOrNull {
it.title.length
} ?: error("No manga found")
val query = subject.title
check(query.isNotBlank()) { "Manga title '$query' is blank" }
val list = parser.searchManga(
MangaSearchQuery.builder()
val list = parser.queryManga(
MangaSearchQuery.Builder()
.order(SortOrder.RELEVANCE)
.criterion(QueryCriteria.Match(TITLE_NAME, query))
.build(),
@ -102,8 +103,8 @@ internal class MangaParserTest {
assert(tags.all { it.source == source })
val tag = tags.last()
val list = parser.searchManga(
MangaSearchQuery.builder()
val list = parser.queryManga(
MangaSearchQuery.Builder()
.offset(0)
.order(parser.defaultSortOrder)
.criterion(Include(TAG, setOf(tag)))
@ -120,8 +121,8 @@ internal class MangaParserTest {
if (!parser.filterCapabilities.isMultipleTagsSupported) return@runTest
val tags = parser.getFilterOptions().availableTags.shuffled().take(2).toSet()
val list = parser.searchManga(
MangaSearchQuery.builder()
val list = parser.queryManga(
MangaSearchQuery.Builder()
.offset(0)
.order(parser.defaultSortOrder)
.criterion(Include(TAG, tags))
@ -141,8 +142,8 @@ internal class MangaParserTest {
return@runTest
}
val locale = locales.random()
val list = parser.searchManga(
MangaSearchQuery.builder()
val list = parser.queryManga(
MangaSearchQuery.Builder()
.offset(0)
.order(parser.defaultSortOrder)
.criterion(Include(LANGUAGE, setOf(locale)))
@ -159,7 +160,7 @@ internal class MangaParserTest {
@MangaSources
fun details(source: MangaParserSource) = runTest(timeout = timeout) {
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]
parser.getDetails(manga).apply {
@ -190,7 +191,7 @@ internal class MangaParserTest {
@MangaSources
fun pages(source: MangaParserSource) = runTest(timeout = timeout) {
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 chapter = parser.getDetails(manga).chapters?.firstOrNull() ?: error("Chapter is null at ${manga.publicUrl}")
val pages = parser.getPages(chapter)
@ -245,7 +246,8 @@ internal class MangaParserTest {
@MangaSources
fun link(source: MangaParserSource) = runTest(timeout = timeout) {
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()
Assertions.assertNotNull(resolved)
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.search.QueryCriteria.*
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import java.util.Locale
import java.util.*
class MangaSearchQueryCapabilitiesTest {
@ -18,12 +18,12 @@ class MangaSearchQueryCapabilitiesTest {
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()
val query = MangaSearchQuery.Builder()
.criterion(Match(TITLE_NAME, "title"))
.build()
@ -32,7 +32,7 @@ class MangaSearchQueryCapabilitiesTest {
@Test
fun validateUnsupportedFieldThrowsException() {
val query = MangaSearchQuery.builder()
val query = MangaSearchQuery.Builder()
.criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.ENGLISH)))
.build()
@ -41,7 +41,7 @@ class MangaSearchQueryCapabilitiesTest {
@Test
fun validateUnsupportedMultiValueThrowsException() {
val query = MangaSearchQuery.builder()
val query = MangaSearchQuery.Builder()
.criterion(Include(STATE, setOf(MangaState.ONGOING, MangaState.FINISHED)))
.build()
@ -50,7 +50,7 @@ class MangaSearchQueryCapabilitiesTest {
@Test
fun validateMultipleCriteriaWithOtherCriteriaAllowed() {
val query = MangaSearchQuery.builder()
val query = MangaSearchQuery.Builder()
.criterion(Include(TAG, setOf(buildTag("tag1"), buildTag("tag2"))))
.criterion(Exclude(TAG, setOf(buildTag("tag3"))))
.build()
@ -60,7 +60,7 @@ class MangaSearchQueryCapabilitiesTest {
@Test
fun validateMultipleCriteriaWithStrictCapabilityThrowsException() {
val query = MangaSearchQuery.builder()
val query = MangaSearchQuery.Builder()
.criterion(Match(TITLE_NAME, "title"))
.criterion(Range(PUBLICATION_YEAR, 1990, 2000))
.build()

@ -34,12 +34,12 @@ class ListFilterToSearchQueryConverterTest {
demographics = demographics,
year = 2020,
yearFrom = 1997,
yearTo = 2024
yearTo = 2024,
)
val searchQuery = convertToMangaSearchQuery(0, SortOrder.NEWEST, filter)
val expectedQuery = MangaSearchQuery.builder()
val expectedQuery = MangaSearchQuery.Builder()
.offset(0)
.order(SortOrder.NEWEST)
.criterion(Match(TITLE_NAME, "title_name"))
@ -64,14 +64,14 @@ class ListFilterToSearchQueryConverterTest {
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 {
return MangaTag(
key = "${name}Key",
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.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.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType.MANGA
import org.koitharu.kotatsu.parsers.model.ContentType.MANHUA
import org.koitharu.kotatsu.parsers.model.Demographic.SEINEN
import org.koitharu.kotatsu.parsers.model.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.SearchableField.*
import java.util.*
@ -22,7 +26,7 @@ class ConvertToMangaListFilterTest {
val contentTypes = setOf(MANGA, MANHUA)
val demographics = setOf(SEINEN)
val query = MangaSearchQuery.builder()
val query = MangaSearchQuery.Builder()
.criterion(Match(TITLE_NAME, "title_name"))
.criterion(Include(TAG, tags))
.criterion(Exclude(TAG, excludedTags))
@ -57,7 +61,7 @@ class ConvertToMangaListFilterTest {
val tags1 = setOf(buildMangaTag("tag1"), buildMangaTag("tag2"))
val tags2 = setOf(buildMangaTag("tag3"), buildMangaTag("tag4"))
val query = MangaSearchQuery.builder()
val query = MangaSearchQuery.Builder()
.criterion(Include(TAG, tags1))
.criterion(Include(TAG, tags2))
.build()
@ -69,11 +73,11 @@ class ConvertToMangaListFilterTest {
@Test
fun convertToMangaListFilterWithUnsupportedFieldTest() {
val query = MangaSearchQuery.builder()
val query = MangaSearchQuery.Builder()
.criterion(Include(AUTHOR, setOf(buildMangaTag("author"))))
.build()
val exception = assertThrows<UnsupportedOperationException> {
val exception = assertThrows<IllegalArgumentException> {
convertToMangaListFilter(query)
}

Loading…
Cancel
Save