Merge branch 'feature/search_query'
commit
cdbb004ca1
@ -1,116 +1,81 @@
|
|||||||
package org.koitharu.kotatsu.parsers
|
package org.koitharu.kotatsu.parsers
|
||||||
|
|
||||||
import androidx.annotation.CallSuper
|
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.Interceptor
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
import org.koitharu.kotatsu.parsers.network.OkHttpWebClient
|
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
||||||
import org.koitharu.kotatsu.parsers.network.WebClient
|
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
|
||||||
import org.koitharu.kotatsu.parsers.util.FaviconParser
|
|
||||||
import org.koitharu.kotatsu.parsers.util.LinkResolver
|
import org.koitharu.kotatsu.parsers.util.LinkResolver
|
||||||
import org.koitharu.kotatsu.parsers.util.RelatedMangaFinder
|
import org.koitharu.kotatsu.parsers.util.convertToMangaSearchQuery
|
||||||
import org.koitharu.kotatsu.parsers.util.domain
|
import org.koitharu.kotatsu.parsers.util.toMangaListFilterCapabilities
|
||||||
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
public abstract class MangaParser @InternalParsersApi constructor(
|
public interface MangaParser : Interceptor {
|
||||||
@property:InternalParsersApi public val context: MangaLoaderContext,
|
|
||||||
public val source: MangaParserSource,
|
public val source: MangaParserSource
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supported [SortOrder] variants. Must not be empty.
|
* Supported [SortOrder] variants. Must not be empty.
|
||||||
*
|
*
|
||||||
* For better performance use [EnumSet] for more than one item.
|
* For better performance use [EnumSet] for more than one item.
|
||||||
*/
|
*/
|
||||||
public abstract val availableSortOrders: Set<SortOrder>
|
public val availableSortOrders: Set<SortOrder>
|
||||||
|
|
||||||
public abstract val filterCapabilities: MangaListFilterCapabilities
|
|
||||||
|
|
||||||
public val config: MangaSourceConfig by lazy { context.getConfig(source) }
|
|
||||||
|
|
||||||
public open val sourceLocale: Locale
|
|
||||||
get() = if (source.locale.isEmpty()) Locale.ROOT else Locale(source.locale)
|
|
||||||
|
|
||||||
protected val isNsfwSource: Boolean = source.contentType == ContentType.HENTAI
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provide default domain and available alternatives, if any.
|
|
||||||
*
|
|
||||||
* Never hardcode domain in requests, use [domain] instead.
|
|
||||||
*/
|
|
||||||
@InternalParsersApi
|
|
||||||
public abstract val configKeyDomain: ConfigKey.Domain
|
|
||||||
|
|
||||||
protected open val userAgentKey: ConfigKey.UserAgent = ConfigKey.UserAgent(context.getDefaultUserAgent())
|
|
||||||
|
|
||||||
public open fun getRequestHeaders(): Headers = Headers.Builder()
|
public val searchQueryCapabilities: MangaSearchQueryCapabilities
|
||||||
.add("User-Agent", config[userAgentKey])
|
|
||||||
.build()
|
|
||||||
|
|
||||||
/**
|
public val config: MangaSourceConfig
|
||||||
* Used as fallback if value of `order` passed to [getList] is null
|
|
||||||
*/
|
|
||||||
public open val defaultSortOrder: SortOrder
|
|
||||||
get() {
|
|
||||||
val supported = availableSortOrders
|
|
||||||
return SortOrder.entries.first { it in supported }
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmField
|
public val domain: String
|
||||||
protected val webClient: WebClient = OkHttpWebClient(context.httpClient, source)
|
|
||||||
|
|
||||||
/**
|
public suspend fun getList(query: MangaSearchQuery): List<Manga>
|
||||||
* Parse list of manga by specified criteria
|
|
||||||
*
|
|
||||||
* @param offset starting from 0 and used for pagination.
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
public abstract suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse details for [Manga]: chapters list, description, large cover, etc.
|
* Parse details for [Manga]: chapters list, description, large cover, etc.
|
||||||
* Must return the same manga, may change any fields excepts id, url and source
|
* Must return the same manga, may change any fields excepts id, url and source
|
||||||
* @see Manga.copy
|
* @see Manga.copy
|
||||||
*/
|
*/
|
||||||
public abstract suspend fun getDetails(manga: Manga): Manga
|
public suspend fun getDetails(manga: Manga): Manga
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse pages list for specified chapter.
|
* Parse pages list for specified chapter.
|
||||||
* @see MangaPage for details
|
* @see MangaPage for details
|
||||||
*/
|
*/
|
||||||
public abstract suspend fun getPages(chapter: MangaChapter): List<MangaPage>
|
public suspend fun getPages(chapter: MangaChapter): List<MangaPage>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch direct link to the page image.
|
* Fetch direct link to the page image.
|
||||||
*/
|
*/
|
||||||
public open suspend fun getPageUrl(page: MangaPage): String = page.url.toAbsoluteUrl(domain)
|
public suspend fun getPageUrl(page: MangaPage): String
|
||||||
|
|
||||||
public abstract suspend fun getFilterOptions(): MangaListFilterOptions
|
public suspend fun getFilterOptions(): MangaListFilterOptions
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse favicons from the main page of the source`s website
|
* Parse favicons from the main page of the source`s website
|
||||||
*/
|
*/
|
||||||
public open suspend fun getFavicons(): Favicons {
|
public suspend fun getFavicons(): Favicons
|
||||||
return FaviconParser(webClient, domain).parseFavicons()
|
|
||||||
}
|
|
||||||
|
|
||||||
@CallSuper
|
public fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>)
|
||||||
public open fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
|
|
||||||
keys.add(configKeyDomain)
|
|
||||||
}
|
|
||||||
|
|
||||||
public open suspend fun getRelatedManga(seed: Manga): List<Manga> {
|
public suspend fun getRelatedManga(seed: Manga): List<Manga>
|
||||||
return RelatedMangaFinder(listOf(this)).invoke(seed)
|
|
||||||
}
|
public fun getRequestHeaders(): Headers
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return [Manga] object by web link to it
|
* Return [Manga] object by web link to it
|
||||||
* @see [Manga.publicUrl]
|
* @see [Manga.publicUrl]
|
||||||
*/
|
*/
|
||||||
internal open suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? = null
|
@InternalParsersApi
|
||||||
|
public suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga?
|
||||||
|
|
||||||
|
@Deprecated("Use getList(query: MangaSearchQuery) instead")
|
||||||
|
public suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
|
||||||
|
return getList(convertToMangaSearchQuery(offset, order, filter))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Please check searchQueryCapabilities")
|
||||||
|
public val filterCapabilities: MangaListFilterCapabilities
|
||||||
|
get() = searchQueryCapabilities.toMangaListFilterCapabilities()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,91 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.core
|
||||||
|
|
||||||
|
import androidx.annotation.CallSuper
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
|
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.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.toAbsoluteUrl
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@InternalParsersApi
|
||||||
|
public abstract class AbstractMangaParser @InternalParsersApi constructor(
|
||||||
|
@property:InternalParsersApi public val context: MangaLoaderContext,
|
||||||
|
public override val source: MangaParserSource,
|
||||||
|
) : MangaParser {
|
||||||
|
|
||||||
|
public override val config: MangaSourceConfig by lazy { context.getConfig(source) }
|
||||||
|
|
||||||
|
public open val sourceLocale: Locale
|
||||||
|
get() = if (source.locale.isEmpty()) Locale.ROOT else Locale(source.locale)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide default domain and available alternatives, if any.
|
||||||
|
*
|
||||||
|
* Never hardcode domain in requests, use [domain] instead.
|
||||||
|
*/
|
||||||
|
@InternalParsersApi
|
||||||
|
public abstract val configKeyDomain: ConfigKey.Domain
|
||||||
|
|
||||||
|
protected open val userAgentKey: ConfigKey.UserAgent = ConfigKey.UserAgent(context.getDefaultUserAgent())
|
||||||
|
|
||||||
|
@Deprecated("Override intercept() instead")
|
||||||
|
override fun getRequestHeaders(): Headers = Headers.Builder()
|
||||||
|
.add("User-Agent", config[userAgentKey])
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used as fallback if value of `order` passed to [getList] is null
|
||||||
|
*/
|
||||||
|
public open val defaultSortOrder: SortOrder
|
||||||
|
get() {
|
||||||
|
val supported = availableSortOrders
|
||||||
|
return SortOrder.entries.first { it in supported }
|
||||||
|
}
|
||||||
|
|
||||||
|
override val domain: String
|
||||||
|
get() = config[configKeyDomain]
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
protected val webClient: WebClient = OkHttpWebClient(context.httpClient, source)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch direct link to the page image.
|
||||||
|
*/
|
||||||
|
public override suspend fun getPageUrl(page: MangaPage): String = page.url.toAbsoluteUrl(domain)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse favicons from the main page of the source`s website
|
||||||
|
*/
|
||||||
|
public override suspend fun getFavicons(): Favicons {
|
||||||
|
return FaviconParser(webClient, domain).parseFavicons()
|
||||||
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
public override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
|
||||||
|
keys.add(configKeyDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
public override suspend fun getRelatedManga(seed: Manga): List<Manga> {
|
||||||
|
return RelatedMangaFinder(listOf(this)).invoke(seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return [Manga] object by web link to it
|
||||||
|
* @see [Manga.publicUrl]
|
||||||
|
*/
|
||||||
|
override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? = null
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request())
|
||||||
|
}
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.core
|
||||||
|
|
||||||
|
import androidx.annotation.CallSuper
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
|
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.MangaSearchQuery
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
|
||||||
|
import org.koitharu.kotatsu.parsers.network.OkHttpWebClient
|
||||||
|
import org.koitharu.kotatsu.parsers.network.WebClient
|
||||||
|
import org.koitharu.kotatsu.parsers.util.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@Suppress("OVERRIDE_DEPRECATION")
|
||||||
|
@InternalParsersApi
|
||||||
|
public abstract class LegacyMangaParser @InternalParsersApi constructor(
|
||||||
|
@property:InternalParsersApi public val context: MangaLoaderContext,
|
||||||
|
public override val source: MangaParserSource,
|
||||||
|
) : MangaParser {
|
||||||
|
|
||||||
|
public final override val searchQueryCapabilities: MangaSearchQueryCapabilities
|
||||||
|
get() = filterCapabilities.toMangaSearchQueryCapabilities()
|
||||||
|
|
||||||
|
abstract override val filterCapabilities: MangaListFilterCapabilities
|
||||||
|
|
||||||
|
public override val config: MangaSourceConfig by lazy { context.getConfig(source) }
|
||||||
|
|
||||||
|
public open val sourceLocale: Locale
|
||||||
|
get() = if (source.locale.isEmpty()) Locale.ROOT else Locale(source.locale)
|
||||||
|
|
||||||
|
protected val isNsfwSource: Boolean = source.contentType == ContentType.HENTAI
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide default domain and available alternatives, if any.
|
||||||
|
*
|
||||||
|
* Never hardcode domain in requests, use [domain] instead.
|
||||||
|
*/
|
||||||
|
@InternalParsersApi
|
||||||
|
public abstract val configKeyDomain: ConfigKey.Domain
|
||||||
|
|
||||||
|
protected open val userAgentKey: ConfigKey.UserAgent = ConfigKey.UserAgent(context.getDefaultUserAgent())
|
||||||
|
|
||||||
|
override fun getRequestHeaders(): Headers = Headers.Builder()
|
||||||
|
.add("User-Agent", config[userAgentKey])
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used as fallback if value of `order` passed to [getList] is null
|
||||||
|
*/
|
||||||
|
public open val defaultSortOrder: SortOrder
|
||||||
|
get() {
|
||||||
|
val supported = availableSortOrders
|
||||||
|
return SortOrder.entries.first { it in supported }
|
||||||
|
}
|
||||||
|
|
||||||
|
override val domain: String
|
||||||
|
get() = config[configKeyDomain]
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
protected val webClient: WebClient = OkHttpWebClient(context.httpClient, source)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search list of manga by specified searchQuery
|
||||||
|
*
|
||||||
|
* @param query searchQuery
|
||||||
|
*/
|
||||||
|
public final override suspend fun getList(query: MangaSearchQuery): List<Manga> = getList(
|
||||||
|
offset = query.offset,
|
||||||
|
order = query.order ?: defaultSortOrder,
|
||||||
|
filter = convertToMangaListFilter(query),
|
||||||
|
)
|
||||||
|
|
||||||
|
abstract override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch direct link to the page image.
|
||||||
|
*/
|
||||||
|
public override suspend fun getPageUrl(page: MangaPage): String = page.url.toAbsoluteUrl(domain)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse favicons from the main page of the source`s website
|
||||||
|
*/
|
||||||
|
public override suspend fun getFavicons(): Favicons {
|
||||||
|
return FaviconParser(webClient, domain).parseFavicons()
|
||||||
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
public override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
|
||||||
|
keys.add(configKeyDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
public override suspend fun getRelatedManga(seed: Manga): List<Manga> {
|
||||||
|
return RelatedMangaFinder(listOf(this)).invoke(seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return [Manga] object by web link to it
|
||||||
|
* @see [Manga.publicUrl]
|
||||||
|
*/
|
||||||
|
override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? = null
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request())
|
||||||
|
}
|
||||||
@ -1,15 +1,17 @@
|
|||||||
package org.koitharu.kotatsu.parsers
|
package org.koitharu.kotatsu.parsers.core
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
|
||||||
@InternalParsersApi
|
@InternalParsersApi
|
||||||
public abstract class SinglePageMangaParser(
|
public abstract class LegacySinglePageMangaParser(
|
||||||
context: MangaLoaderContext,
|
context: MangaLoaderContext,
|
||||||
source: MangaParserSource,
|
source: MangaParserSource,
|
||||||
) : MangaParser(context, source) {
|
) : LegacyMangaParser(context, source) {
|
||||||
|
|
||||||
final override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
|
final override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
|
||||||
if (offset > 0) {
|
if (offset > 0) {
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.core
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||||
|
|
||||||
|
internal class MangaParserWrapper(
|
||||||
|
private val delegate: MangaParser,
|
||||||
|
) : MangaParser by delegate {
|
||||||
|
|
||||||
|
override suspend fun getList(searchQuery: MangaSearchQuery): List<Manga> = withContext(Dispatchers.Default) {
|
||||||
|
if (!searchQuery.skipValidation) {
|
||||||
|
searchQueryCapabilities.validate(searchQuery)
|
||||||
|
}
|
||||||
|
delegate.getList(searchQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga = withContext(Dispatchers.Default) {
|
||||||
|
delegate.getDetails(manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = withContext(Dispatchers.Default) {
|
||||||
|
delegate.getPages(chapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPageUrl(page: MangaPage): String = withContext(Dispatchers.Default) {
|
||||||
|
delegate.getPageUrl(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getFilterOptions(): MangaListFilterOptions = withContext(Dispatchers.Default) {
|
||||||
|
delegate.getFilterOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getFavicons(): Favicons = withContext(Dispatchers.Default) {
|
||||||
|
delegate.getFavicons()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getRelatedManga(seed: Manga): List<Manga> = withContext(Dispatchers.Default) {
|
||||||
|
delegate.getRelatedManga(seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val headers = request.headers.newBuilder()
|
||||||
|
.mergeWith(delegate.getRequestHeaders(), replaceExisting = false)
|
||||||
|
.build()
|
||||||
|
val newRequest = request.newBuilder().headers(headers).build()
|
||||||
|
return delegate.intercept(ProxyChain(chain, newRequest))
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ProxyChain(
|
||||||
|
private val delegate: Interceptor.Chain,
|
||||||
|
private val request: Request,
|
||||||
|
) : Interceptor.Chain by delegate {
|
||||||
|
|
||||||
|
override fun request(): Request = request
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.core
|
||||||
|
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.SearchableField
|
||||||
|
import org.koitharu.kotatsu.parsers.util.Paginator
|
||||||
|
|
||||||
|
@InternalParsersApi
|
||||||
|
public abstract class PagedMangaParser(
|
||||||
|
context: MangaLoaderContext,
|
||||||
|
source: MangaParserSource,
|
||||||
|
@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) @JvmField public val pageSize: Int,
|
||||||
|
searchPageSize: Int = pageSize,
|
||||||
|
) : AbstractMangaParser(context, source) {
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
protected val paginator: Paginator = Paginator(pageSize)
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
protected val searchPaginator: Paginator = Paginator(searchPageSize)
|
||||||
|
|
||||||
|
final override suspend fun getList(query: MangaSearchQuery): List<Manga> {
|
||||||
|
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 abstract suspend fun getListPage(query: MangaSearchQuery, page: Int): List<Manga>
|
||||||
|
|
||||||
|
private suspend fun searchManga(
|
||||||
|
paginator: Paginator,
|
||||||
|
query: MangaSearchQuery,
|
||||||
|
): List<Manga> {
|
||||||
|
val offset: Int = query.offset
|
||||||
|
val page = paginator.getPage(offset)
|
||||||
|
val list = getListPage(query, page)
|
||||||
|
paginator.onListReceived(offset, page, list.size)
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.core
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
||||||
|
|
||||||
|
@InternalParsersApi
|
||||||
|
public abstract class SinglePageMangaParser(
|
||||||
|
context: MangaLoaderContext,
|
||||||
|
source: MangaParserSource,
|
||||||
|
) : AbstractMangaParser(context, source) {
|
||||||
|
|
||||||
|
final override suspend fun getList(query: MangaSearchQuery): List<Manga> {
|
||||||
|
if (query.offset > 0) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
return getSinglePageList(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract suspend fun getSinglePageList(searchQuery: MangaSearchQuery): List<Manga>
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
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<QueryCriteria<*>>,
|
||||||
|
@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<QueryCriteria<*>>()
|
||||||
|
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<QueryCriteria<*>>): Set<QueryCriteria<*>> {
|
||||||
|
val uniqueCriteria =
|
||||||
|
ArrayMap<Pair<SearchableField, Class<out QueryCriteria<*>>>, 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
|
||||||
|
public val EMPTY: MangaSearchQuery = MangaSearchQuery(emptySet(), null, 0, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
public val capabilities: Set<SearchCapability>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<T> {
|
||||||
|
|
||||||
|
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 data class Exclude<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 data class Range<T : Comparable<T>>(
|
||||||
|
public override val field: SearchableField,
|
||||||
|
@JvmField public val from: T,
|
||||||
|
@JvmField public val to: T,
|
||||||
|
) : QueryCriteria<T> {
|
||||||
|
|
||||||
|
init {
|
||||||
|
check(field.type.isInstance(from))
|
||||||
|
check(field.type.isInstance(to))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public data class Match<T : Any>(
|
||||||
|
public override val field: SearchableField,
|
||||||
|
@JvmField public val value: T,
|
||||||
|
) : QueryCriteria<T> {
|
||||||
|
|
||||||
|
init {
|
||||||
|
check(field.type.isInstance(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.model.search
|
||||||
|
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
public data class SearchCapability (
|
||||||
|
@JvmField public val field: SearchableField,
|
||||||
|
@JvmField public val criteriaTypes: Set<KClass<out QueryCriteria<*>>>,
|
||||||
|
@JvmField public val multiValue: Boolean,
|
||||||
|
@JvmField public val otherCriteria: Boolean,
|
||||||
|
)
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.model.search
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue