diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt index cc3a920c..c11a99d9 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt @@ -6,13 +6,10 @@ import okhttp3.HttpUrl import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.model.search.* import org.koitharu.kotatsu.parsers.network.OkHttpWebClient import org.koitharu.kotatsu.parsers.network.WebClient -import org.koitharu.kotatsu.parsers.util.FaviconParser -import org.koitharu.kotatsu.parsers.util.LinkResolver -import org.koitharu.kotatsu.parsers.util.RelatedMangaFinder -import org.koitharu.kotatsu.parsers.util.domain -import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl +import org.koitharu.kotatsu.parsers.util.* import java.util.* public abstract class MangaParser @InternalParsersApi constructor( @@ -27,8 +24,12 @@ public abstract class MangaParser @InternalParsersApi constructor( */ public abstract val availableSortOrders: Set + @Deprecated("Please check searchQueryCapabilities") public abstract val filterCapabilities: MangaListFilterCapabilities + public open val searchQueryCapabilities: MangaSearchQueryCapabilities + get() = filterCapabilities.toMangaSearchQueryCapabilities() + public val config: MangaSourceConfig by lazy { context.getConfig(source) } public open val sourceLocale: Locale @@ -62,6 +63,30 @@ public abstract class MangaParser @InternalParsersApi constructor( @JvmField protected val webClient: WebClient = OkHttpWebClient(context.httpClient, source) + /** + * Search list of manga by specified searchQuery + * + * @param searchQuery searchQuery + */ + public suspend fun queryManga(searchQuery: MangaSearchQuery): List { + if (!searchQuery.skipValidation) { + searchQueryCapabilities.validate(searchQuery) + } + + return getList(searchQuery) + } + + /** + * Search list of manga by specified searchQuery + * + * @param query searchQuery + */ + protected open suspend fun getList(query: MangaSearchQuery): List = getList( + offset = query.offset, + order = query.order ?: defaultSortOrder, + filter = convertToMangaListFilter(query), + ) + /** * Parse list of manga by specified criteria * @@ -69,7 +94,10 @@ public abstract class MangaParser @InternalParsersApi constructor( * Note than passed value may not be divisible by internal page size, so you should adjust it manually. * @param order one of [availableSortOrders] or [defaultSortOrder] for default value * @param filter is a set of filter rules + * + * @deprecated New [getList] should be preferred. */ + @Deprecated("New getList(query: MangaSearchQuery) method should be preferred") public abstract suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List /** @@ -113,4 +141,5 @@ public abstract class MangaParser @InternalParsersApi constructor( * @see [Manga.publicUrl] */ internal open suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? = null + } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/PagedMangaParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/PagedMangaParser.kt index 02492f43..e80a07cc 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/PagedMangaParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/PagedMangaParser.kt @@ -5,7 +5,10 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery +import org.koitharu.kotatsu.parsers.model.search.SearchableField import org.koitharu.kotatsu.parsers.util.Paginator +import org.koitharu.kotatsu.parsers.util.convertToMangaListFilter @InternalParsersApi public abstract class PagedMangaParser( @@ -21,6 +24,33 @@ public abstract class PagedMangaParser( @JvmField protected val searchPaginator: Paginator = Paginator(searchPageSize) + final override suspend fun getList(query: MangaSearchQuery): List { + var containTitleNameCriteria = false + query.criteria.forEach { + if (it.field == SearchableField.TITLE_NAME) { + containTitleNameCriteria = true + } + } + + return searchManga( + paginator = if (containTitleNameCriteria) { + paginator + } else { + searchPaginator + }, + query = query, + ) + } + + public open suspend fun getListPage(query: MangaSearchQuery, page: Int): List { + return getListPage( + page = page, + order = query.order ?: defaultSortOrder, + filter = convertToMangaListFilter(query), + ) + } + + @Deprecated("New searchManga method should be preferred") final override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List { return getList( paginator = if (filter.query.isNullOrEmpty()) { @@ -34,6 +64,7 @@ public abstract class PagedMangaParser( ) } + @Deprecated("New searchManga method should be preferred") public abstract suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List private suspend fun getList( @@ -47,4 +78,15 @@ public abstract class PagedMangaParser( paginator.onListReceived(offset, page, list.size) return list } + + private suspend fun searchManga( + paginator: Paginator, + query: MangaSearchQuery, + ): List { + val offset: Int = query.offset + val page = paginator.getPage(offset) + val list = getListPage(query, page) + paginator.onListReceived(offset, page, list.size) + return list + } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/SinglePageMangaParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/SinglePageMangaParser.kt index 6e2425af..406f2181 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/SinglePageMangaParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/SinglePageMangaParser.kt @@ -4,6 +4,8 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery +import org.koitharu.kotatsu.parsers.util.convertToMangaListFilter @InternalParsersApi public abstract class SinglePageMangaParser( @@ -11,6 +13,23 @@ public abstract class SinglePageMangaParser( source: MangaParserSource, ) : MangaParser(context, source) { + + final override suspend fun getList(query: MangaSearchQuery): List { + if (query.offset > 0) { + return emptyList() + } + return searchSinglePageManga(query) + } + + public open suspend fun searchSinglePageManga(searchQuery: MangaSearchQuery): List { + return getList( + searchQuery.offset, + searchQuery.order ?: defaultSortOrder, + convertToMangaListFilter(searchQuery), + ) + } + + @Deprecated("New searchManga method should be preferred") final override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List { if (offset > 0) { return emptyList() @@ -18,5 +37,6 @@ public abstract class SinglePageMangaParser( return getList(order, filter) } + @Deprecated("New searchManga method should be preferred") public abstract suspend fun getList(order: SortOrder, filter: MangaListFilter): List } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/Manga.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/Manga.kt index d7a9b8dd..53326d8b 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/Manga.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/Manga.kt @@ -52,7 +52,12 @@ public data class Manga private constructor( /** * Author of the manga, may be null */ + @Deprecated("Please use authors") @JvmField public val author: String?, + /** + * Authors of the manga + */ + @JvmField public val authors: Set, /** * Large cover url (absolute), null if is no large cover * @see coverUrl @@ -189,6 +194,7 @@ public data class Manga private constructor( source = source, ) + @Deprecated("") public operator fun invoke( /** * Unique identifier for manga @@ -271,5 +277,89 @@ public data class Manga private constructor( chapters = chapters, source = source, ) + + public operator fun invoke( + /** + * Unique identifier for manga + */ + id: Long, + /** + * Manga title, human-readable + */ + title: String, + /** + * Alternative title (for example on other language), may be null + */ + altTitle: String?, + /** + * Relative url to manga (**without** a domain) or any other uri. + * Used principally in parsers + */ + url: String, + /** + * Absolute url to manga, must be ready to open in browser + */ + publicUrl: String, + /** + * Normalized manga rating, must be in range of 0..1 or [RATING_UNKNOWN] if rating s unknown + * @see hasRating + */ + rating: Float, + /** + * Indicates that manga may contain sensitive information (18+, NSFW) + */ + contentRating: ContentRating?, + /** + * Absolute link to the cover + * @see largeCoverUrl + */ + coverUrl: String?, + /** + * Tags (genres) of the manga + */ + tags: Set, + /** + * Manga status (ongoing, finished) or null if unknown + */ + state: MangaState?, + /** + * Authors of the manga + */ + authors: Set, + /** + * Large cover url (absolute), null if is no large cover + * @see coverUrl + */ + largeCoverUrl: String? = null, + /** + * Manga description, may be html or null + */ + description: String? = null, + /** + * List of chapters + */ + chapters: List? = null, + /** + * Manga source + */ + source: MangaSource, + ): Manga = Manga( + id = id, + title = title, + altTitle = altTitle?.nullIfEmpty(), + url = url, + publicUrl = publicUrl, + rating = rating, + contentRating = contentRating, + coverUrl = coverUrl?.nullIfEmpty(), + tags = tags, + state = state, + authors = authors, + largeCoverUrl = largeCoverUrl?.nullIfEmpty(), + description = description?.nullIfEmpty(), + chapters = chapters, + source = source, + author = authors?.firstOrNull()?.title + ) } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilter.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilter.kt index e996a588..771f2287 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilter.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilter.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.parsers.model import java.util.* +@Deprecated("Please check new searchManga method and MangaSearchQuery class") public data class MangaListFilter( @JvmField val query: String? = null, @JvmField val tags: Set = emptySet(), @@ -42,4 +43,47 @@ public data class MangaListFilter( @JvmStatic public val EMPTY: MangaListFilter = MangaListFilter() } + + internal class Builder { + private var query: String? = null + private val tags: MutableSet = mutableSetOf() + private val tagsExclude: MutableSet = mutableSetOf() + private var locale: Locale? = null + private var originalLocale: Locale? = null + private val states: MutableSet = mutableSetOf() + private val contentRating: MutableSet = mutableSetOf() + private val types: MutableSet = mutableSetOf() + private val demographics: MutableSet = mutableSetOf() + private var year: Int = YEAR_UNKNOWN + private var yearFrom: Int = YEAR_UNKNOWN + private var yearTo: Int = YEAR_UNKNOWN + + fun query(query: String?): Builder = apply { this.query = query } + fun addTag(tag: MangaTag): Builder = apply { tags.add(tag) } + fun addTags(tags: Collection): Builder = apply { this.tags.addAll(tags) } + fun excludeTag(tag: MangaTag): Builder = apply { tagsExclude.add(tag) } + fun excludeTags(tags: Collection): 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): Builder = apply { this.states.addAll(states) } + fun addContentRating(rating: ContentRating): Builder = apply { contentRating.add(rating) } + fun addContentRatings(ratings: Collection): Builder = + apply { this.contentRating.addAll(ratings) } + + fun addType(type: ContentType): Builder = apply { types.add(type) } + fun addTypes(types: Collection): Builder = apply { this.types.addAll(types) } + fun addDemographic(demographic: Demographic): Builder = apply { demographics.add(demographic) } + fun addDemographics(demographics: Collection): 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, + ) + } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilterCapabilities.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilterCapabilities.kt index d0daa06d..00a479cd 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilterCapabilities.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilterCapabilities.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.parsers.model import org.koitharu.kotatsu.parsers.InternalParsersApi +@Deprecated("Please check new MangaSearchQueryCapabilities class") public data class MangaListFilterCapabilities @InternalParsersApi constructor( /** diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQuery.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQuery.kt new file mode 100644 index 00000000..628b13f3 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQuery.kt @@ -0,0 +1,84 @@ +package org.koitharu.kotatsu.parsers.model.search + +import androidx.collection.ArrayMap +import androidx.collection.ArraySet +import org.koitharu.kotatsu.parsers.model.SortOrder + +/** + * Represents a search query for filtering and sorting manga search results. + * This class is immutable and must be constructed using the [Builder]. + * + * @property criteria The set of search criteria applied to the query. + * @property order The sorting order for the results (optional). + * @property offset The offset number for paginated search results (optional). + */ + +@ConsistentCopyVisibility +public data class MangaSearchQuery private constructor( + @JvmField public val criteria: Set>, + @JvmField public val order: SortOrder?, + @JvmField public val offset: Int, + @JvmField public val skipValidation: Boolean, +) { + + public fun newBuilder(): Builder = Builder(this) + + public class Builder { + + private val criteria = ArraySet>() + private var order: SortOrder? = null + private var offset: Int = 0 + private var skipValidation: Boolean = false + + public constructor() + + 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 skipValidation(skip: Boolean): Builder = apply { this.skipValidation = skip } + + @Throws(IllegalArgumentException::class) + public fun build(): MangaSearchQuery { + return MangaSearchQuery(deduplicateCriteria(criteria), order, offset, skipValidation) + } + + private fun deduplicateCriteria(criteria: Set>): Set> { + val uniqueCriteria = + ArrayMap>>, QueryCriteria<*>>(criteria.size) + + for (criterion in criteria) { + val key = criterion.field to criterion::class.java + val existing = uniqueCriteria[key] + + when { + existing == null -> uniqueCriteria[key] = criterion + + existing is QueryCriteria.Include<*> && criterion is QueryCriteria.Include<*> -> { + uniqueCriteria[key] = + QueryCriteria.Include(criterion.field, existing.values union criterion.values) + } + + existing is QueryCriteria.Exclude<*> && criterion is QueryCriteria.Exclude<*> -> { + uniqueCriteria[key] = + QueryCriteria.Exclude(criterion.field, existing.values union criterion.values) + } + + else -> throw IllegalArgumentException( + "Match and Range have only one criterion per type, but found duplicates for: ${criterion.field} in ${criterion::class.simpleName}", + ) + } + } + + return uniqueCriteria.values.toSet() + } + } +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQueryCapabilities.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQueryCapabilities.kt new file mode 100644 index 00000000..8295d18b --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQueryCapabilities.kt @@ -0,0 +1,51 @@ +package org.koitharu.kotatsu.parsers.model.search + +import androidx.collection.ArraySet +import org.koitharu.kotatsu.parsers.InternalParsersApi +import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.* +import org.koitharu.kotatsu.parsers.util.mapToSet + +@ExposedCopyVisibility +public data class MangaSearchQueryCapabilities internal constructor( + val capabilities: Set, +) { + + public constructor(vararg capabilities: SearchCapability) : this(ArraySet(capabilities)) + + @InternalParsersApi + public fun validate(query: MangaSearchQuery) { + 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.", + ) + } + + for (criterion in query.criteria) { + val capability = capabilities.find { it.field == criterion.field } + ?: throw IllegalArgumentException("Unsupported search field: ${criterion.field}") + + if (criterion::class !in capability.criteriaTypes) { + throw IllegalArgumentException( + "Unsupported search criterion: ${criterion::class.simpleName} for field ${criterion.field}", + ) + } + + // Ensure single value per criterion if supportMultiValue is false + if (!capability.multiValue) { + when (criterion) { + is Include<*> -> if (criterion.values.size > 1) + throw IllegalArgumentException("Multiple values are not allowed for field ${criterion.field}") + + is Exclude<*> -> if (criterion.values.size > 1) + throw IllegalArgumentException("Multiple values are not allowed for field ${criterion.field}") + + is Range<*> -> {} // Range is always valid (from, to) + is Match<*> -> {} // Match always has a single value + } + } + } + } +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/QueryCriteria.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/QueryCriteria.kt new file mode 100644 index 00000000..ffaf5759 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/QueryCriteria.kt @@ -0,0 +1,59 @@ +package org.koitharu.kotatsu.parsers.model.search + +/** + * Represents a generic search criterion used for filtering manga search results. + * Each criterion applies a specific condition to a [SearchableField] and operates on values of type [T]. + * + * @param T The type of value associated with the search criterion. + * @property field The field to which this search criterion applies. + */ +public sealed interface QueryCriteria { + + public val field: SearchableField + + override fun equals(other: Any?): Boolean + + override fun hashCode(): Int + + public data class Include( + public override val field: SearchableField, + @JvmField public val values: Set, + ) : QueryCriteria { + + init { + check(values.all { x -> field.type.isInstance(x) }) + } + } + + public data class Exclude( + public override val field: SearchableField, + @JvmField public val values: Set, + ) : QueryCriteria { + + init { + check(values.all { x -> field.type.isInstance(x) }) + } + } + + public data class Range>( + public override val field: SearchableField, + @JvmField public val from: T, + @JvmField public val to: T, + ) : QueryCriteria { + + init { + check(field.type.isInstance(from)) + check(field.type.isInstance(to)) + } + } + + public data class Match( + public override val field: SearchableField, + @JvmField public val value: T, + ) : QueryCriteria { + + init { + check(field.type.isInstance(value)) + } + } +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/SearchCapability.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/SearchCapability.kt new file mode 100644 index 00000000..097986fa --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/SearchCapability.kt @@ -0,0 +1,10 @@ +package org.koitharu.kotatsu.parsers.model.search + +import kotlin.reflect.KClass + +public data class SearchCapability ( + @JvmField public val field: SearchableField, + @JvmField public val criteriaTypes: Set>>, + @JvmField public val multiValue: Boolean, + @JvmField public val otherCriteria: Boolean, +) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/SearchableField.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/SearchableField.kt new file mode 100644 index 00000000..d14e5c16 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/search/SearchableField.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.parsers.model.search + +import org.koitharu.kotatsu.parsers.model.* +import java.util.* + +/** + * Represents the various fields that can be used for searching manga. + * Each field is associated with a specific data type that defines its expected values. + * + * @property type The Java class representing the expected type of values for this field. + */ +public enum class SearchableField(public val type: Class<*>) { + TITLE_NAME(String::class.java), + TAG(MangaTag::class.java), + AUTHOR(MangaTag::class.java), + LANGUAGE(Locale::class.java), + ORIGINAL_LANGUAGE(Locale::class.java), + STATE(MangaState::class.java), + CONTENT_TYPE(ContentType::class.java), + CONTENT_RATING(ContentRating::class.java), + DEMOGRAPHIC(Demographic::class.java), + PUBLICATION_YEAR(Int::class.java); +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/HitomiLaParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/HitomiLaParser.kt index 6d5b4193..24dd603e 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/HitomiLaParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/HitomiLaParser.kt @@ -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) - - "https://${getDomain("${subDomain}a")}/webp/$commonId$imageId/$hash.webp" - }, + 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" + }, 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) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaDexParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaDexParser.kt index 9f57ba58..1305e384 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaDexParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaDexParser.kt @@ -11,9 +11,14 @@ 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.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.* import java.text.SimpleDateFormat @@ -74,6 +79,70 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context isAuthorSearchSupported = true, ) + override val searchQueryCapabilities: MangaSearchQueryCapabilities + get() = MangaSearchQueryCapabilities( + SearchCapability( + field = TAG, + criteriaTypes = setOf(Include::class, Exclude::class), + multiValue = true, + otherCriteria = true, + ), + SearchCapability( + field = TITLE_NAME, + criteriaTypes = setOf(Match::class), + multiValue = false, + otherCriteria = true, + ), + SearchCapability( + field = STATE, + criteriaTypes = setOf(Include::class), + multiValue = true, + otherCriteria = true, + ), + SearchCapability( + field = AUTHOR, + criteriaTypes = setOf(Include::class), + multiValue = true, + otherCriteria = true, + ), + SearchCapability( + field = CONTENT_TYPE, + criteriaTypes = setOf(Include::class), + multiValue = true, + otherCriteria = true, + ), + SearchCapability( + field = CONTENT_RATING, + criteriaTypes = setOf(Include::class), + multiValue = true, + otherCriteria = true, + ), + SearchCapability( + field = DEMOGRAPHIC, + criteriaTypes = setOf(Include::class), + multiValue = true, + otherCriteria = true, + ), + SearchCapability( + field = ORIGINAL_LANGUAGE, + criteriaTypes = setOf(Include::class), + multiValue = true, + otherCriteria = true, + ), + SearchCapability( + field = LANGUAGE, + criteriaTypes = setOf(Include::class), + multiValue = true, + otherCriteria = true, + ), + SearchCapability( + field = PUBLICATION_YEAR, + criteriaTypes = setOf(Match::class), + multiValue = false, + otherCriteria = true, + ), + ) + override suspend fun getFilterOptions(): MangaListFilterOptions = coroutineScope { val localesDeferred = async { fetchAvailableLocales() } val tagsDeferred = async { fetchAvailableTags() } @@ -97,121 +166,122 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context ) } - override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List { - val domain = domain - val url = buildString { - append("https://api.") - append(domain) - append("/manga?limit=") - append(PAGE_SIZE) - append("&offset=") - append(offset) - append("&includes[]=cover_art&includes[]=author&includes[]=artist") + private fun SearchableField.toParamName(): String = when (this) { + TITLE_NAME -> "title" + TAG -> "includedTags[]" + AUTHOR -> "authors[]" + STATE -> "status[]" + CONTENT_TYPE -> "contentType[]" + CONTENT_RATING -> "contentRating[]" + DEMOGRAPHIC -> "publicationDemographic[]" + ORIGINAL_LANGUAGE -> "originalLanguage[]" + LANGUAGE -> "availableTranslatedLanguage[]" + PUBLICATION_YEAR -> "year" + } - filter.query?.let { - append("&title=") - append(filter.query.urlEncoded()) - } + private fun Any?.toQueryParam(): String = when (this) { + is String -> urlEncoded() + is Locale -> if (language == "in") "id" else language + is MangaTag -> key + is MangaState -> when (this) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + MangaState.ABANDONED -> "cancelled" + MangaState.PAUSED -> "hiatus" + else -> "" + } - filter.tags.forEach { - append("&includedTags[]=") - append(it.key) - } + is ContentRating -> when (this) { + ContentRating.SAFE -> "safe" + // quick fix for double value + ContentRating.SUGGESTIVE -> "suggestive&contentRating[]=erotica" + ContentRating.ADULT -> "pornographic" + } - filter.tagsExclude.forEach { - append("&excludedTags[]=") - append(it.key) - } + is Demographic -> when (this) { + Demographic.SHOUNEN -> "shounen" + Demographic.SHOUJO -> "shoujo" + Demographic.SEINEN -> "seinen" + Demographic.JOSEI -> "josei" + Demographic.NONE -> "none" + else -> "" + } - if (filter.contentRating.isNotEmpty()) { - filter.contentRating.forEach { - when (it) { - ContentRating.SAFE -> append("&contentRating[]=safe") - ContentRating.SUGGESTIVE -> append("&contentRating[]=suggestive&contentRating[]=erotica") - ContentRating.ADULT -> append("&contentRating[]=pornographic") + is SortOrder -> when (this) { + SortOrder.UPDATED -> "[latestUploadedChapter]=desc" + SortOrder.UPDATED_ASC -> "[latestUploadedChapter]=asc" + SortOrder.RATING -> "[rating]=desc" + SortOrder.RATING_ASC -> "[rating]=asc" + SortOrder.ALPHABETICAL -> "[title]=asc" + SortOrder.ALPHABETICAL_DESC -> "[title]=desc" + SortOrder.NEWEST -> "[year]=desc" + SortOrder.NEWEST_ASC -> "[year]=asc" + SortOrder.POPULARITY -> "[followedCount]=desc" + SortOrder.POPULARITY_ASC -> "[followedCount]=asc" + SortOrder.ADDED -> "[createdAt]=desc" + SortOrder.ADDED_ASC -> "[createdAt]=asc" + SortOrder.RELEVANCE -> "&order[relevance]=desc" + else -> "[latestUploadedChapter]=desc" + } - } - } - } else { - append("&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic") - } + else -> this.toString().urlEncoded() + } - if (!filter.author.isNullOrEmpty()) { - append("&authorOrArtist=").append(getAuthorId(filter.author)) - } + private fun StringBuilder.appendCriterion(field: SearchableField, value: Any?, paramName: String? = null) { + val param = paramName ?: field.toParamName() + if (param.isNotBlank()) { + append("&$param=") + append(value.toQueryParam()) + } + } - append("&order") - append( - when (order) { - SortOrder.UPDATED -> "[latestUploadedChapter]=desc" - SortOrder.UPDATED_ASC -> "[latestUploadedChapter]=asc" - SortOrder.RATING -> "[rating]=desc" - SortOrder.RATING_ASC -> "[rating]=asc" - SortOrder.ALPHABETICAL -> "[title]=asc" - SortOrder.ALPHABETICAL_DESC -> "[title]=desc" - SortOrder.NEWEST -> "[year]=desc" - SortOrder.NEWEST_ASC -> "[year]=asc" - SortOrder.POPULARITY -> "[followedCount]=desc" - SortOrder.POPULARITY_ASC -> "[followedCount]=asc" - SortOrder.ADDED -> "[createdAt]=desc" - SortOrder.ADDED_ASC -> "[createdAt]=asc" - SortOrder.RELEVANCE -> "&order[relevance]=desc" - else -> "[latestUploadedChapter]=desc" - }, - ) + override suspend fun getList(query: MangaSearchQuery): List { + val url = buildString { + 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 + + query.criteria.forEach { criterion -> + when (criterion) { + is Include<*> -> { + if (criterion.field == CONTENT_RATING) { + hasContentRating = true + } + criterion.values.forEach { appendCriterion(criterion.field, it) } + } - filter.states.forEach { - append("&status[]=") - when (it) { - MangaState.ONGOING -> append("ongoing") - MangaState.FINISHED -> append("completed") - MangaState.ABANDONED -> append("cancelled") - MangaState.PAUSED -> append("hiatus") - else -> append("") - } - } + is Exclude<*> -> { + criterion.values.forEach { appendCriterion(criterion.field, it, "excludedTags[]") } + } - filter.demographics.forEach { - append("&publicationDemographic[]=") - append( - when (it) { - Demographic.SHOUNEN -> "shounen" - Demographic.SHOUJO -> "shoujo" - Demographic.SEINEN -> "seinen" - Demographic.JOSEI -> "josei" - Demographic.NONE -> "none" - else -> "" - }, - ) - } + is Match<*> -> { + appendCriterion(criterion.field, criterion.value) + } - filter.locale?.let { - append("&availableTranslatedLanguage[]=") - if (it.language == "in") { - append("id") - } else { - append(it.language) + else -> { + // Not supported + } } } - filter.originalLocale?.let { - append("&originalLanguage[]=") - if (it.language == "in") { - append("id") - } else { - append(it.language) - } + // If contentRating is not provided, add default values + if (!hasContentRating) { + append("&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic") } - if (filter.year != 0) { - append("&year=") - append(filter.year) - } + append("&order") + append((query.order ?: defaultSortOrder).toQueryParam()) } + val json = webClient.httpGet(url).parseJson().getJSONArray("data") return json.mapJSON { jo -> jo.fetchManga(null) } } + override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List { + return queryManga(convertToMangaSearchQuery(offset, order, filter)) + } + override suspend fun getDetails(manga: Manga): Manga { val mangaId = manga.url.removePrefix("/") return getDetails(mangaId) @@ -282,11 +352,22 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context val attrs = getJSONObject("attributes") val relations = getJSONArray("relationships").associateByKey("type") val cover = relations["cover_art"] + ?.firstOrNull() ?.getJSONObject("attributes") ?.getString("fileName") ?.let { "https://uploads.$domain/covers/$id/$it" } + val authors: Set = (relations["author"] ?: relations["artist"]) + ?.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) } + } + }.orEmpty() + return Manga( id = generateUid(id), title = requireNotNull(attrs.getJSONObject("title").selectByLocale()) { @@ -322,9 +403,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context "cancelled" -> MangaState.ABANDONED else -> null }, - author = (relations["author"] ?: relations["artist"]) - ?.getJSONObject("attributes") - ?.getStringOrNull("name"), + authors = authors, chapters = chapters, source = source, ) @@ -408,22 +487,6 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context } } - private suspend fun getAuthorId(name: String): String { - val url = urlBuilder("api") - .addPathSegment("author") - .addQueryParameter("name", name) - .addQueryParameter("limit", "1") - .build() - val json = webClient.httpGet(url).parseJson() - .getJSONArray("data") - .getJSONObject(0) - if (json.getJSONObject("attributes").getString("name").equals(name, ignoreCase = true)) { - return json.getString("id") - } else { - throw NotFoundException("Author $name not found", url.toString()) - } - } - private fun mapChapters(list: List): List { // 2022-01-02T00:27:11+00:00 val dateFormat = SimpleDateFormat( @@ -443,7 +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"]?.optJSONObject("attributes")?.getStringOrNull("name") + val team = + relations["scanlation_group"]?.firstOrNull()?.optJSONObject("attributes")?.getStringOrNull("name") val branch = (list.indices).firstNotNullOf { i -> val b = if (i == 0) lc else "$lc ($i)" if (branchedChapters[b]?.get(volume to number) == null) b else null @@ -469,12 +533,12 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context return chaptersBuilder.toList() } - private fun JSONArray.associateByKey(key: String): Map { - val destination = LinkedHashMap(length()) + private fun JSONArray.associateByKey(key: String): Map> { + val destination = LinkedHashMap>(length()) repeat(length()) { i -> val item = getJSONObject(i) val keyValue = item.getString(key) - destination[keyValue] = item + destination.computeIfAbsent(keyValue) { mutableListOf() }.add(item) } return destination } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/WeebCentral.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/WeebCentral.kt index 26a8dcb3..d354fa55 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/WeebCentral.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/WeebCentral.kt @@ -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, ) } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/MangaboxParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/MangaboxParser.kt index b008e266..d5ad8753 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/MangaboxParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/MangaboxParser.kt @@ -7,6 +7,12 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.PagedMangaParser import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery +import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities +import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.* +import org.koitharu.kotatsu.parsers.model.search.SearchCapability +import org.koitharu.kotatsu.parsers.model.search.SearchableField +import org.koitharu.kotatsu.parsers.model.search.SearchableField.* import org.koitharu.kotatsu.parsers.util.* import java.text.DateFormat import java.text.SimpleDateFormat @@ -38,6 +44,34 @@ internal abstract class MangaboxParser( isSearchWithFiltersSupported = true, ) + override val searchQueryCapabilities: MangaSearchQueryCapabilities + get() = MangaSearchQueryCapabilities( + SearchCapability( + field = TAG, + criteriaTypes = setOf(Include::class, Exclude::class), + multiValue = true, + otherCriteria = true, + ), + SearchCapability( + field = TITLE_NAME, + criteriaTypes = setOf(Match::class), + multiValue = false, + otherCriteria = true, + ), + SearchCapability( + field = STATE, + criteriaTypes = setOf(Include::class), + multiValue = true, + otherCriteria = true, + ), + SearchCapability( + field = AUTHOR, + criteriaTypes = setOf(Include::class), + multiValue = false, + otherCriteria = false, + ), + ) + override suspend fun getFilterOptions() = MangaListFilterOptions( availableTags = fetchAvailableTags(), availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), @@ -59,64 +93,88 @@ internal abstract class MangaboxParser( ) protected open val listUrl = "/advanced_search" + protected open val authorUrl = "/search/author" protected open val searchUrl = "/search/story/" protected open val datePattern = "MMM dd,yy" - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - val url = buildString { - append("https://") - append(domain) - append(listUrl) - append("/?s=all") - - filter.query?.let { - append("&keyw=") - append(filter.query.replace(" ", "_").urlEncoded()) - } + private fun SearchableField.toParamName(): String = when (this) { + TITLE_NAME, AUTHOR -> "keyw" + TAG -> "g_i" + STATE -> "sts" + else -> "" + } - if (filter.tags.isNotEmpty()) { - append("&g_i=") - filter.tags.forEach { - append("_") - append(it.key) - append("_") - } - } + private fun Any?.toQueryParam(): String = when (this) { + is String -> replace(" ", "_").urlEncoded() + is MangaTag -> key + is MangaState -> when (this) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + else -> "" + } - if (filter.tagsExclude.isNotEmpty()) { - append("&g_e=") - filter.tagsExclude.forEach { - append("_") - append(it.key) - append("_") - } - } + is SortOrder -> when (this) { + SortOrder.ALPHABETICAL -> "az" + SortOrder.NEWEST -> "newest" + SortOrder.POPULARITY -> "topview" + else -> "" + } - filter.states.oneOrThrowIfMany()?.let { - append("&sts=") - append( - when (it) { - MangaState.ONGOING -> "ongoing" - MangaState.FINISHED -> "completed" - else -> "" - }, - ) - } + else -> this.toString().replace(" ", "_").urlEncoded() + } - append("&orby=") - when (order) { - SortOrder.POPULARITY -> append("topview") - SortOrder.UPDATED -> append("") - SortOrder.NEWEST -> append("newest") - SortOrder.ALPHABETICAL -> append("az") - else -> append("") + private fun StringBuilder.appendCriterion(field: SearchableField, value: Any?, paramName: String? = null) { + val param = paramName ?: field.toParamName() + if (param.isNotBlank()) { + append("&$param=") + append(value.toQueryParam()) + } + } + + private val authorKeyRegex by lazy { + Regex("[^/]+\$", RegexOption.IGNORE_CASE) + } + + override suspend fun getListPage(query: MangaSearchQuery, page: Int): List { + var authorSearchUrl: String? = null + val url = buildString { + val pageQueryParameter = "page=$page" + append("https://${domain}${listUrl}/?s=all") + + 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.field.toParamName().takeIf { it.isNotBlank() }?.let { param -> + append("&$param=${criterion.values.joinToString("_") { it.toQueryParam() }}") + } + } + + is Exclude<*> -> { + append("&g_e=${criterion.values.joinToString("_") { it.toQueryParam() }}") + } + + is Match<*> -> { + appendCriterion(criterion.field, criterion.value) + } + + else -> { + // Not supported + } + } } - append("&page=") - append(page.toString()) + append("&${pageQueryParameter}") + append("&orby=${(query.order ?: defaultSortOrder).toQueryParam()}") } - val doc = webClient.httpGet(url).parseHtml() + val doc = webClient.httpGet(authorSearchUrl ?: url).parseHtml() return doc.select("div.content-genres-item, div.list-story-item").ifEmpty { doc.select("div.search-story-item") @@ -131,14 +189,18 @@ internal abstract class MangaboxParser( altTitle = null, rating = RATING_UNKNOWN, tags = emptySet(), - author = null, + authors = emptySet(), state = null, source = source, - isNsfw = isNsfwSource, + contentRating = if (isNsfwSource) ContentRating.ADULT else ContentRating.SAFE, ) } } + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + return queryManga(convertToMangaSearchQuery(page, order, filter)) + } + protected open val selectTagMap = "div.panel-genres-list a:not(.genres-select)" protected open suspend fun fetchAvailableTags(): Set { @@ -175,7 +237,14 @@ internal abstract class MangaboxParser( } } val alt = doc.body().select(selectAlt).text().replace("Alternative : ", "").nullIfEmpty() - val aut = doc.body().select(selectAut).eachText().joinToString().nullIfEmpty() + val authors = doc.body().select(selectAut).mapToSet { + MangaTag( + key = it.attribute("href").value.find(authorKeyRegex) ?: it.text(), + title = it.text(), + source = source, + ) + } + manga.copy( tags = doc.body().select(selectTag).mapToSet { a -> MangaTag( @@ -186,7 +255,8 @@ internal abstract class MangaboxParser( }, description = desc, altTitle = alt, - author = aut, + authors = authors, + author = authors.firstOrNull()?.title, state = state, chapters = chaptersDeferred.await(), ) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangairo.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangairo.kt index fb19b4c4..7fb73556 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangairo.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangairo.kt @@ -6,6 +6,12 @@ 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.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.* import java.util.* @@ -38,6 +44,106 @@ internal class Mangairo(context: MangaLoaderContext) : isSearchWithFiltersSupported = false, ) + override val searchQueryCapabilities: MangaSearchQueryCapabilities + get() = MangaSearchQueryCapabilities( + SearchCapability( + field = TAG, + criteriaTypes = setOf(Include::class), + multiValue = false, + otherCriteria = true, + ), + SearchCapability( + field = TITLE_NAME, + criteriaTypes = setOf(Match::class), + multiValue = false, + otherCriteria = false, + ), + SearchCapability( + field = STATE, + criteriaTypes = setOf(Include::class), + multiValue = false, + otherCriteria = true, + ), + ) + + private fun Any?.toQueryParam(): String = when (this) { + is String -> replace(" ", "_").urlEncoded() + is MangaTag -> key + is MangaState -> when (this) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + else -> "all" + } + + is SortOrder -> when (this) { + SortOrder.POPULARITY -> "topview" + SortOrder.UPDATED -> "latest" + SortOrder.NEWEST -> "newest" + else -> "latest" + } + + else -> this.toString().urlEncoded() + } + + override suspend fun getListPage(query: MangaSearchQuery, page: Int): List { + var titleSearchUrl: String? = null + var category = "all" + var state = "all" + + val url = buildString { + append("https://${domain}${listUrl}") + append("/type-${(query.order ?: defaultSortOrder).toQueryParam()}") + + query.criteria.forEach { criterion -> + when (criterion) { + is Include<*> -> { + when (criterion.field) { + TAG -> category = criterion.values.first().toQueryParam() + STATE -> state = criterion.values.first().toQueryParam() + else -> Unit + } + } + + is Match<*> -> { + if (criterion.field == TITLE_NAME) { + criterion.value.toQueryParam().takeIf { it.isNotBlank() }?.let { titleName -> + titleSearchUrl = "https://${domain}${searchUrl}${titleName}/" + + "?page=${query.offset}" + } + } + } + + else -> { + // Not supported + } + } + } + append("/ctg-$category") + append("/state-$state") + append("/page-$page") + } + + val doc = webClient.httpGet(titleSearchUrl ?: url).parseHtml() + + return doc.select("div.story-item").map { div -> + val href = div.selectFirstOrThrow("a").attrAsRelativeUrl("href") + Manga( + id = generateUid(href), + url = href, + publicUrl = href.toAbsoluteUrl(div.host ?: domain), + coverUrl = div.selectFirst("img")?.src().orEmpty(), + title = (div.selectFirst("h2")?.text() ?: div.selectFirst("h3")?.text()).orEmpty(), + altTitle = null, + rating = RATING_UNKNOWN, + tags = emptySet(), + authors = emptySet(), + state = null, + source = source, + contentRating = if (isNsfwSource) ContentRating.ADULT else ContentRating.SAFE, + ) + } + } + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { val url = buildString { append("https://") diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangakakalot.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangakakalot.kt index 8c77b79a..81300ba2 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangakakalot.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Mangakakalot.kt @@ -5,6 +5,9 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.model.search.* +import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.* +import org.koitharu.kotatsu.parsers.model.search.SearchableField.* import org.koitharu.kotatsu.parsers.site.mangabox.MangaboxParser import org.koitharu.kotatsu.parsers.util.* import java.text.SimpleDateFormat @@ -25,57 +28,109 @@ internal class Mangakakalot(context: MangaLoaderContext) : isMultipleTagsSupported = false, isSearchWithFiltersSupported = false, ) + + override val searchQueryCapabilities: MangaSearchQueryCapabilities + get() = MangaSearchQueryCapabilities( + SearchCapability( + field = TAG, + criteriaTypes = setOf(Include::class), + multiValue = false, + otherCriteria = true, + ), + SearchCapability( + field = TITLE_NAME, + criteriaTypes = setOf(Match::class), + multiValue = false, + otherCriteria = false, + ), + SearchCapability( + field = STATE, + criteriaTypes = setOf(Include::class), + multiValue = false, + otherCriteria = true, + ), + ) + override val otherDomain = "chapmanganato.com" override val listUrl = "/manga_list" - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + private fun SearchableField.toParamName(): String = when (this) { + TAG -> "category" + STATE -> "state" + else -> "" + } + + private fun Any?.toQueryParam(): String = when (this) { + is String -> { + sanitizeTitleNameRegex.replace(this, "") + .replace(" ", "_") + .urlEncoded() + } + + is MangaTag -> key + is MangaState -> when (this) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + else -> "all" + } + + is SortOrder -> when (this) { + SortOrder.POPULARITY -> "topview" + SortOrder.UPDATED -> "latest" + SortOrder.NEWEST -> "newest" + else -> "" + } + + else -> this.toString().replace(" ", "_").urlEncoded() + } + + private fun StringBuilder.appendCriterion(field: SearchableField, value: Any?, paramName: String? = null) { + val param = paramName ?: field.toParamName() + if (param.isNotBlank()) { + append("&$param=") + append(value.toQueryParam()) + } + } + + private val sanitizeTitleNameRegex by lazy { + Regex("[^A-Za-z0-9 ]") + } + + override suspend fun getListPage(query: MangaSearchQuery, page: Int): List { + var titleSearchUrl: String? = null val url = buildString { - append("https://") - append(domain) - when { - - !filter.query.isNullOrEmpty() -> { - append(searchUrl) - val regex = Regex("[^A-Za-z0-9 ]") - val q = regex.replace(filter.query, "") - append(q.replace(" ", "_")) - append("?page=") - } + val pageQueryParameter = "page=$page" + append("https://$domain/?") - else -> { - append(listUrl) - append("?type=") - when (order) { - SortOrder.POPULARITY -> append("topview") - SortOrder.UPDATED -> append("latest") - SortOrder.NEWEST -> append("newest") - else -> append("latest") - } - if (filter.tags.isNotEmpty()) { - append("&category=") - filter.tags.oneOrThrowIfMany()?.let { - append(it.key) + query.criteria.forEach { criterion -> + when (criterion) { + is Include<*> -> { + criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param -> + append("&$param=${criterion.values.first().toQueryParam()}") } } - filter.states.oneOrThrowIfMany()?.let { - append("&state=") - append( - when (it) { - MangaState.ONGOING -> "ongoing" - MangaState.FINISHED -> "completed" - else -> "all" - }, - ) + is Match<*> -> { + if (criterion.field == TITLE_NAME) { + criterion.value.toQueryParam().takeIf { it.isNotBlank() }?.let { titleName -> + titleSearchUrl = "https://${domain}${searchUrl}${titleName}/" + + "?$pageQueryParameter" + } + } + appendCriterion(criterion.field, criterion.value) } - append("&page=") + else -> { + // Not supported + } } } - append(page.toString()) + + append("&$pageQueryParameter") + append("&type=${(query.order ?: defaultSortOrder).toQueryParam()}") } - val doc = webClient.httpGet(url).parseHtml() + val doc = webClient.httpGet(titleSearchUrl ?: url).parseHtml() return doc.select("div.list-truyen-item-wrap").ifEmpty { doc.select("div.story_item") @@ -90,10 +145,10 @@ internal class Mangakakalot(context: MangaLoaderContext) : altTitle = null, rating = RATING_UNKNOWN, tags = emptySet(), - author = null, + authors = emptySet(), state = null, source = source, - isNsfw = isNsfwSource, + contentRating = if (isNsfwSource) ContentRating.ADULT else ContentRating.SAFE, ) } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/MangakakalotTv.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/MangakakalotTv.kt index 3ec07a94..7cfda584 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/MangakakalotTv.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/MangakakalotTv.kt @@ -6,6 +6,9 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.model.search.* +import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.* +import org.koitharu.kotatsu.parsers.model.search.SearchableField.* import org.koitharu.kotatsu.parsers.site.mangabox.MangaboxParser import org.koitharu.kotatsu.parsers.util.* import java.util.* @@ -29,51 +32,98 @@ internal class MangakakalotTv(context: MangaLoaderContext) : isSearchWithFiltersSupported = false, ) - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + + override val searchQueryCapabilities: MangaSearchQueryCapabilities + get() = MangaSearchQueryCapabilities( + SearchCapability( + field = TAG, + criteriaTypes = setOf(Include::class), + multiValue = false, + otherCriteria = true, + ), + SearchCapability( + field = TITLE_NAME, + criteriaTypes = setOf(Match::class), + multiValue = false, + otherCriteria = false, + ), + SearchCapability( + field = STATE, + criteriaTypes = setOf(Include::class), + multiValue = false, + otherCriteria = true, + ), + ) + + private fun SearchableField.toParamName(): String = when (this) { + TAG -> "category" + STATE -> "state" + else -> "" + } + + private fun Any?.toQueryParam(): String = when (this) { + is String -> urlEncoded() + is MangaTag -> key + is MangaState -> when (this) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + else -> "all" + } + + is SortOrder -> when (this) { + SortOrder.POPULARITY -> "topview" + SortOrder.UPDATED -> "latest" + SortOrder.NEWEST -> "newest" + else -> "" + } + + else -> this.toString().urlEncoded() + } + + private fun StringBuilder.appendCriterion(field: SearchableField, value: Any?, paramName: String? = null) { + val param = paramName ?: field.toParamName() + if (param.isNotBlank()) { + append("&$param=") + append(value.toQueryParam()) + } + } + + override suspend fun getListPage(query: MangaSearchQuery, page: Int): List { + var titleSearchUrl: String? = null val url = buildString { - append("https://") - append(domain) - when { - - !filter.query.isNullOrEmpty() -> { - append(searchUrl) - append(filter.query.urlEncoded()) - append("?page=") - } + val pageQueryParameter = "page=$page" + append("https://$domain/?") - else -> { - append(listUrl) - append("?type=") - when (order) { - SortOrder.POPULARITY -> append("topview") - SortOrder.UPDATED -> append("latest") - SortOrder.NEWEST -> append("newest") - else -> append("latest") - } - if (filter.tags.isNotEmpty()) { - append("&category=") - filter.tags.oneOrThrowIfMany()?.let { - append(it.key) + query.criteria.forEach { criterion -> + when (criterion) { + is Include<*> -> { + criterion.field.toParamName().takeIf { it.isNotBlank() }?.let { param -> + append("&$param=${criterion.values.first().toQueryParam()}") } } - filter.states.oneOrThrowIfMany()?.let { - append("&state=") - append( - when (it) { - MangaState.ONGOING -> "Ongoing" - MangaState.FINISHED -> "Completed" - else -> "all" - }, - ) + is Match<*> -> { + if (criterion.field == TITLE_NAME) { + criterion.value.toQueryParam().takeIf { it.isNotBlank() }?.let { titleName -> + titleSearchUrl = "https://${domain}${searchUrl}${titleName}/" + + "?$pageQueryParameter" + } + } + appendCriterion(criterion.field, criterion.value) } - append("&page=") + else -> { + // Not supported + } } } - append(page.toString()) + + append("&$pageQueryParameter") + append("&type=${(query.order ?: defaultSortOrder).toQueryParam()}") } - val doc = webClient.httpGet(url).parseHtml() + + val doc = webClient.httpGet(titleSearchUrl ?: url).parseHtml() + return doc.select("div.list-truyen-item-wrap").ifEmpty { doc.select("div.story_item") }.map { div -> @@ -83,14 +133,14 @@ internal class MangakakalotTv(context: MangaLoaderContext) : url = href, publicUrl = href.toAbsoluteUrl(div.host ?: domain), coverUrl = div.selectFirst("img")?.src().orEmpty(), - title = div.selectFirstOrThrow("h3").text().orEmpty(), + title = div.selectFirst("h3")?.text().orEmpty(), altTitle = null, rating = RATING_UNKNOWN, tags = emptySet(), - author = null, + authors = emptySet(), state = null, source = source, - isNsfw = isNsfwSource, + contentRating = if (isNsfwSource) ContentRating.ADULT else ContentRating.SAFE, ) } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Manganato.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Manganato.kt index a41005c3..e885d9c3 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Manganato.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangabox/en/Manganato.kt @@ -16,5 +16,6 @@ internal class Manganato(context: MangaLoaderContext) : ) override val otherDomain = "chapmanganato.to" + override val authorUrl = "/author/story" override val selectPage = ".container-chapter-reader > img" } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/SearchQueryConverter.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/SearchQueryConverter.kt new file mode 100644 index 00000000..5fe41fc6 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/SearchQueryConverter.kt @@ -0,0 +1,244 @@ +package org.koitharu.kotatsu.parsers.util + +import org.koitharu.kotatsu.parsers.InternalParsersApi +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.* + +/** + * Converts a [MangaListFilter] into a [MangaSearchQuery]. + * + * This function iterates through the filter attributes in [MangaListFilter] and creates corresponding + * search criteria in a [MangaSearchQuery.Builder]. + * + * @param filter The [MangaListFilter] to convert. + * @return A [MangaSearchQuery] constructed based on the given [filter]. + */ +@InternalParsersApi +public fun convertToMangaSearchQuery(offset: Int, sortOrder: SortOrder, filter: MangaListFilter): MangaSearchQuery { + return MangaSearchQuery.Builder().apply { + offset(offset) + order(sortOrder) + if (filter.tags.isNotEmpty()) criterion(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(Match(PUBLICATION_YEAR, filter.year)) + } + filter.locale?.let { + criterion(Include(LANGUAGE, setOf(it))) + } + filter.originalLocale?.let { + criterion(Include(ORIGINAL_LANGUAGE, setOf(it))) + } + filter.query?.takeIf { it.isNotBlank() }?.let { + criterion(Match(TITLE_NAME, it)) + } + }.build() +} + +/** + * Converts a {@link MangaSearchQuery} into a {@link MangaListFilter}. + *

+ * This method iterates through the search criteria defined in the provided {@code searchQuery} + * and applies them to a {@link MangaListFilter.Builder}. The criteria are processed based on + * their types, such as inclusion, exclusion, equality checks, range filtering, and pattern matching. + *

+ *

+ * Supported criteria: + *

    + *
  • {@link QueryCriteria.Include} - Adds tags, states, content types, content ratings, demographics, and languages.
  • + *
  • {@link QueryCriteria.Exclude} - Excludes tags.
  • + *
  • {@link QueryCriteria.Equals} - Sets specific values like publication year.
  • + *
  • {@link QueryCriteria.Between} - Sets a range of values like publication year range.
  • + *
  • {@link QueryCriteria.Match} - Adds a search pattern for the title name.
  • + *
+ *

+ *

+ * If an unsupported field is encountered, an {@link UnsupportedOperationException} is thrown. + *

+ * + * @param searchQuery The {@link MangaSearchQuery} to convert. + * @return A {@link MangaListFilter} constructed based on the given {@code searchQuery}. + * @throws UnsupportedOperationException If the search criteria contain unsupported fields. + */ +@InternalParsersApi +public fun convertToMangaListFilter(searchQuery: MangaSearchQuery): MangaListFilter { + return MangaListFilter.Builder().apply { + for (criterion in searchQuery.criteria) { + when (criterion) { + is Include<*> -> handleInclude(this, criterion) + is Exclude<*> -> handleExclude(this, criterion) + is Range<*> -> handleBetween(this, criterion) + is Match<*> -> handleMatch(this, criterion) + } + } + }.build() +} + +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) { + TAG -> builder.addTags(filterValues(criterion, type)) + STATE -> builder.addStates(filterValues(criterion, type)) + CONTENT_TYPE -> builder.addTypes(filterValues(criterion, type)) + CONTENT_RATING -> builder.addContentRatings(filterValues(criterion, type)) + DEMOGRAPHIC -> builder.addDemographics(filterValues(criterion, type)) + LANGUAGE -> builder.locale(getFirstValue(criterion, type)) + ORIGINAL_LANGUAGE -> builder.originalLocale(getFirstValue(criterion, type)) + else -> throw IllegalArgumentException("Unsupported field for Include criterion: ${criterion.field}") + } +} + +private fun handleExclude(builder: MangaListFilter.Builder, criterion: Exclude<*>) { + val type = criterion.field.type + + when (criterion.field) { + TAG -> builder.excludeTags(filterValues(criterion, type)) + else -> throw IllegalArgumentException("Unsupported field for Exclude criterion: ${criterion.field}") + } +} + +private fun handleBetween(builder: MangaListFilter.Builder, criterion: Range<*>) { + val type = criterion.field.type + + when (criterion.field) { + PUBLICATION_YEAR -> { + builder.yearFrom(getValue(criterion.from, type, YEAR_UNKNOWN)) + builder.yearTo(getValue(criterion.to, type, YEAR_UNKNOWN)) + } + + else -> throw IllegalArgumentException("Unsupported field for Between criterion: ${criterion.field}") + } +} + +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 IllegalArgumentException("Unsupported field for Match criterion: ${criterion.field}") + } +} + +@Suppress("UNCHECKED_CAST") +private fun filterValues(criterion: Include<*>, type: Class<*>): List { + return criterion.values.filter { type.isInstance(it) } as List +} + +@Suppress("UNCHECKED_CAST") +private fun filterValues(criterion: Exclude<*>, type: Class<*>): List { + return criterion.values.filter { type.isInstance(it) } as List +} + +@Suppress("UNCHECKED_CAST") +private fun getFirstValue(criterion: Include<*>, type: Class<*>): T? { + return criterion.values.firstOrNull { type.isInstance(it) } as? T +} + +@Suppress("UNCHECKED_CAST") +private fun getValue(value: Any?, type: Class<*>, default: T): T { + val isCompatibleIntType = (type == Int::class.java && Integer::class.isInstance(value)) + + return if (type.isInstance(value) || isCompatibleIntType) value as T else default +} + +private fun validateYear(year: Int) = year != YEAR_UNKNOWN diff --git a/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt b/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt index 96950060..8fad3243 100644 --- a/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt +++ b/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt @@ -6,6 +6,10 @@ import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Disabled import org.junit.jupiter.params.ParameterizedTest import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery +import org.koitharu.kotatsu.parsers.model.search.QueryCriteria +import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.Include +import org.koitharu.kotatsu.parsers.model.search.SearchableField.* import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.medianOrNull import org.koitharu.kotatsu.parsers.util.mimeType @@ -34,8 +38,9 @@ internal class MangaParserTest { if (parser is SinglePageMangaParser) { return@runTest } - val page1 = parser.getList(0, parser.defaultSortOrder, MangaListFilter.EMPTY) - val page2 = parser.getList(page1.size, parser.defaultSortOrder, MangaListFilter.EMPTY) + val page1 = parser.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" @@ -52,18 +57,25 @@ internal class MangaParserTest { @ParameterizedTest(name = "{index}|search|{0}") @MangaSources - fun search(source: MangaParserSource) = runTest(timeout = timeout) { + fun searchByTitleName(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) - val subject = parser.getList( - offset = 0, - order = SortOrder.POPULARITY, - filter = MangaListFilter.EMPTY, + val subject = parser.queryManga( + MangaSearchQuery.Builder() + .offset(0) + .order(parser.defaultSortOrder) + .build(), ).minByOrNull { it.title.length } ?: error("No manga found") + val query = subject.title check(query.isNotBlank()) { "Manga title '$query' is blank" } - val list = parser.getList(0, SortOrder.RELEVANCE, MangaListFilter(query = query)) + val list = parser.queryManga( + MangaSearchQuery.Builder() + .order(SortOrder.RELEVANCE) + .criterion(QueryCriteria.Match(TITLE_NAME, query)) + .build(), + ) assert(list.isNotEmpty()) { "Empty search results by \"$query\"" } assert(list.singleOrNull { it.url == subject.url && it.id == subject.id } != null) { "Single subject '${subject.title} (${subject.publicUrl})' not found in search results" @@ -91,10 +103,12 @@ internal class MangaParserTest { assert(tags.all { it.source == source }) val tag = tags.last() - val list = parser.getList( - offset = 0, - order = parser.defaultSortOrder, - filter = MangaListFilter(tags = setOf(tag)), + val list = parser.queryManga( + MangaSearchQuery.Builder() + .offset(0) + .order(parser.defaultSortOrder) + .criterion(Include(TAG, setOf(tag))) + .build(), ) checkMangaList(list, "${tag.title} (${tag.key})") assert(list.all { it.source == source }) @@ -107,8 +121,14 @@ internal class MangaParserTest { if (!parser.filterCapabilities.isMultipleTagsSupported) return@runTest val tags = parser.getFilterOptions().availableTags.shuffled().take(2).toSet() - val filter = MangaListFilter(tags = tags) - val list = parser.getList(0, parser.defaultSortOrder, filter) + val list = parser.queryManga( + MangaSearchQuery.Builder() + .offset(0) + .order(parser.defaultSortOrder) + .criterion(Include(TAG, tags)) + .build(), + ) + checkMangaList(list, "${tags.joinToString { it.title }} (${tags.joinToString { it.key }})") assert(list.all { it.source == source }) } @@ -121,12 +141,17 @@ internal class MangaParserTest { if (locales.isEmpty()) { return@runTest } - val filter = MangaListFilter( - locale = locales.random(), - originalLocale = locales.random(), + val locale = locales.random() + val list = parser.queryManga( + MangaSearchQuery.Builder() + .offset(0) + .order(parser.defaultSortOrder) + .criterion(Include(LANGUAGE, setOf(locale))) + .criterion(Include(LANGUAGE, setOf(locale))) + .criterion(Include(ORIGINAL_LANGUAGE, setOf(locales.random()))) + .build(), ) - val list = parser.getList(offset = 0, order = parser.defaultSortOrder, filter) - checkMangaList(list, filter.locale.toString()) + checkMangaList(list, locale.toString()) assert(list.all { it.source == source }) } @@ -135,8 +160,9 @@ internal class MangaParserTest { @MangaSources fun details(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) - val list = parser.getList(0, parser.defaultSortOrder, MangaListFilter.EMPTY) - val manga = list[3] + val list = parser.queryManga(MangaSearchQuery.Builder().offset(0).order(parser.defaultSortOrder).build()) + + val manga = list[0] parser.getDetails(manga).apply { assert(!chapters.isNullOrEmpty()) { "Chapters are null or empty" } assert(publicUrl.isUrlAbsolute()) { "Manga public url is not absolute: '$publicUrl'" } @@ -165,7 +191,7 @@ internal class MangaParserTest { @MangaSources fun pages(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) - val list = parser.getList(0, parser.defaultSortOrder, MangaListFilter.EMPTY) + val list = parser.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) @@ -220,7 +246,8 @@ internal class MangaParserTest { @MangaSources fun link(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) - val manga = parser.getList(0, parser.defaultSortOrder, MangaListFilter.EMPTY).first() + val manga = + parser.queryManga(MangaSearchQuery.Builder().offset(0).order(parser.defaultSortOrder).build()).first() val resolved = context.newLinkResolver(manga.publicUrl).getManga() Assertions.assertNotNull(resolved) resolved ?: return@runTest diff --git a/src/test/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQueryTest.kt b/src/test/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQueryTest.kt new file mode 100644 index 00000000..b7be3832 --- /dev/null +++ b/src/test/kotlin/org/koitharu/kotatsu/parsers/model/search/MangaSearchQueryTest.kt @@ -0,0 +1,72 @@ +package org.koitharu.kotatsu.parsers.model.search + +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.* +import org.koitharu.kotatsu.parsers.model.search.SearchableField.* +import java.util.* + +class MangaSearchQueryCapabilitiesTest { + + private val capabilities = MangaSearchQueryCapabilities( + capabilities = setOf( + SearchCapability(TITLE_NAME, setOf(Match::class), multiValue = false, otherCriteria = false), + SearchCapability(TAG, setOf(Include::class, Exclude::class), multiValue = true, otherCriteria = true), + SearchCapability(PUBLICATION_YEAR, setOf(Range::class), multiValue = false, otherCriteria = true), + SearchCapability(STATE, setOf(Include::class), multiValue = false, otherCriteria = true), + ), + ) + + @Test + fun validateValidSingleCriterionQuery() { + val query = MangaSearchQuery.Builder() + .criterion(Match(TITLE_NAME, "title")) + .build() + + assertDoesNotThrow { capabilities.validate(query) } + } + + @Test + fun validateUnsupportedFieldThrowsException() { + val query = MangaSearchQuery.Builder() + .criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.ENGLISH))) + .build() + + assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) } + } + + @Test + fun validateUnsupportedMultiValueThrowsException() { + val query = MangaSearchQuery.Builder() + .criterion(Include(STATE, setOf(MangaState.ONGOING, MangaState.FINISHED))) + .build() + + assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) } + } + + @Test + fun validateMultipleCriteriaWithOtherCriteriaAllowed() { + val query = MangaSearchQuery.Builder() + .criterion(Include(TAG, setOf(buildTag("tag1"), buildTag("tag2")))) + .criterion(Exclude(TAG, setOf(buildTag("tag3")))) + .build() + + assertDoesNotThrow { capabilities.validate(query) } + } + + @Test + fun validateMultipleCriteriaWithStrictCapabilityThrowsException() { + val query = MangaSearchQuery.Builder() + .criterion(Match(TITLE_NAME, "title")) + .criterion(Range(PUBLICATION_YEAR, 1990, 2000)) + .build() + + assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) } + } + + private fun buildTag(name: String) = MangaTag(title = name, key = "${name}Key", source = MangaParserSource.DUMMY) +} diff --git a/src/test/kotlin/org/koitharu/kotatsu/parsers/util/ListFilterToSearchQueryConverterTest.kt b/src/test/kotlin/org/koitharu/kotatsu/parsers/util/ListFilterToSearchQueryConverterTest.kt new file mode 100644 index 00000000..04530bec --- /dev/null +++ b/src/test/kotlin/org/koitharu/kotatsu/parsers/util/ListFilterToSearchQueryConverterTest.kt @@ -0,0 +1,77 @@ +package org.koitharu.kotatsu.parsers.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.model.ContentType.MANGA +import org.koitharu.kotatsu.parsers.model.ContentType.MANHUA +import org.koitharu.kotatsu.parsers.model.Demographic.SEINEN +import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery +import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.* +import org.koitharu.kotatsu.parsers.model.search.SearchableField.* +import java.util.* + +class ListFilterToSearchQueryConverterTest { + + @Test + fun convertToMangaSearchQueryTest() { + val tags = setOf(buildMangaTag("tag1"), buildMangaTag("tag2")) + val excludedTags = setOf(buildMangaTag("exclude_tag")) + val states = setOf(MangaState.ONGOING) + val contentRatings = setOf(ContentRating.SAFE) + val contentTypes = setOf(MANGA, MANHUA) + val demographics = setOf(SEINEN) + + val filter = MangaListFilter( + query = "title_name", + tags = tags, + tagsExclude = excludedTags, + locale = Locale.ENGLISH, + originalLocale = Locale.JAPANESE, + states = states, + contentRating = contentRatings, + types = contentTypes, + demographics = demographics, + year = 2020, + yearFrom = 1997, + yearTo = 2024, + ) + + val searchQuery = convertToMangaSearchQuery(0, SortOrder.NEWEST, filter) + + val expectedQuery = MangaSearchQuery.Builder() + .offset(0) + .order(SortOrder.NEWEST) + .criterion(Match(TITLE_NAME, "title_name")) + .criterion(Include(TAG, tags)) + .criterion(Exclude(TAG, excludedTags)) + .criterion(Include(LANGUAGE, setOf(Locale.ENGLISH))) + .criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.JAPANESE))) + .criterion(Include(STATE, states)) + .criterion(Include(CONTENT_RATING, contentRatings)) + .criterion(Include(CONTENT_TYPE, contentTypes)) + .criterion(Include(DEMOGRAPHIC, demographics)) + .criterion(Range(PUBLICATION_YEAR, 1997, 2024)) + .criterion(Match(PUBLICATION_YEAR, 2020)) + .build() + + assertEquals(expectedQuery, searchQuery) + } + + @Test + fun convertToMangaSearchQueryWithEmptyFieldsTest() { + val filter = MangaListFilter() + + val searchQuery = convertToMangaSearchQuery(0, SortOrder.NEWEST, filter) + + assertEquals(MangaSearchQuery.Builder().offset(0).order(SortOrder.NEWEST).build(), searchQuery) + } + + private fun buildMangaTag(name: String): MangaTag { + return MangaTag( + key = "${name}Key", + title = name, + source = MangaParserSource.DUMMY, + ) + } +} diff --git a/src/test/kotlin/org/koitharu/kotatsu/parsers/util/SearchQueryToListFilterConverterTest.kt b/src/test/kotlin/org/koitharu/kotatsu/parsers/util/SearchQueryToListFilterConverterTest.kt new file mode 100644 index 00000000..ef282378 --- /dev/null +++ b/src/test/kotlin/org/koitharu/kotatsu/parsers/util/SearchQueryToListFilterConverterTest.kt @@ -0,0 +1,94 @@ +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.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.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.* + +class ConvertToMangaListFilterTest { + + @Test + fun convertToMangaListFilterTest() { + val tags = setOf(buildMangaTag("tag1"), buildMangaTag("tag2")) + val excludedTags = setOf(buildMangaTag("exclude_tag")) + val states = setOf(MangaState.ONGOING) + val contentRatings = setOf(ContentRating.SAFE) + val contentTypes = setOf(MANGA, MANHUA) + val demographics = setOf(SEINEN) + + val query = MangaSearchQuery.Builder() + .criterion(Match(TITLE_NAME, "title_name")) + .criterion(Include(TAG, tags)) + .criterion(Exclude(TAG, excludedTags)) + .criterion(Include(LANGUAGE, setOf(Locale.ENGLISH))) + .criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.JAPANESE))) + .criterion(Include(STATE, states)) + .criterion(Include(CONTENT_RATING, contentRatings)) + .criterion(Include(CONTENT_TYPE, contentTypes)) + .criterion(Include(DEMOGRAPHIC, demographics)) + .criterion(Range(PUBLICATION_YEAR, 1997, 2024)) + .criterion(Match(PUBLICATION_YEAR, 2020)) + .build() + + val listFilter = convertToMangaListFilter(query) + + assertEquals(listFilter.query, "title_name") + assertEquals(listFilter.tags, tags) + assertEquals(listFilter.tagsExclude, excludedTags) + assertEquals(listFilter.locale, Locale.ENGLISH) + assertEquals(listFilter.originalLocale, Locale.JAPANESE) + assertEquals(listFilter.states, states) + assertEquals(listFilter.contentRating, contentRatings) + assertEquals(listFilter.types, contentTypes) + assertEquals(listFilter.demographics, demographics) + assertEquals(listFilter.year, 2020) + assertEquals(listFilter.yearFrom, 1997) + assertEquals(listFilter.yearTo, 2024) + } + + @Test + fun convertToMangaListFilterWithMultipleTagsIncludeTest() { + val tags1 = setOf(buildMangaTag("tag1"), buildMangaTag("tag2")) + val tags2 = setOf(buildMangaTag("tag3"), buildMangaTag("tag4")) + + val query = MangaSearchQuery.Builder() + .criterion(Include(TAG, tags1)) + .criterion(Include(TAG, tags2)) + .build() + + val listFilter = convertToMangaListFilter(query) + + assertEquals(listFilter.tags, tags1 union tags2) + } + + @Test + fun convertToMangaListFilterWithUnsupportedFieldTest() { + val query = MangaSearchQuery.Builder() + .criterion(Include(AUTHOR, setOf(buildMangaTag("author")))) + .build() + + val exception = assertThrows { + convertToMangaListFilter(query) + } + + assert(exception.message!!.contains("Unsupported field for Include criterion: AUTHOR")) + } + + private fun buildMangaTag(name: String): MangaTag { + return MangaTag( + key = "${name}Key", + title = name, + source = MangaParserSource.DUMMY, + ) + } +}