diff --git a/lib/unpacker/bin/main/eu/kanade/tachiyomi/lib/unpacker/SubstringExtractor.kt b/lib/unpacker/bin/main/eu/kanade/tachiyomi/lib/unpacker/SubstringExtractor.kt new file mode 100644 index 000000000..a5f43e017 --- /dev/null +++ b/lib/unpacker/bin/main/eu/kanade/tachiyomi/lib/unpacker/SubstringExtractor.kt @@ -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) + } +} diff --git a/lib/unpacker/bin/main/eu/kanade/tachiyomi/lib/unpacker/Unpacker.kt b/lib/unpacker/bin/main/eu/kanade/tachiyomi/lib/unpacker/Unpacker.kt new file mode 100644 index 000000000..1c4b5bd6c --- /dev/null +++ b/lib/unpacker/bin/main/eu/kanade/tachiyomi/lib/unpacker/Unpacker.kt @@ -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 + } +} diff --git a/multisrc/overrides/madara/s2manga/res/mipmap-hdpi/ic_launcher.png b/multisrc/overrides/madara/s2manga/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..78aed8f9d Binary files /dev/null and b/multisrc/overrides/madara/s2manga/res/mipmap-hdpi/ic_launcher.png differ diff --git a/multisrc/overrides/madara/s2manga/res/mipmap-mdpi/ic_launcher.png b/multisrc/overrides/madara/s2manga/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..57b0e935b Binary files /dev/null and b/multisrc/overrides/madara/s2manga/res/mipmap-mdpi/ic_launcher.png differ diff --git a/multisrc/overrides/madara/s2manga/res/mipmap-xhdpi/ic_launcher.png b/multisrc/overrides/madara/s2manga/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..60ac860b9 Binary files /dev/null and b/multisrc/overrides/madara/s2manga/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/multisrc/overrides/madara/s2manga/res/mipmap-xxhdpi/ic_launcher.png b/multisrc/overrides/madara/s2manga/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..6201c85ab Binary files /dev/null and b/multisrc/overrides/madara/s2manga/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/multisrc/overrides/madara/s2manga/res/mipmap-xxxhdpi/ic_launcher.png b/multisrc/overrides/madara/s2manga/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..ed057db6b Binary files /dev/null and b/multisrc/overrides/madara/s2manga/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/multisrc/overrides/madara/s2manga/res/web_hi_res_512.png b/multisrc/overrides/madara/s2manga/res/web_hi_res_512.png new file mode 100644 index 000000000..c39e43b09 Binary files /dev/null and b/multisrc/overrides/madara/s2manga/res/web_hi_res_512.png differ diff --git a/multisrc/overrides/madara/s2manga/src/S2Manga.kt b/multisrc/overrides/madara/s2manga/src/S2Manga.kt new file mode 100644 index 000000000..017949318 --- /dev/null +++ b/multisrc/overrides/madara/s2manga/src/S2Manga.kt @@ -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\"]" +} diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/madara/MadaraGenerator.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/madara/MadaraGenerator.kt index 8e9be4445..5e347bac4 100644 --- a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/madara/MadaraGenerator.kt +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/madara/MadaraGenerator.kt @@ -422,6 +422,7 @@ class MadaraGenerator : ThemeSourceGenerator { SingleLang("ROG Mangás", "https://rogmangas.com", "pt-BR", pkgName = "mangasoverall", className = "RogMangas", overrideVersionCode = 1), SingleLang("Romantik Manga", "https://romantikmanga.com", "tr"), 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("SamuraiScan", "https://samuraiscan.com", "es", overrideVersionCode = 3), SingleLang("Sawamics", "https://sawamics.com", "en"), diff --git a/src/all/batoto/AndroidManifest.xml b/src/all/batoto/AndroidManifest.xml new file mode 100644 index 000000000..c4ca310dd --- /dev/null +++ b/src/all/batoto/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/all/batoto/CHANGELOG.md b/src/all/batoto/CHANGELOG.md new file mode 100644 index 000000000..e240bf4e9 --- /dev/null +++ b/src/all/batoto/CHANGELOG.md @@ -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 diff --git a/src/all/batoto/README.md b/src/all/batoto/README.md new file mode 100644 index 000000000..8f7c39cc5 --- /dev/null +++ b/src/all/batoto/README.md @@ -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) diff --git a/src/all/batoto/build.gradle b/src/all/batoto/build.gradle new file mode 100644 index 000000000..3589fc1cd --- /dev/null +++ b/src/all/batoto/build.gradle @@ -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')) +} diff --git a/src/all/batoto/res/mipmap-hdpi/ic_launcher.png b/src/all/batoto/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..0c371b3be Binary files /dev/null and b/src/all/batoto/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/batoto/res/mipmap-mdpi/ic_launcher.png b/src/all/batoto/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..718a3b8b1 Binary files /dev/null and b/src/all/batoto/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/batoto/res/mipmap-xhdpi/ic_launcher.png b/src/all/batoto/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..e705165fc Binary files /dev/null and b/src/all/batoto/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/batoto/res/mipmap-xxhdpi/ic_launcher.png b/src/all/batoto/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..7f9d6a195 Binary files /dev/null and b/src/all/batoto/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/batoto/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/batoto/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..d7d91a0db Binary files /dev/null and b/src/all/batoto/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/batoto/res/web_hi_res_512.png b/src/all/batoto/res/web_hi_res_512.png new file mode 100644 index 000000000..973b65efa Binary files /dev/null and b/src/all/batoto/res/web_hi_res_512.png differ diff --git a/src/all/batoto/src/eu/kanade/tachiyomi/extension/all/batoto/BatoTo.kt b/src/all/batoto/src/eu/kanade/tachiyomi/extension/all/batoto/BatoTo.kt new file mode 100644 index 000000000..8d04c0e6b --- /dev/null +++ b/src/all/batoto/src/eu/kanade/tachiyomi/extension/all/batoto/BatoTo.kt @@ -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().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 { + 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(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> { + 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 { + 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 { + 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, default: Int = 0) : Filter.Select(name, options.map { it.name }.toTypedArray(), default) { + val selected: String + get() = options[state].value + } + + abstract class CheckboxGroupFilter(name: String, options: List) : Filter.Group(name, options) { + val selected: List + get() = state.filter { it.state }.map { it.value } + } + + abstract class TriStateGroupFilter(name: String, options: List) : Filter.Group(name, options) { + val included: List + get() = state.filter { it.isIncluded() }.map { it.value } + + val excluded: List + get() = state.filter { it.isExcluded() }.map { it.value } + } + + abstract class TextFilter(name: String) : Filter.Text(name) + + class SortFilter(sortables: Array) : Filter.Sort("Sort", sortables, Selection(5, false)) + class StatusFilter(options: List, default: Int) : SelectFilter("Status", options, default) + class OriginGroupFilter(options: List) : CheckboxGroupFilter("Origin", options) + class GenreGroupFilter(options: List) : TriStateGroupFilter("Genre", options) + class MinChapterTextFilter : TextFilter("Min. Chapters") + class MaxChapterTextFilter : TextFilter("Max. Chapters") + class LangGroupFilter(options: List) : CheckboxGroupFilter("Languages", options) + class LetterFilter(options: List, default: Int) : SelectFilter("Letter matching mode (Slow)", options, default) + class UtilsFilter(options: List, default: Int) : SelectFilter("Utils comic list", options, default) + class HistoryFilter(options: List, 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 + } +} diff --git a/src/all/batoto/src/eu/kanade/tachiyomi/extension/all/batoto/BatoToFactory.kt b/src/all/batoto/src/eu/kanade/tachiyomi/extension/all/batoto/BatoToFactory.kt new file mode 100644 index 000000000..2a4ee05ee --- /dev/null +++ b/src/all/batoto/src/eu/kanade/tachiyomi/extension/all/batoto/BatoToFactory.kt @@ -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 = 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"), +) diff --git a/src/all/batoto/src/eu/kanade/tachiyomi/extension/all/batoto/BatoToUrlActivity.kt b/src/all/batoto/src/eu/kanade/tachiyomi/extension/all/batoto/BatoToUrlActivity.kt new file mode 100644 index 000000000..4f1bd140e --- /dev/null +++ b/src/all/batoto/src/eu/kanade/tachiyomi/extension/all/batoto/BatoToUrlActivity.kt @@ -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? { + return if (pathSegments.size >= 2) { + val id = pathSegments[1] + "ID:$id" + } else { + null + } + } +} diff --git a/src/all/mangadex/AndroidManifest.xml b/src/all/mangadex/AndroidManifest.xml new file mode 100644 index 000000000..4faeb6138 --- /dev/null +++ b/src/all/mangadex/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/all/mangadex/README.md b/src/all/mangadex/README.md new file mode 100644 index 000000000..e1859cc12 --- /dev/null +++ b/src/all/mangadex/README.md @@ -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 + ``` diff --git a/src/all/mangadex/assets/i18n/messages_en.properties b/src/all/mangadex/assets/i18n/messages_en.properties new file mode 100644 index 000000000..435187ebc --- /dev/null +++ b/src/all/mangadex/assets/i18n/messages_en.properties @@ -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 diff --git a/src/all/mangadex/assets/i18n/messages_es.properties b/src/all/mangadex/assets/i18n/messages_es.properties new file mode 100644 index 000000000..fc1dc27ab --- /dev/null +++ b/src/all/mangadex/assets/i18n/messages_es.properties @@ -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 \ No newline at end of file diff --git a/src/all/mangadex/assets/i18n/messages_pt_br.properties b/src/all/mangadex/assets/i18n/messages_pt_br.properties new file mode 100644 index 000000000..ecd4ca7be --- /dev/null +++ b/src/all/mangadex/assets/i18n/messages_pt_br.properties @@ -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 \ No newline at end of file diff --git a/src/all/mangadex/assets/i18n/messages_ru.properties b/src/all/mangadex/assets/i18n/messages_ru.properties new file mode 100644 index 000000000..fbc49b49f --- /dev/null +++ b/src/all/mangadex/assets/i18n/messages_ru.properties @@ -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 \ No newline at end of file diff --git a/src/all/mangadex/build.gradle b/src/all/mangadex/build.gradle new file mode 100644 index 000000000..101966ce6 --- /dev/null +++ b/src/all/mangadex/build.gradle @@ -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" diff --git a/src/all/mangadex/res/mipmap-hdpi/ic_launcher.png b/src/all/mangadex/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..8de6d7f3b Binary files /dev/null and b/src/all/mangadex/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/mangadex/res/mipmap-mdpi/ic_launcher.png b/src/all/mangadex/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..04105b6cd Binary files /dev/null and b/src/all/mangadex/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/mangadex/res/mipmap-xhdpi/ic_launcher.png b/src/all/mangadex/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..6f5065fa3 Binary files /dev/null and b/src/all/mangadex/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/mangadex/res/mipmap-xxhdpi/ic_launcher.png b/src/all/mangadex/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..fc989680e Binary files /dev/null and b/src/all/mangadex/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/mangadex/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/mangadex/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..48d4d3037 Binary files /dev/null and b/src/all/mangadex/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/mangadex/res/web_hi_res_512.png b/src/all/mangadex/res/web_hi_res_512.png new file mode 100644 index 000000000..24ff723c0 Binary files /dev/null and b/src/all/mangadex/res/web_hi_res_512.png differ diff --git a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MDConstants.kt b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MDConstants.kt new file mode 100644 index 000000000..8fc471311 --- /dev/null +++ b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MDConstants.kt @@ -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() + + 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", + ) +} diff --git a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDex.kt b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDex.kt new file mode 100644 index 000000000..06b72b1d0 --- /dev/null +++ b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDex.kt @@ -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().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() + + 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() + ?.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() + + val mangaIds = chapterListDto.data + .flatMap { it.relationships } + .filterIsInstance() + .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() + 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() + ?.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 { + 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 { + 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().data!!.relationships + .firstInstanceOrNull()!!.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() + val listDtoFiltered = listDto.data!!.relationships.filterIsInstance() + 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 { + // 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() + 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() + ?.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() + + 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 { + val url = "${MDConstants.apiMangaUrl}/${manga.data!!.id}/aggregate?translatedLanguage[]=$langCode" + val response = client.newCall(GET(url, headers)).execute() + + return runCatching { response.parseAs() } + .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): Map? { + 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().data + } + + val covers = result.getOrNull() ?: return null + + return covers + .groupBy { it.relationships.firstInstanceOrNull()!!.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 { + if (response.code == 204) { + return emptyList() + } + + val chapterListResponse = response.parseAs() + + 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() + 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 { + val atHomeRequestUrl = response.request.url + val atHomeDto = response.parseAs() + 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 + + 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 + + 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?) = apply { + value?.forEach { addQueryParameter(name, it) } + } + + private inline fun Response.parseAs(): T = use { + helper.json.decodeFromString(body.string()) + } + + private inline fun 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 + 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() + } +} diff --git a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexFactory.kt b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexFactory.kt new file mode 100644 index 000000000..10a39bc6c --- /dev/null +++ b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexFactory.kt @@ -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 = 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") diff --git a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexFilters.kt b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexFilters.kt new file mode 100644 index 000000000..05b452a01 --- /dev/null +++ b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexFilters.kt @@ -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) : + Filter.Group(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 { + 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) : + Filter.Group(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 { + 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) : + Filter.Group(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) : + Filter.Group(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) : + 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) : + Filter.Group(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 { + 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 { + 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 { + 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 { + 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 { + 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) : + Filter.Select(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) : + Filter.Select(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>(intl["tags_mode"], innerFilters), + UrlQueryFilter { + + override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) { + state.filterIsInstance() + .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() + .forEach { filter -> filter.addQueryParameter(url, dexLang) } + + return url.build() + } + + private fun List.sortIfTranslated(intl: Intl): List = apply { + if (intl.chosenLanguage == MangaDexIntl.ENGLISH) { + return this + } + + return sortedWith(compareBy(intl.collator, Tag::name)) + } +} diff --git a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexHelper.kt b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexHelper.kt new file mode 100644 index 000000000..1a72db3bb --- /dev/null +++ b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexHelper.kt @@ -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): 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() + + 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(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, + 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() + .mapNotNull { it.attributes?.name } + .distinct() + + val artists = mangaDataDto.relationships + .filterIsInstance() + .mapNotNull { it.attributes?.name } + .distinct() + + val coverFileName = firstVolumeCover ?: mangaDataDto.relationships + .filterIsInstance() + .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() + .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() + .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() + // 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