re-add removed extensions
|
@ -0,0 +1,34 @@
|
||||||
|
package eu.kanade.tachiyomi.lib.unpacker
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper class to extract substrings efficiently.
|
||||||
|
*
|
||||||
|
* Note that all methods move [startIndex] over the ending delimiter.
|
||||||
|
*/
|
||||||
|
class SubstringExtractor(private val text: String) {
|
||||||
|
private var startIndex = 0
|
||||||
|
|
||||||
|
fun skipOver(str: String) {
|
||||||
|
val index = text.indexOf(str, startIndex)
|
||||||
|
if (index == -1) return
|
||||||
|
startIndex = index + str.length
|
||||||
|
}
|
||||||
|
|
||||||
|
fun substringBefore(str: String): String {
|
||||||
|
val index = text.indexOf(str, startIndex)
|
||||||
|
if (index == -1) return ""
|
||||||
|
val result = text.substring(startIndex, index)
|
||||||
|
startIndex = index + str.length
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun substringBetween(left: String, right: String): String {
|
||||||
|
val index = text.indexOf(left, startIndex)
|
||||||
|
if (index == -1) return ""
|
||||||
|
val leftIndex = index + left.length
|
||||||
|
val rightIndex = text.indexOf(right, leftIndex)
|
||||||
|
if (rightIndex == -1) return ""
|
||||||
|
startIndex = rightIndex + right.length
|
||||||
|
return text.substring(leftIndex, rightIndex)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
package eu.kanade.tachiyomi.lib.unpacker
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper class to unpack JavaScript code compressed by [packer](http://dean.edwards.name/packer/).
|
||||||
|
*
|
||||||
|
* Source code of packer can be found [here](https://github.com/evanw/packer/blob/master/packer.js).
|
||||||
|
*/
|
||||||
|
object Unpacker {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpacks JavaScript code compressed by packer.
|
||||||
|
*
|
||||||
|
* Specify [left] and [right] to unpack only the data between them.
|
||||||
|
*
|
||||||
|
* Note: single quotes `\'` in the data will be replaced with double quotes `"`.
|
||||||
|
*/
|
||||||
|
fun unpack(script: String, left: String? = null, right: String? = null): String =
|
||||||
|
unpack(SubstringExtractor(script), left, right)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpacks JavaScript code compressed by packer.
|
||||||
|
*
|
||||||
|
* Specify [left] and [right] to unpack only the data between them.
|
||||||
|
*
|
||||||
|
* Note: single quotes `\'` in the data will be replaced with double quotes `"`.
|
||||||
|
*/
|
||||||
|
fun unpack(script: SubstringExtractor, left: String? = null, right: String? = null): String {
|
||||||
|
val packed = script
|
||||||
|
.substringBetween("}('", ".split('|'),0,{}))")
|
||||||
|
.replace("\\'", "\"")
|
||||||
|
|
||||||
|
val parser = SubstringExtractor(packed)
|
||||||
|
val data: String
|
||||||
|
if (left != null && right != null) {
|
||||||
|
data = parser.substringBetween(left, right)
|
||||||
|
parser.skipOver("',")
|
||||||
|
} else {
|
||||||
|
data = parser.substringBefore("',")
|
||||||
|
}
|
||||||
|
if (data.isEmpty()) return ""
|
||||||
|
|
||||||
|
val dictionary = parser.substringBetween("'", "'").split("|")
|
||||||
|
val size = dictionary.size
|
||||||
|
|
||||||
|
return wordRegex.replace(data) {
|
||||||
|
val key = it.value
|
||||||
|
val index = parseRadix62(key)
|
||||||
|
if (index >= size) return@replace key
|
||||||
|
dictionary[index].ifEmpty { key }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val wordRegex by lazy { Regex("""\w+""") }
|
||||||
|
|
||||||
|
private fun parseRadix62(str: String): Int {
|
||||||
|
var result = 0
|
||||||
|
for (ch in str.toCharArray()) {
|
||||||
|
result = result * 62 + when {
|
||||||
|
ch.code <= '9'.code -> { // 0-9
|
||||||
|
ch.code - '0'.code
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.code >= 'a'.code -> { // a-z
|
||||||
|
// ch - 'a' + 10
|
||||||
|
ch.code - ('a'.code - 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> { // A-Z
|
||||||
|
// ch - 'A' + 36
|
||||||
|
ch.code - ('A'.code - 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
After Width: | Height: | Size: 7.9 KiB |
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 26 KiB |
After Width: | Height: | Size: 43 KiB |
After Width: | Height: | Size: 338 KiB |
|
@ -0,0 +1,11 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.s2manga
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.multisrc.madara.Madara
|
||||||
|
|
||||||
|
class S2Manga : Madara("S2Manga", "https://www.s2manga.com", "en") {
|
||||||
|
|
||||||
|
override fun headersBuilder() = super.headersBuilder()
|
||||||
|
.add("Referer", "$baseUrl/")
|
||||||
|
|
||||||
|
override val pageListParseSelector = "div.page-break img[src*=\"https\"]"
|
||||||
|
}
|
|
@ -422,6 +422,7 @@ class MadaraGenerator : ThemeSourceGenerator {
|
||||||
SingleLang("ROG Mangás", "https://rogmangas.com", "pt-BR", pkgName = "mangasoverall", className = "RogMangas", overrideVersionCode = 1),
|
SingleLang("ROG Mangás", "https://rogmangas.com", "pt-BR", pkgName = "mangasoverall", className = "RogMangas", overrideVersionCode = 1),
|
||||||
SingleLang("Romantik Manga", "https://romantikmanga.com", "tr"),
|
SingleLang("Romantik Manga", "https://romantikmanga.com", "tr"),
|
||||||
SingleLang("Rüya Manga", "https://www.ruyamanga.com", "tr", className = "RuyaManga", overrideVersionCode = 1),
|
SingleLang("Rüya Manga", "https://www.ruyamanga.com", "tr", className = "RuyaManga", overrideVersionCode = 1),
|
||||||
|
SingleLang("S2Manga", "https://www.s2manga.com", "en", overrideVersionCode = 2),
|
||||||
SingleLang("Sagrado Império da Britannia", "https://imperiodabritannia.com", "pt-BR", className = "ImperioDaBritannia"),
|
SingleLang("Sagrado Império da Britannia", "https://imperiodabritannia.com", "pt-BR", className = "ImperioDaBritannia"),
|
||||||
SingleLang("SamuraiScan", "https://samuraiscan.com", "es", overrideVersionCode = 3),
|
SingleLang("SamuraiScan", "https://samuraiscan.com", "es", overrideVersionCode = 3),
|
||||||
SingleLang("Sawamics", "https://sawamics.com", "en"),
|
SingleLang("Sawamics", "https://sawamics.com", "en"),
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application>
|
||||||
|
<activity
|
||||||
|
android:name=".all.batoto.BatoToUrlActivity"
|
||||||
|
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="*.bato.to" />
|
||||||
|
<data android:host="bato.to" />
|
||||||
|
<data android:host="*.batocc.com" />
|
||||||
|
<data android:host="batocc.com" />
|
||||||
|
<data android:host="*.batotoo.com" />
|
||||||
|
<data android:host="batotoo.com" />
|
||||||
|
<data android:host="*.batotwo.com" />
|
||||||
|
<data android:host="batotwo.com" />
|
||||||
|
<data android:host="*.battwo.com" />
|
||||||
|
<data android:host="battwo.com" />
|
||||||
|
<data android:host="*.comiko.net" />
|
||||||
|
<data android:host="comiko.net" />
|
||||||
|
<data android:host="*.mangatoto.com" />
|
||||||
|
<data android:host="mangatoto.com" />
|
||||||
|
<data android:host="*.mangatoto.net" />
|
||||||
|
<data android:host="mangatoto.net" />
|
||||||
|
<data android:host="*.mangatoto.org" />
|
||||||
|
<data android:host="mangatoto.org" />
|
||||||
|
<data android:host="*.mycordant.co.uk" />
|
||||||
|
<data android:host="mycordant.co.uk" />
|
||||||
|
<data android:host="*.dto.to" />
|
||||||
|
<data android:host="dto.to" />
|
||||||
|
<data android:host="*.hto.to" />
|
||||||
|
<data android:host="hto.to" />
|
||||||
|
<data android:host="*.mto.to" />
|
||||||
|
<data android:host="mto.to" />
|
||||||
|
<data android:host="*.wto.to" />
|
||||||
|
<data android:host="wto.to" />
|
||||||
|
|
||||||
|
<data
|
||||||
|
android:pathPattern="/series/..*"
|
||||||
|
android:scheme="https" />
|
||||||
|
<data
|
||||||
|
android:pathPattern="/subject-overview/..*"
|
||||||
|
android:scheme="https" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
|
@ -0,0 +1,201 @@
|
||||||
|
## 1.3.30
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
* Replace CryptoJS with Native Kotlin Functions
|
||||||
|
* Remove QuickJS dependency
|
||||||
|
|
||||||
|
## 1.3.29
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
* Cleanup pageListParse function
|
||||||
|
* Replace Duktape with QuickJS
|
||||||
|
|
||||||
|
## 1.3.28
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add mirror `batocc.com`
|
||||||
|
* Add mirror `batotwo.com`
|
||||||
|
* Add mirror `mangatoto.net`
|
||||||
|
* Add mirror `mangatoto.org`
|
||||||
|
* Add mirror `mycordant.co.uk`
|
||||||
|
* Add mirror `dto.to`
|
||||||
|
* Add mirror `hto.to`
|
||||||
|
* Add mirror `mto.to`
|
||||||
|
* Add mirror `wto.to`
|
||||||
|
* Remove mirror `mycdhands.com`
|
||||||
|
|
||||||
|
## 1.3.27
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Change default popular sort by `Most Views Totally`
|
||||||
|
|
||||||
|
## 1.3.26
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
* Update author and artist parsing
|
||||||
|
|
||||||
|
## 1.3.25
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
* Status parsing
|
||||||
|
* Artist name parsing
|
||||||
|
|
||||||
|
## 1.3.24
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
* Bump versions for individual extension with URL handler activities
|
||||||
|
|
||||||
|
## 1.2.23
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
* Update pageListParse logic to handle website changes
|
||||||
|
|
||||||
|
## 1.2.22
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add `CHANGELOG.md` & `README.md`
|
||||||
|
|
||||||
|
## 1.2.21
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
* Update lang codes
|
||||||
|
|
||||||
|
## 1.2.20
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Rework of search
|
||||||
|
|
||||||
|
## 1.2.19
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Support for alternative chapter list
|
||||||
|
* Personal lists filter
|
||||||
|
|
||||||
|
## 1.2.18
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Utils lists filter
|
||||||
|
* Letter matching filter
|
||||||
|
|
||||||
|
## 1.2.17
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add mirror `mycdhands.com`
|
||||||
|
|
||||||
|
## 1.2.16
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Mirror support
|
||||||
|
* URL intent updates
|
||||||
|
|
||||||
|
## 1.2.15
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
* Manga description
|
||||||
|
|
||||||
|
## 1.2.14
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Escape entities
|
||||||
|
|
||||||
|
## 1.2.13
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
* Replace Gson with kotlinx.serialization
|
||||||
|
|
||||||
|
## 1.2.12
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
* Infinity search
|
||||||
|
|
||||||
|
## 1.2.11
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
* No search result
|
||||||
|
|
||||||
|
## 1.2.10
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Support for URL intent
|
||||||
|
* Updated filters
|
||||||
|
|
||||||
|
## 1.2.9
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
* Chapter parsing
|
||||||
|
|
||||||
|
## 1.2.8
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* More chapter filtering
|
||||||
|
|
||||||
|
## 1.2.7
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
* Language filtering in latest
|
||||||
|
* Parsing of seconds
|
||||||
|
|
||||||
|
## 1.2.6
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Scanlator support
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
* Date parsing
|
||||||
|
|
||||||
|
## 1.2.5
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Update supported Language list
|
||||||
|
|
||||||
|
## 1.2.4
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Support for excluding genres
|
||||||
|
|
||||||
|
## 1.2.3
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
* Typo in some genres
|
||||||
|
|
||||||
|
## 1.2.2
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Reworked filter option
|
||||||
|
|
||||||
|
## 1.2.1
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Conversion from Emerald to Bato.to
|
||||||
|
* First version
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Bato.to
|
||||||
|
|
||||||
|
Table of Content
|
||||||
|
- [FAQ](#FAQ)
|
||||||
|
- [Why are there Manga of diffrent languge than the selected one in Personal & Utils lists?](#why-are-there-manga-of-diffrent-languge-than-the-selected-one-in-personal--utils-lists)
|
||||||
|
- [Bato.to is not loading anything?](#batoto-is-not-loading-anything)
|
||||||
|
|
||||||
|
[Uncomment this if needed; and replace ( and ) with ( and )]: <> (- [Guides](#Guides))
|
||||||
|
|
||||||
|
Don't find the question you are look for go check out our general FAQs and Guides over at [Extension FAQ](https://tachiyomi.org/help/faq/#extensions) or [Getting Started](https://tachiyomi.org/help/guides/getting-started/#installation)
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
### Why are there Manga of diffrent languge than the selected one in Personal & Utils lists?
|
||||||
|
Personol & Utils lists have no way to difritiate between langueges.
|
||||||
|
|
||||||
|
### Bato.to is not loading anything?
|
||||||
|
Bato.to get blocked by some ISPs, try using a diffrent mirror of Bato.to from the settings.
|
||||||
|
|
||||||
|
[Uncomment this if needed]: <> (## Guides)
|
|
@ -0,0 +1,17 @@
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
apply plugin: 'kotlinx-serialization'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
extName = 'Bato.to'
|
||||||
|
pkgNameSuffix = 'all.batoto'
|
||||||
|
extClass = '.BatoToFactory'
|
||||||
|
extVersionCode = 32
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(':lib-cryptoaes'))
|
||||||
|
}
|
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 5.1 KiB |
After Width: | Height: | Size: 9.3 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 67 KiB |
|
@ -0,0 +1,974 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.batoto
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.preference.CheckBoxPreference
|
||||||
|
import androidx.preference.ListPreference
|
||||||
|
import androidx.preference.PreferenceScreen
|
||||||
|
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
|
||||||
|
import eu.kanade.tachiyomi.lib.cryptoaes.Deobfuscator
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
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.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import org.jsoup.parser.Parser
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
open class BatoTo(
|
||||||
|
final override val lang: String,
|
||||||
|
private val siteLang: String,
|
||||||
|
) : ConfigurableSource, ParsedHttpSource() {
|
||||||
|
|
||||||
|
private val preferences: SharedPreferences by lazy {
|
||||||
|
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||||
|
}
|
||||||
|
|
||||||
|
override val name: String = "Bato.to"
|
||||||
|
override val baseUrl: String = getMirrorPref()!!
|
||||||
|
override val id: Long = when (lang) {
|
||||||
|
"zh-Hans" -> 2818874445640189582
|
||||||
|
"zh-Hant" -> 38886079663327225
|
||||||
|
"ro-MD" -> 8871355786189601023
|
||||||
|
else -> super.id
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
|
val mirrorPref = ListPreference(screen.context).apply {
|
||||||
|
key = "${MIRROR_PREF_KEY}_$lang"
|
||||||
|
title = MIRROR_PREF_TITLE
|
||||||
|
entries = MIRROR_PREF_ENTRIES
|
||||||
|
entryValues = MIRROR_PREF_ENTRY_VALUES
|
||||||
|
setDefaultValue(MIRROR_PREF_DEFAULT_VALUE)
|
||||||
|
summary = "%s"
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
val selected = newValue as String
|
||||||
|
val index = findIndexOfValue(selected)
|
||||||
|
val entry = entryValues[index] as String
|
||||||
|
preferences.edit().putString("${MIRROR_PREF_KEY}_$lang", entry).commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val altChapterListPref = CheckBoxPreference(screen.context).apply {
|
||||||
|
key = "${ALT_CHAPTER_LIST_PREF_KEY}_$lang"
|
||||||
|
title = ALT_CHAPTER_LIST_PREF_TITLE
|
||||||
|
summary = ALT_CHAPTER_LIST_PREF_SUMMARY
|
||||||
|
setDefaultValue(ALT_CHAPTER_LIST_PREF_DEFAULT_VALUE)
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
val checkValue = newValue as Boolean
|
||||||
|
preferences.edit().putBoolean("${ALT_CHAPTER_LIST_PREF_KEY}_$lang", checkValue).commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
screen.addPreference(mirrorPref)
|
||||||
|
screen.addPreference(altChapterListPref)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMirrorPref(): String? = preferences.getString("${MIRROR_PREF_KEY}_$lang", MIRROR_PREF_DEFAULT_VALUE)
|
||||||
|
private fun getAltChapterListPref(): Boolean = preferences.getBoolean("${ALT_CHAPTER_LIST_PREF_KEY}_$lang", ALT_CHAPTER_LIST_PREF_DEFAULT_VALUE)
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request {
|
||||||
|
return GET("$baseUrl/browse?langs=$siteLang&sort=update&page=$page")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector(): String {
|
||||||
|
return when (siteLang) {
|
||||||
|
"" -> "div#series-list div.col"
|
||||||
|
"en" -> "div#series-list div.col.no-flag"
|
||||||
|
else -> "div#series-list div.col:has([data-lang=\"$siteLang\"])"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element): SManga {
|
||||||
|
val manga = SManga.create()
|
||||||
|
val item = element.select("a.item-cover")
|
||||||
|
val imgurl = item.select("img").attr("abs:src")
|
||||||
|
manga.setUrlWithoutDomain(item.attr("href"))
|
||||||
|
manga.title = element.select("a.item-title").text().removeEntities()
|
||||||
|
manga.thumbnail_url = imgurl
|
||||||
|
return manga
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesNextPageSelector() = "div#mainer nav.d-none .pagination .page-item:last-of-type:not(.disabled)"
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int): Request {
|
||||||
|
return GET("$baseUrl/browse?langs=$siteLang&sort=views_a&page=$page")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaSelector() = latestUpdatesSelector()
|
||||||
|
|
||||||
|
override fun popularMangaFromElement(element: Element) = latestUpdatesFromElement(element)
|
||||||
|
|
||||||
|
override fun popularMangaNextPageSelector() = latestUpdatesNextPageSelector()
|
||||||
|
|
||||||
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
|
return when {
|
||||||
|
query.startsWith("ID:") -> {
|
||||||
|
val id = query.substringAfter("ID:")
|
||||||
|
client.newCall(GET("$baseUrl/series/$id", headers)).asObservableSuccess()
|
||||||
|
.map { response ->
|
||||||
|
queryIDParse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query.isNotBlank() -> {
|
||||||
|
val url = "$baseUrl/search".toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("word", query)
|
||||||
|
.addQueryParameter("page", page.toString())
|
||||||
|
filters.forEach { filter ->
|
||||||
|
when (filter) {
|
||||||
|
is LetterFilter -> {
|
||||||
|
if (filter.state == 1) {
|
||||||
|
url.addQueryParameter("mode", "letter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> { /* Do Nothing */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.newCall(GET(url.build().toString(), headers)).asObservableSuccess()
|
||||||
|
.map { response ->
|
||||||
|
queryParse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val url = "$baseUrl/browse".toHttpUrlOrNull()!!.newBuilder()
|
||||||
|
var min = ""
|
||||||
|
var max = ""
|
||||||
|
filters.forEach { filter ->
|
||||||
|
when (filter) {
|
||||||
|
is UtilsFilter -> {
|
||||||
|
if (filter.state != 0) {
|
||||||
|
val filterUrl = "$baseUrl/_utils/comic-list?type=${filter.selected}"
|
||||||
|
return client.newCall(GET(filterUrl, headers)).asObservableSuccess()
|
||||||
|
.map { response ->
|
||||||
|
queryUtilsParse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is HistoryFilter -> {
|
||||||
|
if (filter.state != 0) {
|
||||||
|
val filterUrl = "$baseUrl/ajax.my.${filter.selected}.paging"
|
||||||
|
return client.newCall(POST(filterUrl, headers, formBuilder().build())).asObservableSuccess()
|
||||||
|
.map { response ->
|
||||||
|
queryHistoryParse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LangGroupFilter -> {
|
||||||
|
if (filter.selected.isEmpty()) {
|
||||||
|
url.addQueryParameter("langs", siteLang)
|
||||||
|
} else {
|
||||||
|
val selection = "${filter.selected.joinToString(",")},$siteLang"
|
||||||
|
url.addQueryParameter("langs", selection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is GenreGroupFilter -> {
|
||||||
|
with(filter) {
|
||||||
|
url.addQueryParameter(
|
||||||
|
"genres",
|
||||||
|
included.joinToString(",") + "|" + excluded.joinToString(","),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is StatusFilter -> url.addQueryParameter("release", filter.selected)
|
||||||
|
is SortFilter -> {
|
||||||
|
if (filter.state != null) {
|
||||||
|
val sort = getSortFilter()[filter.state!!.index].value
|
||||||
|
val value = when (filter.state!!.ascending) {
|
||||||
|
true -> "az"
|
||||||
|
false -> "za"
|
||||||
|
}
|
||||||
|
url.addQueryParameter("sort", "$sort.$value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is OriginGroupFilter -> {
|
||||||
|
if (filter.selected.isNotEmpty()) {
|
||||||
|
url.addQueryParameter("origs", filter.selected.joinToString(","))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MinChapterTextFilter -> min = filter.state
|
||||||
|
is MaxChapterTextFilter -> max = filter.state
|
||||||
|
else -> { /* Do Nothing */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url.addQueryParameter("page", page.toString())
|
||||||
|
|
||||||
|
if (max.isNotEmpty() or min.isNotEmpty()) {
|
||||||
|
url.addQueryParameter("chapters", "$min-$max")
|
||||||
|
}
|
||||||
|
|
||||||
|
client.newCall(GET(url.build().toString(), headers)).asObservableSuccess()
|
||||||
|
.map { response ->
|
||||||
|
queryParse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryIDParse(response: Response): MangasPage {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
val infoElement = document.select("div#mainer div.container-fluid")
|
||||||
|
val manga = SManga.create()
|
||||||
|
manga.title = infoElement.select("h3").text().removeEntities()
|
||||||
|
manga.thumbnail_url = document.select("div.attr-cover img")
|
||||||
|
.attr("abs:src")
|
||||||
|
manga.url = infoElement.select("h3 a").attr("abs:href")
|
||||||
|
return MangasPage(listOf(manga), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryParse(response: Response): MangasPage {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
val mangas = document.select(latestUpdatesSelector())
|
||||||
|
.map { element -> latestUpdatesFromElement(element) }
|
||||||
|
val nextPage = document.select(latestUpdatesNextPageSelector()).first() != null
|
||||||
|
return MangasPage(mangas, nextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryUtilsParse(response: Response): MangasPage {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
val mangas = document.select("tbody > tr")
|
||||||
|
.map { element -> searchUtilsFromElement(element) }
|
||||||
|
return MangasPage(mangas, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryHistoryParse(response: Response): MangasPage {
|
||||||
|
val json = json.decodeFromString<JsonObject>(response.body.string())
|
||||||
|
val html = json.jsonObject["html"]!!.jsonPrimitive.content
|
||||||
|
|
||||||
|
val document = Jsoup.parse(html, response.request.url.toString())
|
||||||
|
val mangas = document.select(".my-history-item")
|
||||||
|
.map { element -> searchHistoryFromElement(element) }
|
||||||
|
return MangasPage(mangas, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchUtilsFromElement(element: Element): SManga {
|
||||||
|
val manga = SManga.create()
|
||||||
|
manga.setUrlWithoutDomain(element.select("td a").attr("href"))
|
||||||
|
manga.title = element.select("td a").text()
|
||||||
|
manga.thumbnail_url = element.select("img").attr("abs:src")
|
||||||
|
return manga
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchHistoryFromElement(element: Element): SManga {
|
||||||
|
val manga = SManga.create()
|
||||||
|
manga.setUrlWithoutDomain(element.select(".position-relative a").attr("href"))
|
||||||
|
manga.title = element.select(".position-relative a").text()
|
||||||
|
manga.thumbnail_url = element.select("img").attr("abs:src")
|
||||||
|
return manga
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun formBuilder() = FormBody.Builder().apply {
|
||||||
|
add("_where", "browse")
|
||||||
|
add("first", "0")
|
||||||
|
add("limit", "0")
|
||||||
|
add("prevPos", "null")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = throw UnsupportedOperationException("Not used")
|
||||||
|
override fun searchMangaSelector() = throw UnsupportedOperationException("Not used")
|
||||||
|
override fun searchMangaFromElement(element: Element) = throw UnsupportedOperationException("Not used")
|
||||||
|
override fun searchMangaNextPageSelector() = throw UnsupportedOperationException("Not used")
|
||||||
|
|
||||||
|
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||||
|
if (manga.url.startsWith("http")) {
|
||||||
|
return GET(manga.url, headers)
|
||||||
|
}
|
||||||
|
return super.mangaDetailsRequest(manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(document: Document): SManga {
|
||||||
|
val infoElement = document.select("div#mainer div.container-fluid")
|
||||||
|
val manga = SManga.create()
|
||||||
|
val workStatus = infoElement.select("div.attr-item:contains(original work) span").text()
|
||||||
|
val uploadStatus = infoElement.select("div.attr-item:contains(upload status) span").text()
|
||||||
|
manga.title = infoElement.select("h3").text().removeEntities()
|
||||||
|
manga.author = infoElement.select("div.attr-item:contains(author) span").text()
|
||||||
|
manga.artist = infoElement.select("div.attr-item:contains(artist) span").text()
|
||||||
|
manga.status = parseStatus(workStatus, uploadStatus)
|
||||||
|
manga.genre = infoElement.select(".attr-item b:contains(genres) + span ").joinToString { it.text() }
|
||||||
|
manga.description = infoElement.select("div.limit-html").text() + "\n" + infoElement.select(".episode-list > .alert-warning").text().trim()
|
||||||
|
manga.thumbnail_url = document.select("div.attr-cover img")
|
||||||
|
.attr("abs:src")
|
||||||
|
return manga
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseStatus(workStatus: String?, uploadStatus: String?) = when {
|
||||||
|
workStatus == null -> SManga.UNKNOWN
|
||||||
|
workStatus.contains("Ongoing") -> SManga.ONGOING
|
||||||
|
workStatus.contains("Cancelled") -> SManga.CANCELLED
|
||||||
|
workStatus.contains("Hiatus") -> SManga.ON_HIATUS
|
||||||
|
workStatus.contains("Completed") -> when {
|
||||||
|
uploadStatus?.contains("Ongoing") == true -> SManga.PUBLISHING_FINISHED
|
||||||
|
else -> SManga.COMPLETED
|
||||||
|
}
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||||
|
val url = client.newCall(
|
||||||
|
GET(
|
||||||
|
when {
|
||||||
|
manga.url.startsWith("http") -> manga.url
|
||||||
|
else -> "$baseUrl${manga.url}"
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).execute().asJsoup()
|
||||||
|
if (getAltChapterListPref() || checkChapterLists(url)) {
|
||||||
|
val id = manga.url.substringBeforeLast("/").substringAfterLast("/").trim()
|
||||||
|
return client.newCall(GET("$baseUrl/rss/series/$id.xml"))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { altChapterParse(it, manga.title) }
|
||||||
|
}
|
||||||
|
return super.fetchChapterList(manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun altChapterParse(response: Response, title: String): List<SChapter> {
|
||||||
|
return Jsoup.parse(response.body.string(), response.request.url.toString(), Parser.xmlParser())
|
||||||
|
.select("channel > item").map { item ->
|
||||||
|
SChapter.create().apply {
|
||||||
|
url = item.selectFirst("guid")!!.text()
|
||||||
|
name = item.selectFirst("title")!!.text().substringAfter(title).trim()
|
||||||
|
date_upload = SimpleDateFormat("E, dd MMM yyyy H:m:s Z", Locale.US).parse(item.selectFirst("pubDate")!!.text())?.time ?: 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkChapterLists(document: Document): Boolean {
|
||||||
|
return document.select(".episode-list > .alert-warning").text().contains("This comic has been marked as deleted and the chapter list is not available.")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListRequest(manga: SManga): Request {
|
||||||
|
if (manga.url.startsWith("http")) {
|
||||||
|
return GET(manga.url, headers)
|
||||||
|
}
|
||||||
|
return super.chapterListRequest(manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListSelector() = "div.main div.p-2"
|
||||||
|
|
||||||
|
override fun chapterFromElement(element: Element): SChapter {
|
||||||
|
val chapter = SChapter.create()
|
||||||
|
val urlElement = element.select("a.chapt")
|
||||||
|
val group = element.select("div.extra > a:not(.ps-3)").text()
|
||||||
|
val time = element.select("div.extra > i.ps-3").text()
|
||||||
|
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
||||||
|
chapter.name = urlElement.text()
|
||||||
|
if (group != "") {
|
||||||
|
chapter.scanlator = group
|
||||||
|
}
|
||||||
|
if (time != "") {
|
||||||
|
chapter.date_upload = parseChapterDate(time)
|
||||||
|
}
|
||||||
|
return chapter
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseChapterDate(date: String): Long {
|
||||||
|
val value = date.split(' ')[0].toInt()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
"secs" in date -> Calendar.getInstance().apply {
|
||||||
|
add(Calendar.SECOND, value * -1)
|
||||||
|
}.timeInMillis
|
||||||
|
"mins" in date -> Calendar.getInstance().apply {
|
||||||
|
add(Calendar.MINUTE, value * -1)
|
||||||
|
}.timeInMillis
|
||||||
|
"hours" in date -> Calendar.getInstance().apply {
|
||||||
|
add(Calendar.HOUR_OF_DAY, value * -1)
|
||||||
|
}.timeInMillis
|
||||||
|
"days" in date -> Calendar.getInstance().apply {
|
||||||
|
add(Calendar.DATE, value * -1)
|
||||||
|
}.timeInMillis
|
||||||
|
"weeks" in date -> Calendar.getInstance().apply {
|
||||||
|
add(Calendar.DATE, value * 7 * -1)
|
||||||
|
}.timeInMillis
|
||||||
|
"months" in date -> Calendar.getInstance().apply {
|
||||||
|
add(Calendar.MONTH, value * -1)
|
||||||
|
}.timeInMillis
|
||||||
|
"years" in date -> Calendar.getInstance().apply {
|
||||||
|
add(Calendar.YEAR, value * -1)
|
||||||
|
}.timeInMillis
|
||||||
|
"sec" in date -> Calendar.getInstance().apply {
|
||||||
|
add(Calendar.SECOND, value * -1)
|
||||||
|
}.timeInMillis
|
||||||
|
"min" in date -> Calendar.getInstance().apply {
|
||||||
|
add(Calendar.MINUTE, value * -1)
|
||||||
|
}.timeInMillis
|
||||||
|
"hour" in date -> Calendar.getInstance().apply {
|
||||||
|
add(Calendar.HOUR_OF_DAY, value * -1)
|
||||||
|
}.timeInMillis
|
||||||
|
"day" in date -> Calendar.getInstance().apply {
|
||||||
|
add(Calendar.DATE, value * -1)
|
||||||
|
}.timeInMillis
|
||||||
|
"week" in date -> Calendar.getInstance().apply {
|
||||||
|
add(Calendar.DATE, value * 7 * -1)
|
||||||
|
}.timeInMillis
|
||||||
|
"month" in date -> Calendar.getInstance().apply {
|
||||||
|
add(Calendar.MONTH, value * -1)
|
||||||
|
}.timeInMillis
|
||||||
|
"year" in date -> Calendar.getInstance().apply {
|
||||||
|
add(Calendar.YEAR, value * -1)
|
||||||
|
}.timeInMillis
|
||||||
|
else -> {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListRequest(chapter: SChapter): Request {
|
||||||
|
if (chapter.url.startsWith("http")) {
|
||||||
|
return GET(chapter.url, headers)
|
||||||
|
}
|
||||||
|
return super.pageListRequest(chapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(document: Document): List<Page> {
|
||||||
|
val script = document.selectFirst("script:containsData(imgHttpLis):containsData(batoWord):containsData(batoPass)")?.html()
|
||||||
|
?: throw RuntimeException("Couldn't find script with image data.")
|
||||||
|
|
||||||
|
val imgHttpLisString = script.substringAfter("const imgHttpLis =").substringBefore(";").trim()
|
||||||
|
val imgHttpLis = json.parseToJsonElement(imgHttpLisString).jsonArray.map { it.jsonPrimitive.content }
|
||||||
|
val batoWord = script.substringAfter("const batoWord =").substringBefore(";").trim()
|
||||||
|
val batoPass = script.substringAfter("const batoPass =").substringBefore(";").trim()
|
||||||
|
|
||||||
|
val evaluatedPass: String = Deobfuscator.deobfuscateJsPassword(batoPass)
|
||||||
|
val imgAccListString = CryptoAES.decrypt(batoWord.removeSurrounding("\""), evaluatedPass)
|
||||||
|
val imgAccList = json.parseToJsonElement(imgAccListString).jsonArray.map { it.jsonPrimitive.content }
|
||||||
|
|
||||||
|
return imgHttpLis.zip(imgAccList).mapIndexed { i, (imgUrl, imgAcc) ->
|
||||||
|
Page(i, imageUrl = "$imgUrl?$imgAcc")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used")
|
||||||
|
|
||||||
|
private fun String.removeEntities(): String = Parser.unescapeEntities(this, true)
|
||||||
|
|
||||||
|
override fun getFilterList() = FilterList(
|
||||||
|
LetterFilter(getLetterFilter(), 0),
|
||||||
|
Filter.Separator(),
|
||||||
|
Filter.Header("NOTE: Ignored if using text search!"),
|
||||||
|
Filter.Separator(),
|
||||||
|
SortFilter(getSortFilter().map { it.name }.toTypedArray()),
|
||||||
|
StatusFilter(getStatusFilter(), 0),
|
||||||
|
GenreGroupFilter(getGenreFilter()),
|
||||||
|
OriginGroupFilter(getOrginFilter()),
|
||||||
|
LangGroupFilter(getLangFilter()),
|
||||||
|
MinChapterTextFilter(),
|
||||||
|
MaxChapterTextFilter(),
|
||||||
|
Filter.Separator(),
|
||||||
|
Filter.Header("NOTE: Filters below are incompatible with any other filters!"),
|
||||||
|
Filter.Header("NOTE: Login Required!"),
|
||||||
|
Filter.Separator(),
|
||||||
|
UtilsFilter(getUtilsFilter(), 0),
|
||||||
|
HistoryFilter(getHistoryFilter(), 0),
|
||||||
|
)
|
||||||
|
class SelectFilterOption(val name: String, val value: String)
|
||||||
|
class CheckboxFilterOption(val value: String, name: String, default: Boolean = false) : Filter.CheckBox(name, default)
|
||||||
|
class TriStateFilterOption(val value: String, name: String, default: Int = 0) : Filter.TriState(name, default)
|
||||||
|
|
||||||
|
abstract class SelectFilter(name: String, private val options: List<SelectFilterOption>, default: Int = 0) : Filter.Select<String>(name, options.map { it.name }.toTypedArray(), default) {
|
||||||
|
val selected: String
|
||||||
|
get() = options[state].value
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class CheckboxGroupFilter(name: String, options: List<CheckboxFilterOption>) : Filter.Group<CheckboxFilterOption>(name, options) {
|
||||||
|
val selected: List<String>
|
||||||
|
get() = state.filter { it.state }.map { it.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class TriStateGroupFilter(name: String, options: List<TriStateFilterOption>) : Filter.Group<TriStateFilterOption>(name, options) {
|
||||||
|
val included: List<String>
|
||||||
|
get() = state.filter { it.isIncluded() }.map { it.value }
|
||||||
|
|
||||||
|
val excluded: List<String>
|
||||||
|
get() = state.filter { it.isExcluded() }.map { it.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class TextFilter(name: String) : Filter.Text(name)
|
||||||
|
|
||||||
|
class SortFilter(sortables: Array<String>) : Filter.Sort("Sort", sortables, Selection(5, false))
|
||||||
|
class StatusFilter(options: List<SelectFilterOption>, default: Int) : SelectFilter("Status", options, default)
|
||||||
|
class OriginGroupFilter(options: List<CheckboxFilterOption>) : CheckboxGroupFilter("Origin", options)
|
||||||
|
class GenreGroupFilter(options: List<TriStateFilterOption>) : TriStateGroupFilter("Genre", options)
|
||||||
|
class MinChapterTextFilter : TextFilter("Min. Chapters")
|
||||||
|
class MaxChapterTextFilter : TextFilter("Max. Chapters")
|
||||||
|
class LangGroupFilter(options: List<CheckboxFilterOption>) : CheckboxGroupFilter("Languages", options)
|
||||||
|
class LetterFilter(options: List<SelectFilterOption>, default: Int) : SelectFilter("Letter matching mode (Slow)", options, default)
|
||||||
|
class UtilsFilter(options: List<SelectFilterOption>, default: Int) : SelectFilter("Utils comic list", options, default)
|
||||||
|
class HistoryFilter(options: List<SelectFilterOption>, default: Int) : SelectFilter("Personal list", options, default)
|
||||||
|
|
||||||
|
private fun getLetterFilter() = listOf(
|
||||||
|
SelectFilterOption("Disabled", "disabled"),
|
||||||
|
SelectFilterOption("Enabled", "enabled"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getSortFilter() = listOf(
|
||||||
|
SelectFilterOption("Z-A", "title"),
|
||||||
|
SelectFilterOption("Last Updated", "update"),
|
||||||
|
SelectFilterOption("Newest Added", "create"),
|
||||||
|
SelectFilterOption("Most Views Totally", "views_a"),
|
||||||
|
SelectFilterOption("Most Views 365 days", "views_y"),
|
||||||
|
SelectFilterOption("Most Views 30 days", "views_m"),
|
||||||
|
SelectFilterOption("Most Views 7 days", "views_w"),
|
||||||
|
SelectFilterOption("Most Views 24 hours", "views_d"),
|
||||||
|
SelectFilterOption("Most Views 60 minutes", "views_h"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getHistoryFilter() = listOf(
|
||||||
|
SelectFilterOption("None", ""),
|
||||||
|
SelectFilterOption("My History", "history"),
|
||||||
|
SelectFilterOption("My Updates", "updates"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getUtilsFilter() = listOf(
|
||||||
|
SelectFilterOption("None", ""),
|
||||||
|
SelectFilterOption("Comics: I Created", "i-created"),
|
||||||
|
SelectFilterOption("Comics: I Modified", "i-modified"),
|
||||||
|
SelectFilterOption("Comics: I Uploaded", "i-uploaded"),
|
||||||
|
SelectFilterOption("Comics: Authorized to me", "i-authorized"),
|
||||||
|
SelectFilterOption("Comics: Draft Status", "status-draft"),
|
||||||
|
SelectFilterOption("Comics: Hidden Status", "status-hidden"),
|
||||||
|
SelectFilterOption("Ongoing and Not updated in 30-60 days", "not-updated-30-60"),
|
||||||
|
SelectFilterOption("Ongoing and Not updated in 60-90 days", "not-updated-60-90"),
|
||||||
|
SelectFilterOption("Ongoing and Not updated in 90-180 days", "not-updated-90-180"),
|
||||||
|
SelectFilterOption("Ongoing and Not updated in 180-360 days", "not-updated-180-360"),
|
||||||
|
SelectFilterOption("Ongoing and Not updated in 360-1000 days", "not-updated-360-1000"),
|
||||||
|
SelectFilterOption("Ongoing and Not updated more than 1000 days", "not-updated-1000"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getStatusFilter() = listOf(
|
||||||
|
SelectFilterOption("All", ""),
|
||||||
|
SelectFilterOption("Pending", "pending"),
|
||||||
|
SelectFilterOption("Ongoing", "ongoing"),
|
||||||
|
SelectFilterOption("Completed", "completed"),
|
||||||
|
SelectFilterOption("Hiatus", "hiatus"),
|
||||||
|
SelectFilterOption("Cancelled", "cancelled"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getOrginFilter() = listOf(
|
||||||
|
// Values exported from publish.bato.to
|
||||||
|
CheckboxFilterOption("zh", "Chinese"),
|
||||||
|
CheckboxFilterOption("en", "English"),
|
||||||
|
CheckboxFilterOption("ja", "Japanese"),
|
||||||
|
CheckboxFilterOption("ko", "Korean"),
|
||||||
|
CheckboxFilterOption("af", "Afrikaans"),
|
||||||
|
CheckboxFilterOption("sq", "Albanian"),
|
||||||
|
CheckboxFilterOption("am", "Amharic"),
|
||||||
|
CheckboxFilterOption("ar", "Arabic"),
|
||||||
|
CheckboxFilterOption("hy", "Armenian"),
|
||||||
|
CheckboxFilterOption("az", "Azerbaijani"),
|
||||||
|
CheckboxFilterOption("be", "Belarusian"),
|
||||||
|
CheckboxFilterOption("bn", "Bengali"),
|
||||||
|
CheckboxFilterOption("bs", "Bosnian"),
|
||||||
|
CheckboxFilterOption("bg", "Bulgarian"),
|
||||||
|
CheckboxFilterOption("my", "Burmese"),
|
||||||
|
CheckboxFilterOption("km", "Cambodian"),
|
||||||
|
CheckboxFilterOption("ca", "Catalan"),
|
||||||
|
CheckboxFilterOption("ceb", "Cebuano"),
|
||||||
|
CheckboxFilterOption("zh_hk", "Chinese (Cantonese)"),
|
||||||
|
CheckboxFilterOption("zh_tw", "Chinese (Traditional)"),
|
||||||
|
CheckboxFilterOption("hr", "Croatian"),
|
||||||
|
CheckboxFilterOption("cs", "Czech"),
|
||||||
|
CheckboxFilterOption("da", "Danish"),
|
||||||
|
CheckboxFilterOption("nl", "Dutch"),
|
||||||
|
CheckboxFilterOption("en_us", "English (United States)"),
|
||||||
|
CheckboxFilterOption("eo", "Esperanto"),
|
||||||
|
CheckboxFilterOption("et", "Estonian"),
|
||||||
|
CheckboxFilterOption("fo", "Faroese"),
|
||||||
|
CheckboxFilterOption("fil", "Filipino"),
|
||||||
|
CheckboxFilterOption("fi", "Finnish"),
|
||||||
|
CheckboxFilterOption("fr", "French"),
|
||||||
|
CheckboxFilterOption("ka", "Georgian"),
|
||||||
|
CheckboxFilterOption("de", "German"),
|
||||||
|
CheckboxFilterOption("el", "Greek"),
|
||||||
|
CheckboxFilterOption("gn", "Guarani"),
|
||||||
|
CheckboxFilterOption("gu", "Gujarati"),
|
||||||
|
CheckboxFilterOption("ht", "Haitian Creole"),
|
||||||
|
CheckboxFilterOption("ha", "Hausa"),
|
||||||
|
CheckboxFilterOption("he", "Hebrew"),
|
||||||
|
CheckboxFilterOption("hi", "Hindi"),
|
||||||
|
CheckboxFilterOption("hu", "Hungarian"),
|
||||||
|
CheckboxFilterOption("is", "Icelandic"),
|
||||||
|
CheckboxFilterOption("ig", "Igbo"),
|
||||||
|
CheckboxFilterOption("id", "Indonesian"),
|
||||||
|
CheckboxFilterOption("ga", "Irish"),
|
||||||
|
CheckboxFilterOption("it", "Italian"),
|
||||||
|
CheckboxFilterOption("jv", "Javanese"),
|
||||||
|
CheckboxFilterOption("kn", "Kannada"),
|
||||||
|
CheckboxFilterOption("kk", "Kazakh"),
|
||||||
|
CheckboxFilterOption("ku", "Kurdish"),
|
||||||
|
CheckboxFilterOption("ky", "Kyrgyz"),
|
||||||
|
CheckboxFilterOption("lo", "Laothian"),
|
||||||
|
CheckboxFilterOption("lv", "Latvian"),
|
||||||
|
CheckboxFilterOption("lt", "Lithuanian"),
|
||||||
|
CheckboxFilterOption("lb", "Luxembourgish"),
|
||||||
|
CheckboxFilterOption("mk", "Macedonian"),
|
||||||
|
CheckboxFilterOption("mg", "Malagasy"),
|
||||||
|
CheckboxFilterOption("ms", "Malay"),
|
||||||
|
CheckboxFilterOption("ml", "Malayalam"),
|
||||||
|
CheckboxFilterOption("mt", "Maltese"),
|
||||||
|
CheckboxFilterOption("mi", "Maori"),
|
||||||
|
CheckboxFilterOption("mr", "Marathi"),
|
||||||
|
CheckboxFilterOption("mo", "Moldavian"),
|
||||||
|
CheckboxFilterOption("mn", "Mongolian"),
|
||||||
|
CheckboxFilterOption("ne", "Nepali"),
|
||||||
|
CheckboxFilterOption("no", "Norwegian"),
|
||||||
|
CheckboxFilterOption("ny", "Nyanja"),
|
||||||
|
CheckboxFilterOption("ps", "Pashto"),
|
||||||
|
CheckboxFilterOption("fa", "Persian"),
|
||||||
|
CheckboxFilterOption("pl", "Polish"),
|
||||||
|
CheckboxFilterOption("pt", "Portuguese"),
|
||||||
|
CheckboxFilterOption("pt_br", "Portuguese (Brazil)"),
|
||||||
|
CheckboxFilterOption("ro", "Romanian"),
|
||||||
|
CheckboxFilterOption("rm", "Romansh"),
|
||||||
|
CheckboxFilterOption("ru", "Russian"),
|
||||||
|
CheckboxFilterOption("sm", "Samoan"),
|
||||||
|
CheckboxFilterOption("sr", "Serbian"),
|
||||||
|
CheckboxFilterOption("sh", "Serbo-Croatian"),
|
||||||
|
CheckboxFilterOption("st", "Sesotho"),
|
||||||
|
CheckboxFilterOption("sn", "Shona"),
|
||||||
|
CheckboxFilterOption("sd", "Sindhi"),
|
||||||
|
CheckboxFilterOption("si", "Sinhalese"),
|
||||||
|
CheckboxFilterOption("sk", "Slovak"),
|
||||||
|
CheckboxFilterOption("sl", "Slovenian"),
|
||||||
|
CheckboxFilterOption("so", "Somali"),
|
||||||
|
CheckboxFilterOption("es", "Spanish"),
|
||||||
|
CheckboxFilterOption("es_419", "Spanish (Latin America)"),
|
||||||
|
CheckboxFilterOption("sw", "Swahili"),
|
||||||
|
CheckboxFilterOption("sv", "Swedish"),
|
||||||
|
CheckboxFilterOption("tg", "Tajik"),
|
||||||
|
CheckboxFilterOption("ta", "Tamil"),
|
||||||
|
CheckboxFilterOption("th", "Thai"),
|
||||||
|
CheckboxFilterOption("ti", "Tigrinya"),
|
||||||
|
CheckboxFilterOption("to", "Tonga"),
|
||||||
|
CheckboxFilterOption("tr", "Turkish"),
|
||||||
|
CheckboxFilterOption("tk", "Turkmen"),
|
||||||
|
CheckboxFilterOption("uk", "Ukrainian"),
|
||||||
|
CheckboxFilterOption("ur", "Urdu"),
|
||||||
|
CheckboxFilterOption("uz", "Uzbek"),
|
||||||
|
CheckboxFilterOption("vi", "Vietnamese"),
|
||||||
|
CheckboxFilterOption("yo", "Yoruba"),
|
||||||
|
CheckboxFilterOption("zu", "Zulu"),
|
||||||
|
CheckboxFilterOption("_t", "Other"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getGenreFilter() = listOf(
|
||||||
|
TriStateFilterOption("artbook", "Artbook"),
|
||||||
|
TriStateFilterOption("cartoon", "Cartoon"),
|
||||||
|
TriStateFilterOption("comic", "Comic"),
|
||||||
|
TriStateFilterOption("doujinshi", "Doujinshi"),
|
||||||
|
TriStateFilterOption("imageset", "Imageset"),
|
||||||
|
TriStateFilterOption("manga", "Manga"),
|
||||||
|
TriStateFilterOption("manhua", "Manhua"),
|
||||||
|
TriStateFilterOption("manhwa", "Manhwa"),
|
||||||
|
TriStateFilterOption("webtoon", "Webtoon"),
|
||||||
|
TriStateFilterOption("western", "Western"),
|
||||||
|
|
||||||
|
TriStateFilterOption("shoujo", "Shoujo(G)"),
|
||||||
|
TriStateFilterOption("shounen", "Shounen(B)"),
|
||||||
|
TriStateFilterOption("josei", "Josei(W)"),
|
||||||
|
TriStateFilterOption("seinen", "Seinen(M)"),
|
||||||
|
TriStateFilterOption("yuri", "Yuri(GL)"),
|
||||||
|
TriStateFilterOption("yaoi", "Yaoi(BL)"),
|
||||||
|
TriStateFilterOption("futa", "Futa(WL)"),
|
||||||
|
TriStateFilterOption("bara", "Bara(ML)"),
|
||||||
|
|
||||||
|
TriStateFilterOption("gore", "Gore"),
|
||||||
|
TriStateFilterOption("bloody", "Bloody"),
|
||||||
|
TriStateFilterOption("violence", "Violence"),
|
||||||
|
TriStateFilterOption("ecchi", "Ecchi"),
|
||||||
|
TriStateFilterOption("adult", "Adult"),
|
||||||
|
TriStateFilterOption("mature", "Mature"),
|
||||||
|
TriStateFilterOption("smut", "Smut"),
|
||||||
|
TriStateFilterOption("hentai", "Hentai"),
|
||||||
|
|
||||||
|
TriStateFilterOption("_4_koma", "4-Koma"),
|
||||||
|
TriStateFilterOption("action", "Action"),
|
||||||
|
TriStateFilterOption("adaptation", "Adaptation"),
|
||||||
|
TriStateFilterOption("adventure", "Adventure"),
|
||||||
|
TriStateFilterOption("age_gap", "Age Gap"),
|
||||||
|
TriStateFilterOption("aliens", "Aliens"),
|
||||||
|
TriStateFilterOption("animals", "Animals"),
|
||||||
|
TriStateFilterOption("anthology", "Anthology"),
|
||||||
|
TriStateFilterOption("beasts", "Beasts"),
|
||||||
|
TriStateFilterOption("bodyswap", "Bodyswap"),
|
||||||
|
TriStateFilterOption("cars", "cars"),
|
||||||
|
TriStateFilterOption("cheating_infidelity", "Cheating/Infidelity"),
|
||||||
|
TriStateFilterOption("childhood_friends", "Childhood Friends"),
|
||||||
|
TriStateFilterOption("college_life", "College Life"),
|
||||||
|
TriStateFilterOption("comedy", "Comedy"),
|
||||||
|
TriStateFilterOption("contest_winning", "Contest Winning"),
|
||||||
|
TriStateFilterOption("cooking", "Cooking"),
|
||||||
|
TriStateFilterOption("crime", "crime"),
|
||||||
|
TriStateFilterOption("crossdressing", "Crossdressing"),
|
||||||
|
TriStateFilterOption("delinquents", "Delinquents"),
|
||||||
|
TriStateFilterOption("dementia", "Dementia"),
|
||||||
|
TriStateFilterOption("demons", "Demons"),
|
||||||
|
TriStateFilterOption("drama", "Drama"),
|
||||||
|
TriStateFilterOption("dungeons", "Dungeons"),
|
||||||
|
TriStateFilterOption("emperor_daughte", "Emperor's Daughter"),
|
||||||
|
TriStateFilterOption("fantasy", "Fantasy"),
|
||||||
|
TriStateFilterOption("fan_colored", "Fan-Colored"),
|
||||||
|
TriStateFilterOption("fetish", "Fetish"),
|
||||||
|
TriStateFilterOption("full_color", "Full Color"),
|
||||||
|
TriStateFilterOption("game", "Game"),
|
||||||
|
TriStateFilterOption("gender_bender", "Gender Bender"),
|
||||||
|
TriStateFilterOption("genderswap", "Genderswap"),
|
||||||
|
TriStateFilterOption("ghosts", "Ghosts"),
|
||||||
|
TriStateFilterOption("gyaru", "Gyaru"),
|
||||||
|
TriStateFilterOption("harem", "Harem"),
|
||||||
|
TriStateFilterOption("harlequin", "Harlequin"),
|
||||||
|
TriStateFilterOption("historical", "Historical"),
|
||||||
|
TriStateFilterOption("horror", "Horror"),
|
||||||
|
TriStateFilterOption("incest", "Incest"),
|
||||||
|
TriStateFilterOption("isekai", "Isekai"),
|
||||||
|
TriStateFilterOption("kids", "Kids"),
|
||||||
|
TriStateFilterOption("loli", "Loli"),
|
||||||
|
TriStateFilterOption("magic", "Magic"),
|
||||||
|
TriStateFilterOption("magical_girls", "Magical Girls"),
|
||||||
|
TriStateFilterOption("martial_arts", "Martial Arts"),
|
||||||
|
TriStateFilterOption("mecha", "Mecha"),
|
||||||
|
TriStateFilterOption("medical", "Medical"),
|
||||||
|
TriStateFilterOption("military", "Military"),
|
||||||
|
TriStateFilterOption("monster_girls", "Monster Girls"),
|
||||||
|
TriStateFilterOption("monsters", "Monsters"),
|
||||||
|
TriStateFilterOption("music", "Music"),
|
||||||
|
TriStateFilterOption("mystery", "Mystery"),
|
||||||
|
TriStateFilterOption("netorare", "Netorare/NTR"),
|
||||||
|
TriStateFilterOption("ninja", "Ninja"),
|
||||||
|
TriStateFilterOption("office_workers", "Office Workers"),
|
||||||
|
TriStateFilterOption("omegaverse", "Omegaverse"),
|
||||||
|
TriStateFilterOption("oneshot", "Oneshot"),
|
||||||
|
TriStateFilterOption("parody", "parody"),
|
||||||
|
TriStateFilterOption("philosophical", "Philosophical"),
|
||||||
|
TriStateFilterOption("police", "Police"),
|
||||||
|
TriStateFilterOption("post_apocalyptic", "Post-Apocalyptic"),
|
||||||
|
TriStateFilterOption("psychological", "Psychological"),
|
||||||
|
TriStateFilterOption("regression", "Regression"),
|
||||||
|
TriStateFilterOption("reincarnation", "Reincarnation"),
|
||||||
|
TriStateFilterOption("reverse_harem", "Reverse Harem"),
|
||||||
|
TriStateFilterOption("reverse_isekai", "Reverse Isekai"),
|
||||||
|
TriStateFilterOption("romance", "Romance"),
|
||||||
|
TriStateFilterOption("royal_family", "Royal Family"),
|
||||||
|
TriStateFilterOption("royalty", "Royalty"),
|
||||||
|
TriStateFilterOption("samurai", "Samurai"),
|
||||||
|
TriStateFilterOption("school_life", "School Life"),
|
||||||
|
TriStateFilterOption("sci_fi", "Sci-Fi"),
|
||||||
|
TriStateFilterOption("shota", "Shota"),
|
||||||
|
TriStateFilterOption("shoujo_ai", "Shoujo Ai"),
|
||||||
|
TriStateFilterOption("shounen_ai", "Shounen Ai"),
|
||||||
|
TriStateFilterOption("showbiz", "Showbiz"),
|
||||||
|
TriStateFilterOption("slice_of_life", "Slice of Life"),
|
||||||
|
TriStateFilterOption("sm_bdsm", "SM/BDSM/SUB-DOM"),
|
||||||
|
TriStateFilterOption("space", "Space"),
|
||||||
|
TriStateFilterOption("sports", "Sports"),
|
||||||
|
TriStateFilterOption("super_power", "Super Power"),
|
||||||
|
TriStateFilterOption("superhero", "Superhero"),
|
||||||
|
TriStateFilterOption("supernatural", "Supernatural"),
|
||||||
|
TriStateFilterOption("survival", "Survival"),
|
||||||
|
TriStateFilterOption("thriller", "Thriller"),
|
||||||
|
TriStateFilterOption("time_travel", "Time Travel"),
|
||||||
|
TriStateFilterOption("tower_climbing", "Tower Climbing"),
|
||||||
|
TriStateFilterOption("traditional_games", "Traditional Games"),
|
||||||
|
TriStateFilterOption("tragedy", "Tragedy"),
|
||||||
|
TriStateFilterOption("transmigration", "Transmigration"),
|
||||||
|
TriStateFilterOption("vampires", "Vampires"),
|
||||||
|
TriStateFilterOption("villainess", "Villainess"),
|
||||||
|
TriStateFilterOption("video_games", "Video Games"),
|
||||||
|
TriStateFilterOption("virtual_reality", "Virtual Reality"),
|
||||||
|
TriStateFilterOption("wuxia", "Wuxia"),
|
||||||
|
TriStateFilterOption("xianxia", "Xianxia"),
|
||||||
|
TriStateFilterOption("xuanhuan", "Xuanhuan"),
|
||||||
|
TriStateFilterOption("zombies", "Zombies"),
|
||||||
|
// Hidden Genres
|
||||||
|
TriStateFilterOption("shotacon", "shotacon"),
|
||||||
|
TriStateFilterOption("lolicon", "lolicon"),
|
||||||
|
TriStateFilterOption("award_winning", "Award Winning"),
|
||||||
|
TriStateFilterOption("youkai", "Youkai"),
|
||||||
|
TriStateFilterOption("uncategorized", "Uncategorized"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getLangFilter() = listOf(
|
||||||
|
// Values exported from publish.bato.to
|
||||||
|
CheckboxFilterOption("en", "English"),
|
||||||
|
CheckboxFilterOption("ar", "Arabic"),
|
||||||
|
CheckboxFilterOption("bg", "Bulgarian"),
|
||||||
|
CheckboxFilterOption("zh", "Chinese"),
|
||||||
|
CheckboxFilterOption("cs", "Czech"),
|
||||||
|
CheckboxFilterOption("da", "Danish"),
|
||||||
|
CheckboxFilterOption("nl", "Dutch"),
|
||||||
|
CheckboxFilterOption("fil", "Filipino"),
|
||||||
|
CheckboxFilterOption("fi", "Finnish"),
|
||||||
|
CheckboxFilterOption("fr", "French"),
|
||||||
|
CheckboxFilterOption("de", "German"),
|
||||||
|
CheckboxFilterOption("el", "Greek"),
|
||||||
|
CheckboxFilterOption("he", "Hebrew"),
|
||||||
|
CheckboxFilterOption("hi", "Hindi"),
|
||||||
|
CheckboxFilterOption("hu", "Hungarian"),
|
||||||
|
CheckboxFilterOption("id", "Indonesian"),
|
||||||
|
CheckboxFilterOption("it", "Italian"),
|
||||||
|
CheckboxFilterOption("ja", "Japanese"),
|
||||||
|
CheckboxFilterOption("ko", "Korean"),
|
||||||
|
CheckboxFilterOption("ms", "Malay"),
|
||||||
|
CheckboxFilterOption("pl", "Polish"),
|
||||||
|
CheckboxFilterOption("pt", "Portuguese"),
|
||||||
|
CheckboxFilterOption("pt_br", "Portuguese (Brazil)"),
|
||||||
|
CheckboxFilterOption("ro", "Romanian"),
|
||||||
|
CheckboxFilterOption("ru", "Russian"),
|
||||||
|
CheckboxFilterOption("es", "Spanish"),
|
||||||
|
CheckboxFilterOption("es_419", "Spanish (Latin America)"),
|
||||||
|
CheckboxFilterOption("sv", "Swedish"),
|
||||||
|
CheckboxFilterOption("th", "Thai"),
|
||||||
|
CheckboxFilterOption("tr", "Turkish"),
|
||||||
|
CheckboxFilterOption("uk", "Ukrainian"),
|
||||||
|
CheckboxFilterOption("vi", "Vietnamese"),
|
||||||
|
CheckboxFilterOption("af", "Afrikaans"),
|
||||||
|
CheckboxFilterOption("sq", "Albanian"),
|
||||||
|
CheckboxFilterOption("am", "Amharic"),
|
||||||
|
CheckboxFilterOption("hy", "Armenian"),
|
||||||
|
CheckboxFilterOption("az", "Azerbaijani"),
|
||||||
|
CheckboxFilterOption("be", "Belarusian"),
|
||||||
|
CheckboxFilterOption("bn", "Bengali"),
|
||||||
|
CheckboxFilterOption("bs", "Bosnian"),
|
||||||
|
CheckboxFilterOption("my", "Burmese"),
|
||||||
|
CheckboxFilterOption("km", "Cambodian"),
|
||||||
|
CheckboxFilterOption("ca", "Catalan"),
|
||||||
|
CheckboxFilterOption("ceb", "Cebuano"),
|
||||||
|
CheckboxFilterOption("zh_hk", "Chinese (Cantonese)"),
|
||||||
|
CheckboxFilterOption("zh_tw", "Chinese (Traditional)"),
|
||||||
|
CheckboxFilterOption("hr", "Croatian"),
|
||||||
|
CheckboxFilterOption("en_us", "English (United States)"),
|
||||||
|
CheckboxFilterOption("eo", "Esperanto"),
|
||||||
|
CheckboxFilterOption("et", "Estonian"),
|
||||||
|
CheckboxFilterOption("fo", "Faroese"),
|
||||||
|
CheckboxFilterOption("ka", "Georgian"),
|
||||||
|
CheckboxFilterOption("gn", "Guarani"),
|
||||||
|
CheckboxFilterOption("gu", "Gujarati"),
|
||||||
|
CheckboxFilterOption("ht", "Haitian Creole"),
|
||||||
|
CheckboxFilterOption("ha", "Hausa"),
|
||||||
|
CheckboxFilterOption("is", "Icelandic"),
|
||||||
|
CheckboxFilterOption("ig", "Igbo"),
|
||||||
|
CheckboxFilterOption("ga", "Irish"),
|
||||||
|
CheckboxFilterOption("jv", "Javanese"),
|
||||||
|
CheckboxFilterOption("kn", "Kannada"),
|
||||||
|
CheckboxFilterOption("kk", "Kazakh"),
|
||||||
|
CheckboxFilterOption("ku", "Kurdish"),
|
||||||
|
CheckboxFilterOption("ky", "Kyrgyz"),
|
||||||
|
CheckboxFilterOption("lo", "Laothian"),
|
||||||
|
CheckboxFilterOption("lv", "Latvian"),
|
||||||
|
CheckboxFilterOption("lt", "Lithuanian"),
|
||||||
|
CheckboxFilterOption("lb", "Luxembourgish"),
|
||||||
|
CheckboxFilterOption("mk", "Macedonian"),
|
||||||
|
CheckboxFilterOption("mg", "Malagasy"),
|
||||||
|
CheckboxFilterOption("ml", "Malayalam"),
|
||||||
|
CheckboxFilterOption("mt", "Maltese"),
|
||||||
|
CheckboxFilterOption("mi", "Maori"),
|
||||||
|
CheckboxFilterOption("mr", "Marathi"),
|
||||||
|
CheckboxFilterOption("mo", "Moldavian"),
|
||||||
|
CheckboxFilterOption("mn", "Mongolian"),
|
||||||
|
CheckboxFilterOption("ne", "Nepali"),
|
||||||
|
CheckboxFilterOption("no", "Norwegian"),
|
||||||
|
CheckboxFilterOption("ny", "Nyanja"),
|
||||||
|
CheckboxFilterOption("ps", "Pashto"),
|
||||||
|
CheckboxFilterOption("fa", "Persian"),
|
||||||
|
CheckboxFilterOption("rm", "Romansh"),
|
||||||
|
CheckboxFilterOption("sm", "Samoan"),
|
||||||
|
CheckboxFilterOption("sr", "Serbian"),
|
||||||
|
CheckboxFilterOption("sh", "Serbo-Croatian"),
|
||||||
|
CheckboxFilterOption("st", "Sesotho"),
|
||||||
|
CheckboxFilterOption("sn", "Shona"),
|
||||||
|
CheckboxFilterOption("sd", "Sindhi"),
|
||||||
|
CheckboxFilterOption("si", "Sinhalese"),
|
||||||
|
CheckboxFilterOption("sk", "Slovak"),
|
||||||
|
CheckboxFilterOption("sl", "Slovenian"),
|
||||||
|
CheckboxFilterOption("so", "Somali"),
|
||||||
|
CheckboxFilterOption("sw", "Swahili"),
|
||||||
|
CheckboxFilterOption("tg", "Tajik"),
|
||||||
|
CheckboxFilterOption("ta", "Tamil"),
|
||||||
|
CheckboxFilterOption("ti", "Tigrinya"),
|
||||||
|
CheckboxFilterOption("to", "Tonga"),
|
||||||
|
CheckboxFilterOption("tk", "Turkmen"),
|
||||||
|
CheckboxFilterOption("ur", "Urdu"),
|
||||||
|
CheckboxFilterOption("uz", "Uzbek"),
|
||||||
|
CheckboxFilterOption("yo", "Yoruba"),
|
||||||
|
CheckboxFilterOption("zu", "Zulu"),
|
||||||
|
CheckboxFilterOption("_t", "Other"),
|
||||||
|
// Lang options from bato.to brows not in publish.bato.to
|
||||||
|
CheckboxFilterOption("eu", "Basque"),
|
||||||
|
CheckboxFilterOption("pt-PT", "Portuguese (Portugal)"),
|
||||||
|
).filterNot { it.value == siteLang }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MIRROR_PREF_KEY = "MIRROR"
|
||||||
|
private const val MIRROR_PREF_TITLE = "Mirror"
|
||||||
|
private val MIRROR_PREF_ENTRIES = arrayOf(
|
||||||
|
"bato.to",
|
||||||
|
"batocomic.com",
|
||||||
|
"batocomic.net",
|
||||||
|
"batocomic.org",
|
||||||
|
"batotoo.com",
|
||||||
|
"batotwo.com",
|
||||||
|
"battwo.com",
|
||||||
|
"comiko.net",
|
||||||
|
"comiko.org",
|
||||||
|
"mangatoto.com",
|
||||||
|
"mangatoto.net",
|
||||||
|
"mangatoto.org",
|
||||||
|
"readtoto.com",
|
||||||
|
"readtoto.net",
|
||||||
|
"readtoto.org",
|
||||||
|
"dto.to",
|
||||||
|
"hto.to",
|
||||||
|
"mto.to",
|
||||||
|
"wto.to",
|
||||||
|
"xbato.com",
|
||||||
|
"xbato.net",
|
||||||
|
"xbato.org",
|
||||||
|
"zbato.com",
|
||||||
|
"zbato.net",
|
||||||
|
"zbato.org",
|
||||||
|
)
|
||||||
|
private val MIRROR_PREF_ENTRY_VALUES = MIRROR_PREF_ENTRIES.map { "https://$it" }.toTypedArray()
|
||||||
|
private val MIRROR_PREF_DEFAULT_VALUE = MIRROR_PREF_ENTRY_VALUES[0]
|
||||||
|
|
||||||
|
private const val ALT_CHAPTER_LIST_PREF_KEY = "ALT_CHAPTER_LIST"
|
||||||
|
private const val ALT_CHAPTER_LIST_PREF_TITLE = "Alternative Chapter List"
|
||||||
|
private const val ALT_CHAPTER_LIST_PREF_SUMMARY = "If checked, uses an alternate chapter list"
|
||||||
|
private const val ALT_CHAPTER_LIST_PREF_DEFAULT_VALUE = false
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,122 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.batoto
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.Source
|
||||||
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
|
|
||||||
|
class BatoToFactory : SourceFactory {
|
||||||
|
override fun createSources(): List<Source> = languages.map { BatoTo(it.lang, it.siteLang) }
|
||||||
|
}
|
||||||
|
|
||||||
|
class LanguageOption(val lang: String, val siteLang: String = lang)
|
||||||
|
private val languages = listOf(
|
||||||
|
LanguageOption("all", ""),
|
||||||
|
// Lang options from publish.bato.to
|
||||||
|
LanguageOption("en"),
|
||||||
|
LanguageOption("ar"),
|
||||||
|
LanguageOption("bg"),
|
||||||
|
LanguageOption("zh"),
|
||||||
|
LanguageOption("cs"),
|
||||||
|
LanguageOption("da"),
|
||||||
|
LanguageOption("nl"),
|
||||||
|
LanguageOption("fil"),
|
||||||
|
LanguageOption("fi"),
|
||||||
|
LanguageOption("fr"),
|
||||||
|
LanguageOption("de"),
|
||||||
|
LanguageOption("el"),
|
||||||
|
LanguageOption("he"),
|
||||||
|
LanguageOption("hi"),
|
||||||
|
LanguageOption("hu"),
|
||||||
|
LanguageOption("id"),
|
||||||
|
LanguageOption("it"),
|
||||||
|
LanguageOption("ja"),
|
||||||
|
LanguageOption("ko"),
|
||||||
|
LanguageOption("ms"),
|
||||||
|
LanguageOption("pl"),
|
||||||
|
LanguageOption("pt"),
|
||||||
|
LanguageOption("pt-BR", "pt_br"),
|
||||||
|
LanguageOption("ro"),
|
||||||
|
LanguageOption("ru"),
|
||||||
|
LanguageOption("es"),
|
||||||
|
LanguageOption("es-419", "es_419"),
|
||||||
|
LanguageOption("sv"),
|
||||||
|
LanguageOption("th"),
|
||||||
|
LanguageOption("tr"),
|
||||||
|
LanguageOption("uk"),
|
||||||
|
LanguageOption("vi"),
|
||||||
|
LanguageOption("af"),
|
||||||
|
LanguageOption("sq"),
|
||||||
|
LanguageOption("am"),
|
||||||
|
LanguageOption("hy"),
|
||||||
|
LanguageOption("az"),
|
||||||
|
LanguageOption("be"),
|
||||||
|
LanguageOption("bn"),
|
||||||
|
LanguageOption("bs"),
|
||||||
|
LanguageOption("my"),
|
||||||
|
LanguageOption("km"),
|
||||||
|
LanguageOption("ca"),
|
||||||
|
LanguageOption("ceb"),
|
||||||
|
LanguageOption("zh-Hans", "zh_hk"),
|
||||||
|
LanguageOption("zh-Hant", "zh_tw"),
|
||||||
|
LanguageOption("hr"),
|
||||||
|
LanguageOption("en-US", "en_us"),
|
||||||
|
LanguageOption("eo"),
|
||||||
|
LanguageOption("et"),
|
||||||
|
LanguageOption("fo"),
|
||||||
|
LanguageOption("ka"),
|
||||||
|
LanguageOption("gn"),
|
||||||
|
LanguageOption("gu"),
|
||||||
|
LanguageOption("ht"),
|
||||||
|
LanguageOption("ha"),
|
||||||
|
LanguageOption("is"),
|
||||||
|
LanguageOption("ig"),
|
||||||
|
LanguageOption("ga"),
|
||||||
|
LanguageOption("jv"),
|
||||||
|
LanguageOption("kn"),
|
||||||
|
LanguageOption("kk"),
|
||||||
|
LanguageOption("ku"),
|
||||||
|
LanguageOption("ky"),
|
||||||
|
LanguageOption("lo"),
|
||||||
|
LanguageOption("lv"),
|
||||||
|
LanguageOption("lt"),
|
||||||
|
LanguageOption("lb"),
|
||||||
|
LanguageOption("mk"),
|
||||||
|
LanguageOption("mg"),
|
||||||
|
LanguageOption("ml"),
|
||||||
|
LanguageOption("mt"),
|
||||||
|
LanguageOption("mi"),
|
||||||
|
LanguageOption("mr"),
|
||||||
|
LanguageOption("mo", "ro-MD"),
|
||||||
|
LanguageOption("mn"),
|
||||||
|
LanguageOption("ne"),
|
||||||
|
LanguageOption("no"),
|
||||||
|
LanguageOption("ny"),
|
||||||
|
LanguageOption("ps"),
|
||||||
|
LanguageOption("fa"),
|
||||||
|
LanguageOption("rm"),
|
||||||
|
LanguageOption("sm"),
|
||||||
|
LanguageOption("sr"),
|
||||||
|
LanguageOption("sh"),
|
||||||
|
LanguageOption("st"),
|
||||||
|
LanguageOption("sn"),
|
||||||
|
LanguageOption("sd"),
|
||||||
|
LanguageOption("si"),
|
||||||
|
LanguageOption("sk"),
|
||||||
|
LanguageOption("sl"),
|
||||||
|
LanguageOption("so"),
|
||||||
|
LanguageOption("sw"),
|
||||||
|
LanguageOption("tg"),
|
||||||
|
LanguageOption("ta"),
|
||||||
|
LanguageOption("ti"),
|
||||||
|
LanguageOption("to"),
|
||||||
|
LanguageOption("tk"),
|
||||||
|
LanguageOption("ur"),
|
||||||
|
LanguageOption("uz"),
|
||||||
|
LanguageOption("yo"),
|
||||||
|
LanguageOption("zu"),
|
||||||
|
LanguageOption("other", "_t"),
|
||||||
|
// Lang options from bato.to brows not in publish.bato.to
|
||||||
|
LanguageOption("eu"),
|
||||||
|
LanguageOption("pt-PT", "pt_pt"),
|
||||||
|
// Lang options that got removed
|
||||||
|
// Pair("xh", "xh"),
|
||||||
|
)
|
|
@ -0,0 +1,51 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.batoto
|
||||||
|
|
||||||
|
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 BatoToUrlActivity : Activity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val host = intent?.data?.host
|
||||||
|
val pathSegments = intent?.data?.pathSegments
|
||||||
|
|
||||||
|
if (host != null && pathSegments != null) {
|
||||||
|
val query = fromBatoTo(pathSegments)
|
||||||
|
|
||||||
|
if (query == null) {
|
||||||
|
Log.e("BatoToUrlActivity", "Unable to parse URI from intent $intent")
|
||||||
|
finish()
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mainIntent = Intent().apply {
|
||||||
|
action = "eu.kanade.tachiyomi.SEARCH"
|
||||||
|
putExtra("query", query)
|
||||||
|
putExtra("filter", packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
startActivity(mainIntent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Log.e("BatoToUrlActivity", e.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finish()
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fromBatoTo(pathSegments: MutableList<String>): String? {
|
||||||
|
return if (pathSegments.size >= 2) {
|
||||||
|
val id = pathSegments[1]
|
||||||
|
"ID:$id"
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<activity
|
||||||
|
android:name=".all.mangadex.MangadexUrlActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.NoDisplay">
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data android:host="mangadex.org" />
|
||||||
|
<data android:host="canary.mangadex.dev" />
|
||||||
|
<data android:scheme="https" />
|
||||||
|
|
||||||
|
<data android:pathPattern="/title/..*" />
|
||||||
|
<data android:pathPattern="/manga/..*" />
|
||||||
|
<data android:pathPattern="/chapter/..*" />
|
||||||
|
<data android:pathPattern="/group/..*" />
|
||||||
|
<data android:pathPattern="/author/..*" />
|
||||||
|
<data android:pathPattern="/user/..*" />
|
||||||
|
<data android:pathPattern="/list/..*" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
|
@ -0,0 +1,70 @@
|
||||||
|
# MangaDex
|
||||||
|
|
||||||
|
Table of Content
|
||||||
|
- [FAQ](#FAQ)
|
||||||
|
- [Version 5 API Rewrite](#version-5-api-rewrite)
|
||||||
|
- [Guides](#Guides)
|
||||||
|
- [How can I block particular Scanlator Groups?](#how-can-i-block-particular-scanlator-groups)
|
||||||
|
|
||||||
|
Don't find the question you are look for go check out our general FAQs and Guides over at [Extension FAQ](https://tachiyomi.org/help/faq/#extensions) or [Getting Started](https://tachiyomi.org/help/guides/getting-started/#installation)
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
### Version 5 API Rewrite
|
||||||
|
|
||||||
|
#### Why are all my manga saying "Manga ID format has changed, migrate from MangaDex to MangaDex to continue reading"?
|
||||||
|
You need to [migrate](https://tachiyomi.org/help/guides/source-migration/) all your MangaDex manga from MangaDex to MangaDex as MangaDex has changed their manga ID system from IDs to UUIDs.
|
||||||
|
|
||||||
|
#### Why can I not restore from a JSON backup?
|
||||||
|
JSON backups are now unusable due to the ID change. You will have to manually re-add your manga.
|
||||||
|
|
||||||
|
## Guides
|
||||||
|
|
||||||
|
### What does the Status of a Manga in Tachiyomi mean?
|
||||||
|
|
||||||
|
Please refer to the following table
|
||||||
|
|
||||||
|
| Status in Tachiyomi | in MangaDex | Remarks |
|
||||||
|
|---------------------|------------------------|---------|
|
||||||
|
| Ongoing | Publication: Ongoing | |
|
||||||
|
| Cancelled | Publication: Cancelled | This title was abruptly stopped and will not resume |
|
||||||
|
| Publishing Finished | Publication: Completed | The title is finished in its original language. However, Translations remain |
|
||||||
|
| On_Hiatus | Publication: Hiatus | The title is not currently receiving any new chapters |
|
||||||
|
| Completed | Completed/Cancelled | All chapters are translated and available |
|
||||||
|
| Unknown | Unknown | There is no info about the Status of this Entry |
|
||||||
|
|
||||||
|
### How can I block particular Scanlator Groups?
|
||||||
|
|
||||||
|
The **MangaDex** extension allows blocking **Scanlator Groups**. Chapters uploaded by a **Blocked Scanlator Group** will not show up in **Latest** or in **Manga feed** (chapters list). For now, you can only block Groups by entering their UUIDs manually.
|
||||||
|
|
||||||
|
Follow the following steps to easily block a group from the Tachiyomi MangaDex extension:
|
||||||
|
|
||||||
|
A. Finding the **UUIDs**:
|
||||||
|
- Go to [https://mangadex.org](https://mangadex.org) and **Search** for the Scanlation Group that you wish to block and view their Group Details
|
||||||
|
- Using the URL of this page, get the 16-digit alphanumeric string which will be the UUID for that scanlation group
|
||||||
|
- For Example:
|
||||||
|
* The Group *Tristan's test scans* has the URL
|
||||||
|
- [https://mangadex.org/group/6410209a-0f39-4f51-a139-bc559ad61a4f/tristan-s-test-scans](https://mangadex.org/group/6410209a-0f39-4f51-a139-bc559ad61a4f/tristan-s-test-scans)
|
||||||
|
- Therefore, their UUID will be `6410209a-0f39-4f51-a139-bc559ad61a4f`
|
||||||
|
* Other Examples include:
|
||||||
|
+ Azuki Manga | `5fed0576-8b94-4f9a-b6a7-08eecd69800d`
|
||||||
|
+ Bilibili Comics | `06a9fecb-b608-4f19-b93c-7caab06b7f44`
|
||||||
|
+ Comikey | `8d8ecf83-8d42-4f8c-add8-60963f9f28d9`
|
||||||
|
+ INKR | `caa63201-4a17-4b7f-95ff-ed884a2b7e60`
|
||||||
|
+ MangaHot | `319c1b10-cbd0-4f55-a46e-c4ee17e65139`
|
||||||
|
+ MangaPlus | `4f1de6a2-f0c5-4ac5-bce5-02c7dbb67deb`
|
||||||
|
|
||||||
|
B. Blocking a group using their UUID in Tachiyomi MangaDex extension `v1.2.150+`:
|
||||||
|
1. Go to **Browse** → **Extensions**.
|
||||||
|
1. Click on **MangaDex** extension and then **Settings** under your Language of choice.
|
||||||
|
1. Tap on the option **Block Groups by UUID** and enter the UUIDs.
|
||||||
|
- By Default, the following groups are blocked:
|
||||||
|
```
|
||||||
|
Azuki Manga, Bilibili Comics, Comikey, INKR, MangaHot & MangaPlus
|
||||||
|
```
|
||||||
|
- Which are entered as:
|
||||||
|
```
|
||||||
|
5fed0576-8b94-4f9a-b6a7-08eecd69800d, 06a9fecb-b608-4f19-b93c-7caab06b7f44,
|
||||||
|
8d8ecf83-8d42-4f8c-add8-60963f9f28d9, caa63201-4a17-4b7f-95ff-ed884a2b7e60,
|
||||||
|
319c1b10-cbd0-4f55-a46e-c4ee17e65139, 4f1de6a2-f0c5-4ac5-bce5-02c7dbb67deb
|
||||||
|
```
|
|
@ -0,0 +1,150 @@
|
||||||
|
alternative_titles=Alternative titles:
|
||||||
|
alternative_titles_in_description=Alternative titles in description
|
||||||
|
alternative_titles_in_description_summary=Include a manga's alternative titles at the end of its description
|
||||||
|
block_group_by_uuid=Block groups by UUID
|
||||||
|
block_group_by_uuid_summary=Chapters from blocked groups will not show up in Latest or Manga feed. Enter as a Comma-separated list of group UUIDs
|
||||||
|
block_uploader_by_uuid=Block uploader by UUID
|
||||||
|
block_uploader_by_uuid_summary=Chapters from blocked uploaders will not show up in Latest or Manga feed. Enter as a Comma-separated list of uploader UUIDs
|
||||||
|
content=Content
|
||||||
|
content_gore=Gore
|
||||||
|
content_rating=Content rating
|
||||||
|
content_rating_erotica=Erotica
|
||||||
|
content_rating_genre=Content rating: %s
|
||||||
|
content_rating_pornographic=Pornographic
|
||||||
|
content_rating_safe=Safe
|
||||||
|
content_rating_suggestive=Suggestive
|
||||||
|
content_sexual_violence=Sexual violence
|
||||||
|
cover_quality=Cover quality
|
||||||
|
cover_quality_low=Low
|
||||||
|
cover_quality_medium=Medium
|
||||||
|
cover_quality_original=Original
|
||||||
|
data_saver=Data saver
|
||||||
|
data_saver_summary=Enables smaller, more compressed images
|
||||||
|
excluded_tags_mode=Excluded tags mode
|
||||||
|
filter_original_languages=Filter original languages
|
||||||
|
filter_original_languages_summary=Only show content that was originally published in the selected languages in both latest and browse
|
||||||
|
format=Format
|
||||||
|
format_adaptation=Adaptation
|
||||||
|
format_anthology=Anthology
|
||||||
|
format_award_winning=Award Winning
|
||||||
|
format_doujinshi=Doujinshi
|
||||||
|
format_fan_colored=Fan Colored
|
||||||
|
format_full_color=Full Color
|
||||||
|
format_long_strip=Long Strip
|
||||||
|
format_official_colored=Official Colored
|
||||||
|
format_oneshot=Oneshot
|
||||||
|
format_user_created=User Created
|
||||||
|
format_web_comic=Web Comic
|
||||||
|
format_yonkoma=4-Koma
|
||||||
|
genre=Genre
|
||||||
|
genre_action=Action
|
||||||
|
genre_adventure=Adventure
|
||||||
|
genre_boys_love=Boy's Love
|
||||||
|
genre_comedy=Comedy
|
||||||
|
genre_crime=Crime
|
||||||
|
genre_drama=Drama
|
||||||
|
genre_fantasy=Fantasy
|
||||||
|
genre_girls_love=Girl's Love
|
||||||
|
genre_historical=Historical
|
||||||
|
genre_horror=Horror
|
||||||
|
genre_isekai=Isekai
|
||||||
|
genre_magical_girls=Magical Girls
|
||||||
|
genre_mecha=Mecha
|
||||||
|
genre_medical=Medical
|
||||||
|
genre_mystery=Mystery
|
||||||
|
genre_philosophical=Philosophical
|
||||||
|
genre_romance=Romance
|
||||||
|
genre_sci_fi=Sci-Fi
|
||||||
|
genre_slice_of_life=Slice of Life
|
||||||
|
genre_sports=Sports
|
||||||
|
genre_superhero=Superhero
|
||||||
|
genre_thriller=Thriller
|
||||||
|
genre_tragedy=Tragedy
|
||||||
|
genre_wuxia=Wuxia
|
||||||
|
has_available_chapters=Has available chapters
|
||||||
|
included_tags_mode=Included tags mode
|
||||||
|
invalid_author_id=Not a valid author ID
|
||||||
|
invalid_manga_id=Not a valid manga ID
|
||||||
|
invalid_group_id=Not a valid group ID
|
||||||
|
invalid_uuids=The text contains invalid UUIDs
|
||||||
|
migrate_warning=Migrate this entry from MangaDex to MangaDex to update it
|
||||||
|
mode_and=And
|
||||||
|
mode_or=Or
|
||||||
|
no_group=No Group
|
||||||
|
no_series_in_list=No series in the list
|
||||||
|
original_language=Original language
|
||||||
|
original_language_filter_chinese=%s (Manhua)
|
||||||
|
original_language_filter_japanese=%s (Manga)
|
||||||
|
original_language_filter_korean=%s (Manhwa)
|
||||||
|
publication_demographic=Publication demographic
|
||||||
|
publication_demographic_josei=Josei
|
||||||
|
publication_demographic_none=None
|
||||||
|
publication_demographic_seinen=Seinen
|
||||||
|
publication_demographic_shoujo=Shoujo
|
||||||
|
publication_demographic_shounen=Shounen
|
||||||
|
sort=Sort
|
||||||
|
sort_alphabetic=Alphabetic
|
||||||
|
sort_chapter_uploaded_at=Chapter uploaded at
|
||||||
|
sort_content_created_at=Content created at
|
||||||
|
sort_content_info_updated_at=Content info updated at
|
||||||
|
sort_number_of_follows=Number of follows
|
||||||
|
sort_rating=Rating
|
||||||
|
sort_relevance=Relevance
|
||||||
|
sort_year=Year
|
||||||
|
standard_content_rating=Default content rating
|
||||||
|
standard_content_rating_summary=Show content with the selected ratings by default
|
||||||
|
standard_https_port=Use HTTPS port 443 only
|
||||||
|
standard_https_port_summary=Enable to only request image servers that use port 443. This allows users with stricter firewall restrictions to access MangaDex images
|
||||||
|
status=Status
|
||||||
|
status_cancelled=Cancelled
|
||||||
|
status_completed=Completed
|
||||||
|
status_hiatus=Hiatus
|
||||||
|
status_ongoing=Ongoing
|
||||||
|
tags_mode=Tags mode
|
||||||
|
theme=Theme
|
||||||
|
theme_aliens=Aliens
|
||||||
|
theme_animals=Animals
|
||||||
|
theme_cooking=Cooking
|
||||||
|
theme_crossdressing=Crossdressing
|
||||||
|
theme_delinquents=Delinquents
|
||||||
|
theme_demons=Demons
|
||||||
|
theme_gender_swap=Genderswap
|
||||||
|
theme_ghosts=Ghosts
|
||||||
|
theme_gyaru=Gyaru
|
||||||
|
theme_harem=Harem
|
||||||
|
theme_incest=Incest
|
||||||
|
theme_loli=Loli
|
||||||
|
theme_mafia=Mafia
|
||||||
|
theme_magic=Magic
|
||||||
|
theme_martial_arts=Martial Arts
|
||||||
|
theme_military=Military
|
||||||
|
theme_monster_girls=Monster Girls
|
||||||
|
theme_monsters=Monsters
|
||||||
|
theme_music=Music
|
||||||
|
theme_ninja=Ninja
|
||||||
|
theme_office_workers=Office Workers
|
||||||
|
theme_police=Police
|
||||||
|
theme_post_apocalyptic=Post-Apocalyptic
|
||||||
|
theme_psychological=Psychological
|
||||||
|
theme_reincarnation=Reincarnation
|
||||||
|
theme_reverse_harem=Reverse Harem
|
||||||
|
theme_samurai=Samurai
|
||||||
|
theme_school_life=School Life
|
||||||
|
theme_shota=Shota
|
||||||
|
theme_supernatural=Supernatural
|
||||||
|
theme_survival=Survival
|
||||||
|
theme_time_travel=Time Travel
|
||||||
|
theme_traditional_games=Traditional Games
|
||||||
|
theme_vampires=Vampires
|
||||||
|
theme_video_games=Video Games
|
||||||
|
theme_villainess=Villainess
|
||||||
|
theme_virtual_reality=Virtual Reality
|
||||||
|
theme_zombies=Zombies
|
||||||
|
try_using_first_volume_cover=Attempt to use the first volume cover as cover
|
||||||
|
try_using_first_volume_cover_summary=May need to manually refresh entries already in library. Otherwise, clear database to have new covers to show up.
|
||||||
|
unable_to_process_chapter_request=Unable to process Chapter request. HTTP code: %d
|
||||||
|
uploaded_by=Uploaded by %s
|
||||||
|
set_custom_useragent=Set custom User-Agent
|
||||||
|
set_custom_useragent_summary=Keep it as default
|
||||||
|
set_custom_useragent_dialog=\n\nSpecify a custom user agent\n After each modification, the application needs to be restarted.\n\nDefault value:\n%s
|
||||||
|
set_custom_useragent_error_invalid=Invalid User-Agent: %s
|
|
@ -0,0 +1,108 @@
|
||||||
|
block_group_by_uuid=Bloquear grupos por UUID
|
||||||
|
block_group_by_uuid_summary=Los capítulos de los grupos bloqueados no aparecerán en Recientes o en el Feed de mangas. Introduce una coma para separar la lista de UUIDs
|
||||||
|
block_uploader_by_uuid=Bloquear uploader por UUID
|
||||||
|
block_uploader_by_uuid_summary=Los capítulos de los uploaders bloqueados no aparecerán en Recientes o en el Feed de mangas. Introduce una coma para separar la lista de UUIDs
|
||||||
|
content=Contenido
|
||||||
|
content_rating=Clasificación de contenido
|
||||||
|
content_rating_erotica=Erótico
|
||||||
|
content_rating_genre=Clasificación: %s
|
||||||
|
content_rating_pornographic=Pornográfico
|
||||||
|
content_rating_safe=Seguro
|
||||||
|
content_rating_suggestive=Sugestivo
|
||||||
|
content_sexual_violence=Violencia sexual
|
||||||
|
cover_quality=Calidad de la portada
|
||||||
|
cover_quality_low=Bajo
|
||||||
|
cover_quality_medium=Medio
|
||||||
|
data_saver=Ahorro de datos
|
||||||
|
data_saver_summary=Utiliza imágenes más pequeñas y más comprimidas
|
||||||
|
excluded_tags_mode=Modo de etiquetas excluidas
|
||||||
|
filter_original_languages=Filtrar por lenguajes
|
||||||
|
filter_original_languages_summary=Muestra solo el contenido publicado en los idiomas seleccionados en recientes y en la búsqueda
|
||||||
|
format=Formato
|
||||||
|
format_adaptation=Adaptación
|
||||||
|
format_anthology=Antología
|
||||||
|
format_award_winning=Ganador de premio
|
||||||
|
format_fan_colored=Coloreado por fans
|
||||||
|
format_full_color=Todo a color
|
||||||
|
format_long_strip=Tira larga
|
||||||
|
format_official_colored=Coloreo oficial
|
||||||
|
format_user_created=Creado por usuario
|
||||||
|
genre=Genero
|
||||||
|
genre_action=Acción
|
||||||
|
genre_adventure=Aventura
|
||||||
|
genre_comedy=Comedia
|
||||||
|
genre_crime=Crimen
|
||||||
|
genre_fantasy=Fantasia
|
||||||
|
genre_historical=Histórico
|
||||||
|
genre_magical_girls=Chicas mágicas
|
||||||
|
genre_medical=Medico
|
||||||
|
genre_mystery=Misterio
|
||||||
|
genre_philosophical=Filosófico
|
||||||
|
genre_sci_fi=Ciencia ficción
|
||||||
|
genre_slice_of_life=Recuentos de la vida
|
||||||
|
genre_sports=Deportes
|
||||||
|
genre_superhero=Superhéroes
|
||||||
|
genre_tragedy=Tragedia
|
||||||
|
has_available_chapters=Tiene capítulos disponibles
|
||||||
|
included_tags_mode=Modo de etiquetas incluidas
|
||||||
|
invalid_author_id=ID de autor inválida
|
||||||
|
invalid_group_id=ID de grupo inválida
|
||||||
|
migrate_warning=Migre la entrada MangaDex a MangaDex para actualizarla
|
||||||
|
mode_and=Y
|
||||||
|
mode_or=O
|
||||||
|
no_group=Sin grupo
|
||||||
|
no_series_in_list=No hay series en la lista
|
||||||
|
original_language=Lenguaje original
|
||||||
|
publication_demographic=Demografía
|
||||||
|
publication_demographic_none=Ninguna
|
||||||
|
sort=Ordenar
|
||||||
|
sort_alphabetic=Alfabeticamente
|
||||||
|
sort_chapter_uploaded_at=Capítulo subido en
|
||||||
|
sort_content_created_at=Contenido creado en
|
||||||
|
sort_content_info_updated_at=Información del contenido actualizada en
|
||||||
|
sort_number_of_follows=Número de seguidores
|
||||||
|
sort_rating=Calificación
|
||||||
|
sort_relevance=Relevancia
|
||||||
|
sort_year=Año
|
||||||
|
standard_content_rating=Clasificación de contenido por defecto
|
||||||
|
standard_content_rating_summary=Muestra el contenido con la clasificación de contenido seleccionada por defecto
|
||||||
|
standard_https_port=Utilizar el puerto 443 de HTTPS
|
||||||
|
standard_https_port_summary=Habilite esta opción solicitar las imágenes a los servidores que usan el puerto 443. Esto permite a los usuarios con restricciones estrictas de firewall acceder a las imagenes en MangaDex
|
||||||
|
status=Estado
|
||||||
|
status_cancelled=Cancelado
|
||||||
|
status_completed=Completado
|
||||||
|
status_hiatus=Pausado
|
||||||
|
status_ongoing=Publicandose
|
||||||
|
tags_mode=Modo de etiquetas
|
||||||
|
theme=Tema
|
||||||
|
theme_aliens=Alienígenas
|
||||||
|
theme_animals=Animales
|
||||||
|
theme_cooking=Cocina
|
||||||
|
theme_crossdressing=Travestismo
|
||||||
|
theme_delinquents=Delincuentes
|
||||||
|
theme_demons=Demonios
|
||||||
|
theme_gender_swap=Cambio de sexo
|
||||||
|
theme_ghosts=Fantasmas
|
||||||
|
theme_incest=Incesto
|
||||||
|
theme_magic=Magia
|
||||||
|
theme_martial_arts=Artes marciales
|
||||||
|
theme_military=Militar
|
||||||
|
theme_monster_girls=Chicas monstruo
|
||||||
|
theme_monsters=Monstruos
|
||||||
|
theme_music=Musica
|
||||||
|
theme_office_workers=Oficinistas
|
||||||
|
theme_police=Policial
|
||||||
|
theme_post_apocalyptic=Post-apocalíptico
|
||||||
|
theme_psychological=Psicológico
|
||||||
|
theme_reincarnation=Reencarnación
|
||||||
|
theme_reverse_harem=Harem inverso
|
||||||
|
theme_school_life=Vida escolar
|
||||||
|
theme_supernatural=Sobrenatural
|
||||||
|
theme_survival=Supervivencia
|
||||||
|
theme_time_travel=Viaje en el tiempo
|
||||||
|
theme_traditional_games=Juegos tradicionales
|
||||||
|
theme_vampires=Vampiros
|
||||||
|
theme_villainess=Villana
|
||||||
|
theme_virtual_reality=Realidad virtual
|
||||||
|
unable_to_process_chapter_request=No se ha podido procesar la solicitud del capítulo. Código HTTP: %d
|
||||||
|
uploaded_by=Subido por %s
|
|
@ -0,0 +1,119 @@
|
||||||
|
alternative_titles=Títulos alternativos:
|
||||||
|
alternative_titles_in_description=Títulos alternativos na descrição
|
||||||
|
alternative_titles_in_description_summary=Inclui os títulos alternativos das séries no final de cada descrição
|
||||||
|
block_group_by_uuid=Bloquear grupos por UUID
|
||||||
|
block_group_by_uuid_summary=Capítulos de grupos bloqueados não irão aparecer no feed de Recentes ou Mangás. Digite uma lista de UUIDs dos grupos separados por vírgulas
|
||||||
|
block_uploader_by_uuid=Bloquear uploaders por UUID
|
||||||
|
block_uploader_by_uuid_summary=Capítulos de usuários bloqueados não irão aparecer no feed de Recentes ou Mangás. Digite uma lista de UUIDs dos usuários separados por vírgulas
|
||||||
|
content=Conteúdo
|
||||||
|
content_rating=Classificação de conteúdo
|
||||||
|
content_rating_erotica=Erótico
|
||||||
|
content_rating_genre=Classificação: %s
|
||||||
|
content_rating_pornographic=Pornográfico
|
||||||
|
content_rating_safe=Seguro
|
||||||
|
content_rating_suggestive=Sugestivo
|
||||||
|
content_sexual_violence=Violência sexual
|
||||||
|
cover_quality=Qualidade da capa
|
||||||
|
cover_quality_low=Baixa
|
||||||
|
cover_quality_medium=Média
|
||||||
|
data_saver=Economia de dados
|
||||||
|
data_saver_summary=Utiliza imagens menores e mais compactadas
|
||||||
|
excluded_tags_mode=Modo de exclusão de tags
|
||||||
|
filter_original_languages=Filtrar os idiomas originais
|
||||||
|
filter_original_languages_summary=Mostra somente conteúdos que foram publicados originalmente nos idiomas selecionados nas seções de recentes e navegar
|
||||||
|
format=Formato
|
||||||
|
format_adaptation=Adaptação
|
||||||
|
format_anthology=Antologia
|
||||||
|
format_award_winning=Premiado
|
||||||
|
format_fan_colored=Colorizado por fãs
|
||||||
|
format_full_color=Colorido
|
||||||
|
format_long_strip=Vertical
|
||||||
|
format_official_colored=Colorizado oficialmente
|
||||||
|
format_user_created=Criado por usuários
|
||||||
|
genre=Gênero
|
||||||
|
genre_action=Ação
|
||||||
|
genre_adventure=Aventura
|
||||||
|
genre_comedy=Comédia
|
||||||
|
genre_crime=Crime
|
||||||
|
genre_fantasy=Fantasia
|
||||||
|
genre_historical=Histórico
|
||||||
|
genre_magical_girls=Garotas mágicas
|
||||||
|
genre_medical=Médico
|
||||||
|
genre_mystery=Mistério
|
||||||
|
genre_philosophical=Filosófico
|
||||||
|
genre_sci_fi=Ficção científica
|
||||||
|
genre_slice_of_life=Cotidiano
|
||||||
|
genre_sports=Esportes
|
||||||
|
genre_superhero=Super-heroi
|
||||||
|
genre_tragedy=Tragédia
|
||||||
|
has_available_chapters=Há capítulos disponíveis
|
||||||
|
included_tags_mode=Modo de inclusão de tags
|
||||||
|
invalid_author_id=ID do autor inválido
|
||||||
|
invalid_manga_id=ID do mangá inválido
|
||||||
|
invalid_group_id=ID do grupo inválido
|
||||||
|
invalid_uuids=O texto contém UUIDs inválidos
|
||||||
|
migrate_warning=Migre esta entrada do MangaDex para o MangaDex para atualizar
|
||||||
|
mode_and=E
|
||||||
|
mode_or=Ou
|
||||||
|
no_group=Sem grupo
|
||||||
|
no_series_in_list=Sem séries na lista
|
||||||
|
original_language=Idioma original
|
||||||
|
original_language_filter_japanese=%s (Mangá)
|
||||||
|
publication_demographic=Demografia da publicação
|
||||||
|
publication_demographic_none=Nenhuma
|
||||||
|
sort=Ordenar
|
||||||
|
sort_alphabetic=Alfabeticamente
|
||||||
|
sort_chapter_uploaded_at=Upload do capítulo
|
||||||
|
sort_content_created_at=Criação do conteúdo
|
||||||
|
sort_content_info_updated_at=Atualização das informações
|
||||||
|
sort_number_of_follows=Número de seguidores
|
||||||
|
sort_rating=Nota
|
||||||
|
sort_relevance=Relevância
|
||||||
|
sort_year=Ano de lançamento
|
||||||
|
standard_content_rating=Classificação de conteúdo padrão
|
||||||
|
standard_content_rating_summary=Mostra os conteúdos com as classificações selecionadas por padrão
|
||||||
|
standard_https_port=Utilizar somente a porta 443 do HTTPS
|
||||||
|
standard_https_port_summary=Ative para fazer requisições em somente servidores de imagem que usem a porta 443. Isso permite com que usuários com regras mais restritas de firewall possam acessar as imagens do MangaDex.
|
||||||
|
status=Estado
|
||||||
|
status_cancelled=Cancelado
|
||||||
|
status_completed=Completo
|
||||||
|
status_hiatus=Hiato
|
||||||
|
status_ongoing=Em andamento
|
||||||
|
tags_mode=Modo das tags
|
||||||
|
theme=Tema
|
||||||
|
theme_aliens=Alienígenas
|
||||||
|
theme_animals=Animais
|
||||||
|
theme_cooking=Culinária
|
||||||
|
theme_delinquents=Delinquentes
|
||||||
|
theme_demons=Demônios
|
||||||
|
theme_gender_swap=Troca de gêneros
|
||||||
|
theme_ghosts=Fantasmas
|
||||||
|
theme_harem=Harém
|
||||||
|
theme_incest=Incesto
|
||||||
|
theme_mafia=Máfia
|
||||||
|
theme_magic=Magia
|
||||||
|
theme_martial_arts=Artes marciais
|
||||||
|
theme_military=Militar
|
||||||
|
theme_monster_girls=Garotas monstro
|
||||||
|
theme_monsters=Monstros
|
||||||
|
theme_music=Musical
|
||||||
|
theme_office_workers=Funcionários de escritório
|
||||||
|
theme_police=Policial
|
||||||
|
theme_post_apocalyptic=Pós-apocalíptico
|
||||||
|
theme_psychological=Psicológico
|
||||||
|
theme_reincarnation=Reencarnação
|
||||||
|
theme_reverse_harem=Harém reverso
|
||||||
|
theme_school_life=Vida escolar
|
||||||
|
theme_supernatural=Sobrenatural
|
||||||
|
theme_survival=Sobrevivência
|
||||||
|
theme_time_travel=Viagem no tempo
|
||||||
|
theme_traditional_games=Jogos tradicionais
|
||||||
|
theme_vampires=Vampiros
|
||||||
|
theme_video_games=Videojuegos
|
||||||
|
theme_villainess=Villainess
|
||||||
|
theme_virtual_reality=Realidade virtual
|
||||||
|
theme_zombies=Zumbis
|
||||||
|
try_using_first_volume_cover=Tentar usar a capa do primeiro volume como capa
|
||||||
|
try_using_first_volume_cover_summary=Pode ser necessário atualizar os itens já adicionados na biblioteca. Alternativamente, limpe o banco de dados para as novas capas aparecerem.
|
||||||
|
unable_to_process_chapter_request=Não foi possível processar a requisição do capítulo. Código HTTP: %d
|
||||||
|
uploaded_by=Enviado por %s
|
|
@ -0,0 +1,138 @@
|
||||||
|
block_group_by_uuid=Заблокировать группы по UUID
|
||||||
|
block_group_by_uuid_summary=Главы от заблокированных групп не будут отображаться в последних обновлениях и в списке глав тайтла. Введите через запятую список UUID групп.
|
||||||
|
block_uploader_by_uuid=Заблокировать загрузчика по UUID
|
||||||
|
block_uploader_by_uuid_summary=Главы от заблокированных загрузчиков не будут отображаться в последних обновлениях и в списке глав тайтла. Введите через запятую список UUID загрузчиков.
|
||||||
|
content=Неприемлемый контент
|
||||||
|
content_gore=Жестокость
|
||||||
|
content_rating=Рейтинг контента
|
||||||
|
content_rating_erotica=Эротический
|
||||||
|
content_rating_genre=Рейтинг контента: %s
|
||||||
|
content_rating_pornographic=Порнографический
|
||||||
|
content_rating_safe=Безопасный
|
||||||
|
content_rating_suggestive=Намекающий
|
||||||
|
content_sexual_violence=Сексуальное насилие
|
||||||
|
cover_quality=Качество обложки
|
||||||
|
cover_quality_low=Низкое
|
||||||
|
cover_quality_medium=Среднее
|
||||||
|
cover_quality_original=Оригинальное
|
||||||
|
data_saver=Экономия трафика
|
||||||
|
data_saver_summary=Использует меньшие по размеру, сжатые изображения
|
||||||
|
excluded_tags_mode=Исключая
|
||||||
|
filter_original_languages=Фильтр по языку оригинала
|
||||||
|
filter_original_languages_summary=Показывать тайтлы которые изначально были выпущены только в выбранных языках в последних обновлениях и при поиске
|
||||||
|
format=Формат
|
||||||
|
format_adaptation=Адаптация
|
||||||
|
format_anthology=Антология
|
||||||
|
format_award_winning=Отмеченный наградами
|
||||||
|
format_doujinshi=Додзинси
|
||||||
|
format_fan_colored=Раскрашенная фанатами
|
||||||
|
format_full_color=В цвете
|
||||||
|
format_long_strip=Веб
|
||||||
|
format_official_colored=Официально раскрашенная
|
||||||
|
format_oneshot=Сингл
|
||||||
|
format_user_created=Созданная пользователями
|
||||||
|
format_web_comic=Веб-комикс
|
||||||
|
format_yonkoma=Ёнкома
|
||||||
|
genre=Жанр
|
||||||
|
genre_action=Боевик
|
||||||
|
genre_adventure=Приключения
|
||||||
|
genre_boys_love=BL
|
||||||
|
genre_comedy=Комедия
|
||||||
|
genre_crime=Криминал
|
||||||
|
genre_drama=Драма
|
||||||
|
genre_fantasy=Фэнтези
|
||||||
|
genre_girls_love=GL
|
||||||
|
genre_historical=История
|
||||||
|
genre_horror=Ужасы
|
||||||
|
genre_isekai=Исекай
|
||||||
|
genre_magical_girls=Махо-сёдзё
|
||||||
|
genre_mecha=Меха
|
||||||
|
genre_medical=Медицина
|
||||||
|
genre_mystery=Мистика
|
||||||
|
genre_philosophical=Философия
|
||||||
|
genre_romance=Романтика
|
||||||
|
genre_sci_fi=Научная фантастика
|
||||||
|
genre_slice_of_life=Повседневность
|
||||||
|
genre_sports=Спорт
|
||||||
|
genre_superhero=Супергерои
|
||||||
|
genre_thriller=Триллер
|
||||||
|
genre_tragedy=Трагедия
|
||||||
|
genre_wuxia=Культивация
|
||||||
|
has_available_chapters=Есть главы
|
||||||
|
included_tags_mode=Включая
|
||||||
|
invalid_author_id=Недействительный ID автора
|
||||||
|
invalid_group_id=Недействительный ID группы
|
||||||
|
mode_and=И
|
||||||
|
mode_or=Или
|
||||||
|
no_group=Нет группы
|
||||||
|
no_series_in_list=Лист пуст
|
||||||
|
original_language=Язык оригинала
|
||||||
|
original_language_filter_chinese=%s (Манхуа)
|
||||||
|
original_language_filter_japanese=%s (Манга)
|
||||||
|
original_language_filter_korean=%s (Манхва)
|
||||||
|
publication_demographic=Целевая аудитория
|
||||||
|
publication_demographic_josei=Дзёсэй
|
||||||
|
publication_demographic_none=Нет
|
||||||
|
publication_demographic_seinen=Сэйнэн
|
||||||
|
publication_demographic_shoujo=Сёдзё
|
||||||
|
publication_demographic_shounen=Сёнэн
|
||||||
|
sort=Сортировать по
|
||||||
|
sort_alphabetic=Алфавиту
|
||||||
|
sort_chapter_uploaded_at=Загруженной главе
|
||||||
|
sort_content_created_at=По дате создания
|
||||||
|
sort_content_info_updated_at=По дате обновления
|
||||||
|
sort_number_of_follows=Количеству фолловеров
|
||||||
|
sort_rating=Популярности
|
||||||
|
sort_relevance=Лучшему соответствию
|
||||||
|
sort_year=Год
|
||||||
|
standard_content_rating=Рейтинг контента по умолчанию
|
||||||
|
standard_content_rating_summary=Показывать контент с выбранным рейтингом по умолчанию
|
||||||
|
standard_https_port=Использовать только HTTPS порт 443
|
||||||
|
standard_https_port_summary=Запрашивает изображения только с серверов которые используют порт 443. Это позволяет пользователям со строгими правилами брандмауэра загружать изображения с MangaDex.
|
||||||
|
status=Статус
|
||||||
|
status_cancelled=Отменён
|
||||||
|
status_completed=Завершён
|
||||||
|
status_hiatus=Приостановлен
|
||||||
|
status_ongoing=Онгоинг
|
||||||
|
tags_mode=Режим поиска
|
||||||
|
theme=Теги
|
||||||
|
theme_aliens=Инопланетяне
|
||||||
|
theme_animals=Животные
|
||||||
|
theme_cooking=Животные
|
||||||
|
theme_crossdressing=Кроссдрессинг
|
||||||
|
theme_delinquents=Хулиганы
|
||||||
|
theme_demons=Демоны
|
||||||
|
theme_gender_swap=Смена гендера
|
||||||
|
theme_ghosts=Призраки
|
||||||
|
theme_gyaru=Гяру
|
||||||
|
theme_harem=Гарем
|
||||||
|
theme_incest=Инцест
|
||||||
|
theme_loli=Лоли
|
||||||
|
theme_mafia=Мафия
|
||||||
|
theme_magic=Магия
|
||||||
|
theme_martial_arts=Боевые исскуства
|
||||||
|
theme_military=Военные
|
||||||
|
theme_monster_girls=Монстродевушки
|
||||||
|
theme_monsters=Монстры
|
||||||
|
theme_music=Музыка
|
||||||
|
theme_ninja=Ниндзя
|
||||||
|
theme_office_workers=Офисные работники
|
||||||
|
theme_police=Полиция
|
||||||
|
theme_post_apocalyptic=Постапокалиптика
|
||||||
|
theme_psychological=Психология
|
||||||
|
theme_reincarnation=Реинкарнация
|
||||||
|
theme_reverse_harem=Обратный гарем
|
||||||
|
theme_samurai=Самураи
|
||||||
|
theme_school_life=Школа
|
||||||
|
theme_shota=Шота
|
||||||
|
theme_supernatural=Сверхъестественное
|
||||||
|
theme_survival=Выживание
|
||||||
|
theme_time_travel=Путешествие во времени
|
||||||
|
theme_traditional_games=Путешествие во времени
|
||||||
|
theme_vampires=Вампиры
|
||||||
|
theme_video_games=Видеоигры
|
||||||
|
theme_villainess=Злодейка
|
||||||
|
theme_virtual_reality=Виртуальная реальность
|
||||||
|
theme_zombies=Зомби
|
||||||
|
unable_to_process_chapter_request=Не удалось обработать ссылку на главу. Ошибка: %d
|
||||||
|
uploaded_by=Загрузил %s
|
|
@ -0,0 +1,17 @@
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
apply plugin: 'kotlinx-serialization'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
extName = 'MangaDex'
|
||||||
|
pkgNameSuffix = 'all.mangadex'
|
||||||
|
extClass = '.MangaDexFactory'
|
||||||
|
extVersionCode = 192
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":lib-i18n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
After Width: | Height: | Size: 3.6 KiB |
After Width: | Height: | Size: 2.0 KiB |
After Width: | Height: | Size: 4.7 KiB |
After Width: | Height: | Size: 8.6 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 24 KiB |
|
@ -0,0 +1,163 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.lib.i18n.Intl
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
object MDConstants {
|
||||||
|
|
||||||
|
val uuidRegex =
|
||||||
|
Regex("[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
|
||||||
|
|
||||||
|
const val mangaLimit = 20
|
||||||
|
const val latestChapterLimit = 100
|
||||||
|
|
||||||
|
const val chapter = "chapter"
|
||||||
|
const val manga = "manga"
|
||||||
|
const val coverArt = "cover_art"
|
||||||
|
const val scanlationGroup = "scanlation_group"
|
||||||
|
const val user = "user"
|
||||||
|
const val author = "author"
|
||||||
|
const val artist = "artist"
|
||||||
|
const val tag = "tag"
|
||||||
|
const val list = "custom_list"
|
||||||
|
const val legacyNoGroupId = "00e03853-1b96-4f41-9542-c71b8692033b"
|
||||||
|
|
||||||
|
const val cdnUrl = "https://uploads.mangadex.org"
|
||||||
|
const val apiUrl = "https://api.mangadex.org"
|
||||||
|
const val apiMangaUrl = "$apiUrl/manga"
|
||||||
|
const val apiChapterUrl = "$apiUrl/chapter"
|
||||||
|
const val apiListUrl = "$apiUrl/list"
|
||||||
|
const val atHomePostUrl = "https://api.mangadex.network/report"
|
||||||
|
val whitespaceRegex = "\\s".toRegex()
|
||||||
|
|
||||||
|
val mdAtHomeTokenLifespan = 5.minutes.inWholeMilliseconds
|
||||||
|
|
||||||
|
val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+SSS", Locale.US)
|
||||||
|
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||||
|
|
||||||
|
const val prefixIdSearch = "id:"
|
||||||
|
const val prefixChSearch = "ch:"
|
||||||
|
const val prefixGrpSearch = "grp:"
|
||||||
|
const val prefixAuthSearch = "author:"
|
||||||
|
const val prefixUsrSearch = "usr:"
|
||||||
|
const val prefixListSearch = "list:"
|
||||||
|
|
||||||
|
private const val coverQualityPref = "thumbnailQuality"
|
||||||
|
|
||||||
|
fun getCoverQualityPreferenceKey(dexLang: String): String {
|
||||||
|
return "${coverQualityPref}_$dexLang"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCoverQualityPreferenceEntries(intl: Intl) =
|
||||||
|
arrayOf(intl["cover_quality_original"], intl["cover_quality_medium"], intl["cover_quality_low"])
|
||||||
|
|
||||||
|
fun getCoverQualityPreferenceEntryValues() = arrayOf("", ".512.jpg", ".256.jpg")
|
||||||
|
|
||||||
|
fun getCoverQualityPreferenceDefaultValue() = getCoverQualityPreferenceEntryValues()[0]
|
||||||
|
|
||||||
|
private const val dataSaverPref = "dataSaverV5"
|
||||||
|
|
||||||
|
fun getDataSaverPreferenceKey(dexLang: String): String {
|
||||||
|
return "${dataSaverPref}_$dexLang"
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val standardHttpsPortPref = "usePort443"
|
||||||
|
|
||||||
|
fun getStandardHttpsPreferenceKey(dexLang: String): String {
|
||||||
|
return "${standardHttpsPortPref}_$dexLang"
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val contentRatingPref = "contentRating"
|
||||||
|
const val contentRatingPrefValSafe = "safe"
|
||||||
|
const val contentRatingPrefValSuggestive = "suggestive"
|
||||||
|
const val contentRatingPrefValErotica = "erotica"
|
||||||
|
const val contentRatingPrefValPornographic = "pornographic"
|
||||||
|
val contentRatingPrefDefaults = setOf(contentRatingPrefValSafe, contentRatingPrefValSuggestive)
|
||||||
|
val allContentRatings = setOf(
|
||||||
|
contentRatingPrefValSafe,
|
||||||
|
contentRatingPrefValSuggestive,
|
||||||
|
contentRatingPrefValErotica,
|
||||||
|
contentRatingPrefValPornographic,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getContentRatingPrefKey(dexLang: String): String {
|
||||||
|
return "${contentRatingPref}_$dexLang"
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val originalLanguagePref = "originalLanguage"
|
||||||
|
const val originalLanguagePrefValJapanese = MangaDexIntl.JAPANESE
|
||||||
|
const val originalLanguagePrefValChinese = MangaDexIntl.CHINESE
|
||||||
|
const val originalLanguagePrefValChineseHk = "zh-hk"
|
||||||
|
const val originalLanguagePrefValKorean = MangaDexIntl.KOREAN
|
||||||
|
val originalLanguagePrefDefaults = emptySet<String>()
|
||||||
|
|
||||||
|
fun getOriginalLanguagePrefKey(dexLang: String): String {
|
||||||
|
return "${originalLanguagePref}_$dexLang"
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val groupAzuki = "5fed0576-8b94-4f9a-b6a7-08eecd69800d"
|
||||||
|
private const val groupBilibili = "06a9fecb-b608-4f19-b93c-7caab06b7f44"
|
||||||
|
private const val groupComikey = "8d8ecf83-8d42-4f8c-add8-60963f9f28d9"
|
||||||
|
private const val groupInkr = "caa63201-4a17-4b7f-95ff-ed884a2b7e60"
|
||||||
|
private const val groupMangaHot = "319c1b10-cbd0-4f55-a46e-c4ee17e65139"
|
||||||
|
private const val groupMangaPlus = "4f1de6a2-f0c5-4ac5-bce5-02c7dbb67deb"
|
||||||
|
val defaultBlockedGroups = setOf(
|
||||||
|
groupAzuki,
|
||||||
|
groupBilibili,
|
||||||
|
groupComikey,
|
||||||
|
groupInkr,
|
||||||
|
groupMangaHot,
|
||||||
|
groupMangaPlus,
|
||||||
|
)
|
||||||
|
private const val blockedGroupsPref = "blockedGroups"
|
||||||
|
fun getBlockedGroupsPrefKey(dexLang: String): String {
|
||||||
|
return "${blockedGroupsPref}_$dexLang"
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val blockedUploaderPref = "blockedUploader"
|
||||||
|
fun getBlockedUploaderPrefKey(dexLang: String): String {
|
||||||
|
return "${blockedUploaderPref}_$dexLang"
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val hasSanitizedUuidsPref = "hasSanitizedUuids"
|
||||||
|
fun getHasSanitizedUuidsPrefKey(dexLang: String): String {
|
||||||
|
return "${hasSanitizedUuidsPref}_$dexLang"
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val tryUsingFirstVolumeCoverPref = "tryUsingFirstVolumeCover"
|
||||||
|
const val tryUsingFirstVolumeCoverDefault = false
|
||||||
|
fun getTryUsingFirstVolumeCoverPrefKey(dexLang: String): String {
|
||||||
|
return "${tryUsingFirstVolumeCoverPref}_$dexLang"
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val altTitlesInDescPref = "altTitlesInDesc"
|
||||||
|
fun getAltTitlesInDescPrefKey(dexLang: String): String {
|
||||||
|
return "${altTitlesInDescPref}_$dexLang"
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val customUserAgentPref = "customUserAgent"
|
||||||
|
fun getCustomUserAgentPrefKey(dexLang: String): String {
|
||||||
|
return "${customUserAgentPref}_$dexLang"
|
||||||
|
}
|
||||||
|
|
||||||
|
val defaultUserAgent = "Tachiyomi " + System.getProperty("http.agent")
|
||||||
|
|
||||||
|
private const val tagGroupContent = "content"
|
||||||
|
private const val tagGroupFormat = "format"
|
||||||
|
private const val tagGroupGenre = "genre"
|
||||||
|
private const val tagGroupTheme = "theme"
|
||||||
|
val tagGroupsOrder = arrayOf(tagGroupContent, tagGroupFormat, tagGroupGenre, tagGroupTheme)
|
||||||
|
|
||||||
|
const val tagAnthologyUuid = "51d83883-4103-437c-b4b1-731cb73d786c"
|
||||||
|
const val tagOneShotUuid = "0234a31e-a729-4e28-9d6a-3f87c4966b9e"
|
||||||
|
|
||||||
|
val romanizedLangCodes = mapOf(
|
||||||
|
MangaDexIntl.JAPANESE to "ja-ro",
|
||||||
|
MangaDexIntl.KOREAN to "ko-ro",
|
||||||
|
MangaDexIntl.CHINESE to "zh-ro",
|
||||||
|
"zh-hk" to "zh-ro",
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,903 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Build
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.preference.EditTextPreference
|
||||||
|
import androidx.preference.ListPreference
|
||||||
|
import androidx.preference.MultiSelectListPreference
|
||||||
|
import androidx.preference.PreferenceScreen
|
||||||
|
import androidx.preference.SwitchPreferenceCompat
|
||||||
|
import eu.kanade.tachiyomi.AppInfo
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AggregateDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AggregateVolume
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AtHomeDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ChapterDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ChapterListDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.CoverArtDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.CoverArtListDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ListDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.MangaDataDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.MangaDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.MangaListDto
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.asObservable
|
||||||
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||||
|
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||||
|
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.HttpSource
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import okhttp3.CacheControl
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
abstract class MangaDex(final override val lang: String, private val dexLang: String = lang) :
|
||||||
|
ConfigurableSource, HttpSource() {
|
||||||
|
|
||||||
|
override val name = MangaDexIntl.MANGADEX_NAME
|
||||||
|
|
||||||
|
override val baseUrl = "https://mangadex.org"
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
private val preferences: SharedPreferences by lazy {
|
||||||
|
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val helper = MangaDexHelper(lang)
|
||||||
|
|
||||||
|
final override fun headersBuilder(): Headers.Builder {
|
||||||
|
val extraHeader = "Android/${Build.VERSION.RELEASE} " +
|
||||||
|
"Tachiyomi/${AppInfo.getVersionName()} " +
|
||||||
|
"MangaDex/1.4.190"
|
||||||
|
|
||||||
|
val builder = super.headersBuilder().apply {
|
||||||
|
set("Referer", "$baseUrl/")
|
||||||
|
set("Extra", extraHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
|
||||||
|
override val client = network.client.newBuilder()
|
||||||
|
.rateLimit(3)
|
||||||
|
.addInterceptor(MdAtHomeReportInterceptor(network.client, headers))
|
||||||
|
.addInterceptor(MdUserAgentInterceptor(preferences, dexLang))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
init {
|
||||||
|
preferences.sanitizeExistingUuidPrefs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popular manga section
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int): Request {
|
||||||
|
val url = MDConstants.apiMangaUrl.toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("order[followedCount]", "desc")
|
||||||
|
.addQueryParameter("availableTranslatedLanguage[]", dexLang)
|
||||||
|
.addQueryParameter("limit", MDConstants.mangaLimit.toString())
|
||||||
|
.addQueryParameter("offset", helper.getMangaListOffset(page))
|
||||||
|
.addQueryParameter("includes[]", MDConstants.coverArt)
|
||||||
|
.addQueryParameter("contentRating[]", preferences.contentRating)
|
||||||
|
.addQueryParameter("originalLanguage[]", preferences.originalLanguages)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return GET(url, headers, CacheControl.FORCE_NETWORK)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
if (response.code == 204) {
|
||||||
|
return MangasPage(emptyList(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mangaListDto = response.parseAs<MangaListDto>()
|
||||||
|
|
||||||
|
val coverSuffix = preferences.coverQuality
|
||||||
|
val firstVolumeCovers = fetchFirstVolumeCovers(mangaListDto.data).orEmpty()
|
||||||
|
|
||||||
|
val mangaList = mangaListDto.data.map { mangaDataDto ->
|
||||||
|
val fileName = firstVolumeCovers.getOrElse(mangaDataDto.id) {
|
||||||
|
mangaDataDto.relationships
|
||||||
|
.firstInstanceOrNull<CoverArtDto>()
|
||||||
|
?.attributes?.fileName
|
||||||
|
}
|
||||||
|
helper.createBasicManga(mangaDataDto, fileName, coverSuffix, dexLang)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MangasPage(mangaList, mangaListDto.hasNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest manga section
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request {
|
||||||
|
val url = MDConstants.apiChapterUrl.toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("offset", helper.getLatestChapterOffset(page))
|
||||||
|
.addQueryParameter("limit", MDConstants.latestChapterLimit.toString())
|
||||||
|
.addQueryParameter("translatedLanguage[]", dexLang)
|
||||||
|
.addQueryParameter("order[publishAt]", "desc")
|
||||||
|
.addQueryParameter("includeFutureUpdates", "0")
|
||||||
|
.addQueryParameter("originalLanguage[]", preferences.originalLanguages)
|
||||||
|
.addQueryParameter("contentRating[]", preferences.contentRating)
|
||||||
|
.addQueryParameter(
|
||||||
|
"excludedGroups[]",
|
||||||
|
MDConstants.defaultBlockedGroups + preferences.blockedGroups,
|
||||||
|
)
|
||||||
|
.addQueryParameter("excludedUploaders[]", preferences.blockedUploaders)
|
||||||
|
.addQueryParameter("includeFuturePublishAt", "0")
|
||||||
|
.addQueryParameter("includeEmptyPages", "0")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return GET(url, headers, CacheControl.FORCE_NETWORK)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API endpoint can't sort by date yet, so not implemented.
|
||||||
|
*/
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||||
|
val chapterListDto = response.parseAs<ChapterListDto>()
|
||||||
|
|
||||||
|
val mangaIds = chapterListDto.data
|
||||||
|
.flatMap { it.relationships }
|
||||||
|
.filterIsInstance<MangaDataDto>()
|
||||||
|
.map { it.id }
|
||||||
|
.distinct()
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
val mangaApiUrl = MDConstants.apiMangaUrl.toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("includes[]", MDConstants.coverArt)
|
||||||
|
.addQueryParameter("limit", mangaIds.size.toString())
|
||||||
|
.addQueryParameter("contentRating[]", preferences.contentRating)
|
||||||
|
.addQueryParameter("ids[]", mangaIds)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val mangaRequest = GET(mangaApiUrl, headers, CacheControl.FORCE_NETWORK)
|
||||||
|
val mangaResponse = client.newCall(mangaRequest).execute()
|
||||||
|
val mangaListDto = mangaResponse.parseAs<MangaListDto>()
|
||||||
|
val firstVolumeCovers = fetchFirstVolumeCovers(mangaListDto.data).orEmpty()
|
||||||
|
|
||||||
|
val mangaDtoMap = mangaListDto.data.associateBy({ it.id }, { it })
|
||||||
|
|
||||||
|
val coverSuffix = preferences.coverQuality
|
||||||
|
|
||||||
|
val mangaList = mangaIds.mapNotNull { mangaDtoMap[it] }.map { mangaDataDto ->
|
||||||
|
val fileName = firstVolumeCovers.getOrElse(mangaDataDto.id) {
|
||||||
|
mangaDataDto.relationships
|
||||||
|
.firstInstanceOrNull<CoverArtDto>()
|
||||||
|
?.attributes?.fileName
|
||||||
|
}
|
||||||
|
helper.createBasicManga(mangaDataDto, fileName, coverSuffix, dexLang)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MangasPage(mangaList, chapterListDto.hasNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search manga section
|
||||||
|
|
||||||
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
|
return when {
|
||||||
|
query.startsWith(MDConstants.prefixChSearch) ->
|
||||||
|
getMangaIdFromChapterId(query.removePrefix(MDConstants.prefixChSearch))
|
||||||
|
.flatMap { mangaId ->
|
||||||
|
super.fetchSearchManga(
|
||||||
|
page = page,
|
||||||
|
query = MDConstants.prefixIdSearch + mangaId,
|
||||||
|
filters = filters,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
query.startsWith(MDConstants.prefixUsrSearch) ->
|
||||||
|
client
|
||||||
|
.newCall(
|
||||||
|
request = searchMangaUploaderRequest(
|
||||||
|
page = page,
|
||||||
|
uploader = query.removePrefix(MDConstants.prefixUsrSearch),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { latestUpdatesParse(it) }
|
||||||
|
|
||||||
|
query.startsWith(MDConstants.prefixListSearch) ->
|
||||||
|
client
|
||||||
|
.newCall(
|
||||||
|
request = searchMangaListRequest(
|
||||||
|
list = query.removePrefix(MDConstants.prefixListSearch),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { searchMangaListParse(it, page, filters) }
|
||||||
|
|
||||||
|
else -> super.fetchSearchManga(page, query.trim(), filters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMangaIdFromChapterId(id: String): Observable<String> {
|
||||||
|
return client.newCall(GET("${MDConstants.apiChapterUrl}/$id", headers))
|
||||||
|
.asObservable()
|
||||||
|
.map { response ->
|
||||||
|
if (response.isSuccessful.not()) {
|
||||||
|
throw Exception(helper.intl.format("unable_to_process_chapter_request", response.code))
|
||||||
|
}
|
||||||
|
|
||||||
|
response.parseAs<ChapterDto>().data!!.relationships
|
||||||
|
.firstInstanceOrNull<MangaDataDto>()!!.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
if (query.startsWith(MDConstants.prefixIdSearch)) {
|
||||||
|
val mangaId = query.removePrefix(MDConstants.prefixIdSearch)
|
||||||
|
|
||||||
|
if (!helper.containsUuid(mangaId)) {
|
||||||
|
throw Exception(helper.intl["invalid_manga_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = MDConstants.apiMangaUrl.toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("ids[]", query.removePrefix(MDConstants.prefixIdSearch))
|
||||||
|
.addQueryParameter("includes[]", MDConstants.coverArt)
|
||||||
|
.addQueryParameter("contentRating[]", MDConstants.allContentRatings)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return GET(url, headers, CacheControl.FORCE_NETWORK)
|
||||||
|
}
|
||||||
|
|
||||||
|
val tempUrl = MDConstants.apiMangaUrl.toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("limit", MDConstants.mangaLimit.toString())
|
||||||
|
.addQueryParameter("offset", helper.getMangaListOffset(page))
|
||||||
|
.addQueryParameter("includes[]", MDConstants.coverArt)
|
||||||
|
|
||||||
|
when {
|
||||||
|
query.startsWith(MDConstants.prefixGrpSearch) -> {
|
||||||
|
val groupId = query.removePrefix(MDConstants.prefixGrpSearch)
|
||||||
|
|
||||||
|
if (!helper.containsUuid(groupId)) {
|
||||||
|
throw Exception(helper.intl["invalid_group_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
tempUrl.addQueryParameter("group", groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
query.startsWith(MDConstants.prefixAuthSearch) -> {
|
||||||
|
val authorId = query.removePrefix(MDConstants.prefixAuthSearch)
|
||||||
|
|
||||||
|
if (!helper.containsUuid(authorId)) {
|
||||||
|
throw Exception(helper.intl["invalid_author_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
tempUrl.addQueryParameter("authorOrArtist", authorId)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val actualQuery = query.replace(MDConstants.whitespaceRegex, " ")
|
||||||
|
|
||||||
|
if (actualQuery.isNotBlank()) {
|
||||||
|
tempUrl.addQueryParameter("title", actualQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalUrl = helper.mdFilters.addFiltersToUrl(
|
||||||
|
url = tempUrl,
|
||||||
|
filters = filters.ifEmpty { getFilterList() },
|
||||||
|
dexLang = dexLang,
|
||||||
|
)
|
||||||
|
|
||||||
|
return GET(finalUrl, headers, CacheControl.FORCE_NETWORK)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
|
||||||
|
|
||||||
|
private fun searchMangaListRequest(list: String): Request {
|
||||||
|
return GET("${MDConstants.apiListUrl}/$list", headers, CacheControl.FORCE_NETWORK)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchMangaListParse(response: Response, page: Int, filters: FilterList): MangasPage {
|
||||||
|
val listDto = response.parseAs<ListDto>()
|
||||||
|
val listDtoFiltered = listDto.data!!.relationships.filterIsInstance<MangaDataDto>()
|
||||||
|
val amount = listDtoFiltered.count()
|
||||||
|
|
||||||
|
if (amount < 1) {
|
||||||
|
throw Exception(helper.intl["no_series_in_list"])
|
||||||
|
}
|
||||||
|
|
||||||
|
val minIndex = (page - 1) * MDConstants.mangaLimit
|
||||||
|
|
||||||
|
val tempUrl = MDConstants.apiMangaUrl.toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("limit", MDConstants.mangaLimit.toString())
|
||||||
|
.addQueryParameter("offset", "0")
|
||||||
|
.addQueryParameter("includes[]", MDConstants.coverArt)
|
||||||
|
|
||||||
|
val ids = listDtoFiltered
|
||||||
|
.filterIndexed { i, _ -> i >= minIndex && i < (minIndex + MDConstants.mangaLimit) }
|
||||||
|
.map(MangaDataDto::id)
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
tempUrl.addQueryParameter("ids[]", ids)
|
||||||
|
|
||||||
|
val finalUrl = helper.mdFilters.addFiltersToUrl(
|
||||||
|
url = tempUrl,
|
||||||
|
filters = filters.ifEmpty { getFilterList() },
|
||||||
|
dexLang = dexLang,
|
||||||
|
)
|
||||||
|
|
||||||
|
val mangaRequest = GET(finalUrl, headers, CacheControl.FORCE_NETWORK)
|
||||||
|
val mangaResponse = client.newCall(mangaRequest).execute()
|
||||||
|
val mangaList = searchMangaListParse(mangaResponse)
|
||||||
|
|
||||||
|
val hasNextPage = amount.toFloat() / MDConstants.mangaLimit - (page.toFloat() - 1) > 1 &&
|
||||||
|
ids.size == MDConstants.mangaLimit
|
||||||
|
|
||||||
|
return MangasPage(mangaList, hasNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchMangaListParse(response: Response): List<SManga> {
|
||||||
|
// This check will be used as the source is doing additional requests to this
|
||||||
|
// that are not parsed by the asObservableSuccess() method. It should throw the
|
||||||
|
// HttpException from the app if it becomes available in a future version of extensions-lib.
|
||||||
|
if (response.isSuccessful.not()) {
|
||||||
|
throw Exception("HTTP error ${response.code}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val mangaListDto = response.parseAs<MangaListDto>()
|
||||||
|
val firstVolumeCovers = fetchFirstVolumeCovers(mangaListDto.data).orEmpty()
|
||||||
|
|
||||||
|
val coverSuffix = preferences.coverQuality
|
||||||
|
|
||||||
|
val mangaList = mangaListDto.data.map { mangaDataDto ->
|
||||||
|
val fileName = firstVolumeCovers.getOrElse(mangaDataDto.id) {
|
||||||
|
mangaDataDto.relationships
|
||||||
|
.firstInstanceOrNull<CoverArtDto>()
|
||||||
|
?.attributes?.fileName
|
||||||
|
}
|
||||||
|
helper.createBasicManga(mangaDataDto, fileName, coverSuffix, dexLang)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mangaList
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchMangaUploaderRequest(page: Int, uploader: String): Request {
|
||||||
|
val url = MDConstants.apiChapterUrl.toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("offset", helper.getLatestChapterOffset(page))
|
||||||
|
.addQueryParameter("limit", MDConstants.latestChapterLimit.toString())
|
||||||
|
.addQueryParameter("translatedLanguage[]", dexLang)
|
||||||
|
.addQueryParameter("order[publishAt]", "desc")
|
||||||
|
.addQueryParameter("includeFutureUpdates", "0")
|
||||||
|
.addQueryParameter("includeFuturePublishAt", "0")
|
||||||
|
.addQueryParameter("includeEmptyPages", "0")
|
||||||
|
.addQueryParameter("uploader", uploader)
|
||||||
|
.addQueryParameter("originalLanguage[]", preferences.originalLanguages)
|
||||||
|
.addQueryParameter("contentRating[]", preferences.contentRating)
|
||||||
|
.addQueryParameter(
|
||||||
|
"excludedGroups[]",
|
||||||
|
MDConstants.defaultBlockedGroups + preferences.blockedGroups,
|
||||||
|
)
|
||||||
|
.addQueryParameter("excludedUploaders[]", preferences.blockedUploaders)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return GET(url, headers, CacheControl.FORCE_NETWORK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manga Details section
|
||||||
|
|
||||||
|
override fun getMangaUrl(manga: SManga): String {
|
||||||
|
return baseUrl + manga.url + "/" + helper.titleToSlug(manga.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the API endpoint URL for the entry details.
|
||||||
|
*
|
||||||
|
* @throws Exception if the url is the old format so people migrate
|
||||||
|
*/
|
||||||
|
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||||
|
if (!helper.containsUuid(manga.url.trim())) {
|
||||||
|
throw Exception(helper.intl["migrate_warning"])
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = (MDConstants.apiUrl + manga.url).toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("includes[]", MDConstants.coverArt)
|
||||||
|
.addQueryParameter("includes[]", MDConstants.author)
|
||||||
|
.addQueryParameter("includes[]", MDConstants.artist)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return GET(url, headers, CacheControl.FORCE_NETWORK)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
|
val manga = response.parseAs<MangaDto>()
|
||||||
|
|
||||||
|
return helper.createManga(
|
||||||
|
manga.data!!,
|
||||||
|
fetchSimpleChapterList(manga, dexLang),
|
||||||
|
fetchFirstVolumeCover(manga),
|
||||||
|
dexLang,
|
||||||
|
preferences.coverQuality,
|
||||||
|
preferences.altTitlesInDesc,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a quick-n-dirty list of the chapters to be used in determining the manga status.
|
||||||
|
* Uses the 'aggregate' endpoint.
|
||||||
|
*
|
||||||
|
* @see MangaDexHelper.getPublicationStatus
|
||||||
|
* @see AggregateDto
|
||||||
|
*/
|
||||||
|
private fun fetchSimpleChapterList(manga: MangaDto, langCode: String): Map<String, AggregateVolume> {
|
||||||
|
val url = "${MDConstants.apiMangaUrl}/${manga.data!!.id}/aggregate?translatedLanguage[]=$langCode"
|
||||||
|
val response = client.newCall(GET(url, headers)).execute()
|
||||||
|
|
||||||
|
return runCatching { response.parseAs<AggregateDto>() }
|
||||||
|
.getOrNull()?.volumes.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to get the first volume cover if the setting is enabled.
|
||||||
|
* Uses the 'covers' endpoint.
|
||||||
|
*
|
||||||
|
* @see CoverArtListDto
|
||||||
|
*/
|
||||||
|
private fun fetchFirstVolumeCover(manga: MangaDto): String? {
|
||||||
|
return fetchFirstVolumeCovers(listOf(manga.data!!))?.get(manga.data.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to get the first volume cover if the setting is enabled.
|
||||||
|
* Uses the 'covers' endpoint.
|
||||||
|
*
|
||||||
|
* @see CoverArtListDto
|
||||||
|
*/
|
||||||
|
private fun fetchFirstVolumeCovers(mangaList: List<MangaDataDto>): Map<String, String>? {
|
||||||
|
if (!preferences.tryUsingFirstVolumeCover || mangaList.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val safeMangaList = mangaList.filterNot { it.attributes?.originalLanguage.isNullOrEmpty() }
|
||||||
|
val mangaMap = safeMangaList.associate { it.id to it.attributes!! }
|
||||||
|
val locales = safeMangaList.mapNotNull { it.attributes!!.originalLanguage }.distinct()
|
||||||
|
val limit = (mangaMap.size * locales.size).coerceAtMost(100)
|
||||||
|
|
||||||
|
val apiUrl = "${MDConstants.apiUrl}/cover".toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("order[volume]", "asc")
|
||||||
|
.addQueryParameter("manga[]", mangaMap.keys)
|
||||||
|
.addQueryParameter("locales[]", locales.toSet())
|
||||||
|
.addQueryParameter("limit", limit.toString())
|
||||||
|
.addQueryParameter("offset", "0")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val result = runCatching {
|
||||||
|
client.newCall(GET(apiUrl, headers)).execute().parseAs<CoverArtListDto>().data
|
||||||
|
}
|
||||||
|
|
||||||
|
val covers = result.getOrNull() ?: return null
|
||||||
|
|
||||||
|
return covers
|
||||||
|
.groupBy { it.relationships.firstInstanceOrNull<MangaDataDto>()!!.id }
|
||||||
|
.mapValues {
|
||||||
|
it.value.find { c -> c.attributes?.locale == mangaMap[it.key]?.originalLanguage }
|
||||||
|
}
|
||||||
|
.filterValues { !it?.attributes?.fileName.isNullOrEmpty() }
|
||||||
|
.mapValues { it.value!!.attributes!!.fileName!! }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chapter list section
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the API endpoint URL for the first page of chapter list.
|
||||||
|
*
|
||||||
|
* @throws Exception if the url is the old format so people migrate
|
||||||
|
*/
|
||||||
|
override fun chapterListRequest(manga: SManga): Request {
|
||||||
|
if (!helper.containsUuid(manga.url)) {
|
||||||
|
throw Exception(helper.intl["migrate_warning"])
|
||||||
|
}
|
||||||
|
|
||||||
|
return paginatedChapterListRequest(helper.getUUIDFromUrl(manga.url), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required because the chapter list API endpoint is paginated.
|
||||||
|
*/
|
||||||
|
private fun paginatedChapterListRequest(mangaId: String, offset: Int): Request {
|
||||||
|
val url = helper.getChapterEndpoint(mangaId, offset, dexLang).toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("contentRating[]", MDConstants.allContentRatings)
|
||||||
|
.addQueryParameter("excludedGroups[]", preferences.blockedGroups)
|
||||||
|
.addQueryParameter("excludedUploaders[]", preferences.blockedUploaders)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return GET(url, headers, CacheControl.FORCE_NETWORK)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
if (response.code == 204) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val chapterListResponse = response.parseAs<ChapterListDto>()
|
||||||
|
|
||||||
|
val chapterListResults = chapterListResponse.data.toMutableList()
|
||||||
|
|
||||||
|
val mangaId = response.request.url.toString()
|
||||||
|
.substringBefore("/feed")
|
||||||
|
.substringAfter("${MDConstants.apiMangaUrl}/")
|
||||||
|
|
||||||
|
var offset = chapterListResponse.offset
|
||||||
|
var hasNextPage = chapterListResponse.hasNextPage
|
||||||
|
|
||||||
|
// Max results that can be returned is 500 so need to make more API
|
||||||
|
// calls if the chapter list response has a next page.
|
||||||
|
while (hasNextPage) {
|
||||||
|
offset += chapterListResponse.limit
|
||||||
|
|
||||||
|
val newRequest = paginatedChapterListRequest(mangaId, offset)
|
||||||
|
val newResponse = client.newCall(newRequest).execute()
|
||||||
|
val newChapterList = newResponse.parseAs<ChapterListDto>()
|
||||||
|
chapterListResults.addAll(newChapterList.data)
|
||||||
|
|
||||||
|
hasNextPage = newChapterList.hasNextPage
|
||||||
|
}
|
||||||
|
|
||||||
|
return chapterListResults
|
||||||
|
.filterNot { it.attributes!!.isInvalid }
|
||||||
|
.map(helper::createChapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChapterUrl(chapter: SChapter): String = baseUrl + chapter.url
|
||||||
|
|
||||||
|
override fun pageListRequest(chapter: SChapter): Request {
|
||||||
|
if (!helper.containsUuid(chapter.url)) {
|
||||||
|
throw Exception(helper.intl["migrate_warning"])
|
||||||
|
}
|
||||||
|
|
||||||
|
val chapterId = chapter.url.substringAfter("/chapter/")
|
||||||
|
val atHomeRequestUrl = if (preferences.forceStandardHttps) {
|
||||||
|
"${MDConstants.apiUrl}/at-home/server/$chapterId?forcePort443=true"
|
||||||
|
} else {
|
||||||
|
"${MDConstants.apiUrl}/at-home/server/$chapterId"
|
||||||
|
}
|
||||||
|
|
||||||
|
return helper.mdAtHomeRequest(atHomeRequestUrl, headers, CacheControl.FORCE_NETWORK)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
|
val atHomeRequestUrl = response.request.url
|
||||||
|
val atHomeDto = response.parseAs<AtHomeDto>()
|
||||||
|
val host = atHomeDto.baseUrl
|
||||||
|
|
||||||
|
// Have to add the time, and url to the page because pages timeout within 30 minutes now.
|
||||||
|
val now = Date().time
|
||||||
|
|
||||||
|
val hash = atHomeDto.chapter.hash
|
||||||
|
val pageSuffix = if (preferences.useDataSaver) {
|
||||||
|
atHomeDto.chapter.dataSaver.map { "/data-saver/$hash/$it" }
|
||||||
|
} else {
|
||||||
|
atHomeDto.chapter.data.map { "/data/$hash/$it" }
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageSuffix.mapIndexed { index, imgUrl ->
|
||||||
|
val mdAtHomeMetadataUrl = "$host,$atHomeRequestUrl,$now"
|
||||||
|
Page(index, mdAtHomeMetadataUrl, imgUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageRequest(page: Page): Request {
|
||||||
|
return helper.getValidImageUrlForPage(page, headers, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response): String = ""
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
|
val coverQualityPref = ListPreference(screen.context).apply {
|
||||||
|
key = MDConstants.getCoverQualityPreferenceKey(dexLang)
|
||||||
|
title = helper.intl["cover_quality"]
|
||||||
|
entries = MDConstants.getCoverQualityPreferenceEntries(helper.intl)
|
||||||
|
entryValues = MDConstants.getCoverQualityPreferenceEntryValues()
|
||||||
|
setDefaultValue(MDConstants.getCoverQualityPreferenceDefaultValue())
|
||||||
|
summary = "%s"
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
val selected = newValue as String
|
||||||
|
val index = findIndexOfValue(selected)
|
||||||
|
val entry = entryValues[index] as String
|
||||||
|
|
||||||
|
preferences.edit()
|
||||||
|
.putString(MDConstants.getCoverQualityPreferenceKey(dexLang), entry)
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val tryUsingFirstVolumeCoverPref = SwitchPreferenceCompat(screen.context).apply {
|
||||||
|
key = MDConstants.getTryUsingFirstVolumeCoverPrefKey(dexLang)
|
||||||
|
title = helper.intl["try_using_first_volume_cover"]
|
||||||
|
summary = helper.intl["try_using_first_volume_cover_summary"]
|
||||||
|
setDefaultValue(MDConstants.tryUsingFirstVolumeCoverDefault)
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
val checkValue = newValue as Boolean
|
||||||
|
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(MDConstants.getTryUsingFirstVolumeCoverPrefKey(dexLang), checkValue)
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val dataSaverPref = SwitchPreferenceCompat(screen.context).apply {
|
||||||
|
key = MDConstants.getDataSaverPreferenceKey(dexLang)
|
||||||
|
title = helper.intl["data_saver"]
|
||||||
|
summary = helper.intl["data_saver_summary"]
|
||||||
|
setDefaultValue(false)
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
val checkValue = newValue as Boolean
|
||||||
|
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(MDConstants.getDataSaverPreferenceKey(dexLang), checkValue)
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val standardHttpsPortPref = SwitchPreferenceCompat(screen.context).apply {
|
||||||
|
key = MDConstants.getStandardHttpsPreferenceKey(dexLang)
|
||||||
|
title = helper.intl["standard_https_port"]
|
||||||
|
summary = helper.intl["standard_https_port_summary"]
|
||||||
|
setDefaultValue(false)
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
val checkValue = newValue as Boolean
|
||||||
|
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(MDConstants.getStandardHttpsPreferenceKey(dexLang), checkValue)
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentRatingPref = MultiSelectListPreference(screen.context).apply {
|
||||||
|
key = MDConstants.getContentRatingPrefKey(dexLang)
|
||||||
|
title = helper.intl["standard_content_rating"]
|
||||||
|
summary = helper.intl["standard_content_rating_summary"]
|
||||||
|
entries = arrayOf(
|
||||||
|
helper.intl["content_rating_safe"],
|
||||||
|
helper.intl["content_rating_suggestive"],
|
||||||
|
helper.intl["content_rating_erotica"],
|
||||||
|
helper.intl["content_rating_pornographic"],
|
||||||
|
)
|
||||||
|
entryValues = arrayOf(
|
||||||
|
MDConstants.contentRatingPrefValSafe,
|
||||||
|
MDConstants.contentRatingPrefValSuggestive,
|
||||||
|
MDConstants.contentRatingPrefValErotica,
|
||||||
|
MDConstants.contentRatingPrefValPornographic,
|
||||||
|
)
|
||||||
|
setDefaultValue(MDConstants.contentRatingPrefDefaults)
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
val checkValue = newValue as Set<String>
|
||||||
|
|
||||||
|
preferences.edit()
|
||||||
|
.putStringSet(MDConstants.getContentRatingPrefKey(dexLang), checkValue)
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val originalLanguagePref = MultiSelectListPreference(screen.context).apply {
|
||||||
|
key = MDConstants.getOriginalLanguagePrefKey(dexLang)
|
||||||
|
title = helper.intl["filter_original_languages"]
|
||||||
|
summary = helper.intl["filter_original_languages_summary"]
|
||||||
|
entries = arrayOf(
|
||||||
|
helper.intl.languageDisplayName(MangaDexIntl.JAPANESE),
|
||||||
|
helper.intl.languageDisplayName(MangaDexIntl.CHINESE),
|
||||||
|
helper.intl.languageDisplayName(MangaDexIntl.KOREAN),
|
||||||
|
)
|
||||||
|
entryValues = arrayOf(
|
||||||
|
MDConstants.originalLanguagePrefValJapanese,
|
||||||
|
MDConstants.originalLanguagePrefValChinese,
|
||||||
|
MDConstants.originalLanguagePrefValKorean,
|
||||||
|
)
|
||||||
|
setDefaultValue(MDConstants.originalLanguagePrefDefaults)
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
val checkValue = newValue as Set<String>
|
||||||
|
|
||||||
|
preferences.edit()
|
||||||
|
.putStringSet(MDConstants.getOriginalLanguagePrefKey(dexLang), checkValue)
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val blockedGroupsPref = EditTextPreference(screen.context).apply {
|
||||||
|
key = MDConstants.getBlockedGroupsPrefKey(dexLang)
|
||||||
|
title = helper.intl["block_group_by_uuid"]
|
||||||
|
summary = helper.intl["block_group_by_uuid_summary"]
|
||||||
|
|
||||||
|
setOnBindEditTextListener(helper::setupEditTextUuidValidator)
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
preferences.edit()
|
||||||
|
.putString(MDConstants.getBlockedGroupsPrefKey(dexLang), newValue.toString())
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val blockedUploaderPref = EditTextPreference(screen.context).apply {
|
||||||
|
key = MDConstants.getBlockedUploaderPrefKey(dexLang)
|
||||||
|
title = helper.intl["block_uploader_by_uuid"]
|
||||||
|
summary = helper.intl["block_uploader_by_uuid_summary"]
|
||||||
|
|
||||||
|
setOnBindEditTextListener(helper::setupEditTextUuidValidator)
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
preferences.edit()
|
||||||
|
.putString(MDConstants.getBlockedUploaderPrefKey(dexLang), newValue.toString())
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val altTitlesInDescPref = SwitchPreferenceCompat(screen.context).apply {
|
||||||
|
key = MDConstants.getAltTitlesInDescPrefKey(dexLang)
|
||||||
|
title = helper.intl["alternative_titles_in_description"]
|
||||||
|
summary = helper.intl["alternative_titles_in_description_summary"]
|
||||||
|
setDefaultValue(false)
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
val checkValue = newValue as Boolean
|
||||||
|
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(MDConstants.getAltTitlesInDescPrefKey(dexLang), checkValue)
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val userAgentPref = EditTextPreference(screen.context).apply {
|
||||||
|
key = MDConstants.getCustomUserAgentPrefKey(dexLang)
|
||||||
|
title = helper.intl["set_custom_useragent"]
|
||||||
|
summary = helper.intl["set_custom_useragent_summary"]
|
||||||
|
dialogMessage = helper.intl.format(
|
||||||
|
"set_custom_useragent_dialog",
|
||||||
|
MDConstants.defaultUserAgent,
|
||||||
|
)
|
||||||
|
|
||||||
|
setDefaultValue(MDConstants.defaultUserAgent)
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
try {
|
||||||
|
Headers.Builder().add("User-Agent", newValue as String)
|
||||||
|
summary = newValue
|
||||||
|
true
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
val errorMessage = helper.intl.format("set_custom_useragent_error_invalid", e.message)
|
||||||
|
Toast.makeText(screen.context, errorMessage, Toast.LENGTH_LONG).show()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
screen.addPreference(coverQualityPref)
|
||||||
|
screen.addPreference(tryUsingFirstVolumeCoverPref)
|
||||||
|
screen.addPreference(dataSaverPref)
|
||||||
|
screen.addPreference(standardHttpsPortPref)
|
||||||
|
screen.addPreference(altTitlesInDescPref)
|
||||||
|
screen.addPreference(contentRatingPref)
|
||||||
|
screen.addPreference(originalLanguagePref)
|
||||||
|
screen.addPreference(blockedGroupsPref)
|
||||||
|
screen.addPreference(blockedUploaderPref)
|
||||||
|
screen.addPreference(userAgentPref)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilterList(): FilterList =
|
||||||
|
helper.mdFilters.getMDFilterList(preferences, dexLang, helper.intl)
|
||||||
|
|
||||||
|
private fun HttpUrl.Builder.addQueryParameter(name: String, value: Set<String>?) = apply {
|
||||||
|
value?.forEach { addQueryParameter(name, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> Response.parseAs(): T = use {
|
||||||
|
helper.json.decodeFromString(body.string())
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> List<*>.firstInstanceOrNull(): T? =
|
||||||
|
firstOrNull { it is T } as? T?
|
||||||
|
|
||||||
|
private val SharedPreferences.contentRating
|
||||||
|
get() = getStringSet(
|
||||||
|
MDConstants.getContentRatingPrefKey(dexLang),
|
||||||
|
MDConstants.contentRatingPrefDefaults,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val SharedPreferences.originalLanguages: Set<String>
|
||||||
|
get() {
|
||||||
|
val prefValues = getStringSet(
|
||||||
|
MDConstants.getOriginalLanguagePrefKey(dexLang),
|
||||||
|
MDConstants.originalLanguagePrefDefaults,
|
||||||
|
)
|
||||||
|
|
||||||
|
val originalLanguages = prefValues.orEmpty().toMutableSet()
|
||||||
|
|
||||||
|
if (MDConstants.originalLanguagePrefValChinese in originalLanguages) {
|
||||||
|
originalLanguages.add(MDConstants.originalLanguagePrefValChineseHk)
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalLanguages
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SharedPreferences.coverQuality
|
||||||
|
get() = getString(MDConstants.getCoverQualityPreferenceKey(dexLang), "")
|
||||||
|
|
||||||
|
private val SharedPreferences.tryUsingFirstVolumeCover
|
||||||
|
get() = getBoolean(
|
||||||
|
MDConstants.getTryUsingFirstVolumeCoverPrefKey(dexLang),
|
||||||
|
MDConstants.tryUsingFirstVolumeCoverDefault,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val SharedPreferences.blockedGroups
|
||||||
|
get() = getString(MDConstants.getBlockedGroupsPrefKey(dexLang), "")
|
||||||
|
?.split(",")
|
||||||
|
?.map(String::trim)
|
||||||
|
?.filter(String::isNotEmpty)
|
||||||
|
?.sorted()
|
||||||
|
.orEmpty()
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
private val SharedPreferences.blockedUploaders
|
||||||
|
get() = getString(MDConstants.getBlockedUploaderPrefKey(dexLang), "")
|
||||||
|
?.split(",")
|
||||||
|
?.map(String::trim)
|
||||||
|
?.filter(String::isNotEmpty)
|
||||||
|
?.sorted()
|
||||||
|
.orEmpty()
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
private val SharedPreferences.forceStandardHttps
|
||||||
|
get() = getBoolean(MDConstants.getStandardHttpsPreferenceKey(dexLang), false)
|
||||||
|
|
||||||
|
private val SharedPreferences.useDataSaver
|
||||||
|
get() = getBoolean(MDConstants.getDataSaverPreferenceKey(dexLang), false)
|
||||||
|
|
||||||
|
private val SharedPreferences.altTitlesInDesc
|
||||||
|
get() = getBoolean(MDConstants.getAltTitlesInDescPrefKey(dexLang), false)
|
||||||
|
|
||||||
|
private val SharedPreferences.customUserAgent
|
||||||
|
get() = getString(
|
||||||
|
MDConstants.getCustomUserAgentPrefKey(dexLang),
|
||||||
|
MDConstants.defaultUserAgent,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Previous versions of the extension allowed invalid UUID values to be stored in the
|
||||||
|
* preferences. This method clear invalid UUIDs in case the user have updated from
|
||||||
|
* a previous version with that behaviour.
|
||||||
|
*/
|
||||||
|
private fun SharedPreferences.sanitizeExistingUuidPrefs() {
|
||||||
|
if (getBoolean(MDConstants.getHasSanitizedUuidsPrefKey(dexLang), false)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val blockedGroups = getString(MDConstants.getBlockedGroupsPrefKey(dexLang), "")!!
|
||||||
|
.split(",")
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(helper::isUuid)
|
||||||
|
.joinToString(", ")
|
||||||
|
|
||||||
|
val blockedUploaders = getString(MDConstants.getBlockedUploaderPrefKey(dexLang), "")!!
|
||||||
|
.split(",")
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(helper::isUuid)
|
||||||
|
.joinToString(", ")
|
||||||
|
|
||||||
|
edit()
|
||||||
|
.putString(MDConstants.getBlockedGroupsPrefKey(dexLang), blockedGroups)
|
||||||
|
.putString(MDConstants.getBlockedUploaderPrefKey(dexLang), blockedUploaders)
|
||||||
|
.putBoolean(MDConstants.getHasSanitizedUuidsPrefKey(dexLang), true)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,116 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.Source
|
||||||
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
|
|
||||||
|
class MangaDexFactory : SourceFactory {
|
||||||
|
override fun createSources(): List<Source> = listOf(
|
||||||
|
MangaDexEnglish(),
|
||||||
|
MangaDexAlbanian(),
|
||||||
|
MangaDexArabic(),
|
||||||
|
MangaDexAzerbaijani(),
|
||||||
|
MangaDexBengali(),
|
||||||
|
MangaDexBulgarian(),
|
||||||
|
MangaDexBurmese(),
|
||||||
|
MangaDexCatalan(),
|
||||||
|
MangaDexChineseSimplified(),
|
||||||
|
MangaDexChineseTraditional(),
|
||||||
|
MangaDexCroatian(),
|
||||||
|
MangaDexCzech(),
|
||||||
|
MangaDexDanish(),
|
||||||
|
MangaDexDutch(),
|
||||||
|
MangaDexEsperanto(),
|
||||||
|
MangaDexEstonian(),
|
||||||
|
MangaDexFilipino(),
|
||||||
|
MangaDexFinnish(),
|
||||||
|
MangaDexFrench(),
|
||||||
|
MangaDexGeorgian(),
|
||||||
|
MangaDexGerman(),
|
||||||
|
MangaDexGreek(),
|
||||||
|
MangaDexHebrew(),
|
||||||
|
MangaDexHindi(),
|
||||||
|
MangaDexHungarian(),
|
||||||
|
MangaDexIndonesian(),
|
||||||
|
MangaDexItalian(),
|
||||||
|
MangaDexJapanese(),
|
||||||
|
MangaDexKazakh(),
|
||||||
|
MangaDexKorean(),
|
||||||
|
MangaDexLatin(),
|
||||||
|
MangaDexLithuanian(),
|
||||||
|
MangaDexMalay(),
|
||||||
|
MangaDexMongolian(),
|
||||||
|
MangaDexNepali(),
|
||||||
|
MangaDexNorwegian(),
|
||||||
|
MangaDexPersian(),
|
||||||
|
MangaDexPolish(),
|
||||||
|
MangaDexPortugueseBrazil(),
|
||||||
|
MangaDexPortuguesePortugal(),
|
||||||
|
MangaDexRomanian(),
|
||||||
|
MangaDexRussian(),
|
||||||
|
MangaDexSerbian(),
|
||||||
|
MangaDexSlovak(),
|
||||||
|
MangaDexSpanishLatinAmerica(),
|
||||||
|
MangaDexSpanishSpain(),
|
||||||
|
MangaDexSwedish(),
|
||||||
|
MangaDexTamil(),
|
||||||
|
MangaDexTelugu(),
|
||||||
|
MangaDexThai(),
|
||||||
|
MangaDexTurkish(),
|
||||||
|
MangaDexUkrainian(),
|
||||||
|
MangaDexVietnamese(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class MangaDexAlbanian : MangaDex("sq")
|
||||||
|
class MangaDexArabic : MangaDex("ar")
|
||||||
|
class MangaDexAzerbaijani : MangaDex("az")
|
||||||
|
class MangaDexBengali : MangaDex("bn")
|
||||||
|
class MangaDexBulgarian : MangaDex("bg")
|
||||||
|
class MangaDexBurmese : MangaDex("my")
|
||||||
|
class MangaDexCatalan : MangaDex("ca")
|
||||||
|
class MangaDexChineseSimplified : MangaDex("zh-Hans", "zh")
|
||||||
|
class MangaDexChineseTraditional : MangaDex("zh-Hant", "zh-hk")
|
||||||
|
class MangaDexCroatian : MangaDex("hr")
|
||||||
|
class MangaDexCzech : MangaDex("cs")
|
||||||
|
class MangaDexDanish : MangaDex("da")
|
||||||
|
class MangaDexDutch : MangaDex("nl")
|
||||||
|
class MangaDexEnglish : MangaDex("en")
|
||||||
|
class MangaDexEsperanto : MangaDex("eo")
|
||||||
|
class MangaDexEstonian : MangaDex("et")
|
||||||
|
class MangaDexFilipino : MangaDex("fil", "tl")
|
||||||
|
class MangaDexFinnish : MangaDex("fi")
|
||||||
|
class MangaDexFrench : MangaDex("fr")
|
||||||
|
class MangaDexGeorgian : MangaDex("ka")
|
||||||
|
class MangaDexGerman : MangaDex("de")
|
||||||
|
class MangaDexGreek : MangaDex("el")
|
||||||
|
class MangaDexHebrew : MangaDex("he")
|
||||||
|
class MangaDexHindi : MangaDex("hi")
|
||||||
|
class MangaDexHungarian : MangaDex("hu")
|
||||||
|
class MangaDexIndonesian : MangaDex("id")
|
||||||
|
class MangaDexItalian : MangaDex("it")
|
||||||
|
class MangaDexJapanese : MangaDex("ja")
|
||||||
|
class MangaDexKazakh : MangaDex("kk")
|
||||||
|
class MangaDexKorean : MangaDex("ko")
|
||||||
|
class MangaDexLatin : MangaDex("la")
|
||||||
|
class MangaDexLithuanian : MangaDex("lt")
|
||||||
|
class MangaDexMalay : MangaDex("ms")
|
||||||
|
class MangaDexMongolian : MangaDex("mn")
|
||||||
|
class MangaDexNepali : MangaDex("ne")
|
||||||
|
class MangaDexNorwegian : MangaDex("no")
|
||||||
|
class MangaDexPersian : MangaDex("fa")
|
||||||
|
class MangaDexPolish : MangaDex("pl")
|
||||||
|
class MangaDexPortugueseBrazil : MangaDex("pt-BR", "pt-br")
|
||||||
|
class MangaDexPortuguesePortugal : MangaDex("pt")
|
||||||
|
class MangaDexRomanian : MangaDex("ro")
|
||||||
|
class MangaDexRussian : MangaDex("ru")
|
||||||
|
class MangaDexSerbian : MangaDex("sr")
|
||||||
|
class MangaDexSlovak : MangaDex("sk")
|
||||||
|
class MangaDexSpanishLatinAmerica : MangaDex("es-419", "es-la")
|
||||||
|
class MangaDexSpanishSpain : MangaDex("es")
|
||||||
|
class MangaDexSwedish : MangaDex("sv")
|
||||||
|
class MangaDexTamil : MangaDex("ta")
|
||||||
|
class MangaDexTelugu : MangaDex("te")
|
||||||
|
class MangaDexThai : MangaDex("th")
|
||||||
|
class MangaDexTurkish : MangaDex("tr")
|
||||||
|
class MangaDexUkrainian : MangaDex("uk")
|
||||||
|
class MangaDexVietnamese : MangaDex("vi")
|
|
@ -0,0 +1,400 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ContentRatingDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.PublicationDemographicDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.StatusDto
|
||||||
|
import eu.kanade.tachiyomi.lib.i18n.Intl
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
|
||||||
|
class MangaDexFilters {
|
||||||
|
|
||||||
|
internal fun getMDFilterList(
|
||||||
|
preferences: SharedPreferences,
|
||||||
|
dexLang: String,
|
||||||
|
intl: Intl,
|
||||||
|
): FilterList = FilterList(
|
||||||
|
HasAvailableChaptersFilter(intl),
|
||||||
|
OriginalLanguageList(intl, getOriginalLanguage(preferences, dexLang, intl)),
|
||||||
|
ContentRatingList(intl, getContentRating(preferences, dexLang, intl)),
|
||||||
|
DemographicList(intl, getDemographics(intl)),
|
||||||
|
StatusList(intl, getStatus(intl)),
|
||||||
|
SortFilter(intl, getSortables(intl)),
|
||||||
|
TagsFilter(intl, getTagFilters(intl)),
|
||||||
|
TagList(intl["content"], getContents(intl)),
|
||||||
|
TagList(intl["format"], getFormats(intl)),
|
||||||
|
TagList(intl["genre"], getGenres(intl)),
|
||||||
|
TagList(intl["theme"], getThemes(intl)),
|
||||||
|
)
|
||||||
|
|
||||||
|
private interface UrlQueryFilter {
|
||||||
|
fun addQueryParameter(url: HttpUrl.Builder, dexLang: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class HasAvailableChaptersFilter(intl: Intl) :
|
||||||
|
Filter.CheckBox(intl["has_available_chapters"]),
|
||||||
|
UrlQueryFilter {
|
||||||
|
|
||||||
|
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
|
||||||
|
if (state) {
|
||||||
|
url.addQueryParameter("hasAvailableChapters", "true")
|
||||||
|
url.addQueryParameter("availableTranslatedLanguage[]", dexLang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class OriginalLanguage(
|
||||||
|
name: String,
|
||||||
|
val isoCode: String,
|
||||||
|
state: Boolean = false,
|
||||||
|
) : Filter.CheckBox(name, state)
|
||||||
|
private class OriginalLanguageList(intl: Intl, originalLanguage: List<OriginalLanguage>) :
|
||||||
|
Filter.Group<OriginalLanguage>(intl["original_language"], originalLanguage),
|
||||||
|
UrlQueryFilter {
|
||||||
|
|
||||||
|
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
|
||||||
|
state.filter(OriginalLanguage::state)
|
||||||
|
.forEach { lang ->
|
||||||
|
// dex has zh and zh-hk for chinese manhua
|
||||||
|
if (lang.isoCode == MDConstants.originalLanguagePrefValChinese) {
|
||||||
|
url.addQueryParameter(
|
||||||
|
"originalLanguage[]",
|
||||||
|
MDConstants.originalLanguagePrefValChineseHk,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
url.addQueryParameter("originalLanguage[]", lang.isoCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOriginalLanguage(
|
||||||
|
preferences: SharedPreferences,
|
||||||
|
dexLang: String,
|
||||||
|
intl: Intl,
|
||||||
|
): List<OriginalLanguage> {
|
||||||
|
val originalLanguages = preferences.getStringSet(
|
||||||
|
MDConstants.getOriginalLanguagePrefKey(dexLang),
|
||||||
|
setOf(),
|
||||||
|
)!!
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
OriginalLanguage(
|
||||||
|
name = intl.format(
|
||||||
|
"original_language_filter_japanese",
|
||||||
|
intl.languageDisplayName(MangaDexIntl.JAPANESE),
|
||||||
|
),
|
||||||
|
isoCode = MDConstants.originalLanguagePrefValJapanese,
|
||||||
|
state = MDConstants.originalLanguagePrefValJapanese in originalLanguages,
|
||||||
|
),
|
||||||
|
OriginalLanguage(
|
||||||
|
name = intl.format(
|
||||||
|
"original_language_filter_chinese",
|
||||||
|
intl.languageDisplayName(MangaDexIntl.CHINESE),
|
||||||
|
),
|
||||||
|
isoCode = MDConstants.originalLanguagePrefValChinese,
|
||||||
|
state = MDConstants.originalLanguagePrefValChinese in originalLanguages,
|
||||||
|
),
|
||||||
|
OriginalLanguage(
|
||||||
|
name = intl.format(
|
||||||
|
"original_language_filter_korean",
|
||||||
|
intl.languageDisplayName(MangaDexIntl.KOREAN),
|
||||||
|
),
|
||||||
|
isoCode = MDConstants.originalLanguagePrefValKorean,
|
||||||
|
state = MDConstants.originalLanguagePrefValKorean in originalLanguages,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ContentRating(name: String, val value: String) : Filter.CheckBox(name)
|
||||||
|
private class ContentRatingList(intl: Intl, contentRating: List<ContentRating>) :
|
||||||
|
Filter.Group<ContentRating>(intl["content_rating"], contentRating),
|
||||||
|
UrlQueryFilter {
|
||||||
|
|
||||||
|
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
|
||||||
|
state.filter(ContentRating::state)
|
||||||
|
.forEach { url.addQueryParameter("contentRating[]", it.value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getContentRating(
|
||||||
|
preferences: SharedPreferences,
|
||||||
|
dexLang: String,
|
||||||
|
intl: Intl,
|
||||||
|
): List<ContentRating> {
|
||||||
|
val contentRatings = preferences.getStringSet(
|
||||||
|
MDConstants.getContentRatingPrefKey(dexLang),
|
||||||
|
MDConstants.contentRatingPrefDefaults,
|
||||||
|
)
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
ContentRating(intl["content_rating_safe"], ContentRatingDto.SAFE.value).apply {
|
||||||
|
state = contentRatings?.contains(MDConstants.contentRatingPrefValSafe) ?: true
|
||||||
|
},
|
||||||
|
ContentRating(intl["content_rating_suggestive"], ContentRatingDto.SUGGESTIVE.value).apply {
|
||||||
|
state = contentRatings?.contains(MDConstants.contentRatingPrefValSuggestive) ?: true
|
||||||
|
},
|
||||||
|
ContentRating(intl["content_rating_erotica"], ContentRatingDto.EROTICA.value).apply {
|
||||||
|
state = contentRatings?.contains(MDConstants.contentRatingPrefValErotica) ?: false
|
||||||
|
},
|
||||||
|
ContentRating(intl["content_rating_pornographic"], ContentRatingDto.PORNOGRAPHIC.value).apply {
|
||||||
|
state = contentRatings?.contains(MDConstants.contentRatingPrefValPornographic) ?: false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Demographic(name: String, val value: String) : Filter.CheckBox(name)
|
||||||
|
private class DemographicList(intl: Intl, demographics: List<Demographic>) :
|
||||||
|
Filter.Group<Demographic>(intl["publication_demographic"], demographics),
|
||||||
|
UrlQueryFilter {
|
||||||
|
|
||||||
|
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
|
||||||
|
state.filter(Demographic::state)
|
||||||
|
.forEach { url.addQueryParameter("publicationDemographic[]", it.value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDemographics(intl: Intl) = listOf(
|
||||||
|
Demographic(intl["publication_demographic_none"], PublicationDemographicDto.NONE.value),
|
||||||
|
Demographic(intl["publication_demographic_shounen"], PublicationDemographicDto.SHOUNEN.value),
|
||||||
|
Demographic(intl["publication_demographic_shoujo"], PublicationDemographicDto.SHOUJO.value),
|
||||||
|
Demographic(intl["publication_demographic_seinen"], PublicationDemographicDto.SEINEN.value),
|
||||||
|
Demographic(intl["publication_demographic_josei"], PublicationDemographicDto.JOSEI.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
private class Status(name: String, val value: String) : Filter.CheckBox(name)
|
||||||
|
private class StatusList(intl: Intl, status: List<Status>) :
|
||||||
|
Filter.Group<Status>(intl["status"], status),
|
||||||
|
UrlQueryFilter {
|
||||||
|
|
||||||
|
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
|
||||||
|
state.filter(Status::state)
|
||||||
|
.forEach { url.addQueryParameter("status[]", it.value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getStatus(intl: Intl) = listOf(
|
||||||
|
Status(intl["status_ongoing"], StatusDto.ONGOING.value),
|
||||||
|
Status(intl["status_completed"], StatusDto.COMPLETED.value),
|
||||||
|
Status(intl["status_hiatus"], StatusDto.HIATUS.value),
|
||||||
|
Status(intl["status_cancelled"], StatusDto.CANCELLED.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Sortable(val title: String, val value: String) {
|
||||||
|
override fun toString(): String = title
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSortables(intl: Intl) = arrayOf(
|
||||||
|
Sortable(intl["sort_alphabetic"], "title"),
|
||||||
|
Sortable(intl["sort_chapter_uploaded_at"], "latestUploadedChapter"),
|
||||||
|
Sortable(intl["sort_number_of_follows"], "followedCount"),
|
||||||
|
Sortable(intl["sort_content_created_at"], "createdAt"),
|
||||||
|
Sortable(intl["sort_content_info_updated_at"], "updatedAt"),
|
||||||
|
Sortable(intl["sort_relevance"], "relevance"),
|
||||||
|
Sortable(intl["sort_year"], "year"),
|
||||||
|
Sortable(intl["sort_rating"], "rating"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class SortFilter(intl: Intl, private val sortables: Array<Sortable>) :
|
||||||
|
Filter.Sort(
|
||||||
|
intl["sort"],
|
||||||
|
sortables.map(Sortable::title).toTypedArray(),
|
||||||
|
Selection(5, false),
|
||||||
|
),
|
||||||
|
UrlQueryFilter {
|
||||||
|
|
||||||
|
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
|
||||||
|
if (state != null) {
|
||||||
|
val query = sortables[state!!.index].value
|
||||||
|
val value = if (state!!.ascending) "asc" else "desc"
|
||||||
|
|
||||||
|
url.addQueryParameter("order[$query]", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Tag(val id: String, name: String) : Filter.TriState(name)
|
||||||
|
|
||||||
|
private class TagList(collection: String, tags: List<Tag>) :
|
||||||
|
Filter.Group<Tag>(collection, tags),
|
||||||
|
UrlQueryFilter {
|
||||||
|
|
||||||
|
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
|
||||||
|
state.forEach { tag ->
|
||||||
|
if (tag.isIncluded()) {
|
||||||
|
url.addQueryParameter("includedTags[]", tag.id)
|
||||||
|
} else if (tag.isExcluded()) {
|
||||||
|
url.addQueryParameter("excludedTags[]", tag.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getContents(intl: Intl): List<Tag> {
|
||||||
|
val tags = listOf(
|
||||||
|
Tag("b29d6a3d-1569-4e7a-8caf-7557bc92cd5d", intl["content_gore"]),
|
||||||
|
Tag("97893a4c-12af-4dac-b6be-0dffb353568e", intl["content_sexual_violence"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return tags.sortIfTranslated(intl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFormats(intl: Intl): List<Tag> {
|
||||||
|
val tags = listOf(
|
||||||
|
Tag("b11fda93-8f1d-4bef-b2ed-8803d3733170", intl["format_yonkoma"]),
|
||||||
|
Tag("f4122d1c-3b44-44d0-9936-ff7502c39ad3", intl["format_adaptation"]),
|
||||||
|
Tag("51d83883-4103-437c-b4b1-731cb73d786c", intl["format_anthology"]),
|
||||||
|
Tag("0a39b5a1-b235-4886-a747-1d05d216532d", intl["format_award_winning"]),
|
||||||
|
Tag("b13b2a48-c720-44a9-9c77-39c9979373fb", intl["format_doujinshi"]),
|
||||||
|
Tag("7b2ce280-79ef-4c09-9b58-12b7c23a9b78", intl["format_fan_colored"]),
|
||||||
|
Tag("f5ba408b-0e7a-484d-8d49-4e9125ac96de", intl["format_full_color"]),
|
||||||
|
Tag("3e2b8dae-350e-4ab8-a8ce-016e844b9f0d", intl["format_long_strip"]),
|
||||||
|
Tag("320831a8-4026-470b-94f6-8353740e6f04", intl["format_official_colored"]),
|
||||||
|
Tag("0234a31e-a729-4e28-9d6a-3f87c4966b9e", intl["format_oneshot"]),
|
||||||
|
Tag("891cf039-b895-47f0-9229-bef4c96eccd4", intl["format_user_created"]),
|
||||||
|
Tag("e197df38-d0e7-43b5-9b09-2842d0c326dd", intl["format_web_comic"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return tags.sortIfTranslated(intl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getGenres(intl: Intl): List<Tag> {
|
||||||
|
val tags = listOf(
|
||||||
|
Tag("391b0423-d847-456f-aff0-8b0cfc03066b", intl["genre_action"]),
|
||||||
|
Tag("87cc87cd-a395-47af-b27a-93258283bbc6", intl["genre_adventure"]),
|
||||||
|
Tag("5920b825-4181-4a17-beeb-9918b0ff7a30", intl["genre_boys_love"]),
|
||||||
|
Tag("4d32cc48-9f00-4cca-9b5a-a839f0764984", intl["genre_comedy"]),
|
||||||
|
Tag("5ca48985-9a9d-4bd8-be29-80dc0303db72", intl["genre_crime"]),
|
||||||
|
Tag("b9af3a63-f058-46de-a9a0-e0c13906197a", intl["genre_drama"]),
|
||||||
|
Tag("cdc58593-87dd-415e-bbc0-2ec27bf404cc", intl["genre_fantasy"]),
|
||||||
|
Tag("a3c67850-4684-404e-9b7f-c69850ee5da6", intl["genre_girls_love"]),
|
||||||
|
Tag("33771934-028e-4cb3-8744-691e866a923e", intl["genre_historical"]),
|
||||||
|
Tag("cdad7e68-1419-41dd-bdce-27753074a640", intl["genre_horror"]),
|
||||||
|
Tag("ace04997-f6bd-436e-b261-779182193d3d", intl["genre_isekai"]),
|
||||||
|
Tag("81c836c9-914a-4eca-981a-560dad663e73", intl["genre_magical_girls"]),
|
||||||
|
Tag("50880a9d-5440-4732-9afb-8f457127e836", intl["genre_mecha"]),
|
||||||
|
Tag("c8cbe35b-1b2b-4a3f-9c37-db84c4514856", intl["genre_medical"]),
|
||||||
|
Tag("ee968100-4191-4968-93d3-f82d72be7e46", intl["genre_mystery"]),
|
||||||
|
Tag("b1e97889-25b4-4258-b28b-cd7f4d28ea9b", intl["genre_philosophical"]),
|
||||||
|
Tag("423e2eae-a7a2-4a8b-ac03-a8351462d71d", intl["genre_romance"]),
|
||||||
|
Tag("256c8bd9-4904-4360-bf4f-508a76d67183", intl["genre_sci_fi"]),
|
||||||
|
Tag("e5301a23-ebd9-49dd-a0cb-2add944c7fe9", intl["genre_slice_of_life"]),
|
||||||
|
Tag("69964a64-2f90-4d33-beeb-f3ed2875eb4c", intl["genre_sports"]),
|
||||||
|
Tag("7064a261-a137-4d3a-8848-2d385de3a99c", intl["genre_superhero"]),
|
||||||
|
Tag("07251805-a27e-4d59-b488-f0bfbec15168", intl["genre_thriller"]),
|
||||||
|
Tag("f8f62932-27da-4fe4-8ee1-6779a8c5edba", intl["genre_tragedy"]),
|
||||||
|
Tag("acc803a4-c95a-4c22-86fc-eb6b582d82a2", intl["genre_wuxia"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return tags.sortIfTranslated(intl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getThemes(intl: Intl): List<Tag> {
|
||||||
|
val tags = listOf(
|
||||||
|
Tag("e64f6742-c834-471d-8d72-dd51fc02b835", intl["theme_aliens"]),
|
||||||
|
Tag("3de8c75d-8ee3-48ff-98ee-e20a65c86451", intl["theme_animals"]),
|
||||||
|
Tag("ea2bc92d-1c26-4930-9b7c-d5c0dc1b6869", intl["theme_cooking"]),
|
||||||
|
Tag("9ab53f92-3eed-4e9b-903a-917c86035ee3", intl["theme_crossdressing"]),
|
||||||
|
Tag("da2d50ca-3018-4cc0-ac7a-6b7d472a29ea", intl["theme_delinquents"]),
|
||||||
|
Tag("39730448-9a5f-48a2-85b0-a70db87b1233", intl["theme_demons"]),
|
||||||
|
Tag("2bd2e8d0-f146-434a-9b51-fc9ff2c5fe6a", intl["theme_gender_swap"]),
|
||||||
|
Tag("3bb26d85-09d5-4d2e-880c-c34b974339e9", intl["theme_ghosts"]),
|
||||||
|
Tag("fad12b5e-68ba-460e-b933-9ae8318f5b65", intl["theme_gyaru"]),
|
||||||
|
Tag("aafb99c1-7f60-43fa-b75f-fc9502ce29c7", intl["theme_harem"]),
|
||||||
|
Tag("5bd0e105-4481-44ca-b6e7-7544da56b1a3", intl["theme_incest"]),
|
||||||
|
Tag("2d1f5d56-a1e5-4d0d-a961-2193588b08ec", intl["theme_loli"]),
|
||||||
|
Tag("85daba54-a71c-4554-8a28-9901a8b0afad", intl["theme_mafia"]),
|
||||||
|
Tag("a1f53773-c69a-4ce5-8cab-fffcd90b1565", intl["theme_magic"]),
|
||||||
|
Tag("799c202e-7daa-44eb-9cf7-8a3c0441531e", intl["theme_martial_arts"]),
|
||||||
|
Tag("ac72833b-c4e9-4878-b9db-6c8a4a99444a", intl["theme_military"]),
|
||||||
|
Tag("dd1f77c5-dea9-4e2b-97ae-224af09caf99", intl["theme_monster_girls"]),
|
||||||
|
Tag("36fd93ea-e8b8-445e-b836-358f02b3d33d", intl["theme_monsters"]),
|
||||||
|
Tag("f42fbf9e-188a-447b-9fdc-f19dc1e4d685", intl["theme_music"]),
|
||||||
|
Tag("489dd859-9b61-4c37-af75-5b18e88daafc", intl["theme_ninja"]),
|
||||||
|
Tag("92d6d951-ca5e-429c-ac78-451071cbf064", intl["theme_office_workers"]),
|
||||||
|
Tag("df33b754-73a3-4c54-80e6-1a74a8058539", intl["theme_police"]),
|
||||||
|
Tag("9467335a-1b83-4497-9231-765337a00b96", intl["theme_post_apocalyptic"]),
|
||||||
|
Tag("3b60b75c-a2d7-4860-ab56-05f391bb889c", intl["theme_psychological"]),
|
||||||
|
Tag("0bc90acb-ccc1-44ca-a34a-b9f3a73259d0", intl["theme_reincarnation"]),
|
||||||
|
Tag("65761a2a-415e-47f3-bef2-a9dababba7a6", intl["theme_reverse_harem"]),
|
||||||
|
Tag("81183756-1453-4c81-aa9e-f6e1b63be016", intl["theme_samurai"]),
|
||||||
|
Tag("caaa44eb-cd40-4177-b930-79d3ef2afe87", intl["theme_school_life"]),
|
||||||
|
Tag("ddefd648-5140-4e5f-ba18-4eca4071d19b", intl["theme_shota"]),
|
||||||
|
Tag("eabc5b4c-6aff-42f3-b657-3e90cbd00b75", intl["theme_supernatural"]),
|
||||||
|
Tag("5fff9cde-849c-4d78-aab0-0d52b2ee1d25", intl["theme_survival"]),
|
||||||
|
Tag("292e862b-2d17-4062-90a2-0356caa4ae27", intl["theme_time_travel"]),
|
||||||
|
Tag("31932a7e-5b8e-49a6-9f12-2afa39dc544c", intl["theme_traditional_games"]),
|
||||||
|
Tag("d7d1730f-6eb0-4ba6-9437-602cac38664c", intl["theme_vampires"]),
|
||||||
|
Tag("9438db5a-7e2a-4ac0-b39e-e0d95a34b8a8", intl["theme_video_games"]),
|
||||||
|
Tag("d14322ac-4d6f-4e9b-afd9-629d5f4d8a41", intl["theme_villainess"]),
|
||||||
|
Tag("8c86611e-fab7-4986-9dec-d1a2f44acdd5", intl["theme_virtual_reality"]),
|
||||||
|
Tag("631ef465-9aba-4afb-b0fc-ea10efe274a8", intl["theme_zombies"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return tags.sortIfTranslated(intl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// to get all tags from dex https://api.mangadex.org/manga/tag
|
||||||
|
internal fun getTags(intl: Intl): List<Tag> {
|
||||||
|
return getContents(intl) + getFormats(intl) + getGenres(intl) + getThemes(intl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class TagMode(val title: String, val value: String) {
|
||||||
|
override fun toString(): String = title
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTagModes(intl: Intl) = arrayOf(
|
||||||
|
TagMode(intl["mode_and"], "AND"),
|
||||||
|
TagMode(intl["mode_or"], "OR"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private class TagInclusionMode(intl: Intl, modes: Array<TagMode>) :
|
||||||
|
Filter.Select<TagMode>(intl["included_tags_mode"], modes, 0),
|
||||||
|
UrlQueryFilter {
|
||||||
|
|
||||||
|
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
|
||||||
|
url.addQueryParameter("includedTagsMode", values[state].value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TagExclusionMode(intl: Intl, modes: Array<TagMode>) :
|
||||||
|
Filter.Select<TagMode>(intl["excluded_tags_mode"], modes, 1),
|
||||||
|
UrlQueryFilter {
|
||||||
|
|
||||||
|
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
|
||||||
|
url.addQueryParameter("excludedTagsMode", values[state].value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TagsFilter(intl: Intl, innerFilters: FilterList) :
|
||||||
|
Filter.Group<Filter<*>>(intl["tags_mode"], innerFilters),
|
||||||
|
UrlQueryFilter {
|
||||||
|
|
||||||
|
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
|
||||||
|
state.filterIsInstance<UrlQueryFilter>()
|
||||||
|
.forEach { filter -> filter.addQueryParameter(url, dexLang) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTagFilters(intl: Intl): FilterList = FilterList(
|
||||||
|
TagInclusionMode(intl, getTagModes(intl)),
|
||||||
|
TagExclusionMode(intl, getTagModes(intl)),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun addFiltersToUrl(url: HttpUrl.Builder, filters: FilterList, dexLang: String): HttpUrl {
|
||||||
|
filters.filterIsInstance<UrlQueryFilter>()
|
||||||
|
.forEach { filter -> filter.addQueryParameter(url, dexLang) }
|
||||||
|
|
||||||
|
return url.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<Tag>.sortIfTranslated(intl: Intl): List<Tag> = apply {
|
||||||
|
if (intl.chosenLanguage == MangaDexIntl.ENGLISH) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedWith(compareBy(intl.collator, Tag::name))
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,490 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex
|
||||||
|
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.EditText
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AggregateVolume
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ArtistDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AtHomeDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AttributesDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AuthorArtistAttributesDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AuthorDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ChapterAttributesDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ChapterDataDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ContentRatingDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.CoverArtAttributesDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.CoverArtDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.EntityDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ListAttributesDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ListDataDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.MangaAttributesDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.MangaDataDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ScanlationGroupAttributes
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ScanlationGroupDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.StatusDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.TagAttributesDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.TagDto
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.UnknownEntity
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.UserAttributes
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.UserDto
|
||||||
|
import eu.kanade.tachiyomi.lib.i18n.Intl
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
import kotlinx.serialization.modules.plus
|
||||||
|
import kotlinx.serialization.modules.polymorphic
|
||||||
|
import kotlinx.serialization.modules.subclass
|
||||||
|
import okhttp3.CacheControl
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.jsoup.parser.Parser
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class MangaDexHelper(lang: String) {
|
||||||
|
|
||||||
|
val mdFilters = MangaDexFilters()
|
||||||
|
|
||||||
|
val json = Json {
|
||||||
|
isLenient = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
allowSpecialFloatingPointValues = true
|
||||||
|
prettyPrint = true
|
||||||
|
serializersModule += SerializersModule {
|
||||||
|
polymorphic(EntityDto::class) {
|
||||||
|
subclass(AuthorDto::class)
|
||||||
|
subclass(ArtistDto::class)
|
||||||
|
subclass(ChapterDataDto::class)
|
||||||
|
subclass(CoverArtDto::class)
|
||||||
|
subclass(ListDataDto::class)
|
||||||
|
subclass(MangaDataDto::class)
|
||||||
|
subclass(ScanlationGroupDto::class)
|
||||||
|
subclass(TagDto::class)
|
||||||
|
subclass(UserDto::class)
|
||||||
|
defaultDeserializer { UnknownEntity.serializer() }
|
||||||
|
}
|
||||||
|
|
||||||
|
polymorphic(AttributesDto::class) {
|
||||||
|
subclass(AuthorArtistAttributesDto::class)
|
||||||
|
subclass(ChapterAttributesDto::class)
|
||||||
|
subclass(CoverArtAttributesDto::class)
|
||||||
|
subclass(ListAttributesDto::class)
|
||||||
|
subclass(MangaAttributesDto::class)
|
||||||
|
subclass(ScanlationGroupAttributes::class)
|
||||||
|
subclass(TagAttributesDto::class)
|
||||||
|
subclass(UserAttributes::class)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val intl = Intl(
|
||||||
|
language = lang,
|
||||||
|
baseLanguage = MangaDexIntl.ENGLISH,
|
||||||
|
availableLanguages = MangaDexIntl.AVAILABLE_LANGS,
|
||||||
|
classLoader = this::class.java.classLoader!!,
|
||||||
|
createMessageFileName = { lang ->
|
||||||
|
when (lang) {
|
||||||
|
MangaDexIntl.SPANISH_LATAM -> Intl.createDefaultMessageFileName(MangaDexIntl.SPANISH)
|
||||||
|
MangaDexIntl.PORTUGUESE -> Intl.createDefaultMessageFileName(MangaDexIntl.BRAZILIAN_PORTUGUESE)
|
||||||
|
else -> Intl.createDefaultMessageFileName(lang)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the UUID from the url
|
||||||
|
*/
|
||||||
|
fun getUUIDFromUrl(url: String) = url.substringAfterLast("/")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chapters for manga (aka manga/$id/feed endpoint)
|
||||||
|
*/
|
||||||
|
fun getChapterEndpoint(mangaId: String, offset: Int, langCode: String) =
|
||||||
|
"${MDConstants.apiMangaUrl}/$mangaId/feed".toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("includes[]", MDConstants.scanlationGroup)
|
||||||
|
.addQueryParameter("includes[]", MDConstants.user)
|
||||||
|
.addQueryParameter("limit", "500")
|
||||||
|
.addQueryParameter("offset", offset.toString())
|
||||||
|
.addQueryParameter("translatedLanguage[]", langCode)
|
||||||
|
.addQueryParameter("order[volume]", "desc")
|
||||||
|
.addQueryParameter("order[chapter]", "desc")
|
||||||
|
.addQueryParameter("includeFuturePublishAt", "0")
|
||||||
|
.addQueryParameter("includeEmptyPages", "0")
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the manga url is a valid uuid
|
||||||
|
*/
|
||||||
|
fun containsUuid(url: String) = url.contains(MDConstants.uuidRegex)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the string is a valid uuid
|
||||||
|
*/
|
||||||
|
fun isUuid(text: String) = MDConstants.uuidRegex matches text
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the manga offset pages are 1 based, so subtract 1
|
||||||
|
*/
|
||||||
|
fun getMangaListOffset(page: Int): String = (MDConstants.mangaLimit * (page - 1)).toString()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest chapter offset pages are 1 based, so subtract 1
|
||||||
|
*/
|
||||||
|
fun getLatestChapterOffset(page: Int): String =
|
||||||
|
(MDConstants.latestChapterLimit * (page - 1)).toString()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove any HTML characters in manga or chapter name to actual
|
||||||
|
* characters. For example ♥ will show ♥.
|
||||||
|
*/
|
||||||
|
private fun String.removeEntities(): String {
|
||||||
|
return Parser.unescapeEntities(this, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove any HTML characters in description to actual characters.
|
||||||
|
* It also removes Markdown syntax for links, italic and bold.
|
||||||
|
*/
|
||||||
|
private fun String.removeEntitiesAndMarkdown(): String {
|
||||||
|
return removeEntities()
|
||||||
|
.substringBefore("---")
|
||||||
|
.replace(markdownLinksRegex, "$1")
|
||||||
|
.replace(markdownItalicBoldRegex, "$1")
|
||||||
|
.replace(markdownItalicRegex, "$1")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps MangaDex status to Tachiyomi status.
|
||||||
|
* Adapted from the MangaDex handler from TachiyomiSY.
|
||||||
|
*/
|
||||||
|
fun getPublicationStatus(attr: MangaAttributesDto, volumes: Map<String, AggregateVolume>): Int {
|
||||||
|
val chaptersList = volumes.values
|
||||||
|
.flatMap { it.chapters.values }
|
||||||
|
.map { it.chapter }
|
||||||
|
|
||||||
|
val tempStatus = when (attr.status) {
|
||||||
|
StatusDto.ONGOING -> SManga.ONGOING
|
||||||
|
StatusDto.CANCELLED -> SManga.CANCELLED
|
||||||
|
StatusDto.COMPLETED -> SManga.PUBLISHING_FINISHED
|
||||||
|
StatusDto.HIATUS -> SManga.ON_HIATUS
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
val publishedOrCancelled = tempStatus == SManga.PUBLISHING_FINISHED ||
|
||||||
|
tempStatus == SManga.CANCELLED
|
||||||
|
|
||||||
|
val isOneShot = attr.tags.any { it.id == MDConstants.tagOneShotUuid } &&
|
||||||
|
attr.tags.none { it.id == MDConstants.tagAnthologyUuid }
|
||||||
|
|
||||||
|
return when {
|
||||||
|
chaptersList.contains(attr.lastChapter) && publishedOrCancelled -> SManga.COMPLETED
|
||||||
|
isOneShot && volumes["none"]?.chapters?.get("none") != null -> SManga.COMPLETED
|
||||||
|
else -> tempStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDate(dateAsString: String): Long =
|
||||||
|
MDConstants.dateFormatter.parse(dateAsString)?.time ?: 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chapter URL where we get the token, last request time.
|
||||||
|
*/
|
||||||
|
private val tokenTracker = hashMapOf<String, Long>()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val USE_CACHE = CacheControl.Builder()
|
||||||
|
.maxStale(Integer.MAX_VALUE, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val markdownLinksRegex = "\\[([^]]+)\\]\\(([^)]+)\\)".toRegex()
|
||||||
|
val markdownItalicBoldRegex = "\\*+\\s*([^\\*]*)\\s*\\*+".toRegex()
|
||||||
|
val markdownItalicRegex = "_+\\s*([^_]*)\\s*_+".toRegex()
|
||||||
|
|
||||||
|
val titleSpecialCharactersRegex = "[^a-z0-9]+".toRegex()
|
||||||
|
|
||||||
|
val trailingHyphenRegex = "-+$".toRegex()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the token map to see if the MD@Home host is still valid.
|
||||||
|
*/
|
||||||
|
fun getValidImageUrlForPage(page: Page, headers: Headers, client: OkHttpClient): Request {
|
||||||
|
val (host, tokenRequestUrl, time) = page.url.split(",")
|
||||||
|
|
||||||
|
val mdAtHomeServerUrl =
|
||||||
|
when (Date().time - time.toLong() > MDConstants.mdAtHomeTokenLifespan) {
|
||||||
|
false -> host
|
||||||
|
true -> {
|
||||||
|
val tokenLifespan = Date().time - (tokenTracker[tokenRequestUrl] ?: 0)
|
||||||
|
val cacheControl = if (tokenLifespan > MDConstants.mdAtHomeTokenLifespan) {
|
||||||
|
CacheControl.FORCE_NETWORK
|
||||||
|
} else {
|
||||||
|
USE_CACHE
|
||||||
|
}
|
||||||
|
getMdAtHomeUrl(tokenRequestUrl, client, headers, cacheControl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GET(mdAtHomeServerUrl + page.imageUrl, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the MD@Home URL.
|
||||||
|
*/
|
||||||
|
private fun getMdAtHomeUrl(
|
||||||
|
tokenRequestUrl: String,
|
||||||
|
client: OkHttpClient,
|
||||||
|
headers: Headers,
|
||||||
|
cacheControl: CacheControl,
|
||||||
|
): String {
|
||||||
|
val request = mdAtHomeRequest(tokenRequestUrl, headers, cacheControl)
|
||||||
|
val response = client.newCall(request).execute()
|
||||||
|
|
||||||
|
// This check is for the error that causes pages to fail to load.
|
||||||
|
// It should never be entered, but in case it is, we retry the request.
|
||||||
|
if (response.code == 504) {
|
||||||
|
Log.wtf("MangaDex", "Failed to read cache for \"$tokenRequestUrl\"")
|
||||||
|
return getMdAtHomeUrl(tokenRequestUrl, client, headers, CacheControl.FORCE_NETWORK)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.use { json.decodeFromString<AtHomeDto>(it.body.string()).baseUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* create an md at home Request
|
||||||
|
*/
|
||||||
|
fun mdAtHomeRequest(
|
||||||
|
tokenRequestUrl: String,
|
||||||
|
headers: Headers,
|
||||||
|
cacheControl: CacheControl,
|
||||||
|
): Request {
|
||||||
|
if (cacheControl == CacheControl.FORCE_NETWORK) {
|
||||||
|
tokenTracker[tokenRequestUrl] = Date().time
|
||||||
|
}
|
||||||
|
|
||||||
|
return GET(tokenRequestUrl, headers, cacheControl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a [SManga] from the JSON element with only basic attributes filled.
|
||||||
|
*/
|
||||||
|
fun createBasicManga(
|
||||||
|
mangaDataDto: MangaDataDto,
|
||||||
|
coverFileName: String?,
|
||||||
|
coverSuffix: String?,
|
||||||
|
lang: String,
|
||||||
|
): SManga = SManga.create().apply {
|
||||||
|
url = "/manga/${mangaDataDto.id}"
|
||||||
|
val titleMap = mangaDataDto.attributes!!.title
|
||||||
|
val dirtyTitle =
|
||||||
|
titleMap.values.firstOrNull() // use literally anything from title as first resort
|
||||||
|
?: mangaDataDto.attributes.altTitles
|
||||||
|
.find { (it[lang] ?: it["en"]) !== null }
|
||||||
|
?.values?.singleOrNull() // find something else from alt titles
|
||||||
|
title = dirtyTitle?.removeEntities().orEmpty()
|
||||||
|
|
||||||
|
coverFileName?.let {
|
||||||
|
thumbnail_url = when (!coverSuffix.isNullOrEmpty()) {
|
||||||
|
true -> "${MDConstants.cdnUrl}/covers/${mangaDataDto.id}/$coverFileName$coverSuffix"
|
||||||
|
else -> "${MDConstants.cdnUrl}/covers/${mangaDataDto.id}/$coverFileName"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an [SManga] from the JSON element with all attributes filled.
|
||||||
|
*/
|
||||||
|
fun createManga(
|
||||||
|
mangaDataDto: MangaDataDto,
|
||||||
|
chapters: Map<String, AggregateVolume>,
|
||||||
|
firstVolumeCover: String?,
|
||||||
|
lang: String,
|
||||||
|
coverSuffix: String?,
|
||||||
|
altTitlesInDesc: Boolean,
|
||||||
|
): SManga {
|
||||||
|
val attr = mangaDataDto.attributes!!
|
||||||
|
|
||||||
|
// Things that will go with the genre tags but aren't actually genre
|
||||||
|
val dexLocale = Locale.forLanguageTag(lang)
|
||||||
|
|
||||||
|
val nonGenres = listOfNotNull(
|
||||||
|
attr.publicationDemographic
|
||||||
|
?.let { intl["publication_demographic_${it.name.lowercase()}"] },
|
||||||
|
attr.contentRating
|
||||||
|
.takeIf { it != ContentRatingDto.SAFE }
|
||||||
|
?.let { intl.format("content_rating_genre", intl["content_rating_${it.name.lowercase()}"]) },
|
||||||
|
attr.originalLanguage
|
||||||
|
?.let { Locale.forLanguageTag(it) }
|
||||||
|
?.getDisplayName(dexLocale)
|
||||||
|
?.replaceFirstChar { it.uppercase(dexLocale) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val authors = mangaDataDto.relationships
|
||||||
|
.filterIsInstance<AuthorDto>()
|
||||||
|
.mapNotNull { it.attributes?.name }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
val artists = mangaDataDto.relationships
|
||||||
|
.filterIsInstance<ArtistDto>()
|
||||||
|
.mapNotNull { it.attributes?.name }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
val coverFileName = firstVolumeCover ?: mangaDataDto.relationships
|
||||||
|
.filterIsInstance<CoverArtDto>()
|
||||||
|
.firstOrNull()
|
||||||
|
?.attributes?.fileName
|
||||||
|
|
||||||
|
val tags = mdFilters.getTags(intl).associate { it.id to it.name }
|
||||||
|
|
||||||
|
val genresMap = attr.tags
|
||||||
|
.groupBy({ it.attributes!!.group }) { tagDto -> tags[tagDto.id] }
|
||||||
|
.mapValues { it.value.filterNotNull().sortedWith(intl.collator) }
|
||||||
|
|
||||||
|
val genreList = MDConstants.tagGroupsOrder.flatMap { genresMap[it].orEmpty() } + nonGenres
|
||||||
|
|
||||||
|
var desc = (attr.description[lang] ?: attr.description["en"])
|
||||||
|
?.removeEntitiesAndMarkdown()
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
if (altTitlesInDesc) {
|
||||||
|
val romanizedOriginalLang = MDConstants.romanizedLangCodes[attr.originalLanguage].orEmpty()
|
||||||
|
val altTitles = attr.altTitles
|
||||||
|
.filter { it.containsKey(lang) || it.containsKey(romanizedOriginalLang) }
|
||||||
|
.mapNotNull { it.values.singleOrNull() }
|
||||||
|
.filter(String::isNotEmpty)
|
||||||
|
|
||||||
|
if (altTitles.isNotEmpty()) {
|
||||||
|
val altTitlesDesc = altTitles
|
||||||
|
.joinToString("\n", "${intl["alternative_titles"]}\n") { "• $it" }
|
||||||
|
desc += (if (desc.isBlank()) "" else "\n\n") + altTitlesDesc.removeEntities()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createBasicManga(mangaDataDto, coverFileName, coverSuffix, lang).apply {
|
||||||
|
description = desc
|
||||||
|
author = authors.joinToString()
|
||||||
|
artist = artists.joinToString()
|
||||||
|
status = getPublicationStatus(attr, chapters)
|
||||||
|
genre = genreList
|
||||||
|
.filter(String::isNotEmpty)
|
||||||
|
.joinToString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the [SChapter] from the JSON element.
|
||||||
|
*/
|
||||||
|
fun createChapter(chapterDataDto: ChapterDataDto): SChapter {
|
||||||
|
val attr = chapterDataDto.attributes!!
|
||||||
|
|
||||||
|
val groups = chapterDataDto.relationships
|
||||||
|
.filterIsInstance<ScanlationGroupDto>()
|
||||||
|
.filterNot { it.id == MDConstants.legacyNoGroupId } // 'no group' left over from MDv3
|
||||||
|
.mapNotNull { it.attributes?.name }
|
||||||
|
.joinToString(" & ")
|
||||||
|
.ifEmpty {
|
||||||
|
// Fallback to uploader name if no group is set.
|
||||||
|
val users = chapterDataDto.relationships
|
||||||
|
.filterIsInstance<UserDto>()
|
||||||
|
.mapNotNull { it.attributes?.username }
|
||||||
|
if (users.isNotEmpty()) intl.format("uploaded_by", users.joinToString(" & ")) else ""
|
||||||
|
}
|
||||||
|
.ifEmpty { intl["no_group"] } // "No Group" as final resort
|
||||||
|
|
||||||
|
val chapterName = mutableListOf<String>()
|
||||||
|
// Build chapter name
|
||||||
|
|
||||||
|
attr.volume?.let {
|
||||||
|
if (it.isNotEmpty()) {
|
||||||
|
chapterName.add("Vol.$it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attr.chapter?.let {
|
||||||
|
if (it.isNotEmpty()) {
|
||||||
|
chapterName.add("Ch.$it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attr.title?.let {
|
||||||
|
if (it.isNotEmpty()) {
|
||||||
|
if (chapterName.isNotEmpty()) {
|
||||||
|
chapterName.add("-")
|
||||||
|
}
|
||||||
|
chapterName.add(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if volume, chapter and title is empty its a oneshot
|
||||||
|
if (chapterName.isEmpty()) {
|
||||||
|
chapterName.add("Oneshot")
|
||||||
|
}
|
||||||
|
|
||||||
|
// In future calculate [END] if non mvp api doesn't provide it
|
||||||
|
|
||||||
|
return SChapter.create().apply {
|
||||||
|
url = "/chapter/${chapterDataDto.id}"
|
||||||
|
name = chapterName.joinToString(" ").removeEntities()
|
||||||
|
date_upload = parseDate(attr.publishAt)
|
||||||
|
scanlator = groups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun titleToSlug(title: String) = title.trim()
|
||||||
|
.lowercase(Locale.US)
|
||||||
|
.replace(titleSpecialCharactersRegex, "-")
|
||||||
|
.replace(trailingHyphenRegex, "")
|
||||||
|
.split("-")
|
||||||
|
.reduce { accumulator, element ->
|
||||||
|
val currentSlug = "$accumulator-$element"
|
||||||
|
if (currentSlug.length > 100) {
|
||||||
|
accumulator
|
||||||
|
} else {
|
||||||
|
currentSlug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a custom [TextWatcher] to the preference's [EditText] that show an
|
||||||
|
* error if the input value contains invalid UUIDs. If the validation fails,
|
||||||
|
* the Ok button is disabled to prevent the user from saving the value.
|
||||||
|
*
|
||||||
|
* This will likely need to be removed or revisited when the app migrates the
|
||||||
|
* extension preferences screen to Compose.
|
||||||
|
*/
|
||||||
|
fun setupEditTextUuidValidator(editText: EditText) {
|
||||||
|
editText.addTextChangedListener(
|
||||||
|
object : TextWatcher {
|
||||||
|
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||||
|
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||||
|
|
||||||
|
override fun afterTextChanged(editable: Editable?) {
|
||||||
|
requireNotNull(editable)
|
||||||
|
|
||||||
|
val text = editable.toString()
|
||||||
|
|
||||||
|
val isValid = text.isBlank() || text
|
||||||
|
.split(",")
|
||||||
|
.map(String::trim)
|
||||||
|
.all(::isUuid)
|
||||||
|
|
||||||
|
editText.error = if (!isValid) intl["invalid_uuids"] else null
|
||||||
|
editText.rootView.findViewById<Button>(android.R.id.button1)
|
||||||
|
?.isEnabled = editText.error == null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex
|
||||||
|
|
||||||
|
object MangaDexIntl {
|
||||||
|
const val BRAZILIAN_PORTUGUESE = "pt-BR"
|
||||||
|
const val CHINESE = "zh"
|
||||||
|
const val ENGLISH = "en"
|
||||||
|
const val JAPANESE = "ja"
|
||||||
|
const val KOREAN = "ko"
|
||||||
|
const val PORTUGUESE = "pt"
|
||||||
|
const val SPANISH_LATAM = "es-419"
|
||||||
|
const val SPANISH = "es"
|
||||||
|
const val RUSSIAN = "ru"
|
||||||
|
|
||||||
|
val AVAILABLE_LANGS = setOf(
|
||||||
|
ENGLISH,
|
||||||
|
BRAZILIAN_PORTUGUESE,
|
||||||
|
PORTUGUESE,
|
||||||
|
SPANISH,
|
||||||
|
SPANISH_LATAM,
|
||||||
|
RUSSIAN,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val MANGADEX_NAME = "MangaDex"
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Springboard that accepts https://mangadex.com/title/xxx intents and redirects them to
|
||||||
|
* the main tachiyomi process. The idea is to not install the intent filter unless
|
||||||
|
* you have this extension installed, but still let the main tachiyomi app control
|
||||||
|
* things.
|
||||||
|
*
|
||||||
|
* Main goal was to make it easier to open manga in Tachiyomi in spite of the DDoS blocking
|
||||||
|
* the usual search screen from working.
|
||||||
|
*/
|
||||||
|
class MangadexUrlActivity : Activity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val pathSegments = intent?.data?.pathSegments
|
||||||
|
if (pathSegments != null && pathSegments.size > 1) {
|
||||||
|
val titleId = pathSegments[1]
|
||||||
|
val mainIntent = Intent().apply {
|
||||||
|
action = "eu.kanade.tachiyomi.SEARCH"
|
||||||
|
with(pathSegments[0]) {
|
||||||
|
when {
|
||||||
|
equals("chapter") -> putExtra("query", MDConstants.prefixChSearch + titleId)
|
||||||
|
equals("group") -> putExtra("query", MDConstants.prefixGrpSearch + titleId)
|
||||||
|
equals("user") -> putExtra("query", MDConstants.prefixUsrSearch + titleId)
|
||||||
|
equals("author") -> putExtra("query", MDConstants.prefixAuthSearch + titleId)
|
||||||
|
equals("list") -> putExtra("query", MDConstants.prefixListSearch + titleId)
|
||||||
|
else -> putExtra("query", MDConstants.prefixIdSearch + titleId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
putExtra("filter", packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
startActivity(mainIntent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Log.e("MangadexUrlActivity", e.toString())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e("MangadexUrlActivity", "Could not parse URI from intent $intent")
|
||||||
|
}
|
||||||
|
|
||||||
|
finish()
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,109 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ImageReportDto
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interceptor to post to MD@Home for MangaDex Stats
|
||||||
|
*/
|
||||||
|
class MdAtHomeReportInterceptor(
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
private val headers: Headers,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val originalRequest = chain.request()
|
||||||
|
val response = chain.proceed(chain.request())
|
||||||
|
val url = originalRequest.url.toString()
|
||||||
|
|
||||||
|
if (!url.contains(MD_AT_HOME_URL_REGEX)) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.e("MangaDex", "Connecting to MD@Home node at $url")
|
||||||
|
|
||||||
|
val reportRequest = mdAtHomeReportRequest(response)
|
||||||
|
|
||||||
|
// Execute the report endpoint network call asynchronously to avoid blocking
|
||||||
|
// the reader from showing the image once it's fully loaded if the report call
|
||||||
|
// gets stuck, as it tend to happens sometimes.
|
||||||
|
client.newCall(reportRequest).enqueue(REPORT_CALLBACK)
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
response.close()
|
||||||
|
|
||||||
|
Log.e("MangaDex", "Error connecting to MD@Home node, fallback to uploads server")
|
||||||
|
|
||||||
|
val imagePath = originalRequest.url.pathSegments
|
||||||
|
.dropWhile { it != "data" && it != "data-saver" }
|
||||||
|
.joinToString("/")
|
||||||
|
|
||||||
|
val fallbackUrl = MDConstants.cdnUrl.toHttpUrl().newBuilder()
|
||||||
|
.addPathSegments(imagePath)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val fallbackRequest = originalRequest.newBuilder()
|
||||||
|
.url(fallbackUrl)
|
||||||
|
.headers(headers)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return chain.proceed(fallbackRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mdAtHomeReportRequest(response: Response): Request {
|
||||||
|
val result = ImageReportDto(
|
||||||
|
url = response.request.url.toString(),
|
||||||
|
success = response.isSuccessful,
|
||||||
|
bytes = response.peekBody(Long.MAX_VALUE).bytes().size,
|
||||||
|
cached = response.headers["X-Cache"] == "HIT",
|
||||||
|
duration = response.receivedResponseAtMillis - response.sentRequestAtMillis,
|
||||||
|
)
|
||||||
|
|
||||||
|
val payload = json.encodeToString(result)
|
||||||
|
|
||||||
|
return POST(
|
||||||
|
url = MDConstants.atHomePostUrl,
|
||||||
|
headers = headers,
|
||||||
|
body = payload.toRequestBody(JSON_MEDIA_TYPE),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val JSON_MEDIA_TYPE = "application/json".toMediaType()
|
||||||
|
private val MD_AT_HOME_URL_REGEX =
|
||||||
|
"""^https://[\w\d]+\.[\w\d]+\.mangadex(\b-test\b)?\.network.*${'$'}""".toRegex()
|
||||||
|
|
||||||
|
private val REPORT_CALLBACK = object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: okio.IOException) {
|
||||||
|
Log.e("MangaDex", "Error trying to POST report to MD@Home: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
Log.e("MangaDex", "Error trying to POST report to MD@Home: HTTP error ${response.code}")
|
||||||
|
}
|
||||||
|
|
||||||
|
response.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interceptor to set custom useragent for MangaDex
|
||||||
|
*/
|
||||||
|
class MdUserAgentInterceptor(
|
||||||
|
private val preferences: SharedPreferences,
|
||||||
|
private val dexLang: String,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
private val SharedPreferences.customUserAgent
|
||||||
|
get() = getString(
|
||||||
|
MDConstants.getCustomUserAgentPrefKey(dexLang),
|
||||||
|
MDConstants.defaultUserAgent,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val originalRequest = chain.request()
|
||||||
|
|
||||||
|
val newUserAgent = preferences.customUserAgent
|
||||||
|
?: return chain.proceed(originalRequest)
|
||||||
|
|
||||||
|
val originalHeaders = originalRequest.headers
|
||||||
|
|
||||||
|
val modifiedHeaders = originalHeaders.newBuilder()
|
||||||
|
.set("User-Agent", newUserAgent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val modifiedRequest = originalRequest.newBuilder()
|
||||||
|
.headers(modifiedHeaders)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return chain.proceed(modifiedRequest)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AggregateDto(
|
||||||
|
val result: String,
|
||||||
|
val volumes: Map<String, AggregateVolume>?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AggregateVolume(
|
||||||
|
val volume: String,
|
||||||
|
val count: String,
|
||||||
|
val chapters: Map<String, AggregateChapter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AggregateChapter(
|
||||||
|
val chapter: String,
|
||||||
|
val count: String,
|
||||||
|
)
|
|
@ -0,0 +1,25 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AtHomeDto(
|
||||||
|
val baseUrl: String,
|
||||||
|
val chapter: AtHomeChapterDto,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AtHomeChapterDto(
|
||||||
|
val hash: String,
|
||||||
|
val data: List<String>,
|
||||||
|
val dataSaver: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ImageReportDto(
|
||||||
|
val url: String,
|
||||||
|
val success: Boolean,
|
||||||
|
val bytes: Int?,
|
||||||
|
val cached: Boolean,
|
||||||
|
val duration: Long,
|
||||||
|
)
|
|
@ -0,0 +1,16 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex.dto
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.MDConstants
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName(MDConstants.author)
|
||||||
|
data class AuthorDto(override val attributes: AuthorArtistAttributesDto? = null) : EntityDto()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName(MDConstants.artist)
|
||||||
|
data class ArtistDto(override val attributes: AuthorArtistAttributesDto? = null) : EntityDto()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthorArtistAttributesDto(val name: String) : AttributesDto()
|
|
@ -0,0 +1,30 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex.dto
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.MDConstants
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
typealias ChapterListDto = PaginatedResponseDto<ChapterDataDto>
|
||||||
|
|
||||||
|
typealias ChapterDto = ResponseDto<ChapterDataDto>
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName(MDConstants.chapter)
|
||||||
|
data class ChapterDataDto(override val attributes: ChapterAttributesDto? = null) : EntityDto()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChapterAttributesDto(
|
||||||
|
val title: String?,
|
||||||
|
val volume: String?,
|
||||||
|
val chapter: String?,
|
||||||
|
val pages: Int,
|
||||||
|
val publishAt: String,
|
||||||
|
val externalUrl: String?,
|
||||||
|
) : AttributesDto() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the chapter is from an external website and have no pages.
|
||||||
|
*/
|
||||||
|
val isInvalid: Boolean
|
||||||
|
get() = externalUrl != null && pages == 0
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex.dto
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.MDConstants
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
typealias CoverArtListDto = PaginatedResponseDto<CoverArtDto>
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName(MDConstants.coverArt)
|
||||||
|
data class CoverArtDto(override val attributes: CoverArtAttributesDto? = null) : EntityDto()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CoverArtAttributesDto(
|
||||||
|
val fileName: String? = null,
|
||||||
|
val locale: String? = null,
|
||||||
|
) : AttributesDto()
|
|
@ -0,0 +1,16 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
abstract class EntityDto {
|
||||||
|
val id: String = ""
|
||||||
|
val relationships: List<EntityDto> = emptyList()
|
||||||
|
abstract val attributes: AttributesDto?
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
abstract class AttributesDto
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UnknownEntity(override val attributes: AttributesDto? = null) : EntityDto()
|
|
@ -0,0 +1,18 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex.dto
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.MDConstants
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
typealias ListDto = ResponseDto<ListDataDto>
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName(MDConstants.list)
|
||||||
|
data class ListDataDto(override val attributes: ListAttributesDto? = null) : EntityDto()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ListAttributesDto(
|
||||||
|
val name: String,
|
||||||
|
val visibility: String,
|
||||||
|
val version: Int,
|
||||||
|
) : AttributesDto()
|
|
@ -0,0 +1,114 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex.dto
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.MDConstants
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import kotlinx.serialization.json.JsonDecoder
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
|
|
||||||
|
typealias MangaListDto = PaginatedResponseDto<MangaDataDto>
|
||||||
|
|
||||||
|
typealias MangaDto = ResponseDto<MangaDataDto>
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName(MDConstants.manga)
|
||||||
|
data class MangaDataDto(override val attributes: MangaAttributesDto? = null) : EntityDto()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MangaAttributesDto(
|
||||||
|
val title: LocalizedString,
|
||||||
|
val altTitles: List<LocalizedString>,
|
||||||
|
val description: LocalizedString,
|
||||||
|
val originalLanguage: String?,
|
||||||
|
val lastVolume: String?,
|
||||||
|
val lastChapter: String?,
|
||||||
|
val contentRating: ContentRatingDto? = null,
|
||||||
|
val publicationDemographic: PublicationDemographicDto? = null,
|
||||||
|
val status: StatusDto? = null,
|
||||||
|
val tags: List<TagDto>,
|
||||||
|
) : AttributesDto()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ContentRatingDto(val value: String) {
|
||||||
|
@SerialName("safe")
|
||||||
|
SAFE("safe"),
|
||||||
|
|
||||||
|
@SerialName("suggestive")
|
||||||
|
SUGGESTIVE("suggestive"),
|
||||||
|
|
||||||
|
@SerialName("erotica")
|
||||||
|
EROTICA("erotica"),
|
||||||
|
|
||||||
|
@SerialName("pornographic")
|
||||||
|
PORNOGRAPHIC("pornographic"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class PublicationDemographicDto(val value: String) {
|
||||||
|
@SerialName("none")
|
||||||
|
NONE("none"),
|
||||||
|
|
||||||
|
@SerialName("shounen")
|
||||||
|
SHOUNEN("shounen"),
|
||||||
|
|
||||||
|
@SerialName("shoujo")
|
||||||
|
SHOUJO("shoujo"),
|
||||||
|
|
||||||
|
@SerialName("josei")
|
||||||
|
JOSEI("josei"),
|
||||||
|
|
||||||
|
@SerialName("seinen")
|
||||||
|
SEINEN("seinen"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class StatusDto(val value: String) {
|
||||||
|
@SerialName("ongoing")
|
||||||
|
ONGOING("ongoing"),
|
||||||
|
|
||||||
|
@SerialName("completed")
|
||||||
|
COMPLETED("completed"),
|
||||||
|
|
||||||
|
@SerialName("hiatus")
|
||||||
|
HIATUS("hiatus"),
|
||||||
|
|
||||||
|
@SerialName("cancelled")
|
||||||
|
CANCELLED("cancelled"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName(MDConstants.tag)
|
||||||
|
data class TagDto(override val attributes: TagAttributesDto? = null) : EntityDto()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TagAttributesDto(val group: String) : AttributesDto()
|
||||||
|
|
||||||
|
typealias LocalizedString = @Serializable(LocalizedStringSerializer::class)
|
||||||
|
Map<String, String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary workaround while Dex API still returns arrays instead of objects
|
||||||
|
* in the places that uses [LocalizedString].
|
||||||
|
*/
|
||||||
|
object LocalizedStringSerializer : KSerializer<Map<String, String>> {
|
||||||
|
override val descriptor = buildClassSerialDescriptor("LocalizedString")
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): Map<String, String> {
|
||||||
|
require(decoder is JsonDecoder)
|
||||||
|
|
||||||
|
return (decoder.decodeJsonElement() as? JsonObject)
|
||||||
|
?.mapValues { it.value.jsonPrimitive.contentOrNull ?: "" }
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: Map<String, String>) {
|
||||||
|
encoder.encodeSerializableValue(serializer(), value)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PaginatedResponseDto<T : EntityDto>(
|
||||||
|
val result: String,
|
||||||
|
val response: String = "",
|
||||||
|
val data: List<T> = emptyList(),
|
||||||
|
val limit: Int = 0,
|
||||||
|
val offset: Int = 0,
|
||||||
|
val total: Int = 0,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val hasNextPage: Boolean
|
||||||
|
get() = limit + offset < total
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ResponseDto<T : EntityDto>(
|
||||||
|
val result: String,
|
||||||
|
val response: String = "",
|
||||||
|
val data: T? = null,
|
||||||
|
)
|
|
@ -0,0 +1,12 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex.dto
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.MDConstants
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName(MDConstants.scanlationGroup)
|
||||||
|
data class ScanlationGroupDto(override val attributes: ScanlationGroupAttributes? = null) : EntityDto()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ScanlationGroupAttributes(val name: String) : AttributesDto()
|
|
@ -0,0 +1,12 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangadex.dto
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.extension.all.mangadex.MDConstants
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName(MDConstants.user)
|
||||||
|
data class UserDto(override val attributes: UserAttributes? = null) : EntityDto()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserAttributes(val username: String) : AttributesDto()
|
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest />
|
|
@ -0,0 +1,48 @@
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
extName = 'NewToki / ManaToki'
|
||||||
|
pkgNameSuffix = 'ko.newtoki'
|
||||||
|
extClass = '.TokiFactory'
|
||||||
|
extVersionCode = 29
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
||||||
|
|
||||||
|
def domainNumberFileName = "src/ko/newtoki/src/eu/kanade/tachiyomi/extension/ko/newtoki/FallbackDomainNumber.kt"
|
||||||
|
def domainNumberFile = new File(domainNumberFileName)
|
||||||
|
def backupFile = new File(domainNumberFileName + "_bak")
|
||||||
|
|
||||||
|
task updateDomainNumber {
|
||||||
|
doLast {
|
||||||
|
def domainNumberUrl = "https://stevenyomi.github.io/source-domains/newtoki.txt"
|
||||||
|
def number = new URL(domainNumberUrl).withInputStream { it.readLines()[0] }
|
||||||
|
println("[NewToki] Updating domain number to $number")
|
||||||
|
domainNumberFile.renameTo(backupFile)
|
||||||
|
domainNumberFile.withPrintWriter {
|
||||||
|
it.println("// THIS FILE IS AUTO-GENERATED, DO NOT COMMIT")
|
||||||
|
it.println("package eu.kanade.tachiyomi.extension.ko.newtoki")
|
||||||
|
it.println("const val fallbackDomainNumber = \"$number\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
preBuild.dependsOn updateDomainNumber
|
||||||
|
|
||||||
|
task restoreBackup {
|
||||||
|
doLast {
|
||||||
|
if (backupFile.exists()) {
|
||||||
|
println("[NewToki] Restoring placeholder file")
|
||||||
|
domainNumberFile.delete()
|
||||||
|
backupFile.renameTo(domainNumberFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.whenTaskAdded { task ->
|
||||||
|
if (task.name == "assembleDebug" || task.name == "assembleRelease") {
|
||||||
|
task.finalizedBy(restoreBackup)
|
||||||
|
}
|
||||||
|
}
|
After Width: | Height: | Size: 6.2 KiB |
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 36 KiB |
After Width: | Height: | Size: 348 KiB |
|
@ -0,0 +1,73 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.ko.newtoki
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
var domainNumber = ""
|
||||||
|
get() {
|
||||||
|
val currentValue = field
|
||||||
|
if (currentValue.isNotEmpty()) return currentValue
|
||||||
|
|
||||||
|
val prefValue = newTokiPreferences.domainNumber
|
||||||
|
if (prefValue.isNotEmpty()) {
|
||||||
|
field = prefValue
|
||||||
|
return prefValue
|
||||||
|
}
|
||||||
|
|
||||||
|
val fallback = fallbackDomainNumber
|
||||||
|
domainNumber = fallback
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
set(value) {
|
||||||
|
for (preference in arrayOf(manaTokiPreferences, newTokiPreferences)) {
|
||||||
|
preference.domainNumber = value
|
||||||
|
}
|
||||||
|
field = value
|
||||||
|
}
|
||||||
|
|
||||||
|
object DomainInterceptor : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
|
||||||
|
val response = try {
|
||||||
|
chain.proceed(request)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
if (chain.call().isCanceled()) throw e
|
||||||
|
Log.e("NewToki", "failed to fetch ${request.url}", e)
|
||||||
|
|
||||||
|
val newDomainNumber = try {
|
||||||
|
val domainNumberUrl = "https://stevenyomi.github.io/source-domains/newtoki.txt"
|
||||||
|
chain.proceed(GET(domainNumberUrl)).body.string().also { it.toInt() }
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
throw IOException(editDomainNumber(), e)
|
||||||
|
}
|
||||||
|
domainNumber = newDomainNumber
|
||||||
|
|
||||||
|
val url = request.url
|
||||||
|
val newHost = numberRegex.replaceFirst(url.host, newDomainNumber)
|
||||||
|
val newUrl = url.newBuilder().host(newHost).build()
|
||||||
|
try {
|
||||||
|
chain.proceed(request.newBuilder().url(newUrl).build())
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e("NewToki", "failed to fetch $newUrl", e)
|
||||||
|
throw IOException(editDomainNumber(), e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.priorResponse == null) return response
|
||||||
|
|
||||||
|
val newUrl = response.request.url
|
||||||
|
if ("captcha" in newUrl.toString()) throw IOException(solveCaptcha())
|
||||||
|
|
||||||
|
val newHost = newUrl.host
|
||||||
|
if (newHost.startsWith(MANATOKI_PREFIX) || newHost.startsWith(NEWTOKI_PREFIX)) {
|
||||||
|
numberRegex.find(newHost)?.run { domainNumber = value }
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
private val numberRegex by lazy { Regex("""\d+|$fallbackDomainNumber""") }
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.ko.newtoki
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This value will be automatically overwritten when building the extension.
|
||||||
|
* After building, this file will be restored.
|
||||||
|
*
|
||||||
|
* Even if this value is built into the extension, the network call will fail
|
||||||
|
* because of underscore character and the extension will update it on its own.
|
||||||
|
*/
|
||||||
|
const val fallbackDomainNumber = "_failed_to_fetch_domain_number"
|
|
@ -0,0 +1,174 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.ko.newtoki
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ManaToki is too big to support in a Factory File., So split into separate file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
object ManaToki : NewToki("ManaToki", "comic", manaTokiPreferences) {
|
||||||
|
// / ! DO NOT CHANGE THIS ! Only the site name changed from newtoki.
|
||||||
|
override val id = MANATOKI_ID
|
||||||
|
|
||||||
|
override val baseUrl get() = "https://$MANATOKI_PREFIX$domainNumber.net"
|
||||||
|
|
||||||
|
private val chapterRegex by lazy { Regex(""" [ \d,~.-]+화$""") }
|
||||||
|
|
||||||
|
fun latestUpdatesElementParse(element: Element): SManga {
|
||||||
|
val linkElement = element.select("a.btn-primary")
|
||||||
|
val rawTitle = element.select(".post-subject > a").first()!!.ownText().trim()
|
||||||
|
|
||||||
|
val title = rawTitle.trim().replace(chapterRegex, "")
|
||||||
|
|
||||||
|
val manga = SManga.create()
|
||||||
|
manga.url = getUrlPath(linkElement.attr("href"))
|
||||||
|
manga.title = title
|
||||||
|
manga.thumbnail_url = element.select(".img-item > img").attr("src")
|
||||||
|
manga.initialized = false
|
||||||
|
return manga
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val url = ("$baseUrl/comic" + (if (page > 1) "/p$page" else "")).toHttpUrl().newBuilder()
|
||||||
|
|
||||||
|
filters.forEach { filter ->
|
||||||
|
when (filter) {
|
||||||
|
is SearchPublishTypeList -> {
|
||||||
|
if (filter.state > 0) {
|
||||||
|
url.addQueryParameter("publish", filter.values[filter.state])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is SearchJaumTypeList -> {
|
||||||
|
if (filter.state > 0) {
|
||||||
|
url.addQueryParameter("jaum", filter.values[filter.state])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is SearchGenreTypeList -> {
|
||||||
|
val genres = filter.state.filter { it.state }.joinToString(",") { it.id }
|
||||||
|
url.addQueryParameter("tag", genres)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SearchSortTypeList -> {
|
||||||
|
val state = filter.state ?: return@forEach
|
||||||
|
url.addQueryParameter("sst", arrayOf("wr_datetime", "wr_hit", "wr_good", "as_update")[state.index])
|
||||||
|
url.addQueryParameter("sod", if (state.ascending) "asc" else "desc")
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isNotBlank()) {
|
||||||
|
url.addQueryParameter("stx", query)
|
||||||
|
|
||||||
|
// Remove some filter QueryParams that not working with query
|
||||||
|
url.removeAllQueryParameters("publish")
|
||||||
|
url.removeAllQueryParameters("jaum")
|
||||||
|
url.removeAllQueryParameters("tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
return GET(url.toString(), headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SearchCheckBox(name: String, val id: String = name) : Filter.CheckBox(name)
|
||||||
|
|
||||||
|
// [...document.querySelectorAll("form.form td")[3].querySelectorAll("span.btn")].map((el, i) => `"${el.innerText.trim()}"`).join(',\n')
|
||||||
|
private class SearchPublishTypeList : Filter.Select<String>(
|
||||||
|
"Publish",
|
||||||
|
arrayOf(
|
||||||
|
"전체",
|
||||||
|
"주간",
|
||||||
|
"격주",
|
||||||
|
"월간",
|
||||||
|
"단편",
|
||||||
|
"단행본",
|
||||||
|
"완결",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// [...document.querySelectorAll("form.form td")[4].querySelectorAll("span.btn")].map((el, i) => `"${el.innerText.trim()}"`).join(',\n')
|
||||||
|
private class SearchJaumTypeList : Filter.Select<String>(
|
||||||
|
"Jaum",
|
||||||
|
arrayOf(
|
||||||
|
"전체",
|
||||||
|
"ㄱ",
|
||||||
|
"ㄴ",
|
||||||
|
"ㄷ",
|
||||||
|
"ㄹ",
|
||||||
|
"ㅁ",
|
||||||
|
"ㅂ",
|
||||||
|
"ㅅ",
|
||||||
|
"ㅇ",
|
||||||
|
"ㅈ",
|
||||||
|
"ㅊ",
|
||||||
|
"ㅋ",
|
||||||
|
"ㅌ",
|
||||||
|
"ㅍ",
|
||||||
|
"ㅎ",
|
||||||
|
"0-9",
|
||||||
|
"a-z",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// [...document.querySelectorAll("form.form td")[6].querySelectorAll("span.btn")].map((el, i) => `"${el.innerText.trim()}"`).join(',\n')
|
||||||
|
private class SearchGenreTypeList : Filter.Group<SearchCheckBox>(
|
||||||
|
"Genres",
|
||||||
|
arrayOf(
|
||||||
|
"전체",
|
||||||
|
"17",
|
||||||
|
"BL",
|
||||||
|
"SF",
|
||||||
|
"TS",
|
||||||
|
"개그",
|
||||||
|
"게임",
|
||||||
|
"도박",
|
||||||
|
"드라마",
|
||||||
|
"라노벨",
|
||||||
|
"러브코미디",
|
||||||
|
"먹방",
|
||||||
|
"백합",
|
||||||
|
"붕탁",
|
||||||
|
"순정",
|
||||||
|
"스릴러",
|
||||||
|
"스포츠",
|
||||||
|
"시대",
|
||||||
|
"애니화",
|
||||||
|
"액션",
|
||||||
|
"음악",
|
||||||
|
"이세계",
|
||||||
|
"일상",
|
||||||
|
"전생",
|
||||||
|
"추리",
|
||||||
|
"판타지",
|
||||||
|
"학원",
|
||||||
|
"호러",
|
||||||
|
).map { SearchCheckBox(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
private class SearchSortTypeList : Filter.Sort(
|
||||||
|
"Sort",
|
||||||
|
arrayOf(
|
||||||
|
"기본(날짜순)",
|
||||||
|
"인기순",
|
||||||
|
"추천순",
|
||||||
|
"업데이트순",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun getFilterList() = FilterList(
|
||||||
|
SearchSortTypeList(),
|
||||||
|
Filter.Separator(),
|
||||||
|
Filter.Header(ignoredForTextSearch()),
|
||||||
|
SearchPublishTypeList(),
|
||||||
|
SearchJaumTypeList(),
|
||||||
|
SearchGenreTypeList(),
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,284 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.ko.newtoki
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Log
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||||
|
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||||
|
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 okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import rx.Observable
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NewToki Source
|
||||||
|
*
|
||||||
|
* Based on https://github.com/gnuboard/gnuboard5
|
||||||
|
**/
|
||||||
|
abstract class NewToki(
|
||||||
|
override val name: String,
|
||||||
|
private val boardName: String,
|
||||||
|
private val preferences: SharedPreferences,
|
||||||
|
) : ParsedHttpSource(), ConfigurableSource {
|
||||||
|
|
||||||
|
override val lang: String = "ko"
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
override val client by lazy { buildClient(withRateLimit = false) }
|
||||||
|
private val rateLimitedClient by lazy { buildClient(withRateLimit = true) }
|
||||||
|
|
||||||
|
private fun buildClient(withRateLimit: Boolean) =
|
||||||
|
network.cloudflareClient.newBuilder()
|
||||||
|
.apply { if (withRateLimit) rateLimit(1, preferences.rateLimitPeriod.toLong()) }
|
||||||
|
.addInterceptor(DomainInterceptor) // not rate-limited
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS) // fail fast
|
||||||
|
.build()
|
||||||
|
|
||||||
|
override fun popularMangaSelector() = "div#webtoon-list > ul > li"
|
||||||
|
|
||||||
|
override fun popularMangaFromElement(element: Element): SManga {
|
||||||
|
val linkElement = element.getElementsByTag("a").first()!!
|
||||||
|
|
||||||
|
val manga = SManga.create()
|
||||||
|
manga.url = getUrlPath(linkElement.attr("href"))
|
||||||
|
manga.title = element.select("span.title").first()!!.ownText()
|
||||||
|
manga.thumbnail_url = linkElement.getElementsByTag("img").attr("src")
|
||||||
|
return manga
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaNextPageSelector() = "ul.pagination > li:last-child:not(.disabled)"
|
||||||
|
|
||||||
|
// Do not add page parameter if page is 1 to prevent tracking.
|
||||||
|
override fun popularMangaRequest(page: Int) = GET("$baseUrl/$boardName" + if (page > 1) "/p$page" else "", headers)
|
||||||
|
|
||||||
|
override fun searchMangaSelector() = popularMangaSelector()
|
||||||
|
override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
|
||||||
|
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
||||||
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
|
return if (query.startsWith(PREFIX_ID_SEARCH)) {
|
||||||
|
val realQuery = query.removePrefix(PREFIX_ID_SEARCH)
|
||||||
|
val urlPath = "/$boardName/$realQuery"
|
||||||
|
rateLimitedClient.newCall(GET("$baseUrl$urlPath", headers))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { response ->
|
||||||
|
// the id is matches any of 'post' from their CMS board.
|
||||||
|
// Includes Manga Details Page, Chapters, Comments, and etcs...
|
||||||
|
actualMangaParseById(urlPath, response)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
super.fetchSearchManga(page, query, filters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun actualMangaParseById(urlPath: String, response: Response): MangasPage {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
|
||||||
|
// Only exists on detail page.
|
||||||
|
val firstChapterButton = document.select("tr > th > button.btn-blue").first()
|
||||||
|
// only exists on chapter with proper manga detail page.
|
||||||
|
val fullListButton = document.select(".comic-navbar .toon-nav a").last()
|
||||||
|
|
||||||
|
val list: List<SManga> = when {
|
||||||
|
firstChapterButton?.text()?.contains("첫회보기") == true -> { // Check this page is detail page
|
||||||
|
val details = mangaDetailsParse(document)
|
||||||
|
details.url = urlPath
|
||||||
|
listOf(details)
|
||||||
|
}
|
||||||
|
fullListButton?.text()?.contains("전체목록") == true -> { // Check this page is chapter page
|
||||||
|
val url = fullListButton.attr("abs:href")
|
||||||
|
val details = mangaDetailsParse(rateLimitedClient.newCall(GET(url, headers)).execute())
|
||||||
|
details.url = getUrlPath(url)
|
||||||
|
listOf(details)
|
||||||
|
}
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return MangasPage(list, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(document: Document): SManga {
|
||||||
|
val info = document.select("div.view-title > .view-content").first()!!
|
||||||
|
val title = document.select("div.view-content > span > b").text()
|
||||||
|
val thumbnail = document.select("div.row div.view-img > img").attr("src")
|
||||||
|
val descriptionElement = info.select("div.row div.view-content:not([style])")
|
||||||
|
val description = descriptionElement.map {
|
||||||
|
it.text().trim()
|
||||||
|
}
|
||||||
|
val prefix = if (isCleanPath(document.location())) "" else needMigration()
|
||||||
|
|
||||||
|
val manga = SManga.create()
|
||||||
|
manga.title = title
|
||||||
|
manga.description = description.joinToString("\n", prefix = prefix)
|
||||||
|
manga.thumbnail_url = thumbnail
|
||||||
|
descriptionElement.forEach {
|
||||||
|
val text = it.text()
|
||||||
|
when {
|
||||||
|
"작가" in text -> manga.author = it.getElementsByTag("a").text()
|
||||||
|
"분류" in text -> {
|
||||||
|
val genres = mutableListOf<String>()
|
||||||
|
it.getElementsByTag("a").forEach { item ->
|
||||||
|
genres.add(item.text())
|
||||||
|
}
|
||||||
|
manga.genre = genres.joinToString(", ")
|
||||||
|
}
|
||||||
|
"발행구분" in text -> manga.status = parseStatus(it.getElementsByTag("a").text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return manga
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseStatus(status: String) = when (status.trim()) {
|
||||||
|
"주간", "격주", "월간", "격월/비정기", "단행본" -> SManga.ONGOING
|
||||||
|
"단편", "완결" -> SManga.COMPLETED
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListSelector() = "div.serial-list > ul.list-body > li.list-item"
|
||||||
|
|
||||||
|
override fun chapterFromElement(element: Element): SChapter {
|
||||||
|
val linkElement = element.select(".wr-subject > a.item-subject").last()!!
|
||||||
|
val rawName = linkElement.ownText().trim()
|
||||||
|
|
||||||
|
val chapter = SChapter.create()
|
||||||
|
chapter.setUrlWithoutDomain(linkElement.attr("href"))
|
||||||
|
chapter.chapter_number = parseChapterNumber(rawName)
|
||||||
|
chapter.name = rawName
|
||||||
|
chapter.date_upload = parseChapterDate(element.select(".wr-date").last()!!.text().trim())
|
||||||
|
return chapter
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseChapterNumber(name: String): Float {
|
||||||
|
try {
|
||||||
|
if (name.contains("[단편]")) return 1f
|
||||||
|
// `특별` means `Special`, so It can be buggy. so pad `편`(Chapter) to prevent false return
|
||||||
|
if (name.contains("번외") || name.contains("특별편")) return -2f
|
||||||
|
val regex = chapterNumberRegex
|
||||||
|
val (ch_primal, ch_second) = regex.find(name)!!.destructured
|
||||||
|
return (ch_primal + if (ch_second.isBlank()) "" else ".$ch_second").toFloatOrNull()
|
||||||
|
?: -1f
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("NewToki", "failed to parse chapter number '$name'", e)
|
||||||
|
return -1f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mangaDetailsParseWithTitleCheck(manga: SManga, document: Document) =
|
||||||
|
mangaDetailsParse(document).apply {
|
||||||
|
// TODO: don't throw when there is download folder rename feature
|
||||||
|
if (manga.description.isNullOrEmpty() && manga.title != title) {
|
||||||
|
throw Exception(titleNotMatch(title))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||||
|
return rateLimitedClient.newCall(mangaDetailsRequest(manga))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { response ->
|
||||||
|
val document = response.asJsoup()
|
||||||
|
mangaDetailsParseWithTitleCheck(manga, document).apply { initialized = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||||
|
return rateLimitedClient.newCall(chapterListRequest(manga))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { response ->
|
||||||
|
val document = response.asJsoup()
|
||||||
|
val title = mangaDetailsParseWithTitleCheck(manga, document).title
|
||||||
|
document.select(chapterListSelector()).map {
|
||||||
|
chapterFromElement(it).apply {
|
||||||
|
name = name.removePrefix(title).trimStart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// not thread-safe
|
||||||
|
private val dateFormat by lazy { SimpleDateFormat("yyyy.MM.dd", Locale.ENGLISH) }
|
||||||
|
|
||||||
|
private fun parseChapterDate(date: String): Long {
|
||||||
|
return try {
|
||||||
|
if (date.contains(":")) {
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
val splitDate = date.split(":")
|
||||||
|
|
||||||
|
val hours = splitDate.first().toInt()
|
||||||
|
val minutes = splitDate.last().toInt()
|
||||||
|
|
||||||
|
val calendarHours = calendar.get(Calendar.HOUR)
|
||||||
|
val calendarMinutes = calendar.get(Calendar.MINUTE)
|
||||||
|
|
||||||
|
if (calendarHours >= hours && calendarMinutes > minutes) {
|
||||||
|
calendar.add(Calendar.DATE, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
calendar.timeInMillis
|
||||||
|
} else {
|
||||||
|
dateFormat.parse(date)?.time ?: 0
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("NewToki", "failed to parse chapter date '$date'", e)
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(document: Document): List<Page> {
|
||||||
|
val script = document.select("script:containsData(html_data)").firstOrNull()?.data()
|
||||||
|
?: throw Exception("data script not found")
|
||||||
|
val loadScript = document.select("script:containsData(data_attribute)").firstOrNull()?.data()
|
||||||
|
?: throw Exception("load script not found")
|
||||||
|
val dataAttr = "abs:data-" + loadScript.substringAfter("data_attribute: '").substringBefore("',")
|
||||||
|
|
||||||
|
return htmlDataRegex.findAll(script).map { it.groupValues[1] }
|
||||||
|
.asIterable()
|
||||||
|
.flatMap { it.split(".") }
|
||||||
|
.joinToString("") { it.toIntOrNull(16)?.toChar()?.toString() ?: "" }
|
||||||
|
.let { Jsoup.parse(it) }
|
||||||
|
.select("img[src=/img/loading-image.gif], .view-img > img[itemprop]")
|
||||||
|
.mapIndexed { i, img -> Page(i, "", if (img.hasAttr(dataAttr)) img.attr(dataAttr) else img.attr("abs:content")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = ".media.post-list"
|
||||||
|
override fun latestUpdatesFromElement(element: Element) = ManaToki.latestUpdatesElementParse(element)
|
||||||
|
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/page/update?hid=update&page=$page", headers)
|
||||||
|
override fun latestUpdatesNextPageSelector() = ".pg_end"
|
||||||
|
|
||||||
|
// We are able to get the image URL directly from the page list
|
||||||
|
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) {
|
||||||
|
getPreferencesInternal(screen.context).map(screen::addPreference)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun getUrlPath(orig: String): String {
|
||||||
|
val url = baseUrl.toHttpUrl().resolve(orig) ?: return orig
|
||||||
|
val pathSegments = url.pathSegments
|
||||||
|
return "/${pathSegments[0]}/${pathSegments[1]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCleanPath(absUrl: String): Boolean {
|
||||||
|
val url = absUrl.toHttpUrl()
|
||||||
|
return url.pathSegments.size == 2 && url.querySize == 0 && url.fragment == null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREFIX_ID_SEARCH = "id:"
|
||||||
|
|
||||||
|
private val chapterNumberRegex by lazy { Regex("([0-9]+)(?:[-.]([0-9]+))?화") }
|
||||||
|
private val htmlDataRegex by lazy { Regex("""html_data\+='([^']+)'""") }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,146 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.ko.newtoki
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
|
||||||
|
object NewTokiWebtoon : NewToki("NewToki", "webtoon", newTokiPreferences) {
|
||||||
|
// / ! DO NOT CHANGE THIS ! Prevent to treating as a new site
|
||||||
|
override val id = NEWTOKI_ID
|
||||||
|
|
||||||
|
override val baseUrl get() = "https://$NEWTOKI_PREFIX$domainNumber.com"
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val url = ("$baseUrl/webtoon" + (if (page > 1) "/p$page" else "")).toHttpUrl().newBuilder()
|
||||||
|
filters.forEach { filter ->
|
||||||
|
when (filter) {
|
||||||
|
is SearchTargetTypeList -> {
|
||||||
|
if (filter.state > 0) {
|
||||||
|
url.addQueryParameter("toon", filter.values[filter.state])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is SearchSortTypeList -> {
|
||||||
|
val state = filter.state ?: return@forEach
|
||||||
|
url.addQueryParameter("sst", arrayOf("as_update", "wr_hit", "wr_good")[state.index])
|
||||||
|
url.addQueryParameter("sod", if (state.ascending) "asc" else "desc")
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incompatible with Other Search Parameter
|
||||||
|
if (!query.isBlank()) {
|
||||||
|
url.addQueryParameter("stx", query)
|
||||||
|
} else {
|
||||||
|
filters.forEach { filter ->
|
||||||
|
when (filter) {
|
||||||
|
is SearchYoilTypeList -> {
|
||||||
|
if (filter.state > 0) {
|
||||||
|
url.addQueryParameter("yoil", filter.values[filter.state])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is SearchJaumTypeList -> {
|
||||||
|
if (filter.state > 0) {
|
||||||
|
url.addQueryParameter("jaum", filter.values[filter.state])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is SearchGenreTypeList -> {
|
||||||
|
if (filter.state > 0) {
|
||||||
|
url.addQueryParameter("tag", filter.values[filter.state])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GET(url.toString(), headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SearchTargetTypeList : Filter.Select<String>("Type", arrayOf("전체", "일반웹툰", "성인웹툰", "BL/GL", "완결웹툰"))
|
||||||
|
|
||||||
|
// [...document.querySelectorAll("form.form td")[1].querySelectorAll("a")].map((el, i) => `"${el.innerText.trim()}"`).join(',\n')
|
||||||
|
private class SearchYoilTypeList : Filter.Select<String>(
|
||||||
|
"Day of the Week",
|
||||||
|
arrayOf(
|
||||||
|
"전체",
|
||||||
|
"월",
|
||||||
|
"화",
|
||||||
|
"수",
|
||||||
|
"목",
|
||||||
|
"금",
|
||||||
|
"토",
|
||||||
|
"일",
|
||||||
|
"열흘",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// [...document.querySelectorAll("form.form td")[2].querySelectorAll("a")].map((el, i) => `"${el.innerText.trim()}"`).join(',\n')
|
||||||
|
private class SearchJaumTypeList : Filter.Select<String>(
|
||||||
|
"Jaum",
|
||||||
|
arrayOf(
|
||||||
|
"전체",
|
||||||
|
"ㄱ",
|
||||||
|
"ㄴ",
|
||||||
|
"ㄷ",
|
||||||
|
"ㄹ",
|
||||||
|
"ㅁ",
|
||||||
|
"ㅂ",
|
||||||
|
"ㅅ",
|
||||||
|
"ㅇ",
|
||||||
|
"ㅈ",
|
||||||
|
"ㅊ",
|
||||||
|
"ㅋ",
|
||||||
|
"ㅌ",
|
||||||
|
"ㅍ",
|
||||||
|
"ㅎ",
|
||||||
|
"a-z",
|
||||||
|
"0-9",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// [...document.querySelectorAll("form.form td")[3].querySelectorAll("a")].map((el, i) => `"${el.innerText.trim()}"`).join(',\n')
|
||||||
|
private class SearchGenreTypeList : Filter.Select<String>(
|
||||||
|
"Genre",
|
||||||
|
arrayOf(
|
||||||
|
"전체",
|
||||||
|
"판타지",
|
||||||
|
"액션",
|
||||||
|
"개그",
|
||||||
|
"미스터리",
|
||||||
|
"로맨스",
|
||||||
|
"드라마",
|
||||||
|
"무협",
|
||||||
|
"스포츠",
|
||||||
|
"일상",
|
||||||
|
"학원",
|
||||||
|
"성인",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private class SearchSortTypeList : Filter.Sort(
|
||||||
|
"Sort",
|
||||||
|
arrayOf(
|
||||||
|
"기본(업데이트순)",
|
||||||
|
"인기순",
|
||||||
|
"추천순",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun getFilterList() = FilterList(
|
||||||
|
SearchTargetTypeList(),
|
||||||
|
SearchSortTypeList(),
|
||||||
|
Filter.Separator(),
|
||||||
|
Filter.Header(ignoredForTextSearch()),
|
||||||
|
SearchYoilTypeList(),
|
||||||
|
SearchJaumTypeList(),
|
||||||
|
SearchGenreTypeList(),
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.ko.newtoki
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.preference.EditTextPreference
|
||||||
|
import androidx.preference.ListPreference
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
const val MANATOKI_ID = 2526381983439079467L // "NewToki/ko/1"
|
||||||
|
const val NEWTOKI_ID = 1977818283770282459L // "NewToki (Webtoon)/ko/1"
|
||||||
|
|
||||||
|
const val MANATOKI_PREFIX = "manatoki"
|
||||||
|
const val NEWTOKI_PREFIX = "newtoki"
|
||||||
|
|
||||||
|
val manaTokiPreferences = getSharedPreferences(MANATOKI_ID).migrate()
|
||||||
|
val newTokiPreferences = getSharedPreferences(NEWTOKI_ID).migrate()
|
||||||
|
|
||||||
|
fun getPreferencesInternal(context: Context) = arrayOf(
|
||||||
|
|
||||||
|
EditTextPreference(context).apply {
|
||||||
|
key = DOMAIN_NUMBER_PREF
|
||||||
|
title = domainNumberTitle()
|
||||||
|
summary = domainNumberSummary()
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
val value = newValue as String
|
||||||
|
if (value.isEmpty() || value != value.trim()) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
domainNumber = value
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
ListPreference(context).apply {
|
||||||
|
key = RATE_LIMIT_PERIOD_PREF
|
||||||
|
title = rateLimitTitle()
|
||||||
|
summary = "%s\n" + requiresAppRestart()
|
||||||
|
|
||||||
|
val values = Array(RATE_LIMIT_PERIOD_MAX) { (it + 1).toString() }
|
||||||
|
entries = Array(RATE_LIMIT_PERIOD_MAX) { rateLimitEntry(values[it]) }
|
||||||
|
entryValues = values
|
||||||
|
|
||||||
|
setDefaultValue(RATE_LIMIT_PERIOD_DEFAULT)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
var SharedPreferences.domainNumber: String
|
||||||
|
get() = getString(DOMAIN_NUMBER_PREF, "")!!
|
||||||
|
set(value) = edit().putString(DOMAIN_NUMBER_PREF, value).apply()
|
||||||
|
|
||||||
|
val SharedPreferences.rateLimitPeriod: Int
|
||||||
|
get() = getString(RATE_LIMIT_PERIOD_PREF, RATE_LIMIT_PERIOD_DEFAULT)!!.toInt().coerceIn(1, RATE_LIMIT_PERIOD_MAX)
|
||||||
|
|
||||||
|
private fun SharedPreferences.migrate(): SharedPreferences {
|
||||||
|
if ("Override BaseUrl" !in this) return this // already migrated
|
||||||
|
val editor = edit().clear() // clear all legacy preferences listed below
|
||||||
|
val oldValue = try { // this was a long
|
||||||
|
getLong(RATE_LIMIT_PERIOD_PREF, -1).toInt()
|
||||||
|
} catch (_: ClassCastException) {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
if (oldValue != -1) { // convert to string
|
||||||
|
val newValue = oldValue.coerceIn(1, RATE_LIMIT_PERIOD_MAX)
|
||||||
|
editor.putString(RATE_LIMIT_PERIOD_PREF, newValue.toString())
|
||||||
|
}
|
||||||
|
editor.apply()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Don't use the following legacy keys:
|
||||||
|
* - "Override BaseUrl"
|
||||||
|
* - "overrideBaseUrl_v${AppInfo.getVersionName()}"
|
||||||
|
* - "Enable Latest (Experimental)"
|
||||||
|
* - "fetchLatestExperiment"
|
||||||
|
* - "Fetch Latest with detail (Optional)"
|
||||||
|
* - "fetchLatestWithDetail"
|
||||||
|
* - "Rate Limit Request Period Seconds"
|
||||||
|
*/
|
||||||
|
|
||||||
|
private const val DOMAIN_NUMBER_PREF = "domainNumber"
|
||||||
|
private const val RATE_LIMIT_PERIOD_PREF = "rateLimitPeriod"
|
||||||
|
private const val RATE_LIMIT_PERIOD_DEFAULT = 2.toString()
|
||||||
|
private const val RATE_LIMIT_PERIOD_MAX = 9
|
||||||
|
|
||||||
|
private fun getSharedPreferences(id: Long): SharedPreferences =
|
||||||
|
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
|
@ -0,0 +1,76 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.ko.newtoki
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.LocaleList
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
private val useKorean by lazy {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
LocaleList.getDefault().getFirstMatch(arrayOf("ko", "en"))?.language == "ko"
|
||||||
|
} else {
|
||||||
|
Locale.getDefault().language == "ko"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// region Prompts
|
||||||
|
|
||||||
|
fun solveCaptcha() = when {
|
||||||
|
useKorean -> "WebView에서 캡챠 풀기"
|
||||||
|
else -> "Solve Captcha with WebView"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun titleNotMatch(realTitle: String) = when {
|
||||||
|
useKorean -> "이 만화를 찾으시려면 '$realTitle'으로 검색하세요"
|
||||||
|
else -> "Find this manga by searching '$realTitle'"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun needMigration() = when {
|
||||||
|
useKorean -> "이 항목은 URL 포맷이 틀립니다. 중복된 항목을 피하려면 동일한 소스로 이전하세요.\n\n"
|
||||||
|
else -> "This entry has wrong URL format. Please migrate to the same source to avoid duplicates.\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Filters
|
||||||
|
|
||||||
|
fun ignoredForTextSearch() = when {
|
||||||
|
useKorean -> "검색에서 다음 필터 항목은 무시됩니다"
|
||||||
|
else -> "The following filters are ignored for text search"
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Preferences
|
||||||
|
|
||||||
|
fun domainNumberTitle() = when {
|
||||||
|
useKorean -> "도메인 번호"
|
||||||
|
else -> "Domain number"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun domainNumberSummary() = when {
|
||||||
|
useKorean -> "도메인 번호는 자동으로 갱신됩니다"
|
||||||
|
else -> "This number is updated automatically"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun editDomainNumber() = when {
|
||||||
|
useKorean -> "확장기능 설정에서 도메인 번호를 수정해 주세요"
|
||||||
|
else -> "Please edit domain number in extension settings"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rateLimitTitle() = when {
|
||||||
|
useKorean -> "요청 제한"
|
||||||
|
else -> "Rate limit"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rateLimitEntry(period: String) = when {
|
||||||
|
useKorean -> "${period}초마다 요청"
|
||||||
|
else -> "1 request every $period seconds"
|
||||||
|
}
|
||||||
|
|
||||||
|
// taken from app strings
|
||||||
|
fun requiresAppRestart() = when {
|
||||||
|
useKorean -> "설정을 적용하려면 앱을 재시작하세요"
|
||||||
|
else -> "Requires app restart to take effect"
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
|
@ -0,0 +1,7 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.ko.newtoki
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
|
|
||||||
|
class TokiFactory : SourceFactory {
|
||||||
|
override fun createSources() = listOf(ManaToki, NewTokiWebtoon)
|
||||||
|
}
|