Multiple tags support in (almost) all sources #19
This commit is contained in:
@@ -6,5 +6,5 @@ import kotlinx.parcelize.Parcelize
|
|||||||
@Parcelize
|
@Parcelize
|
||||||
data class MangaFilter(
|
data class MangaFilter(
|
||||||
val sortOrder: SortOrder?,
|
val sortOrder: SortOrder?,
|
||||||
val tag: MangaTag?,
|
val tags: Set<MangaTag>,
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
@@ -9,24 +9,12 @@ interface MangaRepository {
|
|||||||
|
|
||||||
val sortOrders: Set<SortOrder>
|
val sortOrders: Set<SortOrder>
|
||||||
|
|
||||||
suspend fun getList(
|
suspend fun getList2(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String? = null,
|
query: String? = null,
|
||||||
tags: Set<MangaTag>? = null,
|
tags: Set<MangaTag>? = null,
|
||||||
sortOrder: SortOrder? = null,
|
sortOrder: SortOrder? = null,
|
||||||
): List<Manga> = if (tags == null || tags.size <= 1) {
|
): List<Manga>
|
||||||
getList(offset, query, sortOrder, tags?.singleOrNull())
|
|
||||||
} else {
|
|
||||||
throw NotImplementedError("Multiple filter are not supported by this source yet")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Deprecated("Use multiple tag variant")
|
|
||||||
suspend fun getList(
|
|
||||||
offset: Int,
|
|
||||||
query: String? = null,
|
|
||||||
sortOrder: SortOrder? = null,
|
|
||||||
tag: MangaTag? = null,
|
|
||||||
): List<Manga> = throw NotImplementedError("This is fine")
|
|
||||||
|
|
||||||
suspend fun getDetails(manga: Manga): Manga
|
suspend fun getDetails(manga: Manga): Manga
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
|||||||
SortOrder.NEWEST
|
SortOrder.NEWEST
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList2(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
|
|||||||
SortOrder.ALPHABETICAL
|
SortOrder.ALPHABETICAL
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList2(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
sortOrder: SortOrder?,
|
tags: Set<MangaTag>?,
|
||||||
tag: MangaTag?
|
sortOrder: SortOrder?
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
val domain = getDomain()
|
val domain = getDomain()
|
||||||
val url = when {
|
val url = when {
|
||||||
@@ -31,7 +31,11 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
|
|||||||
}
|
}
|
||||||
"https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}"
|
"https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}"
|
||||||
}
|
}
|
||||||
tag != null -> "https://$domain/tags/${tag.key}&n=${getSortKey2(sortOrder)}?offset=$offset"
|
!tags.isNullOrEmpty() -> tags.joinToString(
|
||||||
|
prefix = "https://$domain/tags/",
|
||||||
|
postfix = "&n=${getSortKey2(sortOrder)}?offset=$offset",
|
||||||
|
separator = "+",
|
||||||
|
) { tag -> tag.key }
|
||||||
else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset"
|
else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset"
|
||||||
}
|
}
|
||||||
val doc = loaderContext.httpGet(url).parseHtml()
|
val doc = loaderContext.httpGet(url).parseHtml()
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
|||||||
SortOrder.ALPHABETICAL
|
SortOrder.ALPHABETICAL
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList2(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
sortOrder: SortOrder?,
|
tags: Set<MangaTag>?,
|
||||||
tag: MangaTag?
|
sortOrder: SortOrder?
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
if (query != null && offset != 0) {
|
if (query != null && offset != 0) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
@@ -37,9 +37,9 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
|||||||
append(getSortKey(sortOrder))
|
append(getSortKey(sortOrder))
|
||||||
append("&page=")
|
append("&page=")
|
||||||
append((offset / 20) + 1)
|
append((offset / 20) + 1)
|
||||||
if (tag != null) {
|
if (!tags.isNullOrEmpty()) {
|
||||||
append("&genres=")
|
append("&genres=")
|
||||||
append(tag.key)
|
appendAll(tags, ",") { it.key }
|
||||||
}
|
}
|
||||||
if (query != null) {
|
if (query != null) {
|
||||||
append("&search=")
|
append("&search=")
|
||||||
|
|||||||
@@ -32,14 +32,7 @@ class ExHentaiRepository(
|
|||||||
loaderContext.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2")
|
loaderContext.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2")
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList2(
|
||||||
offset: Int,
|
|
||||||
query: String?,
|
|
||||||
sortOrder: SortOrder?,
|
|
||||||
tag: MangaTag?,
|
|
||||||
): List<Manga> = getList(offset, query, setOfNotNull(tag), sortOrder)
|
|
||||||
|
|
||||||
override suspend fun getList(
|
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
@@ -80,7 +73,7 @@ class ExHentaiRepository(
|
|||||||
parseFailed("Cannot find root")
|
parseFailed("Cannot find root")
|
||||||
} else {
|
} else {
|
||||||
updateDm = true
|
updateDm = true
|
||||||
return getList(offset, query, tags, sortOrder)
|
return getList2(offset, query, tags, sortOrder)
|
||||||
}
|
}
|
||||||
updateDm = false
|
updateDm = false
|
||||||
return root.children().mapNotNull { tr ->
|
return root.children().mapNotNull { tr ->
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core.parser.site
|
package org.koitharu.kotatsu.core.parser.site
|
||||||
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Response
|
||||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.core.exceptions.ParseException
|
import org.koitharu.kotatsu.core.exceptions.ParseException
|
||||||
import org.koitharu.kotatsu.core.model.*
|
import org.koitharu.kotatsu.core.model.*
|
||||||
@@ -18,11 +19,11 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
|||||||
SortOrder.RATING
|
SortOrder.RATING
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList2(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
sortOrder: SortOrder?,
|
tags: Set<MangaTag>?,
|
||||||
tag: MangaTag?
|
sortOrder: SortOrder?
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
val domain = getDomain()
|
val domain = getDomain()
|
||||||
val doc = when {
|
val doc = when {
|
||||||
@@ -33,22 +34,24 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
|||||||
"offset" to (offset upBy PAGE_SIZE_SEARCH).toString()
|
"offset" to (offset upBy PAGE_SIZE_SEARCH).toString()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
tag == null -> loaderContext.httpGet(
|
tags.isNullOrEmpty() -> loaderContext.httpGet(
|
||||||
"https://$domain/list?sortType=${
|
"https://$domain/list?sortType=${
|
||||||
getSortKey(
|
getSortKey(
|
||||||
sortOrder
|
sortOrder
|
||||||
)
|
)
|
||||||
}&offset=${offset upBy PAGE_SIZE}"
|
}&offset=${offset upBy PAGE_SIZE}"
|
||||||
)
|
)
|
||||||
else -> loaderContext.httpGet(
|
tags.size == 1 -> loaderContext.httpGet(
|
||||||
"https://$domain/list/genre/${tag.key}?sortType=${
|
"https://$domain/list/genre/${tags.first().key}?sortType=${
|
||||||
getSortKey(
|
getSortKey(
|
||||||
sortOrder
|
sortOrder
|
||||||
)
|
)
|
||||||
}&offset=${offset upBy PAGE_SIZE}"
|
}&offset=${offset upBy PAGE_SIZE}"
|
||||||
)
|
)
|
||||||
}.parseHtml()
|
offset > 0 -> return emptyList()
|
||||||
val root = doc.body().getElementById("mangaBox")
|
else -> advancedSearch(domain, tags)
|
||||||
|
}.parseHtml().body()
|
||||||
|
val root = (doc.getElementById("mangaBox") ?: doc.getElementById("mangaResults"))
|
||||||
?.selectFirst("div.tiles.row") ?: throw ParseException("Cannot find root")
|
?.selectFirst("div.tiles.row") ?: throw ParseException("Cannot find root")
|
||||||
val baseHost = root.baseUri().toHttpUrl().host
|
val baseHost = root.baseUri().toHttpUrl().host
|
||||||
return root.select("div.tile").mapNotNull { node ->
|
return root.select("div.tile").mapNotNull { node ->
|
||||||
@@ -182,6 +185,43 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
|||||||
null -> "updated"
|
null -> "updated"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun advancedSearch(domain: String, tags: Set<MangaTag>): Response {
|
||||||
|
val url = "https://$domain/search/advanced"
|
||||||
|
// Step 1: map catalog genres names to advanced-search genres ids
|
||||||
|
val tagsIndex = loaderContext.httpGet(url).parseHtml()
|
||||||
|
.body().selectFirst("form.search-form")
|
||||||
|
?.select("div.form-group")
|
||||||
|
?.get(1) ?: parseFailed("Genres filter element not found")
|
||||||
|
val tagNames = tags.map { it.title.lowercase() }
|
||||||
|
val payload = HashMap<String, String>()
|
||||||
|
var foundGenres = 0
|
||||||
|
tagsIndex.select("li.property").forEach { li ->
|
||||||
|
val name = li.text().trim().lowercase()
|
||||||
|
val id = li.selectFirst("input")?.id()
|
||||||
|
?: parseFailed("Id for tag $name not found")
|
||||||
|
payload[id] = if (name in tagNames) {
|
||||||
|
foundGenres++
|
||||||
|
"in"
|
||||||
|
} else ""
|
||||||
|
}
|
||||||
|
if (foundGenres != tags.size) {
|
||||||
|
parseFailed("Some genres are not found")
|
||||||
|
}
|
||||||
|
// Step 2: advanced search
|
||||||
|
payload["q"] = ""
|
||||||
|
payload["s_high_rate"] = ""
|
||||||
|
payload["s_single"] = ""
|
||||||
|
payload["s_mature"] = ""
|
||||||
|
payload["s_completed"] = ""
|
||||||
|
payload["s_translated"] = ""
|
||||||
|
payload["s_many_chapters"] = ""
|
||||||
|
payload["s_wait_upload"] = ""
|
||||||
|
payload["s_sale"] = ""
|
||||||
|
payload["years"] = "1900,2099"
|
||||||
|
payload["+"] = "Искать".urlEncoded()
|
||||||
|
return loaderContext.httpPost(url, payload)
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|
||||||
private const val PAGE_SIZE = 70
|
private const val PAGE_SIZE = 70
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
|
|||||||
override val defaultDomain = "hentaichan.live"
|
override val defaultDomain = "hentaichan.live"
|
||||||
override val source = MangaSource.HENCHAN
|
override val source = MangaSource.HENCHAN
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList2(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
sortOrder: SortOrder?,
|
tags: Set<MangaTag>?,
|
||||||
tag: MangaTag?
|
sortOrder: SortOrder?
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
return super.getList(offset, query, sortOrder, tag).map {
|
return super.getList2(offset, query, tags, sortOrder).map {
|
||||||
val cover = it.coverUrl
|
val cover = it.coverUrl
|
||||||
if (cover.contains("_blur")) {
|
if (cover.contains("_blur")) {
|
||||||
it.copy(coverUrl = cover.replace("_blur", ""))
|
it.copy(coverUrl = cover.replace("_blur", ""))
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
|||||||
SortOrder.NEWEST
|
SortOrder.NEWEST
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList2(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
sortOrder: SortOrder?,
|
tags: Set<MangaTag>?,
|
||||||
tag: MangaTag?
|
sortOrder: SortOrder?
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
if (!query.isNullOrEmpty()) {
|
if (!query.isNullOrEmpty()) {
|
||||||
return if (offset == 0) search(query) else emptyList()
|
return if (offset == 0) search(query) else emptyList()
|
||||||
@@ -43,8 +43,8 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
|||||||
append(getSortKey(sortOrder))
|
append(getSortKey(sortOrder))
|
||||||
append("&page=")
|
append("&page=")
|
||||||
append(page)
|
append(page)
|
||||||
if (tag != null) {
|
tags?.forEach { tag ->
|
||||||
append("&includeGenres[]=")
|
append("&genres[include][]=")
|
||||||
append(tag.key)
|
append(tag.key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
|||||||
SortOrder.UPDATED
|
SortOrder.UPDATED
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList2(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
sortOrder: SortOrder?,
|
tags: Set<MangaTag>?,
|
||||||
tag: MangaTag?
|
sortOrder: SortOrder?
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
val sortKey = when (sortOrder) {
|
val sortKey = when (sortOrder) {
|
||||||
SortOrder.ALPHABETICAL -> "?name.az"
|
SortOrder.ALPHABETICAL -> "?name.az"
|
||||||
@@ -43,8 +43,13 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
|||||||
}
|
}
|
||||||
"/search?name=${query.urlEncoded()}".withDomain()
|
"/search?name=${query.urlEncoded()}".withDomain()
|
||||||
}
|
}
|
||||||
tag != null -> "/directory/${tag.key}/$page.htm$sortKey".withDomain()
|
tags.isNullOrEmpty() -> "/directory/$page.htm$sortKey".withDomain()
|
||||||
else -> "/directory/$page.htm$sortKey".withDomain()
|
tags.size == 1 -> "/directory/${tags.first().key}/$page.htm$sortKey".withDomain()
|
||||||
|
else -> tags.joinToString(
|
||||||
|
prefix = "/search?page=$page".withDomain()
|
||||||
|
) { tag ->
|
||||||
|
"&genres[${tag.key}]=1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val doc = loaderContext.httpGet(url).parseHtml()
|
val doc = loaderContext.httpGet(url).parseHtml()
|
||||||
val root = doc.body().selectFirst("ul.manga_pic_list")
|
val root = doc.body().selectFirst("ul.manga_pic_list")
|
||||||
|
|||||||
@@ -20,12 +20,17 @@ class MangareadRepository(
|
|||||||
SortOrder.POPULARITY
|
SortOrder.POPULARITY
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList2(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
sortOrder: SortOrder?,
|
tags: Set<MangaTag>?,
|
||||||
tag: MangaTag?
|
sortOrder: SortOrder?
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
|
val tag = when {
|
||||||
|
tags.isNullOrEmpty() -> null
|
||||||
|
tags.size == 1 -> tags.first()
|
||||||
|
else -> throw NotImplementedError("Multiple genres are not supported by this source")
|
||||||
|
}
|
||||||
val payload = createRequestTemplate()
|
val payload = createRequestTemplate()
|
||||||
payload["page"] = (offset / PAGE_SIZE.toFloat()).toIntUp().toString()
|
payload["page"] = (offset / PAGE_SIZE.toFloat()).toIntUp().toString()
|
||||||
payload["vars[meta_key]"] = when (sortOrder) {
|
payload["vars[meta_key]"] = when (sortOrder) {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ abstract class NineMangaRepository(
|
|||||||
SortOrder.POPULARITY,
|
SortOrder.POPULARITY,
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList2(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
@@ -146,7 +146,7 @@ abstract class NineMangaRepository(
|
|||||||
val cateId = li.attr("cate_id") ?: return@mapNotNullToSet null
|
val cateId = li.attr("cate_id") ?: return@mapNotNullToSet null
|
||||||
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
|
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
|
||||||
MangaTag(
|
MangaTag(
|
||||||
title = a.text(),
|
title = a.text().toTitleCase(),
|
||||||
key = cateId,
|
key = cateId,
|
||||||
source = source
|
source = source
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import org.koitharu.kotatsu.core.model.*
|
|||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
import org.koitharu.kotatsu.utils.ext.*
|
import org.koitharu.kotatsu.utils.ext.*
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.collections.ArrayList
|
|
||||||
|
|
||||||
class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||||
|
|
||||||
@@ -24,11 +23,11 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
|||||||
SortOrder.NEWEST
|
SortOrder.NEWEST
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList2(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
sortOrder: SortOrder?,
|
tags: Set<MangaTag>?,
|
||||||
tag: MangaTag?
|
sortOrder: SortOrder?
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
val domain = getDomain()
|
val domain = getDomain()
|
||||||
val urlBuilder = StringBuilder()
|
val urlBuilder = StringBuilder()
|
||||||
@@ -40,8 +39,9 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
|||||||
} else {
|
} else {
|
||||||
urlBuilder.append("/api/search/catalog/?ordering=")
|
urlBuilder.append("/api/search/catalog/?ordering=")
|
||||||
.append(getSortKey(sortOrder))
|
.append(getSortKey(sortOrder))
|
||||||
if (tag != null) {
|
tags?.forEach { tag ->
|
||||||
urlBuilder.append("&genres=" + tag.key)
|
urlBuilder.append("&genres=")
|
||||||
|
urlBuilder.append(tag.key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
urlBuilder
|
urlBuilder
|
||||||
|
|||||||
@@ -217,10 +217,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
|||||||
activity?.invalidateOptionsMenu()
|
activity?.invalidateOptionsMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
@CallSuper
|
override fun onFilterChanged(filter: MangaFilter) = Unit
|
||||||
override fun onFilterChanged(filter: MangaFilter) {
|
|
||||||
drawer?.closeDrawers()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
|
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
|
||||||
|
|||||||
@@ -6,19 +6,15 @@ import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
|
|||||||
import org.koitharu.kotatsu.core.model.MangaFilter
|
import org.koitharu.kotatsu.core.model.MangaFilter
|
||||||
import org.koitharu.kotatsu.core.model.MangaTag
|
import org.koitharu.kotatsu.core.model.MangaTag
|
||||||
import org.koitharu.kotatsu.core.model.SortOrder
|
import org.koitharu.kotatsu.core.model.SortOrder
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class FilterAdapter(
|
class FilterAdapter(
|
||||||
sortOrders: List<SortOrder> = emptyList(),
|
private val sortOrders: List<SortOrder> = emptyList(),
|
||||||
tags: List<MangaTag> = emptyList(),
|
private val tags: List<MangaTag> = emptyList(),
|
||||||
state: MangaFilter?,
|
state: MangaFilter?,
|
||||||
private val listener: OnFilterChangedListener
|
private val listener: OnFilterChangedListener
|
||||||
) : RecyclerView.Adapter<BaseViewHolder<*, Boolean, *>>() {
|
) : RecyclerView.Adapter<BaseViewHolder<*, Boolean, *>>() {
|
||||||
|
|
||||||
private val sortOrders = ArrayList<SortOrder>(sortOrders)
|
private var currentState = state ?: MangaFilter(sortOrders.firstOrNull(), emptySet())
|
||||||
private val tags = ArrayList(Collections.singletonList(null) + tags)
|
|
||||||
|
|
||||||
private var currentState = state ?: MangaFilter(sortOrders.firstOrNull(), null)
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) {
|
||||||
VIEW_TYPE_SORT -> FilterSortHolder(parent).apply {
|
VIEW_TYPE_SORT -> FilterSortHolder(parent).apply {
|
||||||
@@ -28,7 +24,7 @@ class FilterAdapter(
|
|||||||
}
|
}
|
||||||
VIEW_TYPE_TAG -> FilterTagHolder(parent).apply {
|
VIEW_TYPE_TAG -> FilterTagHolder(parent).apply {
|
||||||
itemView.setOnClickListener {
|
itemView.setOnClickListener {
|
||||||
setCheckedTag(boundData)
|
setCheckedTag(boundData ?: return@setOnClickListener, !isChecked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> throw IllegalArgumentException("Unknown viewType $viewType")
|
else -> throw IllegalArgumentException("Unknown viewType $viewType")
|
||||||
@@ -44,7 +40,7 @@ class FilterAdapter(
|
|||||||
}
|
}
|
||||||
is FilterTagHolder -> {
|
is FilterTagHolder -> {
|
||||||
val item = tags[position - sortOrders.size]
|
val item = tags[position - sortOrders.size]
|
||||||
holder.bind(item, item == currentState.tag)
|
holder.bind(item, item in currentState.tags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,19 +50,25 @@ class FilterAdapter(
|
|||||||
else -> VIEW_TYPE_TAG
|
else -> VIEW_TYPE_TAG
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCheckedTag(tag: MangaTag?) {
|
fun setCheckedTag(tag: MangaTag, isChecked: Boolean) {
|
||||||
if (tag != currentState.tag) {
|
currentState = if (tag in currentState.tags) {
|
||||||
val oldItemPos = tags.indexOf(currentState.tag)
|
if (!isChecked) {
|
||||||
val newItemPos = tags.indexOf(tag)
|
currentState.copy(tags = currentState.tags - tag)
|
||||||
currentState = currentState.copy(tag = tag)
|
} else {
|
||||||
if (oldItemPos in tags.indices) {
|
return
|
||||||
notifyItemChanged(sortOrders.size + oldItemPos)
|
|
||||||
}
|
}
|
||||||
if (newItemPos in tags.indices) {
|
} else {
|
||||||
notifyItemChanged(sortOrders.size + newItemPos)
|
if (isChecked) {
|
||||||
|
currentState.copy(tags = currentState.tags + tag)
|
||||||
|
} else {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
listener.onFilterChanged(currentState)
|
|
||||||
}
|
}
|
||||||
|
val index = tags.indexOf(tag)
|
||||||
|
if (index in tags.indices) {
|
||||||
|
notifyItemChanged(sortOrders.size + index)
|
||||||
|
}
|
||||||
|
listener.onFilterChanged(currentState)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCheckedSort(sort: SortOrder) {
|
fun setCheckedSort(sort: SortOrder) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class FilterSortHolder(parent: ViewGroup) :
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
override fun onBind(data: SortOrder, extra: Boolean) {
|
override fun onBind(data: SortOrder, extra: Boolean) {
|
||||||
binding.radio.setText(data.titleRes)
|
binding.root.setText(data.titleRes)
|
||||||
binding.radio.isChecked = extra
|
binding.root.isChecked = extra
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,18 +2,20 @@ package org.koitharu.kotatsu.list.ui.filter
|
|||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
|
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
|
||||||
import org.koitharu.kotatsu.core.model.MangaTag
|
import org.koitharu.kotatsu.core.model.MangaTag
|
||||||
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
|
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
|
||||||
|
|
||||||
class FilterTagHolder(parent: ViewGroup) :
|
class FilterTagHolder(parent: ViewGroup) :
|
||||||
BaseViewHolder<MangaTag?, Boolean, ItemCheckableSingleBinding>(
|
BaseViewHolder<MangaTag, Boolean, ItemCheckableMultipleBinding>(
|
||||||
ItemCheckableSingleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
ItemCheckableMultipleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
override fun onBind(data: MangaTag?, extra: Boolean) {
|
val isChecked: Boolean
|
||||||
binding.radio.text = data?.title ?: context.getString(R.string.all)
|
get() = binding.root.isChecked
|
||||||
binding.radio.isChecked = extra
|
|
||||||
|
override fun onBind(data: MangaTag, extra: Boolean) {
|
||||||
|
binding.root.text = data.title
|
||||||
|
binding.root.isChecked = extra
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,11 +25,11 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
|
|||||||
|
|
||||||
private val filenameFilter = CbzFilter()
|
private val filenameFilter = CbzFilter()
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList2(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
sortOrder: SortOrder?,
|
tags: Set<MangaTag>?,
|
||||||
tag: MangaTag?
|
sortOrder: SortOrder?
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
require(offset == 0) {
|
require(offset == 0) {
|
||||||
"LocalMangaRepository does not support pagination"
|
"LocalMangaRepository does not support pagination"
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class LocalListViewModel(
|
|||||||
launchLoadingJob(Dispatchers.Default) {
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
try {
|
try {
|
||||||
listError.value = null
|
listError.value = null
|
||||||
mangaList.value = repository.getList(0, tags = null)
|
mangaList.value = repository.getList2(0)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
listError.value = e
|
listError.value = e
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ class RemoteListFragment : MangaListFragment() {
|
|||||||
|
|
||||||
override fun onFilterChanged(filter: MangaFilter) {
|
override fun onFilterChanged(filter: MangaFilter) {
|
||||||
viewModel.applyFilter(filter)
|
viewModel.applyFilter(filter)
|
||||||
super.onFilterChanged(filter)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
|
|||||||
@@ -78,10 +78,10 @@ class RemoteListViewModel(
|
|||||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||||
try {
|
try {
|
||||||
listError.value = null
|
listError.value = null
|
||||||
val list = repository.getList(
|
val list = repository.getList2(
|
||||||
offset = if (append) mangaList.value?.size ?: 0 else 0,
|
offset = if (append) mangaList.value?.size ?: 0 else 0,
|
||||||
sortOrder = appliedFilter?.sortOrder,
|
sortOrder = appliedFilter?.sortOrder,
|
||||||
tag = appliedFilter?.tag
|
tags = appliedFilter?.tags,
|
||||||
)
|
)
|
||||||
if (!append) {
|
if (!append) {
|
||||||
mangaList.value = list
|
mangaList.value = list
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ class MangaSearchRepository(
|
|||||||
MangaProviderFactory.getSources(settings, includeHidden = false).asFlow()
|
MangaProviderFactory.getSources(settings, includeHidden = false).asFlow()
|
||||||
.flatMapMerge(concurrency) { source ->
|
.flatMapMerge(concurrency) { source ->
|
||||||
runCatching {
|
runCatching {
|
||||||
source.repository.getList(0, query, SortOrder.POPULARITY)
|
source.repository.getList2(
|
||||||
|
offset = 0,
|
||||||
|
query = query,
|
||||||
|
sortOrder = SortOrder.POPULARITY
|
||||||
|
)
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
emptyList()
|
emptyList()
|
||||||
}.asFlow()
|
}.asFlow()
|
||||||
|
|||||||
@@ -71,10 +71,9 @@ class SearchViewModel(
|
|||||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||||
try {
|
try {
|
||||||
listError.value = null
|
listError.value = null
|
||||||
val list = repository.getList(
|
val list = repository.getList2(
|
||||||
offset = if (append) mangaList.value?.size ?: 0 else 0,
|
offset = if (append) mangaList.value?.size ?: 0 else 0,
|
||||||
tags = null,
|
query = query,
|
||||||
query = query
|
|
||||||
)
|
)
|
||||||
if (!append) {
|
if (!append) {
|
||||||
mangaList.value = list
|
mangaList.value = list
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ fun String.toCamelCase(): String {
|
|||||||
return result.toString()
|
return result.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String.toTitleCase(): String {
|
||||||
|
return replaceFirstChar { x -> x.uppercase() }
|
||||||
|
}
|
||||||
|
|
||||||
fun String.transliterate(skipMissing: Boolean): String {
|
fun String.transliterate(skipMissing: Boolean): String {
|
||||||
val cyr = charArrayOf(
|
val cyr = charArrayOf(
|
||||||
'а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о', 'п',
|
'а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о', 'п',
|
||||||
@@ -200,4 +204,20 @@ fun String.levenshteinDistance(other: String): Int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return cost[lhsLength - 1]
|
return cost[lhsLength - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T> StringBuilder.appendAll(
|
||||||
|
items: Iterable<T>,
|
||||||
|
separator: CharSequence,
|
||||||
|
transform: (T) -> CharSequence = { it.toString() },
|
||||||
|
) {
|
||||||
|
var isFirst = true
|
||||||
|
for (item in items) {
|
||||||
|
if (isFirst) {
|
||||||
|
isFirst = false
|
||||||
|
} else {
|
||||||
|
append(separator)
|
||||||
|
}
|
||||||
|
append(transform(item))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
13
app/src/main/res/layout/item_checkable_multiple.xml
Normal file
13
app/src/main/res/layout/item_checkable_multiple.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<CheckedTextView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?android:listPreferredItemHeightSmall"
|
||||||
|
android:background="?android:selectableItemBackground"
|
||||||
|
android:drawableStart="?android:listChoiceIndicatorMultiple"
|
||||||
|
android:drawablePadding="12dp"
|
||||||
|
android:gravity="center_vertical|start"
|
||||||
|
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||||
|
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||||
|
tools:text="@tools:sample/full_names" />
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
<CheckedTextView
|
<CheckedTextView
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/radio"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="?android:listPreferredItemHeightSmall"
|
android:layout_height="?android:listPreferredItemHeightSmall"
|
||||||
android:background="?android:selectableItemBackground"
|
android:background="?android:selectableItemBackground"
|
||||||
|
|||||||
@@ -38,15 +38,15 @@ class RemoteMangaRepositoryTest(private val source: MangaSource) : KoinTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun list() = coroutineTestRule.runBlockingTest {
|
fun list() = coroutineTestRule.runBlockingTest {
|
||||||
val list = repo.getList(20, query = null, sortOrder = SortOrder.POPULARITY, tag = null)
|
val list = repo.getList2(20, query = null, sortOrder = SortOrder.POPULARITY, tags = null)
|
||||||
checkMangaList(list)
|
checkMangaList(list)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun search() = coroutineTestRule.runBlockingTest {
|
fun search() = coroutineTestRule.runBlockingTest {
|
||||||
val subject = repo.getList(20, query = null, sortOrder = SortOrder.POPULARITY, tag = null)
|
val subject = repo.getList2(20, query = null, sortOrder = SortOrder.POPULARITY, tags = null)
|
||||||
.first()
|
.first()
|
||||||
val list = repo.getList(offset = 0, query = subject.title, sortOrder = null, tag = null)
|
val list = repo.getList2(offset = 0, query = subject.title, sortOrder = null, tags = null)
|
||||||
checkMangaList(list)
|
checkMangaList(list)
|
||||||
Truth.assertThat(list.map { it.url }).contains(subject.url)
|
Truth.assertThat(list.map { it.url }).contains(subject.url)
|
||||||
}
|
}
|
||||||
@@ -63,13 +63,13 @@ class RemoteMangaRepositoryTest(private val source: MangaSource) : KoinTest {
|
|||||||
Truth.assertThat(titles).doesNotContain("")
|
Truth.assertThat(titles).doesNotContain("")
|
||||||
Truth.assertThat(tags.mapToSet { it.source }).containsExactly(source)
|
Truth.assertThat(tags.mapToSet { it.source }).containsExactly(source)
|
||||||
|
|
||||||
val list = repo.getList(offset = 0, tag = tags.last(), query = null, sortOrder = null)
|
val list = repo.getList2(offset = 0, tags = setOf(tags.last()), query = null, sortOrder = null)
|
||||||
checkMangaList(list)
|
checkMangaList(list)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun details() = coroutineTestRule.runBlockingTest {
|
fun details() = coroutineTestRule.runBlockingTest {
|
||||||
val list = repo.getList(20, query = null, sortOrder = SortOrder.POPULARITY, tag = null)
|
val list = repo.getList2(20, query = null, sortOrder = SortOrder.POPULARITY, tags = null)
|
||||||
val item = list.first()
|
val item = list.first()
|
||||||
val details = repo.getDetails(item)
|
val details = repo.getDetails(item)
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ class RemoteMangaRepositoryTest(private val source: MangaSource) : KoinTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun pages() = coroutineTestRule.runBlockingTest {
|
fun pages() = coroutineTestRule.runBlockingTest {
|
||||||
val list = repo.getList(20, query = null, sortOrder = SortOrder.POPULARITY, tag = null)
|
val list = repo.getList2(20, query = null, sortOrder = SortOrder.POPULARITY, tags = null)
|
||||||
val chapter =
|
val chapter =
|
||||||
repo.getDetails(list.first()).chapters?.firstOrNull() ?: error("Chapter is null")
|
repo.getDetails(list.first()).chapters?.firstOrNull() ?: error("Chapter is null")
|
||||||
val pages = repo.getPages(chapter)
|
val pages = repo.getPages(chapter)
|
||||||
|
|||||||
Reference in New Issue
Block a user