update src/en
22
src/en/kissmangain/AndroidManifest.xml
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<activity
|
||||||
|
android:name="eu.kanade.tachiyomi.multisrc.madara.MadaraUrlActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.NoDisplay">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data
|
||||||
|
android:host="${SOURCEHOST}"
|
||||||
|
android:pathPattern="/.*/..*"
|
||||||
|
android:scheme="${SOURCESCHEME}" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
29
src/en/kissmangain/build.gradle
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
// THIS FILE IS AUTO-GENERATED; DO NOT EDIT
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
apply plugin: 'kotlinx-serialization'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
extName = 'Kissmanga.in'
|
||||||
|
pkgNameSuffix = 'en.kissmangain'
|
||||||
|
extClass = '.KissmangaIn'
|
||||||
|
extFactory = 'madara'
|
||||||
|
extVersionCode = 35
|
||||||
|
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":lib-cryptoaes"))
|
||||||
|
implementation(project(":lib-randomua"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
||||||
|
|
||||||
|
android {
|
||||||
|
defaultConfig {
|
||||||
|
manifestPlaceholders += [
|
||||||
|
SOURCEHOST: "kissmanga.in",
|
||||||
|
SOURCESCHEME: "https"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
BIN
src/en/kissmangain/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
src/en/kissmangain/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
src/en/kissmangain/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 4.7 KiB |
BIN
src/en/kissmangain/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 8.9 KiB |
BIN
src/en/kissmangain/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
src/en/kissmangain/res/web_hi_res_512.png
Normal file
After Width: | Height: | Size: 83 KiB |
|
@ -0,0 +1,7 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.kissmangain
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.multisrc.madara.Madara
|
||||||
|
|
||||||
|
class KissmangaIn : Madara("Kissmanga.in", "https://kissmanga.in", "en") {
|
||||||
|
override val mangaSubString = "kissmanga"
|
||||||
|
}
|
|
@ -0,0 +1,971 @@
|
||||||
|
package eu.kanade.tachiyomi.multisrc.madara
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.preference.PreferenceScreen
|
||||||
|
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
|
||||||
|
import eu.kanade.tachiyomi.lib.randomua.addRandomUAPreferenceToScreen
|
||||||
|
import eu.kanade.tachiyomi.lib.randomua.getPrefCustomUA
|
||||||
|
import eu.kanade.tachiyomi.lib.randomua.getPrefUAType
|
||||||
|
import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import eu.kanade.tachiyomi.network.asObservable
|
||||||
|
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||||
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.CacheControl
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.text.ParseException
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
abstract class Madara(
|
||||||
|
override val name: String,
|
||||||
|
override val baseUrl: String,
|
||||||
|
final override val lang: String,
|
||||||
|
private val dateFormat: SimpleDateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US),
|
||||||
|
) : ParsedHttpSource(), ConfigurableSource {
|
||||||
|
|
||||||
|
private val preferences: SharedPreferences by lazy {
|
||||||
|
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||||
|
}
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
override val client: OkHttpClient by lazy {
|
||||||
|
network.cloudflareClient.newBuilder()
|
||||||
|
.setRandomUserAgent(
|
||||||
|
preferences.getPrefUAType(),
|
||||||
|
preferences.getPrefCustomUA(),
|
||||||
|
)
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun headersBuilder() = super.headersBuilder()
|
||||||
|
.add("Referer", "$baseUrl/")
|
||||||
|
|
||||||
|
protected open val json: Json by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If enabled, will attempt to remove non-manga items in popular and latest.
|
||||||
|
* The filter will not be used in search as the theme doesn't set the CSS class.
|
||||||
|
* Can be disabled if the source incorrectly sets the entry types.
|
||||||
|
*/
|
||||||
|
protected open val filterNonMangaItems = true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The CSS selector used to filter manga items in popular and latest
|
||||||
|
* if `filterNonMangaItems` is set to `true`. Can be override if needed.
|
||||||
|
* If the flag is set to `false`, it will be empty by default.
|
||||||
|
*/
|
||||||
|
protected open val mangaEntrySelector: String by lazy {
|
||||||
|
if (filterNonMangaItems) ".manga" else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically fetched genres from the source to be used in the filters.
|
||||||
|
*/
|
||||||
|
private var genresList: List<Genre> = emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inner variable to control the genre fetching failed state.
|
||||||
|
*/
|
||||||
|
private var fetchGenresFailed: Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inner variable to control how much tries the genres request was called.
|
||||||
|
*/
|
||||||
|
private var fetchGenresAttempts: Int = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable it if you don't want the genres to be fetched.
|
||||||
|
*/
|
||||||
|
protected open val fetchGenres: Boolean = true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path used in the URL for the manga pages. Can be
|
||||||
|
* changed if needed as some sites modify it to other words.
|
||||||
|
*/
|
||||||
|
protected open val mangaSubString = "manga"
|
||||||
|
|
||||||
|
// Popular Manga
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
runCatching { fetchGenres() }
|
||||||
|
return super.popularMangaParse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exclude/filter bilibili manga from list
|
||||||
|
override fun popularMangaSelector() = "div.page-item-detail:not(:has(a[href*='bilibilicomics.com']))$mangaEntrySelector"
|
||||||
|
|
||||||
|
open val popularMangaUrlSelector = "div.post-title a"
|
||||||
|
|
||||||
|
override fun popularMangaFromElement(element: Element): SManga {
|
||||||
|
val manga = SManga.create()
|
||||||
|
|
||||||
|
with(element) {
|
||||||
|
select(popularMangaUrlSelector).first()?.let {
|
||||||
|
manga.setUrlWithoutDomain(it.attr("abs:href"))
|
||||||
|
manga.title = it.ownText()
|
||||||
|
}
|
||||||
|
|
||||||
|
select("img").first()?.let {
|
||||||
|
manga.thumbnail_url = imageFromElement(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return manga
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int): Request {
|
||||||
|
return GET(
|
||||||
|
url = "$baseUrl/$mangaSubString/${searchPage(page)}?m_orderby=views",
|
||||||
|
headers = headers,
|
||||||
|
cache = CacheControl.FORCE_NETWORK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaNextPageSelector(): String? = searchMangaNextPageSelector()
|
||||||
|
|
||||||
|
// Latest Updates
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = popularMangaSelector()
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element): SManga {
|
||||||
|
// Even if it's different from the popular manga's list, the relevant classes are the same
|
||||||
|
return popularMangaFromElement(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request {
|
||||||
|
return GET(
|
||||||
|
url = "$baseUrl/$mangaSubString/${searchPage(page)}?m_orderby=latest",
|
||||||
|
headers = headers,
|
||||||
|
cache = CacheControl.FORCE_NETWORK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesNextPageSelector(): String? = popularMangaNextPageSelector()
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||||
|
val mp = super.latestUpdatesParse(response)
|
||||||
|
val mangas = mp.mangas.distinctBy { it.url }
|
||||||
|
return MangasPage(mangas, mp.hasNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search Manga
|
||||||
|
|
||||||
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
|
if (query.startsWith(URL_SEARCH_PREFIX)) {
|
||||||
|
val mangaUrl = "$baseUrl/$mangaSubString/${query.substringAfter(URL_SEARCH_PREFIX)}"
|
||||||
|
return client.newCall(GET(mangaUrl, headers))
|
||||||
|
.asObservable().map { response ->
|
||||||
|
MangasPage(listOf(mangaDetailsParse(response.asJsoup()).apply { url = "/$mangaSubString/${query.substringAfter(URL_SEARCH_PREFIX)}/" }), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.newCall(searchMangaRequest(page, query, filters))
|
||||||
|
.asObservable().doOnNext { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
response.close()
|
||||||
|
// Error message for exceeding last page
|
||||||
|
if (response.code == 404) {
|
||||||
|
error("Already on the Last Page!")
|
||||||
|
} else {
|
||||||
|
throw Exception("HTTP error ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map { response ->
|
||||||
|
searchMangaParse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open fun searchPage(page: Int): String = "page/$page/"
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val url = "$baseUrl/${searchPage(page)}".toHttpUrlOrNull()!!.newBuilder()
|
||||||
|
url.addQueryParameter("s", query)
|
||||||
|
url.addQueryParameter("post_type", "wp-manga")
|
||||||
|
filters.forEach { filter ->
|
||||||
|
when (filter) {
|
||||||
|
is AuthorFilter -> {
|
||||||
|
if (filter.state.isNotBlank()) {
|
||||||
|
url.addQueryParameter("author", filter.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ArtistFilter -> {
|
||||||
|
if (filter.state.isNotBlank()) {
|
||||||
|
url.addQueryParameter("artist", filter.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is YearFilter -> {
|
||||||
|
if (filter.state.isNotBlank()) {
|
||||||
|
url.addQueryParameter("release", filter.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is StatusFilter -> {
|
||||||
|
filter.state.forEach {
|
||||||
|
if (it.state) {
|
||||||
|
url.addQueryParameter("status[]", it.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is OrderByFilter -> {
|
||||||
|
if (filter.state != 0) {
|
||||||
|
url.addQueryParameter("m_orderby", filter.toUriPart())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is AdultContentFilter -> {
|
||||||
|
url.addQueryParameter("adult", filter.toUriPart())
|
||||||
|
}
|
||||||
|
is GenreConditionFilter -> {
|
||||||
|
url.addQueryParameter("op", filter.toUriPart())
|
||||||
|
}
|
||||||
|
is GenreList -> {
|
||||||
|
filter.state
|
||||||
|
.filter { it.state }
|
||||||
|
.let { list ->
|
||||||
|
if (list.isNotEmpty()) { list.forEach { genre -> url.addQueryParameter("genre[]", genre.id) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return GET(url.toString(), headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open val authorFilterTitle: String = when (lang) {
|
||||||
|
"pt-BR" -> "Autor"
|
||||||
|
else -> "Author"
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open val artistFilterTitle: String = when (lang) {
|
||||||
|
"pt-BR" -> "Artista"
|
||||||
|
else -> "Artist"
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open val yearFilterTitle: String = when (lang) {
|
||||||
|
"pt-BR" -> "Ano de lançamento"
|
||||||
|
else -> "Year of Released"
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open val statusFilterTitle: String = when (lang) {
|
||||||
|
"pt-BR" -> "Estado"
|
||||||
|
else -> "Status"
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open val statusFilterOptions: Array<String> = when (lang) {
|
||||||
|
"pt-BR" -> arrayOf("Completo", "Em andamento", "Cancelado", "Pausado")
|
||||||
|
else -> arrayOf("Completed", "Ongoing", "Canceled", "On Hold")
|
||||||
|
}
|
||||||
|
|
||||||
|
protected val statusFilterOptionsValues: Array<String> = arrayOf(
|
||||||
|
"end",
|
||||||
|
"on-going",
|
||||||
|
"canceled",
|
||||||
|
"on-hold",
|
||||||
|
)
|
||||||
|
|
||||||
|
protected open val orderByFilterTitle: String = when (lang) {
|
||||||
|
"pt-BR" -> "Ordenar por"
|
||||||
|
else -> "Order By"
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open val orderByFilterOptions: Array<String> = when (lang) {
|
||||||
|
"pt-BR" -> arrayOf(
|
||||||
|
"Relevância",
|
||||||
|
"Recentes",
|
||||||
|
"A-Z",
|
||||||
|
"Avaliação",
|
||||||
|
"Tendência",
|
||||||
|
"Visualizações",
|
||||||
|
"Novos",
|
||||||
|
)
|
||||||
|
else -> arrayOf(
|
||||||
|
"Relevance",
|
||||||
|
"Latest",
|
||||||
|
"A-Z",
|
||||||
|
"Rating",
|
||||||
|
"Trending",
|
||||||
|
"Most Views",
|
||||||
|
"New",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected val orderByFilterOptionsValues: Array<String> = arrayOf(
|
||||||
|
"",
|
||||||
|
"latest",
|
||||||
|
"alphabet",
|
||||||
|
"rating",
|
||||||
|
"trending",
|
||||||
|
"views",
|
||||||
|
"new-manga",
|
||||||
|
)
|
||||||
|
|
||||||
|
protected open val genreConditionFilterTitle: String = when (lang) {
|
||||||
|
"pt-BR" -> "Operador dos gêneros"
|
||||||
|
else -> "Genre condition"
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open val genreConditionFilterOptions: Array<String> = when (lang) {
|
||||||
|
"pt-BR" -> arrayOf("OU", "E")
|
||||||
|
else -> arrayOf("OR", "AND")
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open val adultContentFilterTitle: String = when (lang) {
|
||||||
|
"pt-BR" -> "Conteúdo adulto"
|
||||||
|
else -> "Adult Content"
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open val adultContentFilterOptions: Array<String> = when (lang) {
|
||||||
|
"pt-BR" -> arrayOf("Indiferente", "Nenhum", "Somente")
|
||||||
|
else -> arrayOf("All", "None", "Only")
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open val genreFilterHeader: String = when (lang) {
|
||||||
|
"pt-BR" -> "O filtro de gêneros pode não funcionar"
|
||||||
|
else -> "Genres filter may not work for all sources"
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open val genreFilterTitle: String = when (lang) {
|
||||||
|
"pt-BR" -> "Gêneros"
|
||||||
|
else -> "Genres"
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open val genresMissingWarning: String = when (lang) {
|
||||||
|
"pt-BR" -> "Aperte 'Redefinir' para tentar mostrar os gêneros"
|
||||||
|
else -> "Press 'Reset' to attempt to show the genres"
|
||||||
|
}
|
||||||
|
|
||||||
|
protected class AuthorFilter(title: String) : Filter.Text(title)
|
||||||
|
protected class ArtistFilter(title: String) : Filter.Text(title)
|
||||||
|
protected class YearFilter(title: String) : Filter.Text(title)
|
||||||
|
protected class StatusFilter(title: String, status: List<Tag>) :
|
||||||
|
Filter.Group<Tag>(title, status)
|
||||||
|
|
||||||
|
protected class OrderByFilter(title: String, options: List<Pair<String, String>>, state: Int = 0) :
|
||||||
|
UriPartFilter(title, options.toTypedArray(), state)
|
||||||
|
|
||||||
|
protected class GenreConditionFilter(title: String, options: Array<String>) : UriPartFilter(
|
||||||
|
title,
|
||||||
|
options.zip(arrayOf("", "1")).toTypedArray(),
|
||||||
|
)
|
||||||
|
|
||||||
|
protected class AdultContentFilter(title: String, options: Array<String>) : UriPartFilter(
|
||||||
|
title,
|
||||||
|
options.zip(arrayOf("", "0", "1")).toTypedArray(),
|
||||||
|
)
|
||||||
|
|
||||||
|
protected class GenreList(title: String, genres: List<Genre>) : Filter.Group<Genre>(title, genres)
|
||||||
|
class Genre(name: String, val id: String = name) : Filter.CheckBox(name)
|
||||||
|
|
||||||
|
override fun getFilterList(): FilterList {
|
||||||
|
val filters = mutableListOf(
|
||||||
|
AuthorFilter(authorFilterTitle),
|
||||||
|
ArtistFilter(artistFilterTitle),
|
||||||
|
YearFilter(yearFilterTitle),
|
||||||
|
StatusFilter(statusFilterTitle, getStatusList()),
|
||||||
|
OrderByFilter(
|
||||||
|
title = orderByFilterTitle,
|
||||||
|
options = orderByFilterOptions.zip(orderByFilterOptionsValues),
|
||||||
|
state = 0,
|
||||||
|
),
|
||||||
|
AdultContentFilter(adultContentFilterTitle, adultContentFilterOptions),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (genresList.isNotEmpty()) {
|
||||||
|
filters += listOf(
|
||||||
|
Filter.Separator(),
|
||||||
|
Filter.Header(genreFilterHeader),
|
||||||
|
GenreConditionFilter(genreConditionFilterTitle, genreConditionFilterOptions),
|
||||||
|
GenreList(genreFilterTitle, genresList),
|
||||||
|
)
|
||||||
|
} else if (fetchGenres) {
|
||||||
|
filters += listOf(
|
||||||
|
Filter.Separator(),
|
||||||
|
Filter.Header(genresMissingWarning),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return FilterList(filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun getStatusList() = statusFilterOptionsValues
|
||||||
|
.zip(statusFilterOptions)
|
||||||
|
.map { Tag(it.first, it.second) }
|
||||||
|
|
||||||
|
open class UriPartFilter(displayName: String, private val vals: Array<Pair<String, String>>, state: Int = 0) :
|
||||||
|
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray(), state) {
|
||||||
|
fun toUriPart() = vals[state].second
|
||||||
|
}
|
||||||
|
|
||||||
|
open class Tag(val id: String, name: String) : Filter.CheckBox(name)
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
|
runCatching { fetchGenres() }
|
||||||
|
return super.searchMangaParse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaSelector() = "div.c-tabs-item__content"
|
||||||
|
|
||||||
|
override fun searchMangaFromElement(element: Element): SManga {
|
||||||
|
val manga = SManga.create()
|
||||||
|
|
||||||
|
with(element) {
|
||||||
|
select("div.post-title a").first()?.let {
|
||||||
|
manga.setUrlWithoutDomain(it.attr("abs:href"))
|
||||||
|
manga.title = it.ownText()
|
||||||
|
}
|
||||||
|
select("img").first()?.let {
|
||||||
|
manga.thumbnail_url = imageFromElement(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return manga
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaNextPageSelector(): String? = "div.nav-previous, nav.navigation-ajax, a.nextpostslink"
|
||||||
|
|
||||||
|
// Manga Details Parse
|
||||||
|
|
||||||
|
protected val completedStatusList: Array<String> = arrayOf(
|
||||||
|
"Completed",
|
||||||
|
"Completo",
|
||||||
|
"Completado",
|
||||||
|
"Concluído",
|
||||||
|
"Concluido",
|
||||||
|
"Finalizado",
|
||||||
|
"Achevé",
|
||||||
|
"Terminé",
|
||||||
|
"Hoàn Thành",
|
||||||
|
"مكتملة",
|
||||||
|
"مكتمل",
|
||||||
|
"已完结",
|
||||||
|
)
|
||||||
|
|
||||||
|
protected val ongoingStatusList: Array<String> = arrayOf(
|
||||||
|
"OnGoing", "Продолжается", "Updating", "Em Lançamento", "Em lançamento", "Em andamento",
|
||||||
|
"Em Andamento", "En cours", "En Cours", "En cours de publication", "Ativo", "Lançando", "Đang Tiến Hành", "Devam Ediyor",
|
||||||
|
"Devam ediyor", "In Corso", "In Arrivo", "مستمرة", "مستمر", "En Curso", "En curso", "Emision",
|
||||||
|
"Curso", "En marcha", "Publicandose", "En emision", "连载中", "Em Lançamento",
|
||||||
|
)
|
||||||
|
|
||||||
|
protected val hiatusStatusList: Array<String> = arrayOf(
|
||||||
|
"On Hold",
|
||||||
|
"Pausado",
|
||||||
|
"En espera",
|
||||||
|
)
|
||||||
|
|
||||||
|
protected val canceledStatusList: Array<String> = arrayOf(
|
||||||
|
"Canceled",
|
||||||
|
"Cancelado",
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(document: Document): SManga {
|
||||||
|
val manga = SManga.create()
|
||||||
|
with(document) {
|
||||||
|
select(mangaDetailsSelectorTitle).first()?.let {
|
||||||
|
manga.title = it.ownText()
|
||||||
|
}
|
||||||
|
select(mangaDetailsSelectorAuthor).eachText().filter {
|
||||||
|
it.notUpdating()
|
||||||
|
}.joinToString().takeIf { it.isNotBlank() }?.let {
|
||||||
|
manga.author = it
|
||||||
|
}
|
||||||
|
select(mangaDetailsSelectorArtist).eachText().filter {
|
||||||
|
it.notUpdating()
|
||||||
|
}.joinToString().takeIf { it.isNotBlank() }?.let {
|
||||||
|
manga.artist = it
|
||||||
|
}
|
||||||
|
select(mangaDetailsSelectorDescription).let {
|
||||||
|
if (it.select("p").text().isNotEmpty()) {
|
||||||
|
manga.description = it.select("p").joinToString(separator = "\n\n") { p ->
|
||||||
|
p.text().replace("<br>", "\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
manga.description = it.text()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select(mangaDetailsSelectorThumbnail).first()?.let {
|
||||||
|
manga.thumbnail_url = imageFromElement(it)
|
||||||
|
}
|
||||||
|
select(mangaDetailsSelectorStatus).last()?.let {
|
||||||
|
manga.status = with(it.text()) {
|
||||||
|
when {
|
||||||
|
containsIn(completedStatusList) -> SManga.COMPLETED
|
||||||
|
containsIn(ongoingStatusList) -> SManga.ONGOING
|
||||||
|
containsIn(hiatusStatusList) -> SManga.ON_HIATUS
|
||||||
|
containsIn(canceledStatusList) -> SManga.CANCELLED
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val genres = select(mangaDetailsSelectorGenre)
|
||||||
|
.map { element -> element.text().lowercase(Locale.ROOT) }
|
||||||
|
.toMutableSet()
|
||||||
|
|
||||||
|
// add tag(s) to genre
|
||||||
|
val mangaTitle = try {
|
||||||
|
manga.title
|
||||||
|
} catch (_: UninitializedPropertyAccessException) {
|
||||||
|
"not initialized"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mangaDetailsSelectorTag.isNotEmpty()) {
|
||||||
|
select(mangaDetailsSelectorTag).forEach { element ->
|
||||||
|
if (genres.contains(element.text()).not() &&
|
||||||
|
element.text().length <= 25 &&
|
||||||
|
element.text().contains("read", true).not() &&
|
||||||
|
element.text().contains(name, true).not() &&
|
||||||
|
element.text().contains(name.replace(" ", ""), true).not() &&
|
||||||
|
element.text().contains(mangaTitle, true).not() &&
|
||||||
|
element.text().contains(altName, true).not()
|
||||||
|
) {
|
||||||
|
genres.add(element.text().lowercase(Locale.ROOT))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add manga/manhwa/manhua thinggy to genre
|
||||||
|
document.select(seriesTypeSelector).firstOrNull()?.ownText()?.let {
|
||||||
|
if (it.isEmpty().not() && it.notUpdating() && it != "-" && genres.contains(it).not()) {
|
||||||
|
genres.add(it.lowercase(Locale.ROOT))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manga.genre = genres.toList().joinToString(", ") { genre ->
|
||||||
|
genre.replaceFirstChar {
|
||||||
|
if (it.isLowerCase()) {
|
||||||
|
it.titlecase(
|
||||||
|
Locale.ROOT,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
it.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add alternative name to manga description
|
||||||
|
document.select(altNameSelector).firstOrNull()?.ownText()?.let {
|
||||||
|
if (it.isBlank().not() && it.notUpdating()) {
|
||||||
|
manga.description = when {
|
||||||
|
manga.description.isNullOrBlank() -> altName + it
|
||||||
|
else -> manga.description + "\n\n$altName" + it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return manga
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manga Details Selector
|
||||||
|
open val mangaDetailsSelectorTitle = "div.post-title h3, div.post-title h1"
|
||||||
|
open val mangaDetailsSelectorAuthor = "div.author-content > a"
|
||||||
|
open val mangaDetailsSelectorArtist = "div.artist-content > a"
|
||||||
|
open val mangaDetailsSelectorStatus = "div.summary-content"
|
||||||
|
open val mangaDetailsSelectorDescription = "div.description-summary div.summary__content, div.summary_content div.post-content_item > h5 + div, div.summary_content div.manga-excerpt"
|
||||||
|
open val mangaDetailsSelectorThumbnail = "div.summary_image img"
|
||||||
|
open val mangaDetailsSelectorGenre = "div.genres-content a"
|
||||||
|
open val mangaDetailsSelectorTag = "div.tags-content a"
|
||||||
|
|
||||||
|
open val seriesTypeSelector = ".post-content_item:contains(Type) .summary-content"
|
||||||
|
open val altNameSelector = ".post-content_item:contains(Alt) .summary-content"
|
||||||
|
open val altName = when (lang) {
|
||||||
|
"pt-BR" -> "Nomes alternativos: "
|
||||||
|
else -> "Alternative Names: "
|
||||||
|
}
|
||||||
|
open val updatingRegex = "Updating|Atualizando".toRegex(RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
fun String.notUpdating(): Boolean {
|
||||||
|
return this.contains(updatingRegex).not()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.containsIn(array: Array<String>): Boolean {
|
||||||
|
return this.lowercase() in array.map { it.lowercase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open fun imageFromElement(element: Element): String? {
|
||||||
|
return when {
|
||||||
|
element.hasAttr("data-src") -> element.attr("abs:data-src")
|
||||||
|
element.hasAttr("data-lazy-src") -> element.attr("abs:data-lazy-src")
|
||||||
|
element.hasAttr("srcset") -> element.attr("abs:srcset").substringBefore(" ")
|
||||||
|
else -> element.attr("abs:src")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set it to true if the source uses the new AJAX endpoint to
|
||||||
|
* fetch the manga chapters instead of the old admin-ajax.php one.
|
||||||
|
*/
|
||||||
|
protected open val useNewChapterEndpoint: Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal attribute to control if it should always use the
|
||||||
|
* new chapter endpoint after a first check if useNewChapterEndpoint is
|
||||||
|
* set to false. Using a separate variable to still allow the other
|
||||||
|
* one to be overridable manually in each source.
|
||||||
|
*/
|
||||||
|
private var oldChapterEndpointDisabled: Boolean = false
|
||||||
|
|
||||||
|
protected open fun oldXhrChaptersRequest(mangaId: String): Request {
|
||||||
|
val form = FormBody.Builder()
|
||||||
|
.add("action", "manga_get_chapters")
|
||||||
|
.add("manga", mangaId)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val xhrHeaders = headersBuilder()
|
||||||
|
.add("Content-Length", form.contentLength().toString())
|
||||||
|
.add("Content-Type", form.contentType().toString())
|
||||||
|
.add("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return POST("$baseUrl/wp-admin/admin-ajax.php", xhrHeaders, form)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open fun xhrChaptersRequest(mangaUrl: String): Request {
|
||||||
|
val xhrHeaders = headersBuilder()
|
||||||
|
.add("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return POST("$mangaUrl/ajax/chapters", xhrHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
val chaptersWrapper = document.select("div[id^=manga-chapters-holder]")
|
||||||
|
|
||||||
|
var chapterElements = document.select(chapterListSelector())
|
||||||
|
|
||||||
|
if (chapterElements.isEmpty() && !chaptersWrapper.isNullOrEmpty()) {
|
||||||
|
val mangaUrl = document.location().removeSuffix("/")
|
||||||
|
val mangaId = chaptersWrapper.attr("data-id")
|
||||||
|
|
||||||
|
var xhrRequest = if (useNewChapterEndpoint || oldChapterEndpointDisabled) {
|
||||||
|
xhrChaptersRequest(mangaUrl)
|
||||||
|
} else {
|
||||||
|
oldXhrChaptersRequest(mangaId)
|
||||||
|
}
|
||||||
|
var xhrResponse = client.newCall(xhrRequest).execute()
|
||||||
|
|
||||||
|
// Newer Madara versions throws HTTP 400 when using the old endpoint.
|
||||||
|
if (!useNewChapterEndpoint && xhrResponse.code == 400) {
|
||||||
|
xhrResponse.close()
|
||||||
|
// Set it to true so following calls will be made directly to the new endpoint.
|
||||||
|
oldChapterEndpointDisabled = true
|
||||||
|
|
||||||
|
xhrRequest = xhrChaptersRequest(mangaUrl)
|
||||||
|
xhrResponse = client.newCall(xhrRequest).execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
chapterElements = xhrResponse.asJsoup().select(chapterListSelector())
|
||||||
|
xhrResponse.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
countViews(document)
|
||||||
|
|
||||||
|
return chapterElements.map(::chapterFromElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListSelector() = "li.wp-manga-chapter"
|
||||||
|
|
||||||
|
protected open fun chapterDateSelector() = "span.chapter-release-date"
|
||||||
|
|
||||||
|
open val chapterUrlSelector = "a"
|
||||||
|
|
||||||
|
// can cause some issue for some site. blocked by cloudflare when opening the chapter pages
|
||||||
|
open val chapterUrlSuffix = "?style=list"
|
||||||
|
|
||||||
|
override fun chapterFromElement(element: Element): SChapter {
|
||||||
|
val chapter = SChapter.create()
|
||||||
|
|
||||||
|
with(element) {
|
||||||
|
select(chapterUrlSelector).first()?.let { urlElement ->
|
||||||
|
chapter.url = urlElement.attr("abs:href").let {
|
||||||
|
it.substringBefore("?style=paged") + if (!it.endsWith(chapterUrlSuffix)) chapterUrlSuffix else ""
|
||||||
|
}
|
||||||
|
chapter.name = urlElement.text()
|
||||||
|
}
|
||||||
|
// Dates can be part of a "new" graphic or plain text
|
||||||
|
// Added "title" alternative
|
||||||
|
chapter.date_upload = select("img:not(.thumb)").firstOrNull()?.attr("alt")?.let { parseRelativeDate(it) }
|
||||||
|
?: select("span a").firstOrNull()?.attr("title")?.let { parseRelativeDate(it) }
|
||||||
|
?: parseChapterDate(select(chapterDateSelector()).firstOrNull()?.text())
|
||||||
|
}
|
||||||
|
|
||||||
|
return chapter
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun parseChapterDate(date: String?): Long {
|
||||||
|
date ?: return 0
|
||||||
|
|
||||||
|
fun SimpleDateFormat.tryParse(string: String): Long {
|
||||||
|
return try {
|
||||||
|
parse(string)?.time ?: 0
|
||||||
|
} catch (_: ParseException) {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return when {
|
||||||
|
// Handle 'yesterday' and 'today', using midnight
|
||||||
|
WordSet("yesterday", "يوم واحد").startsWith(date) -> {
|
||||||
|
Calendar.getInstance().apply {
|
||||||
|
add(Calendar.DAY_OF_MONTH, -1) // yesterday
|
||||||
|
set(Calendar.HOUR_OF_DAY, 0)
|
||||||
|
set(Calendar.MINUTE, 0)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
}
|
||||||
|
WordSet("today").startsWith(date) -> {
|
||||||
|
Calendar.getInstance().apply {
|
||||||
|
set(Calendar.HOUR_OF_DAY, 0)
|
||||||
|
set(Calendar.MINUTE, 0)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
}
|
||||||
|
WordSet("يومين").startsWith(date) -> {
|
||||||
|
Calendar.getInstance().apply {
|
||||||
|
add(Calendar.DAY_OF_MONTH, -2) // day before yesterday
|
||||||
|
set(Calendar.HOUR_OF_DAY, 0)
|
||||||
|
set(Calendar.MINUTE, 0)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
}
|
||||||
|
WordSet("ago", "atrás", "önce", "قبل").endsWith(date) -> {
|
||||||
|
parseRelativeDate(date)
|
||||||
|
}
|
||||||
|
WordSet("hace").startsWith(date) -> {
|
||||||
|
parseRelativeDate(date)
|
||||||
|
}
|
||||||
|
date.contains(Regex("""\d(st|nd|rd|th)""")) -> {
|
||||||
|
// Clean date (e.g. 5th December 2019 to 5 December 2019) before parsing it
|
||||||
|
date.split(" ").map {
|
||||||
|
if (it.contains(Regex("""\d\D\D"""))) {
|
||||||
|
it.replace(Regex("""\D"""), "")
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { dateFormat.tryParse(it.joinToString(" ")) }
|
||||||
|
}
|
||||||
|
else -> dateFormat.tryParse(date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses dates in this form:
|
||||||
|
// 21 horas ago
|
||||||
|
protected open fun parseRelativeDate(date: String): Long {
|
||||||
|
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
WordSet("hari", "gün", "jour", "día", "dia", "day", "วัน", "ngày", "giorni", "أيام", "天").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
|
||||||
|
WordSet("jam", "saat", "heure", "hora", "hour", "ชั่วโมง", "giờ", "ore", "ساعة", "小时").anyWordIn(date) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
|
||||||
|
WordSet("menit", "dakika", "min", "minute", "minuto", "นาที", "دقائق").anyWordIn(date) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
|
||||||
|
WordSet("detik", "segundo", "second", "วินาที").anyWordIn(date) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
|
||||||
|
WordSet("week", "semana").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }.timeInMillis
|
||||||
|
WordSet("month", "mes").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
|
||||||
|
WordSet("year", "año").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListRequest(chapter: SChapter): Request {
|
||||||
|
if (chapter.url.startsWith("http")) {
|
||||||
|
return GET(chapter.url, headers)
|
||||||
|
}
|
||||||
|
return super.pageListRequest(chapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
open val pageListParseSelector = "div.page-break, li.blocks-gallery-item, .reading-content .text-left:not(:has(.blocks-gallery-item)) img"
|
||||||
|
|
||||||
|
open val chapterProtectorSelector = "#chapter-protector-data"
|
||||||
|
|
||||||
|
override fun pageListParse(document: Document): List<Page> {
|
||||||
|
countViews(document)
|
||||||
|
|
||||||
|
val chapterProtector = document.selectFirst(chapterProtectorSelector)
|
||||||
|
?: return document.select(pageListParseSelector).mapIndexed { index, element ->
|
||||||
|
val imageUrl = element.selectFirst("img")?.let { imageFromElement(it) }
|
||||||
|
Page(index, document.location(), imageUrl)
|
||||||
|
}
|
||||||
|
val chapterProtectorHtml = chapterProtector.html()
|
||||||
|
val password = chapterProtectorHtml
|
||||||
|
.substringAfter("wpmangaprotectornonce='")
|
||||||
|
.substringBefore("';")
|
||||||
|
val chapterData = json.parseToJsonElement(
|
||||||
|
chapterProtectorHtml
|
||||||
|
.substringAfter("chapter_data='")
|
||||||
|
.substringBefore("';")
|
||||||
|
.replace("\\/", "/"),
|
||||||
|
).jsonObject
|
||||||
|
|
||||||
|
val unsaltedCiphertext = Base64.decode(chapterData["ct"]!!.jsonPrimitive.content, Base64.DEFAULT)
|
||||||
|
val salt = chapterData["s"]!!.jsonPrimitive.content.decodeHex()
|
||||||
|
val ciphertext = SALTED + salt + unsaltedCiphertext
|
||||||
|
|
||||||
|
val rawImgArray = CryptoAES.decrypt(Base64.encodeToString(ciphertext, Base64.DEFAULT), password)
|
||||||
|
val imgArrayString = json.parseToJsonElement(rawImgArray).jsonPrimitive.content
|
||||||
|
val imgArray = json.parseToJsonElement(imgArrayString).jsonArray
|
||||||
|
|
||||||
|
return imgArray.mapIndexed { idx, it ->
|
||||||
|
Page(idx, document.location(), it.jsonPrimitive.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageRequest(page: Page): Request {
|
||||||
|
return GET(page.imageUrl!!, headers.newBuilder().set("Referer", page.url).build())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(document: Document) = ""
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set it to false if you want to disable the extension reporting the view count
|
||||||
|
* back to the source website through admin-ajax.php.
|
||||||
|
*/
|
||||||
|
protected open val sendViewCount: Boolean = true
|
||||||
|
|
||||||
|
protected open fun countViewsRequest(document: Document): Request? {
|
||||||
|
val wpMangaData = document.select("script#wp-manga-js-extra").firstOrNull()
|
||||||
|
?.data() ?: return null
|
||||||
|
|
||||||
|
val wpMangaInfo = wpMangaData
|
||||||
|
.substringAfter("var manga = ")
|
||||||
|
.substringBeforeLast(";")
|
||||||
|
|
||||||
|
val wpManga = runCatching { json.parseToJsonElement(wpMangaInfo).jsonObject }
|
||||||
|
.getOrNull() ?: return null
|
||||||
|
|
||||||
|
if (wpManga["enable_manga_view"]?.jsonPrimitive?.content == "1") {
|
||||||
|
val formBuilder = FormBody.Builder()
|
||||||
|
.add("action", "manga_views")
|
||||||
|
.add("manga", wpManga["manga_id"]!!.jsonPrimitive.content)
|
||||||
|
|
||||||
|
if (wpManga["chapter_slug"] != null) {
|
||||||
|
formBuilder.add("chapter", wpManga["chapter_slug"]!!.jsonPrimitive.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
val formBody = formBuilder.build()
|
||||||
|
|
||||||
|
val newHeaders = headersBuilder()
|
||||||
|
.set("Content-Length", formBody.contentLength().toString())
|
||||||
|
.set("Content-Type", formBody.contentType().toString())
|
||||||
|
.set("Referer", document.location())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val ajaxUrl = wpManga["ajax_url"]!!.jsonPrimitive.content
|
||||||
|
|
||||||
|
return POST(ajaxUrl, newHeaders, formBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the view count request to the Madara endpoint.
|
||||||
|
*
|
||||||
|
* @param document The response document with the wp-manga data
|
||||||
|
*/
|
||||||
|
protected open fun countViews(document: Document) {
|
||||||
|
if (!sendViewCount) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = countViewsRequest(document) ?: return
|
||||||
|
runCatching { client.newCall(request).execute().close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the genres from the source to be used in the filters.
|
||||||
|
*/
|
||||||
|
protected open fun fetchGenres() {
|
||||||
|
if (fetchGenres && fetchGenresAttempts <= 3 && (genresList.isEmpty() || fetchGenresFailed)) {
|
||||||
|
val genres = runCatching {
|
||||||
|
client.newCall(genresRequest()).execute()
|
||||||
|
.use { parseGenres(it.asJsoup()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchGenresFailed = genres.isFailure
|
||||||
|
genresList = genres.getOrNull().orEmpty()
|
||||||
|
fetchGenresAttempts++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request to the search page (or another one) that have the genres list.
|
||||||
|
*/
|
||||||
|
protected open fun genresRequest(): Request {
|
||||||
|
return GET("$baseUrl/?s=genre&post_type=wp-manga", headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the genres from the search page document.
|
||||||
|
*
|
||||||
|
* @param document The search page document
|
||||||
|
*/
|
||||||
|
protected open fun parseGenres(document: Document): List<Genre> {
|
||||||
|
return document.selectFirst("div.checkbox-group")
|
||||||
|
?.select("div.checkbox")
|
||||||
|
.orEmpty()
|
||||||
|
.map { li ->
|
||||||
|
Genre(
|
||||||
|
li.selectFirst("label")!!.text(),
|
||||||
|
li.selectFirst("input[type=checkbox]")!!.`val`(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/66614516
|
||||||
|
private fun String.decodeHex(): ByteArray {
|
||||||
|
check(length % 2 == 0) { "Must have an even length" }
|
||||||
|
|
||||||
|
return chunked(2)
|
||||||
|
.map { it.toInt(16).toByte() }
|
||||||
|
.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
|
addRandomUAPreferenceToScreen(screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val URL_SEARCH_PREFIX = "slug:"
|
||||||
|
val SALTED = "Salted__".toByteArray(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WordSet(private vararg val words: String) {
|
||||||
|
fun anyWordIn(dateString: String): Boolean = words.any { dateString.contains(it, ignoreCase = true) }
|
||||||
|
fun startsWith(dateString: String): Boolean = words.any { dateString.startsWith(it, ignoreCase = true) }
|
||||||
|
fun endsWith(dateString: String): Boolean = words.any { dateString.endsWith(it, ignoreCase = true) }
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
package eu.kanade.tachiyomi.multisrc.madara
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
class MadaraUrlActivity : Activity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val pathSegments = intent?.data?.pathSegments
|
||||||
|
|
||||||
|
if (pathSegments != null && pathSegments.size >= 2) {
|
||||||
|
val mainIntent = Intent().apply {
|
||||||
|
action = "eu.kanade.tachiyomi.SEARCH"
|
||||||
|
putExtra("query", "${getSLUG(pathSegments)}")
|
||||||
|
putExtra("filter", packageName)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
startActivity(mainIntent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Log.e("MadaraUrl", e.toString())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e("MadaraUrl", "could not parse uri from intent $intent")
|
||||||
|
}
|
||||||
|
|
||||||
|
finish()
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSLUG(pathSegments: MutableList<String>): String? {
|
||||||
|
return if (pathSegments.size >= 2) {
|
||||||
|
val slug = pathSegments[1]
|
||||||
|
"${Madara.URL_SEARCH_PREFIX}$slug"
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
3
src/en/mangabuddy/AndroidManifest.xml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- THIS FILE IS AUTO-GENERATED; DO NOT EDIT -->
|
||||||
|
<manifest />
|
26
src/en/mangabuddy/build.gradle
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
// THIS FILE IS AUTO-GENERATED; DO NOT EDIT
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
apply plugin: 'kotlinx-serialization'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
extName = 'MangaBuddy'
|
||||||
|
pkgNameSuffix = 'en.mangabuddy'
|
||||||
|
extClass = '.MangaBuddy'
|
||||||
|
extFactory = 'madtheme'
|
||||||
|
extVersionCode = 15
|
||||||
|
isNsfw = true
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
||||||
|
|
||||||
|
android {
|
||||||
|
defaultConfig {
|
||||||
|
manifestPlaceholders += [
|
||||||
|
SOURCEHOST: "mangabuddy.com",
|
||||||
|
SOURCESCHEME: "https"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
BIN
src/en/mangabuddy/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
src/en/mangabuddy/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
src/en/mangabuddy/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 5 KiB |
BIN
src/en/mangabuddy/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 8.7 KiB |
BIN
src/en/mangabuddy/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/en/mangabuddy/res/web_hi_res_512.png
Normal file
After Width: | Height: | Size: 46 KiB |
|
@ -0,0 +1,8 @@
|
||||||
|
/* ktlint-disable */
|
||||||
|
// THIS FILE IS AUTO-GENERATED; DO NOT EDIT
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangabuddy
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.multisrc.madtheme.MadTheme
|
||||||
|
|
||||||
|
|
||||||
|
class MangaBuddy : MadTheme("MangaBuddy", "https://mangabuddy.com", "en")
|
|
@ -0,0 +1,368 @@
|
||||||
|
package eu.kanade.tachiyomi.multisrc.madtheme
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.asObservable
|
||||||
|
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||||
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.text.ParseException
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
abstract class MadTheme(
|
||||||
|
override val name: String,
|
||||||
|
override val baseUrl: String,
|
||||||
|
override val lang: String,
|
||||||
|
private val dateFormat: SimpleDateFormat = SimpleDateFormat("MMM dd, yyy", Locale.US),
|
||||||
|
) : ParsedHttpSource() {
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||||
|
.rateLimit(1, 1)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// TODO: better cookie sharing
|
||||||
|
// TODO: don't count cached responses against rate limit
|
||||||
|
private val chapterClient: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||||
|
.rateLimit(1, 12)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
override fun headersBuilder() = Headers.Builder().apply {
|
||||||
|
add("Referer", "$baseUrl/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
// Popular
|
||||||
|
override fun popularMangaRequest(page: Int): Request =
|
||||||
|
searchMangaRequest(page, "", FilterList(OrderFilter(0)))
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage =
|
||||||
|
searchMangaParse(response)
|
||||||
|
|
||||||
|
override fun popularMangaSelector(): String =
|
||||||
|
searchMangaSelector()
|
||||||
|
|
||||||
|
override fun popularMangaFromElement(element: Element): SManga =
|
||||||
|
searchMangaFromElement(element)
|
||||||
|
|
||||||
|
override fun popularMangaNextPageSelector(): String? =
|
||||||
|
searchMangaNextPageSelector()
|
||||||
|
|
||||||
|
// Latest
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request =
|
||||||
|
searchMangaRequest(page, "", FilterList(OrderFilter(1)))
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage =
|
||||||
|
searchMangaParse(response)
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector(): String =
|
||||||
|
searchMangaSelector()
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element): SManga =
|
||||||
|
searchMangaFromElement(element)
|
||||||
|
|
||||||
|
override fun latestUpdatesNextPageSelector(): String? =
|
||||||
|
searchMangaNextPageSelector()
|
||||||
|
|
||||||
|
// Search
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val url = "$baseUrl/search".toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("q", query)
|
||||||
|
.addQueryParameter("page", page.toString())
|
||||||
|
|
||||||
|
filters.forEach { filter ->
|
||||||
|
when (filter) {
|
||||||
|
is GenreFilter -> {
|
||||||
|
filter.state
|
||||||
|
.filter { it.state }
|
||||||
|
.let { list ->
|
||||||
|
if (list.isNotEmpty()) {
|
||||||
|
list.forEach { genre -> url.addQueryParameter("genre[]", genre.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is StatusFilter -> {
|
||||||
|
url.addQueryParameter("status", filter.toUriPart())
|
||||||
|
}
|
||||||
|
is OrderFilter -> {
|
||||||
|
url.addQueryParameter("sort", filter.toUriPart())
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GET(url.toString(), headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaSelector(): String = ".book-detailed-item"
|
||||||
|
|
||||||
|
override fun searchMangaFromElement(element: Element): SManga = SManga.create().apply {
|
||||||
|
setUrlWithoutDomain(element.select("a").first()!!.attr("abs:href"))
|
||||||
|
title = element.select("a").first()!!.attr("title")
|
||||||
|
description = element.select(".summary").first()?.text()
|
||||||
|
genre = element.select(".genres > *").joinToString { it.text() }
|
||||||
|
thumbnail_url = element.select("img").first()!!.attr("abs:data-src")
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Only some sites use the next/previous buttons, so instead we check for the next link
|
||||||
|
* after the active one. We use the :not() selector to exclude the optional next button
|
||||||
|
*/
|
||||||
|
override fun searchMangaNextPageSelector(): String? = ".paginator > a.active + a:not([rel=next])"
|
||||||
|
|
||||||
|
// Details
|
||||||
|
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
|
||||||
|
title = document.select(".detail h1").first()!!.text()
|
||||||
|
author = document.select(".detail .meta > p > strong:contains(Authors) ~ a").joinToString { it.text().trim(',', ' ') }
|
||||||
|
genre = document.select(".detail .meta > p > strong:contains(Genres) ~ a").joinToString { it.text().trim(',', ' ') }
|
||||||
|
thumbnail_url = document.select("#cover img").first()!!.attr("abs:data-src")
|
||||||
|
|
||||||
|
val altNames = document.select(".detail h2").first()?.text()
|
||||||
|
?.split(',', ';')
|
||||||
|
?.mapNotNull { it.trim().takeIf { it != title } }
|
||||||
|
?: listOf()
|
||||||
|
|
||||||
|
description = document.select(".summary .content").first()?.text() +
|
||||||
|
(altNames.takeIf { it.isNotEmpty() }?.let { "\n\nAlt name(s): ${it.joinToString()}" } ?: "")
|
||||||
|
|
||||||
|
val statusText = document.select(".detail .meta > p > strong:contains(Status) ~ a").first()!!.text()
|
||||||
|
status = when (statusText.lowercase(Locale.US)) {
|
||||||
|
"ongoing" -> SManga.ONGOING
|
||||||
|
"completed" -> SManga.COMPLETED
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chapters
|
||||||
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||||
|
// API is heavily rate limited. Use custom client
|
||||||
|
return if (manga.status != SManga.LICENSED) {
|
||||||
|
chapterClient.newCall(chapterListRequest(manga))
|
||||||
|
.asObservable()
|
||||||
|
.map { response ->
|
||||||
|
chapterListParse(response)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Observable.error(Exception("Licensed - No chapters to show"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
if (response.code in 200..299) {
|
||||||
|
return super.chapterListParse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to show message/error from site
|
||||||
|
response.body.let { body ->
|
||||||
|
json.decodeFromString<JsonObject>(body.string())["message"]
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.content
|
||||||
|
?.let { throw Exception(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception("HTTP error ${response.code}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListRequest(manga: SManga): Request =
|
||||||
|
GET("$baseUrl/api/manga${manga.url}/chapters?source=detail", headers)
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
|
if (genresList == null) {
|
||||||
|
genresList = parseGenres(response.asJsoup(response.peekBody(Long.MAX_VALUE).string()))
|
||||||
|
}
|
||||||
|
return super.searchMangaParse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListSelector(): String = "#chapter-list > li"
|
||||||
|
|
||||||
|
override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply {
|
||||||
|
// Not using setUrlWithoutDomain() to support external chapters
|
||||||
|
url = element.selectFirst("a")!!
|
||||||
|
.absUrl("href")
|
||||||
|
.removePrefix(baseUrl)
|
||||||
|
|
||||||
|
name = element.select(".chapter-title").first()!!.text()
|
||||||
|
date_upload = parseChapterDate(element.select(".chapter-update").first()?.text())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
override fun pageListParse(document: Document): List<Page> {
|
||||||
|
val html = document.html()
|
||||||
|
|
||||||
|
if (!html.contains("var mainServer = \"")) {
|
||||||
|
val chapterImagesFromHtml = document.select("#chapter-images img")
|
||||||
|
|
||||||
|
// 17/03/2023: Certain hosts only embed two pages in their "#chapter-images" and leave
|
||||||
|
// the rest to be lazily(?) loaded by javascript. Let's extract `chapImages` and compare
|
||||||
|
// the count against our select query. If both counts are the same, extract the original
|
||||||
|
// images directly from the <img> tags otherwise pick the higher count. (heuristic)
|
||||||
|
// First things first, let's verify `chapImages` actually exists.
|
||||||
|
if (html.contains("var chapImages = '")) {
|
||||||
|
val chapterImagesFromJs = html
|
||||||
|
.substringAfter("var chapImages = '")
|
||||||
|
.substringBefore("'")
|
||||||
|
.split(',')
|
||||||
|
|
||||||
|
// Make sure chapter images we've got from javascript all have a host, otherwise
|
||||||
|
// we've got no choice but to fallback to chapter images from HTML.
|
||||||
|
// TODO: This might need to be solved one day ^
|
||||||
|
if (chapterImagesFromJs.all { e ->
|
||||||
|
e.startsWith("http://") || e.startsWith("https://")
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
// Great, we can use these.
|
||||||
|
if (chapterImagesFromHtml.count() < chapterImagesFromJs.count()) {
|
||||||
|
// Seems like we've hit such a host, let's use the images we've obtained
|
||||||
|
// from the javascript string.
|
||||||
|
return chapterImagesFromJs.mapIndexed { index, path ->
|
||||||
|
Page(index, imageUrl = path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No fancy CDN, all images are available directly in <img> tags (hopefully)
|
||||||
|
return chapterImagesFromHtml.mapIndexed { index, element ->
|
||||||
|
Page(index, imageUrl = element.attr("abs:data-src"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// While the site may support multiple CDN hosts, we have opted to ignore those
|
||||||
|
val mainServer = html
|
||||||
|
.substringAfter("var mainServer = \"")
|
||||||
|
.substringBefore("\"")
|
||||||
|
val schemePrefix = if (mainServer.startsWith("//")) "https:" else ""
|
||||||
|
|
||||||
|
val chapImages = html
|
||||||
|
.substringAfter("var chapImages = '")
|
||||||
|
.substringBefore("'")
|
||||||
|
.split(',')
|
||||||
|
|
||||||
|
return chapImages.mapIndexed { index, path ->
|
||||||
|
Page(index, imageUrl = "$schemePrefix$mainServer$path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image
|
||||||
|
override fun pageListRequest(chapter: SChapter): Request {
|
||||||
|
return if (chapter.url.toHttpUrlOrNull() != null) {
|
||||||
|
// External chapter
|
||||||
|
GET(chapter.url, headers)
|
||||||
|
} else {
|
||||||
|
super.pageListRequest(chapter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(document: Document): String =
|
||||||
|
throw UnsupportedOperationException("Not used.")
|
||||||
|
|
||||||
|
// Date logic lifted from Madara
|
||||||
|
private fun parseChapterDate(date: String?): Long {
|
||||||
|
date ?: return 0
|
||||||
|
|
||||||
|
fun SimpleDateFormat.tryParse(string: String): Long {
|
||||||
|
return try {
|
||||||
|
parse(string)?.time ?: 0
|
||||||
|
} catch (_: ParseException) {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return when {
|
||||||
|
"ago".endsWith(date) -> {
|
||||||
|
parseRelativeDate(date)
|
||||||
|
}
|
||||||
|
else -> dateFormat.tryParse(date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRelativeDate(date: String): Long {
|
||||||
|
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
date.contains("day") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
|
||||||
|
date.contains("hour") -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
|
||||||
|
date.contains("minute") -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
|
||||||
|
date.contains("second") -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic genres
|
||||||
|
private fun parseGenres(document: Document): List<Genre>? {
|
||||||
|
return document.select(".checkbox-group.genres").first()?.select("label")?.map {
|
||||||
|
Genre(it.select(".radio__label").first()!!.text(), it.select("input").`val`())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
override fun getFilterList() = FilterList(
|
||||||
|
GenreFilter(getGenreList()),
|
||||||
|
StatusFilter(),
|
||||||
|
OrderFilter(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private class GenreFilter(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
|
||||||
|
private class Genre(name: String, val id: String) : Filter.CheckBox(name)
|
||||||
|
private var genresList: List<Genre>? = null
|
||||||
|
private fun getGenreList(): List<Genre> {
|
||||||
|
// Filters are fetched immediately once an extension loads
|
||||||
|
// We're only able to get filters after a loading the manga directory, and resetting
|
||||||
|
// the filters is the only thing that seems to reinflate the view
|
||||||
|
return genresList ?: listOf(Genre("Press reset to attempt to fetch genres", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatusFilter : UriPartFilter(
|
||||||
|
"Status",
|
||||||
|
arrayOf(
|
||||||
|
Pair("All", "all"),
|
||||||
|
Pair("Ongoing", "ongoing"),
|
||||||
|
Pair("Completed", "completed"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class OrderFilter(state: Int = 0) : UriPartFilter(
|
||||||
|
"Order By",
|
||||||
|
arrayOf(
|
||||||
|
Pair("Views", "views"),
|
||||||
|
Pair("Updated", "updated_at"),
|
||||||
|
Pair("Created", "created_at"),
|
||||||
|
Pair("Name A-Z", "name"),
|
||||||
|
Pair("Rating", "rating"),
|
||||||
|
),
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
|
||||||
|
open class UriPartFilter(
|
||||||
|
displayName: String,
|
||||||
|
private val vals: Array<Pair<String, String>>,
|
||||||
|
state: Int = 0,
|
||||||
|
) :
|
||||||
|
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray(), state) {
|
||||||
|
fun toUriPart() = vals[state].second
|
||||||
|
}
|
||||||
|
}
|