package exh.md.utils import android.app.Application import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.track.mdlist.MdList import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.all.MangaDex import eu.kanade.tachiyomi.util.PkceUtil import exh.md.dto.MangaAttributesDto import exh.md.dto.MangaDataDto import exh.source.getMainSource import exh.util.dropBlank import exh.util.floor import exh.util.nullIfZero import kotlinx.serialization.json.Json import okhttp3.FormBody import okhttp3.Headers import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import org.jsoup.parser.Parser import tachiyomi.domain.source.service.SourceManager import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone class MdUtil { companion object { const val cdnUrl = "https://uploads.mangadex.org" const val baseUrl = "https://mangadex.org" const val chapterSuffix = "/chapter/" const val similarCacheMapping = "https://api.similarmanga.com/mapping/mdex2search.csv" const val similarCacheMangas = "https://api.similarmanga.com/manga/" const val similarBaseApi = "https://api.similarmanga.com/similar/" const val groupSearchUrl = "$baseUrl/groups/0/1/" const val reportUrl = "https://api.mangadex.network/report" const val mdAtHomeTokenLifespan = 10 * 60 * 1000 const val mangaLimit = 20 /** * Get the manga offset pages are 1 based, so subtract 1 */ fun getMangaListOffset(page: Int): String = (mangaLimit * (page - 1)).toString() val jsonParser = Json { isLenient = true ignoreUnknownKeys = true allowSpecialFloatingPointValues = true useArrayPolymorphism = true prettyPrint = true } private const val scanlatorSeparator = " & " const val contentRatingSafe = "safe" const val contentRatingSuggestive = "suggestive" const val contentRatingErotica = "erotica" const val contentRatingPornographic = "pornographic" val validOneShotFinalChapters = listOf("0", "1") val markdownLinksRegex = "\\[([^]]+)\\]\\(([^)]+)\\)".toRegex() val markdownItalicBoldRegex = "\\*+\\s*([^\\*]*)\\s*\\*+".toRegex() val markdownItalicRegex = "_+\\s*([^_]*)\\s*_+".toRegex() fun buildMangaUrl(mangaUuid: String): String { return "/manga/$mangaUuid" } // Get the ID from the manga url fun getMangaId(url: String): String = url.trimEnd('/').substringAfterLast("/") fun getChapterId(url: String) = url.substringAfterLast("/") fun cleanDescription(string: String): String { return Parser.unescapeEntities(string, false) .substringBefore("\n---") .replace(markdownLinksRegex, "$1") .replace(markdownItalicBoldRegex, "$1") .replace(markdownItalicRegex, "$1") .trim() } fun getImageUrl(attr: String): String { // Some images are hosted elsewhere if (attr.startsWith("http")) { return attr } return baseUrl + attr } fun getScanlators(scanlators: String?): Set { return scanlators?.split(scanlatorSeparator)?.dropBlank()?.toSet().orEmpty() } fun getScanlatorString(scanlators: Set): String { return scanlators.sorted().joinToString(scanlatorSeparator) } fun getMissingChapterCount(chapters: List, mangaStatus: Int): String? { if (mangaStatus == SManga.COMPLETED) return null val remove0ChaptersFromCount = chapters.distinctBy { /*if (it.chapter_txt.isNotEmpty()) { it.vol + it.chapter_txt } else {*/ it.name /*}*/ }.sortedByDescending { it.chapter_number } remove0ChaptersFromCount.firstOrNull()?.let { chapter -> val chpNumber = chapter.chapter_number.floor() val allChapters = (1..chpNumber).toMutableSet() remove0ChaptersFromCount.forEach { allChapters.remove(it.chapter_number.floor()) } if (allChapters.isEmpty()) return null return allChapters.size.toString() } return null } val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+SSS", Locale.US) .apply { timeZone = TimeZone.getTimeZone("UTC") } fun parseDate(dateAsString: String): Long = dateFormatter.parse(dateAsString)?.time ?: 0 fun createMangaEntry(json: MangaDataDto, lang: String): SManga { return SManga( url = buildMangaUrl(json.id), title = getTitleFromManga(json.attributes, lang), thumbnail_url = json.relationships .firstOrNull { relationshipDto -> relationshipDto.type == MdConstants.Types.coverArt } ?.attributes ?.fileName ?.let { coverFileName -> cdnCoverUrl(json.id, coverFileName) }.orEmpty(), ) } fun getTitleFromManga(json: MangaAttributesDto, lang: String): String { return getFromLangMap(json.title.asMdMap(), lang, json.originalLanguage) ?: getAltTitle(json.altTitles, lang, json.originalLanguage) ?: json.title.asMdMap()[json.originalLanguage] ?: json.altTitles.firstNotNullOfOrNull { it[json.originalLanguage] } .orEmpty() } fun getFromLangMap(langMap: Map, currentLang: String, originalLanguage: String): String? { return langMap[currentLang] ?: langMap["en"] ?: if (originalLanguage == "ja") { langMap["ja-ro"] ?: langMap["jp-ro"] } else { null } } fun getAltTitle(langMaps: List>, currentLang: String, originalLanguage: String): String? { return langMaps.firstNotNullOfOrNull { it[currentLang] } ?: langMaps.firstNotNullOfOrNull { it["en"] } ?: if (originalLanguage == "ja") { langMaps.firstNotNullOfOrNull { it["ja-ro"] } ?: langMaps.firstNotNullOfOrNull { it["jp-ro"] } } else { null } } fun cdnCoverUrl(dexId: String, fileName: String): String { return "$cdnUrl/covers/$dexId/$fileName" } fun saveOAuth(preferences: TrackPreferences, mdList: MdList, oAuth: MALOAuth?) { if (oAuth == null) { preferences.trackToken(mdList).delete() } else { preferences.trackToken(mdList).set(jsonParser.encodeToString(oAuth)) } } fun loadOAuth(preferences: TrackPreferences, mdList: MdList): MALOAuth? { return try { jsonParser.decodeFromString(preferences.trackToken(mdList).get()) } catch (e: Exception) { null } } private var codeVerifier: String? = null fun refreshTokenRequest(oauth: MALOAuth): Request { val formBody = FormBody.Builder() .add("client_id", MdConstants.Login.clientId) .add("grant_type", MdConstants.Login.refreshToken) .add("refresh_token", oauth.refreshToken) .add("code_verifier", getPkceChallengeCode()) .add("redirect_uri", MdConstants.Login.redirectUri) .build() // Add the Authorization header manually as this particular // request is called by the interceptor itself so it doesn't reach // the part where the token is added automatically. val headers = Headers.Builder() .add("Authorization", "Bearer ${oauth.accessToken}") .build() return POST(MdApi.baseAuthUrl + MdApi.token, body = formBody, headers = headers) } fun getPkceChallengeCode(): String { return codeVerifier ?: PkceUtil.generateCodeVerifier().also { codeVerifier = it } } fun getEnabledMangaDex(sourcePreferences: SourcePreferences = Injekt.get(), sourceManager: SourceManager = Injekt.get()): MangaDex? { return getEnabledMangaDexs(sourcePreferences, sourceManager).let { mangadexs -> sourcePreferences.preferredMangaDexId().get().toLongOrNull()?.nullIfZero() ?.let { preferredMangaDexId -> mangadexs.firstOrNull { it.id == preferredMangaDexId } } ?: mangadexs.firstOrNull() } } fun getEnabledMangaDexs(preferences: SourcePreferences, sourceManager: SourceManager = Injekt.get()): List { val languages = preferences.enabledLanguages().get() val disabledSourceIds = preferences.disabledSources().get() return sourceManager.getVisibleOnlineSources() .asSequence() .mapNotNull { it.getMainSource() } .filter { it.lang in languages } .filterNot { it.id.toString() in disabledSourceIds } .toList() } inline fun encodeToBody(body: T): RequestBody { return jsonParser.encodeToString(body) .toRequestBody("application/json".toMediaType()) } fun addAltTitleToDesc(description: String, altTitles: List?): String { return if (altTitles.isNullOrEmpty()) { description } else { val altTitlesDesc = altTitles .joinToString("\n", "${Injekt.get().getString(R.string.alt_titles)}:\n") { "• $it" } description + (if (description.isBlank()) "" else "\n\n") + Parser.unescapeEntities(altTitlesDesc, false) } } } }