Refactoring
This commit is contained in:
@@ -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,
|
||||
|
||||
@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,
|
||||
) {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is MangaSearchQuery) return false
|
||||
|
||||
return criteria == other.criteria &&
|
||||
order == other.order &&
|
||||
offset == other.offset
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = criteria.hashCode()
|
||||
result = 31 * result + (order?.hashCode() ?: 0)
|
||||
result = 31 * result + (offset ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
public companion object {
|
||||
public fun builder(): Builder = Builder()
|
||||
}
|
||||
public 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()
|
||||
|
||||
public constructor(query: MangaSearchQuery) : this() {
|
||||
criteria.addAll(query.criteria)
|
||||
order = query.order
|
||||
offset = query.offset
|
||||
}
|
||||
|
||||
@Throws(IllegalArgumentException::class)
|
||||
public fun criterion(criterion: QueryCriteria<*>): Builder = apply {
|
||||
validateCriterion(criterion)
|
||||
this.criteria.add(criterion)
|
||||
}
|
||||
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> {
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return 31 * field.hashCode() + values.hashCode()
|
||||
public val field: SearchableField
|
||||
|
||||
override fun equals(other: Any?): Boolean
|
||||
|
||||
override fun hashCode(): Int
|
||||
|
||||
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.
|
||||
|
||||
@@ -516,14 +516,14 @@ internal class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context
|
||||
title = doc.selectFirstOrThrow("h1").text(),
|
||||
url = id.toString(),
|
||||
coverUrl =
|
||||
"https:" +
|
||||
doc.selectFirstOrThrow("picture > source")
|
||||
.attr("data-srcset")
|
||||
.substringBefore(" "),
|
||||
"https:" +
|
||||
doc.selectFirstOrThrow("picture > source")
|
||||
.attr("data-srcset")
|
||||
.substringBefore(" "),
|
||||
publicUrl =
|
||||
doc.selectFirstOrThrow("h1 > a")
|
||||
.attrAsRelativeUrl("href")
|
||||
.toAbsoluteUrl(domain),
|
||||
doc.selectFirstOrThrow("h1 > a")
|
||||
.attrAsRelativeUrl("href")
|
||||
.toAbsoluteUrl(domain),
|
||||
author = null,
|
||||
tags = emptySet(),
|
||||
isNsfw = true,
|
||||
@@ -546,37 +546,37 @@ internal class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context
|
||||
return manga.copy(
|
||||
title = json.getString("title"),
|
||||
largeCoverUrl =
|
||||
json.getJSONArray("files").getJSONObject(0).let {
|
||||
val hash = it.getString("hash")
|
||||
val commonId = commonImageId()
|
||||
val imageId = imageIdFromHash(hash)
|
||||
val subDomain = 'a' + subdomainOffset(imageId)
|
||||
json.getJSONArray("files").getJSONObject(0).let {
|
||||
val hash = it.getString("hash")
|
||||
val commonId = commonImageId()
|
||||
val imageId = imageIdFromHash(hash)
|
||||
val subDomain = 'a' + subdomainOffset(imageId)
|
||||
|
||||
"https://${getDomain("${subDomain}a")}/webp/$commonId$imageId/$hash.webp"
|
||||
},
|
||||
"https://${getDomain("${subDomain}a")}/webp/$commonId$imageId/$hash.webp"
|
||||
},
|
||||
author =
|
||||
json.optJSONArray("artists")
|
||||
?.mapJSON { it.getString("artist").toCamelCase() }
|
||||
?.joinToString(),
|
||||
json.optJSONArray("artists")
|
||||
?.mapJSON { it.getString("artist").toCamelCase() }
|
||||
?.joinToString(),
|
||||
publicUrl = json.getString("galleryurl").toAbsoluteUrl(domain),
|
||||
tags =
|
||||
buildSet {
|
||||
json.optJSONArray("characters")
|
||||
?.mapToTags("character")
|
||||
?.let(::addAll)
|
||||
json.optJSONArray("tags")
|
||||
?.mapToTags("tag")
|
||||
?.let(::addAll)
|
||||
json.optJSONArray("artists")
|
||||
?.mapToTags("artist")
|
||||
?.let(::addAll)
|
||||
json.optJSONArray("parodys")
|
||||
?.mapToTags("parody")
|
||||
?.let(::addAll)
|
||||
json.optJSONArray("groups")
|
||||
?.mapToTags("group")
|
||||
?.let(::addAll)
|
||||
},
|
||||
buildSet {
|
||||
json.optJSONArray("characters")
|
||||
?.mapToTags("character")
|
||||
?.let(::addAll)
|
||||
json.optJSONArray("tags")
|
||||
?.mapToTags("tag")
|
||||
?.let(::addAll)
|
||||
json.optJSONArray("artists")
|
||||
?.mapToTags("artist")
|
||||
?.let(::addAll)
|
||||
json.optJSONArray("parodys")
|
||||
?.mapToTags("parody")
|
||||
?.let(::addAll)
|
||||
json.optJSONArray("groups")
|
||||
?.mapToTags("group")
|
||||
?.let(::addAll)
|
||||
},
|
||||
chapters = listOf(
|
||||
MangaChapter(
|
||||
id = generateUid(manga.url),
|
||||
@@ -600,15 +600,15 @@ internal class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context
|
||||
mapJSON {
|
||||
MangaTag(
|
||||
title =
|
||||
it.getString(key).toCamelCase().let { title ->
|
||||
if (it.getStringOrNull("female")?.toIntOrNull() == 1) {
|
||||
"$title ♀"
|
||||
} else if (it.getStringOrNull("male")?.toIntOrNull() == 1) {
|
||||
"$title ♂"
|
||||
} else {
|
||||
title
|
||||
}
|
||||
},
|
||||
it.getString(key).toCamelCase().let { title ->
|
||||
if (it.getStringOrNull("female")?.toIntOrNull() == 1) {
|
||||
"$title ♀"
|
||||
} else if (it.getStringOrNull("male")?.toIntOrNull() == 1) {
|
||||
"$title ♂"
|
||||
} else {
|
||||
title
|
||||
}
|
||||
},
|
||||
key = it.getString("url").tagUrlToTag(),
|
||||
source = source,
|
||||
).let(tags::add)
|
||||
|
||||
@@ -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,31 +135,35 @@ 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 ->
|
||||
authorSearchUrl = "https://${domain}${authorUrl}/${authorKey}/?$pageQueryParameter"
|
||||
}
|
||||
criterion.values.firstOrNull()?.toQueryParam()?.takeIf { it.isNotBlank() }
|
||||
?.let { authorKey ->
|
||||
authorSearchUrl = "https://${domain}${authorUrl}/${authorKey}/?$pageQueryParameter"
|
||||
}
|
||||
}
|
||||
|
||||
criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param ->
|
||||
append("&$param=${criterion.values.joinToString("_") { it.toQueryParam() }}")
|
||||
}
|
||||
}
|
||||
|
||||
is Exclude<*> -> {
|
||||
append("&g_e=${criterion.values.joinToString("_") { it.toQueryParam() }}")
|
||||
}
|
||||
|
||||
is Match<*> -> {
|
||||
appendCriterion(criterion.field, criterion.value)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Not supported
|
||||
}
|
||||
@@ -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()
|
||||
@@ -68,5 +68,5 @@ class MangaSearchQueryCapabilitiesTest {
|
||||
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,
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user