diff --git a/src/all/namicomi/AndroidManifest.xml b/src/all/namicomi/AndroidManifest.xml new file mode 100644 index 000000000..64995a98d --- /dev/null +++ b/src/all/namicomi/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/all/namicomi/assets/i18n/messages_en.properties b/src/all/namicomi/assets/i18n/messages_en.properties new file mode 100644 index 000000000..1d250c929 --- /dev/null +++ b/src/all/namicomi/assets/i18n/messages_en.properties @@ -0,0 +1,114 @@ +content=Content +content_rating=Content rating +content_rating_genre=Content rating: %s +content_rating_mature=Mature +content_rating_restricted=Restricted +content_rating_safe=Safe +content_warnings_drugs=Drugs +content_warnings_gambling=Gambling +content_warnings_gore=Gore +content_warnings_mental_disorders=Mental Disorders +content_warnings_physical_abuse=Physical Abuse +content_warnings_racism=Racism +content_warnings_self_harm=Self-harm +content_warnings_sexual_abuse=Sexual Abuse +content_warnings_verbal_abuse=Verbal Abuse +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 +error_payment_required=Payment required. Chapter requires a premium subscription +excluded_tags_mode=Excluded tags mode +format=Format +format_4_koma=4-Koma +format_adaptation=Adaptation +format_anthology=Anthology +format_full_color=Full Color +format_oneshot=Oneshot +format_silent=Silent +genre=Genre +genre_action=Action +genre_adventure=Adventure +genre_boys_love=Boys' Love +genre_comedy=Comedy +genre_crime=Crime +genre_drama=Drama +genre_fantasy=Fantasy +genre_girls_love=Girls' Love +genre_historical=Historical +genre_horror=Horror +genre_isekai=Isekai +genre_mecha=Mecha +genre_medical=Medical +genre_mystery=Mystery +genre_philosophical=Philosophical +genre_psychological=Psychological +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_manga_id=Not a valid title ID +mode_and=And +mode_or=Or +show_locked_chapters=Show locked/paywalled chapters +show_locked_chapters_summary=Display chapters that require an account with a premium subscription +sort=Sort +sort_alphabetic=Alphabetic +sort_content_created_at=Content created at +sort_number_of_chapters=Chapter count +sort_number_of_comments=Comment count +sort_number_of_follows=Followers +sort_number_of_likes=Likes +sort_rating=Rating +sort_views=Views +sort_year=Year +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_genderswap=Genderswap +theme_ghosts=Ghosts +theme_gyaru=Gyaru +theme_harem=Harem +theme_mafia=Mafia +theme_magic=Magic +theme_magical_girls=Magical Girls +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_reincarnation=Reincarnation +theme_reverse_harem=Reverse Harem +theme_samurai=Samurai +theme_school_life=School Life +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 diff --git a/src/all/namicomi/build.gradle b/src/all/namicomi/build.gradle new file mode 100644 index 000000000..11a531ba5 --- /dev/null +++ b/src/all/namicomi/build.gradle @@ -0,0 +1,12 @@ +ext { + extName = 'NamiComi' + extClass = '.NamiComiFactory' + extVersionCode = 1 + isNsfw = false +} + +apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(":lib:i18n")) +} diff --git a/src/all/namicomi/res/mipmap-hdpi/ic_launcher.png b/src/all/namicomi/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..fc8d6dcfc Binary files /dev/null and b/src/all/namicomi/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/namicomi/res/mipmap-mdpi/ic_launcher.png b/src/all/namicomi/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..fb113908c Binary files /dev/null and b/src/all/namicomi/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/namicomi/res/mipmap-xhdpi/ic_launcher.png b/src/all/namicomi/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..1cd542fd4 Binary files /dev/null and b/src/all/namicomi/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/namicomi/res/mipmap-xxhdpi/ic_launcher.png b/src/all/namicomi/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..042f96f89 Binary files /dev/null and b/src/all/namicomi/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/namicomi/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/namicomi/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..593877ceb Binary files /dev/null and b/src/all/namicomi/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComi.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComi.kt new file mode 100644 index 000000000..5290e2a9e --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComi.kt @@ -0,0 +1,354 @@ +package eu.kanade.tachiyomi.extension.all.namicomi + +import android.app.Application +import android.content.SharedPreferences +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreferenceCompat +import eu.kanade.tachiyomi.extension.all.namicomi.dto.ChapterListDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.EntityAccessMapDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.EntityAccessRequestDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.EntityAccessRequestItemDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.MangaDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.MangaListDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.PageListDto +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +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 kotlinx.serialization.encodeToString +import okhttp3.CacheControl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okio.IOException +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +abstract class NamiComi(final override val lang: String, private val extLang: String = lang) : + ConfigurableSource, HttpSource() { + + override val name = "NamiComi" + override val baseUrl = NamiComiConstants.webUrl + override val supportsLatest = true + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + private val helper = NamiComiHelper(lang) + + final override fun headersBuilder() = super.headersBuilder().apply { + set("Referer", "$baseUrl/") + set("Origin", baseUrl) + } + + override val client = network.client.newBuilder() + .rateLimit(3) + .addNetworkInterceptor { chain -> + val response = chain.proceed(chain.request()) + + if (response.code == 402) { + response.close() + throw IOException(helper.intl["error_payment_required"]) + } + + return@addNetworkInterceptor response + } + .build() + + private fun sortedMangaRequest(page: Int, orderBy: String): Request { + val url = NamiComiConstants.apiSearchUrl.toHttpUrl().newBuilder() + .addQueryParameter("order[$orderBy]", "desc") + .addQueryParameter("availableTranslatedLanguages[]", extLang) + .addQueryParameter("limit", NamiComiConstants.mangaLimit.toString()) + .addQueryParameter("offset", helper.getMangaListOffset(page)) + .addQueryParameter("includes[]", NamiComiConstants.coverArt) + .addQueryParameter("includes[]", NamiComiConstants.primaryTag) + .addQueryParameter("includes[]", NamiComiConstants.secondaryTag) + .addQueryParameter("includes[]", NamiComiConstants.tag) + .build() + + return GET(url, headers, CacheControl.FORCE_NETWORK) + } + + // Popular manga section + + override fun popularMangaRequest(page: Int): Request = + sortedMangaRequest(page, "views") + + override fun popularMangaParse(response: Response): MangasPage = + mangaListParse(response) + + // Latest manga section + + override fun latestUpdatesRequest(page: Int): Request = + sortedMangaRequest(page, "publishedAt") + + override fun latestUpdatesParse(response: Response): MangasPage = + mangaListParse(response) + + private fun mangaListParse(response: Response): MangasPage { + if (response.code == 204) { + return MangasPage(emptyList(), false) + } + + val mangaListDto = response.parseAs() + val mangaList = mangaListDto.data.map { mangaDataDto -> + helper.createManga( + mangaDataDto, + extLang, + preferences.coverQuality, + ) + } + + return MangasPage(mangaList, mangaListDto.meta.hasNextPage) + } + + // Search manga section + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (query.startsWith(NamiComiConstants.prefixIdSearch)) { + val mangaId = query.removePrefix(NamiComiConstants.prefixIdSearch) + + if (mangaId.isEmpty()) { + throw Exception(helper.intl["invalid_manga_id"]) + } + + // If the query is an ID, return the manga directly + val url = NamiComiConstants.apiSearchUrl.toHttpUrl().newBuilder() + .addQueryParameter("ids[]", query.removePrefix(NamiComiConstants.prefixIdSearch)) + .addQueryParameter("includes[]", NamiComiConstants.coverArt) + .build() + + return GET(url, headers, CacheControl.FORCE_NETWORK) + } + + val tempUrl = NamiComiConstants.apiSearchUrl.toHttpUrl().newBuilder() + .addQueryParameter("limit", NamiComiConstants.mangaLimit.toString()) + .addQueryParameter("offset", helper.getMangaListOffset(page)) + .addQueryParameter("includes[]", NamiComiConstants.coverArt) + + val actualQuery = query.replace(NamiComiConstants.whitespaceRegex, " ") + if (actualQuery.isNotBlank()) { + tempUrl.addQueryParameter("title", actualQuery) + } + + val finalUrl = helper.filters.addFiltersToUrl( + url = tempUrl, + filters = filters.ifEmpty { getFilterList() }, + extLang = extLang, + ) + + return GET(finalUrl, headers, CacheControl.FORCE_NETWORK) + } + + override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response) + + // Manga Details section + + override fun getMangaUrl(manga: SManga): String = + "$baseUrl/$extLang/title/${manga.url}/${helper.titleToSlug(manga.title)}" + + /** + * Get the API endpoint URL for the entry details. + */ + override fun mangaDetailsRequest(manga: SManga): Request { + val url = (NamiComiConstants.apiMangaUrl + manga.url).toHttpUrl().newBuilder() + .addQueryParameter("includes[]", NamiComiConstants.coverArt) + .addQueryParameter("includes[]", NamiComiConstants.organization) + .addQueryParameter("includes[]", NamiComiConstants.tag) + .addQueryParameter("includes[]", NamiComiConstants.primaryTag) + .addQueryParameter("includes[]", NamiComiConstants.secondaryTag) + .build() + + return GET(url, headers, CacheControl.FORCE_NETWORK) + } + + override fun mangaDetailsParse(response: Response): SManga { + val manga = response.parseAs() + + return helper.createManga( + manga.data!!, + extLang, + preferences.coverQuality, + ) + } + + // Chapter list section + + /** + * Get the API endpoint URL for the first page of chapter list. + */ + override fun chapterListRequest(manga: SManga): Request { + return paginatedChapterListRequest(manga.url, 0) + } + + /** + * Required because the chapter list API endpoint is paginated. + */ + private fun paginatedChapterListRequest(mangaId: String, offset: Int): Request { + val url = NamiComiConstants.apiChapterUrl.toHttpUrl().newBuilder() + .addQueryParameter("titleId", mangaId) + .addQueryParameter("includes[]", NamiComiConstants.organization) + .addQueryParameter("limit", "500") + .addQueryParameter("offset", offset.toString()) + .addQueryParameter("translatedLanguages[]", extLang) + .addQueryParameter("order[volume]", "desc") + .addQueryParameter("order[chapter]", "desc") + .toString() + + return GET(url, headers, CacheControl.FORCE_NETWORK) + } + + /** + * Requests information about gated chapters (requiring payment & login). + */ + private fun accessibleChapterListRequest(chapterIds: List): Request { + return POST( + NamiComiConstants.apiGatingCheckUrl, + headers, + chapterIds + .map { EntityAccessRequestItemDto(it, NamiComiConstants.chapter) } + .let { helper.json.encodeToString(EntityAccessRequestDto(it)) } + .toRequestBody(), + CacheControl.FORCE_NETWORK, + ) + } + + override fun chapterListParse(response: Response): List { + if (response.code == 204) { + return emptyList() + } + + val mangaId = response.request.url.toString() + .substringBefore("/chapter") + .substringAfter("${NamiComiConstants.apiMangaUrl}/") + + val chapterListResponse = response.parseAs() + val chapterListResults = chapterListResponse.data.toMutableList() + var offset = chapterListResponse.meta.offset + var hasNextPage = chapterListResponse.meta.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.meta.limit + + val newRequest = paginatedChapterListRequest(mangaId, offset) + val newResponse = client.newCall(newRequest).execute() + val newChapterList = newResponse.parseAs() + chapterListResults.addAll(newChapterList.data) + + hasNextPage = newChapterList.meta.hasNextPage + } + + // If there are no chapters, don't attempt to check gating + if (chapterListResults.isEmpty()) { + return emptyList() + } + + val gatingCheckRequest = accessibleChapterListRequest(chapterListResults.map { it.id }) + val gatingCheckResponse = client.newCall(gatingCheckRequest).execute() + val accessibleChapterMap = gatingCheckResponse.parseAs() + .data?.attributes?.map ?: emptyMap() + + return chapterListResults.mapNotNull { + val isAccessible = accessibleChapterMap[it.id]!! + when { + // Chapter can be viewed + isAccessible -> helper.createChapter(it) + // Chapter cannot be viewed and user wants to see locked chapters + preferences.showLockedChapters -> { + helper.createChapter(it).apply { + name = "${NamiComiConstants.lockSymbol} $name" + } + } + // Ignore locked chapters otherwise + else -> null + } + } + } + + override fun getChapterUrl(chapter: SChapter): String = + "$baseUrl/$extLang/chapter/${chapter.url}" + + override fun pageListRequest(chapter: SChapter): Request { + val chapterId = chapter.url + val url = "${NamiComiConstants.apiUrl}/images/chapter/$chapterId?newQualities=true" + return GET(url, headers, CacheControl.FORCE_NETWORK) + } + + override fun pageListParse(response: Response): List { + val chapterId = response.request.url.pathSegments.last() + val pageListDataDto = response.parseAs().data ?: return emptyList() + + val hash = pageListDataDto.hash + val prefix = "${pageListDataDto.baseUrl}/chapter/$chapterId/$hash" + + val urls = if (preferences.useDataSaver) { + pageListDataDto.low.map { prefix + "/low/${it.filename}" } + } else { + pageListDataDto.source.map { prefix + "/source/${it.filename}" } + } + + return urls.mapIndexed { index, url -> + Page(index, url, url) + } + } + + override fun imageUrlParse(response: Response): String = "" + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + val coverQualityPref = ListPreference(screen.context).apply { + key = NamiComiConstants.getCoverQualityPreferenceKey(extLang) + title = helper.intl["cover_quality"] + entries = NamiComiConstants.getCoverQualityPreferenceEntries(helper.intl) + entryValues = NamiComiConstants.getCoverQualityPreferenceEntryValues() + setDefaultValue(NamiComiConstants.getCoverQualityPreferenceDefaultValue()) + summary = "%s" + } + + val dataSaverPref = SwitchPreferenceCompat(screen.context).apply { + key = NamiComiConstants.getDataSaverPreferenceKey(extLang) + title = helper.intl["data_saver"] + summary = helper.intl["data_saver_summary"] + setDefaultValue(false) + } + + val showLockedChaptersPref = SwitchPreferenceCompat(screen.context).apply { + key = NamiComiConstants.getShowLockedChaptersPreferenceKey(extLang) + title = helper.intl["show_locked_chapters"] + summary = helper.intl["show_locked_chapters_summary"] + setDefaultValue(false) + } + + screen.addPreference(coverQualityPref) + screen.addPreference(dataSaverPref) + screen.addPreference(showLockedChaptersPref) + } + + override fun getFilterList(): FilterList = + helper.filters.getFilterList(helper.intl) + + private inline fun Response.parseAs(): T = use { + helper.json.decodeFromString(body.string()) + } + + private val SharedPreferences.coverQuality + get() = getString(NamiComiConstants.getCoverQualityPreferenceKey(extLang), "") + + private val SharedPreferences.useDataSaver + get() = getBoolean(NamiComiConstants.getDataSaverPreferenceKey(extLang), false) + + private val SharedPreferences.showLockedChapters + get() = getBoolean(NamiComiConstants.getShowLockedChaptersPreferenceKey(extLang), false) +} diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiConstants.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiConstants.kt new file mode 100644 index 000000000..082909a1d --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiConstants.kt @@ -0,0 +1,63 @@ +package eu.kanade.tachiyomi.extension.all.namicomi + +import eu.kanade.tachiyomi.lib.i18n.Intl +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +object NamiComiConstants { + const val mangaLimit = 20 + + val whitespaceRegex = "\\s".toRegex() + val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+SSS", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } + + const val lockSymbol = "🔒" + + // Language codes used for translations + const val english = "en" + + // JSON discriminators + const val chapter = "chapter" + const val manga = "title" + const val coverArt = "cover_art" + const val organization = "organization" + const val tag = "tag" + const val primaryTag = "primary_tag" + const val secondaryTag = "secondary_tag" + const val imageData = "image_data" + const val entityAccessMap = "entity_access_map" + + // URLs & API endpoints + const val webUrl = "https://namicomi.com" + const val cdnUrl = "https://uploads.namicomi.com" + const val apiUrl = "https://api.namicomi.com" + const val apiMangaUrl = "$apiUrl/title" + const val apiSearchUrl = "$apiMangaUrl/search" + const val apiChapterUrl = "$apiUrl/chapter" + const val apiGatingCheckUrl = "$apiUrl/gating/check" + + // Search prefix for title ids + const val prefixIdSearch = "id:" + + // Preferences + private const val coverQualityPref = "thumbnailQuality" + fun getCoverQualityPreferenceKey(extLang: String): String = "${coverQualityPref}_$extLang" + 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 = "dataSaver" + fun getDataSaverPreferenceKey(extLang: String): String = "${dataSaverPref}_$extLang" + + private const val showLockedChaptersPref = "showLockedChapters" + fun getShowLockedChaptersPreferenceKey(extLang: String): String = "${showLockedChaptersPref}_$extLang" + + // Tag types + private const val tagGroupContent = "content-warnings" + private const val tagGroupFormat = "format" + private const val tagGroupGenre = "genre" + private const val tagGroupTheme = "theme" + val tagGroupsOrder = arrayOf(tagGroupContent, tagGroupFormat, tagGroupGenre, tagGroupTheme) +} diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFactory.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFactory.kt new file mode 100644 index 000000000..7dcb6af2c --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFactory.kt @@ -0,0 +1,96 @@ +package eu.kanade.tachiyomi.extension.all.namicomi + +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceFactory + +class NamiComiFactory : SourceFactory { + override fun createSources(): List = listOf( + NamiComiEnglish(), + NamiComiArabic(), + NamiComiBulgarian(), + NamiComiCatalan(), + NamiComiChineseSimplified(), + NamiComiChineseTraditional(), + NamiComiCroatian(), + NamiComiCzech(), + NamiComiDanish(), + NamiComiDutch(), + NamiComiEstonian(), + NamiComiFilipino(), + NamiComiFinnish(), + NamiComiFrench(), + NamiComiGerman(), + NamiComiGreek(), + NamiComiHebrew(), + NamiComiHindi(), + NamiComiHungarian(), + NamiComiIcelandic(), + NamiComiIrish(), + NamiComiIndonesian(), + NamiComiItalian(), + NamiComiJapanese(), + NamiComiKorean(), + NamiComiLithuanian(), + NamiComiMalay(), + NamiComiNepali(), + NamiComiNorwegian(), + NamiComiPanjabi(), + NamiComiPersian(), + NamiComiPolish(), + NamiComiPortugueseBrazil(), + NamiComiPortuguesePortugal(), + NamiComiRussian(), + NamiComiSlovak(), + NamiComiSlovenian(), + NamiComiSpanishLatinAmerica(), + NamiComiSpanishSpain(), + NamiComiSwedish(), + NamiComiThai(), + NamiComiTurkish(), + NamiComiUkrainian(), + ) +} + +class NamiComiArabic : NamiComi("ar") +class NamiComiBulgarian : NamiComi("bg") +class NamiComiCatalan : NamiComi("ca") +class NamiComiChineseSimplified : NamiComi("zh-Hans", "zh-hans") +class NamiComiChineseTraditional : NamiComi("zh-Hant", "zh-hant") +class NamiComiCroatian : NamiComi("hr") +class NamiComiCzech : NamiComi("cs") +class NamiComiDanish : NamiComi("da") +class NamiComiDutch : NamiComi("nl") +class NamiComiEnglish : NamiComi("en") +class NamiComiEstonian : NamiComi("et") +class NamiComiFilipino : NamiComi("fil") +class NamiComiFinnish : NamiComi("fi") +class NamiComiFrench : NamiComi("fr") +class NamiComiGerman : NamiComi("de") +class NamiComiGreek : NamiComi("el") +class NamiComiHebrew : NamiComi("he") +class NamiComiHindi : NamiComi("hi") +class NamiComiHungarian : NamiComi("hu") +class NamiComiIcelandic : NamiComi("is") +class NamiComiIrish : NamiComi("ga") +class NamiComiIndonesian : NamiComi("id") +class NamiComiItalian : NamiComi("it") +class NamiComiJapanese : NamiComi("ja") +class NamiComiKorean : NamiComi("ko") +class NamiComiLithuanian : NamiComi("lt") +class NamiComiMalay : NamiComi("ms") +class NamiComiNepali : NamiComi("ne") +class NamiComiNorwegian : NamiComi("no") +class NamiComiPanjabi : NamiComi("pa") +class NamiComiPersian : NamiComi("fa") +class NamiComiPolish : NamiComi("pl") +class NamiComiPortugueseBrazil : NamiComi("pt-BR", "pt-br") +class NamiComiPortuguesePortugal : NamiComi("pt", "pt-pt") +class NamiComiRussian : NamiComi("ru") +class NamiComiSlovak : NamiComi("sk") +class NamiComiSlovenian : NamiComi("sl") +class NamiComiSpanishLatinAmerica : NamiComi("es-419") +class NamiComiSpanishSpain : NamiComi("es", "es-es") +class NamiComiSwedish : NamiComi("sv") +class NamiComiThai : NamiComi("th") +class NamiComiTurkish : NamiComi("tr") +class NamiComiUkrainian : NamiComi("uk") diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFilters.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFilters.kt new file mode 100644 index 000000000..88223e1ba --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFilters.kt @@ -0,0 +1,289 @@ +package eu.kanade.tachiyomi.extension.all.namicomi + +import eu.kanade.tachiyomi.extension.all.namicomi.dto.ContentRatingDto +import eu.kanade.tachiyomi.extension.all.namicomi.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 NamiComiFilters { + + internal fun getFilterList(intl: Intl): FilterList = FilterList( + HasAvailableChaptersFilter(intl), + ContentRatingList(intl, getContentRatings(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, extLang: String) + } + + private class HasAvailableChaptersFilter(intl: Intl) : + Filter.CheckBox(intl["has_available_chapters"]), + UrlQueryFilter { + + override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) { + if (state) { + url.addQueryParameter("hasAvailableChapters", "true") + url.addQueryParameter("availableTranslatedLanguages[]", extLang) + } + } + } + + 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, extLang: String) { + state.filter(ContentRating::state) + .forEach { url.addQueryParameter("contentRatings[]", it.value) } + } + } + + private fun getContentRatings(intl: Intl) = listOf( + ContentRating(intl["content_rating_safe"], ContentRatingDto.SAFE.value), + ContentRating(intl["content_rating_restricted"], ContentRatingDto.RESTRICTED.value), + ContentRating(intl["content_rating_mature"], ContentRatingDto.MATURE.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, extLang: String) { + state.filter(Status::state) + .forEach { url.addQueryParameter("publicationStatuses[]", 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_number_of_chapters"], "chapterCount"), + Sortable(intl["sort_number_of_follows"], "followCount"), + Sortable(intl["sort_number_of_likes"], "reactions"), + Sortable(intl["sort_number_of_comments"], "commentCount"), + Sortable(intl["sort_content_created_at"], "publishedAt"), + Sortable(intl["sort_views"], "views"), + 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, extLang: 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, extLang: 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("drugs", intl["content_warnings_drugs"]), + Tag("gambling", intl["content_warnings_gambling"]), + Tag("gore", intl["content_warnings_gore"]), + Tag("mental-disorders", intl["content_warnings_mental_disorders"]), + Tag("physical-abuse", intl["content_warnings_physical_abuse"]), + Tag("racism", intl["content_warnings_racism"]), + Tag("self-harm", intl["content_warnings_self_harm"]), + Tag("sexual-abuse", intl["content_warnings_sexual_abuse"]), + Tag("verbal-abuse", intl["content_warnings_verbal_abuse"]), + ) + + return tags.sortIfTranslated(intl) + } + + private fun getFormats(intl: Intl): List { + val tags = listOf( + Tag("4-koma", intl["format_4_koma"]), + Tag("adaptation", intl["format_adaptation"]), + Tag("anthology", intl["format_anthology"]), + Tag("full-color", intl["format_full_color"]), + Tag("oneshot", intl["format_oneshot"]), + Tag("silent", intl["format_silent"]), + ) + + return tags.sortIfTranslated(intl) + } + + private fun getGenres(intl: Intl): List { + val tags = listOf( + Tag("action", intl["genre_action"]), + Tag("adventure", intl["genre_adventure"]), + Tag("boys-love", intl["genre_boys_love"]), + Tag("comedy", intl["genre_comedy"]), + Tag("crime", intl["genre_crime"]), + Tag("drama", intl["genre_drama"]), + Tag("fantasy", intl["genre_fantasy"]), + Tag("girls-love", intl["genre_girls_love"]), + Tag("historical", intl["genre_historical"]), + Tag("horror", intl["genre_horror"]), + Tag("isekai", intl["genre_isekai"]), + Tag("mecha", intl["genre_mecha"]), + Tag("medical", intl["genre_medical"]), + Tag("mystery", intl["genre_mystery"]), + Tag("philosophical", intl["genre_philosophical"]), + Tag("psychological", intl["genre_psychological"]), + Tag("romance", intl["genre_romance"]), + Tag("sci-fi", intl["genre_sci_fi"]), + Tag("slice-of-life", intl["genre_slice_of_life"]), + Tag("sports", intl["genre_sports"]), + Tag("superhero", intl["genre_superhero"]), + Tag("thriller", intl["genre_thriller"]), + Tag("tragedy", intl["genre_tragedy"]), + Tag("wuxia", intl["genre_wuxia"]), + ) + + return tags.sortIfTranslated(intl) + } + + private fun getThemes(intl: Intl): List { + val tags = listOf( + Tag("aliens", intl["theme_aliens"]), + Tag("animals", intl["theme_animals"]), + Tag("cooking", intl["theme_cooking"]), + Tag("crossdressing", intl["theme_crossdressing"]), + Tag("delinquents", intl["theme_delinquents"]), + Tag("demons", intl["theme_demons"]), + Tag("genderswap", intl["theme_genderswap"]), + Tag("ghosts", intl["theme_ghosts"]), + Tag("gyaru", intl["theme_gyaru"]), + Tag("harem", intl["theme_harem"]), + Tag("mafia", intl["theme_mafia"]), + Tag("magic", intl["theme_magic"]), + Tag("magical-girls", intl["theme_magical_girls"]), + Tag("martial-arts", intl["theme_martial_arts"]), + Tag("military", intl["theme_military"]), + Tag("monster-girls", intl["theme_monster_girls"]), + Tag("monsters", intl["theme_monsters"]), + Tag("music", intl["theme_music"]), + Tag("ninja", intl["theme_ninja"]), + Tag("office-workers", intl["theme_office_workers"]), + Tag("police", intl["theme_police"]), + Tag("post-apocalyptic", intl["theme_post_apocalyptic"]), + Tag("reincarnation", intl["theme_reincarnation"]), + Tag("reverse-harem", intl["theme_reverse_harem"]), + Tag("samurai", intl["theme_samurai"]), + Tag("school-life", intl["theme_school_life"]), + Tag("supernatural", intl["theme_supernatural"]), + Tag("survival", intl["theme_survival"]), + Tag("time-travel", intl["theme_time_travel"]), + Tag("traditional-games", intl["theme_traditional_games"]), + Tag("vampires", intl["theme_vampires"]), + Tag("video-games", intl["theme_video_games"]), + Tag("villainess", intl["theme_villainess"]), + Tag("virtual-reality", intl["theme_virtual_reality"]), + Tag("zombies", intl["theme_zombies"]), + ) + + return tags.sortIfTranslated(intl) + } + + // Tags taken from: https://api.namicomi.com/title/tags + 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, extLang: 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, extLang: 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, extLang: String) { + state.filterIsInstance() + .forEach { filter -> filter.addQueryParameter(url, extLang) } + } + } + + private fun getTagFilters(intl: Intl): FilterList = FilterList( + TagInclusionMode(intl, getTagModes(intl)), + TagExclusionMode(intl, getTagModes(intl)), + ) + + internal fun addFiltersToUrl(url: HttpUrl.Builder, filters: FilterList, extLang: String): HttpUrl { + filters.filterIsInstance() + .forEach { filter -> filter.addQueryParameter(url, extLang) } + + return url.build() + } + + private fun List.sortIfTranslated(intl: Intl): List = apply { + if (intl.chosenLanguage == NamiComiConstants.english) { + return this + } + + return sortedWith(compareBy(intl.collator, Tag::name)) + } +} diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiHelper.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiHelper.kt new file mode 100644 index 000000000..9f586cae0 --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiHelper.kt @@ -0,0 +1,182 @@ +package eu.kanade.tachiyomi.extension.all.namicomi + +import eu.kanade.tachiyomi.extension.all.namicomi.dto.AbstractTagDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.ChapterDataDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.ContentRatingDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.CoverArtDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.EntityDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.MangaDataDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.OrganizationDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.StatusDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.UnknownEntity +import eu.kanade.tachiyomi.lib.i18n.Intl +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.plus +import kotlinx.serialization.modules.polymorphic +import java.util.Locale + +class NamiComiHelper(lang: String) { + + val filters = NamiComiFilters() + + val json = Json { + isLenient = true + ignoreUnknownKeys = true + serializersModule += SerializersModule { + polymorphic(EntityDto::class) { + defaultDeserializer { UnknownEntity.serializer() } + } + } + } + + val intl = Intl( + language = lang, + baseLanguage = NamiComiConstants.english, + availableLanguages = setOf(NamiComiConstants.english), + classLoader = this::class.java.classLoader!!, + createMessageFileName = { lang -> Intl.createDefaultMessageFileName(lang) }, + ) + + /** + * Get the manga offset pages are 1 based, so subtract 1 + */ + fun getMangaListOffset(page: Int): String = (NamiComiConstants.mangaLimit * (page - 1)).toString() + + private fun getPublicationStatus(mangaDataDto: MangaDataDto): Int { + return when (mangaDataDto.attributes!!.publicationStatus) { + StatusDto.ONGOING -> SManga.ONGOING + StatusDto.CANCELLED -> SManga.CANCELLED + StatusDto.COMPLETED -> SManga.COMPLETED + StatusDto.HIATUS -> SManga.ON_HIATUS + else -> SManga.UNKNOWN + } + } + + private fun parseDate(dateAsString: String): Long = + NamiComiConstants.dateFormatter.parse(dateAsString)?.time ?: 0 + + /** + * Create an [SManga] from the JSON element with all attributes filled. + */ + fun createManga( + mangaDataDto: MangaDataDto, + lang: String, + coverSuffix: String?, + ): SManga { + val attr = mangaDataDto.attributes!! + + // Things that will go with the genre tags but aren't actually genre + val extLocale = Locale.forLanguageTag(lang) + + val nonGenres = listOfNotNull( + 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(extLocale) + ?.replaceFirstChar { it.uppercase(extLocale) }, + ) + + val organization = mangaDataDto.relationships + .filterIsInstance() + .mapNotNull { it.attributes?.name } + .distinct() + + val coverFileName = mangaDataDto.relationships + .filterIsInstance() + .firstOrNull() + ?.attributes?.fileName + + val tags = filters.getTags(intl).associate { it.id to it.name } + + val genresMap = mangaDataDto.relationships + .filterIsInstance() + .groupBy({ it.attributes!!.group }) { tagDto -> tags[tagDto.id] } + .mapValues { it.value.filterNotNull().sortedWith(intl.collator) } + + val genreList = NamiComiConstants.tagGroupsOrder.flatMap { genresMap[it].orEmpty() } + nonGenres + + val desc = (attr.description[lang] ?: attr.description["en"]) + .orEmpty() + + return SManga.create().apply { + initialized = true + url = mangaDataDto.id + description = desc + author = organization.joinToString() + status = getPublicationStatus(mangaDataDto) + genre = genreList + .filter(String::isNotEmpty) + .joinToString() + + mangaDataDto.attributes.title.let { titleMap -> + title = titleMap[lang] ?: titleMap.values.first() + } + + coverFileName?.let { + thumbnail_url = when (!coverSuffix.isNullOrEmpty()) { + true -> "${NamiComiConstants.cdnUrl}/covers/${mangaDataDto.id}/$coverFileName$coverSuffix" + else -> "${NamiComiConstants.cdnUrl}/covers/${mangaDataDto.id}/$coverFileName" + } + } + } + } + + /** + * Create the [SChapter] from the JSON element. + */ + fun createChapter(chapterDataDto: ChapterDataDto): SChapter { + val attr = chapterDataDto.attributes!! + val chapterName = mutableListOf() + + attr.volume?.let { + if (it.isNotEmpty()) { + chapterName.add("Vol.$it") + } + } + + attr.chapter?.let { + if (it.isNotEmpty()) { + chapterName.add("Ch.$it") + } + } + + attr.name?.let { + if (it.isNotEmpty()) { + if (chapterName.isNotEmpty()) { + chapterName.add("-") + } + chapterName.add(it) + } + } + + return SChapter.create().apply { + url = chapterDataDto.id + name = chapterName.joinToString(" ") + date_upload = parseDate(attr.publishAt) + } + } + + 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 + } + } + + companion object { + val titleSpecialCharactersRegex = "[^a-z0-9]+".toRegex() + val trailingHyphenRegex = "-+$".toRegex() + } +} diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiUrlActivity.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiUrlActivity.kt new file mode 100644 index 000000000..c5d2b57da --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiUrlActivity.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.extension.all.namicomi + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import kotlin.system.exitProcess + +/** + * Springboard that accepts https://namicomi.com/xx/title/yyy intents and redirects them to + * the main tachiyomi process. The idea is to not install the intent filter unless + * you have this extension installed, but still let the main tachiyomi app control + * things. + */ +class NamiComiUrlActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val pathSegments = intent?.data?.pathSegments + + // Supported path: /en/title/12345 + if (pathSegments != null && pathSegments.size > 2) { + val titleId = pathSegments[2] + val mainIntent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", NamiComiConstants.prefixIdSearch + titleId) + putExtra("filter", packageName) + } + + try { + startActivity(mainIntent) + } catch (e: ActivityNotFoundException) { + Log.e("NamiComiUrlActivity", e.toString()) + } + } else { + Toast.makeText(this, "This URL cannot be handled by the Namicomi extension.", Toast.LENGTH_SHORT).show() + Log.e("NamiComiUrlActivity", "Could not parse URI from intent $intent") + } + + finish() + exitProcess(0) + } +} diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ChapterDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ChapterDto.kt new file mode 100644 index 000000000..204e6c7f6 --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ChapterDto.kt @@ -0,0 +1,20 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias ChapterListDto = PaginatedResponseDto + +@Serializable +@SerialName(NamiComiConstants.chapter) +class ChapterDataDto(override val attributes: ChapterAttributesDto? = null) : EntityDto() + +@Serializable +class ChapterAttributesDto( + val name: String?, + val volume: String?, + val chapter: String?, + val pages: Int, + val publishAt: String, +) : AttributesDto diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/CoverArtDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/CoverArtDto.kt new file mode 100644 index 000000000..e476c705d --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/CoverArtDto.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName(NamiComiConstants.coverArt) +class CoverArtDto(override val attributes: CoverArtAttributesDto? = null) : EntityDto() + +@Serializable +class CoverArtAttributesDto( + val fileName: String? = null, + val locale: String? = null, +) : AttributesDto diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityAccessMapDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityAccessMapDto.kt new file mode 100644 index 000000000..c277d127f --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityAccessMapDto.kt @@ -0,0 +1,30 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias EntityAccessMapDto = ResponseDto + +@Serializable +@SerialName(NamiComiConstants.entityAccessMap) +class EntityAccessMapDataDto( + override val attributes: EntityAccessMapAttributesDto? = null, +) : EntityDto() + +@Serializable +class EntityAccessMapAttributesDto( + // Map of entity IDs to whether the user has access to them + val map: Map, +) : AttributesDto + +@Serializable +class EntityAccessRequestDto( + val entities: List, +) + +@Serializable +class EntityAccessRequestItemDto( + val entityId: String, + val entityType: String, +) diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityDto.kt new file mode 100644 index 000000000..8699f1876 --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityDto.kt @@ -0,0 +1,16 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import kotlinx.serialization.Serializable + +@Serializable +sealed class EntityDto { + val id: String = "" + val relationships: List = emptyList() + abstract val attributes: AttributesDto? +} + +@Serializable +sealed interface AttributesDto + +@Serializable +class UnknownEntity(override val attributes: AttributesDto? = null) : EntityDto() diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/MangaDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/MangaDto.kt new file mode 100644 index 000000000..baf3d0ff1 --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/MangaDto.kt @@ -0,0 +1,70 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias MangaListDto = PaginatedResponseDto + +typealias MangaDto = ResponseDto + +@Serializable +@SerialName(NamiComiConstants.manga) +class MangaDataDto(override val attributes: MangaAttributesDto? = null) : EntityDto() + +@Serializable +class MangaAttributesDto( + // Title and description are maps of language codes to localized strings + val title: Map, + val description: Map, + val slug: String, + val originalLanguage: String?, + val year: Int?, + val contentRating: ContentRatingDto? = null, + val publicationStatus: StatusDto? = null, +) : AttributesDto + +@Serializable +enum class ContentRatingDto(val value: String) { + @SerialName("safe") + SAFE("safe"), + + @SerialName("restricted") + RESTRICTED("restricted"), + + @SerialName("mature") + MATURE("mature"), +} + +@Serializable +enum class StatusDto(val value: String) { + @SerialName("ongoing") + ONGOING("ongoing"), + + @SerialName("completed") + COMPLETED("completed"), + + @SerialName("hiatus") + HIATUS("hiatus"), + + @SerialName("cancelled") + CANCELLED("cancelled"), +} + +@Serializable +sealed class AbstractTagDto(override val attributes: TagAttributesDto? = null) : EntityDto() + +@Serializable +@SerialName(NamiComiConstants.tag) +class TagDto : AbstractTagDto() + +@Serializable +@SerialName(NamiComiConstants.primaryTag) +class PrimaryTagDto : AbstractTagDto() + +@Serializable +@SerialName(NamiComiConstants.secondaryTag) +class SecondaryTagDto : AbstractTagDto() + +@Serializable +class TagAttributesDto(val group: String) : AttributesDto diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/OrganizationDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/OrganizationDto.kt new file mode 100644 index 000000000..ec864ef8e --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/OrganizationDto.kt @@ -0,0 +1,12 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName(NamiComiConstants.organization) +class OrganizationDto(override val attributes: OrganizationAttributesDto? = null) : EntityDto() + +@Serializable +class OrganizationAttributesDto(val name: String) : AttributesDto diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/PageListDataDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/PageListDataDto.kt new file mode 100644 index 000000000..0c5485931 --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/PageListDataDto.kt @@ -0,0 +1,26 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias PageListDto = ResponseDto + +@Serializable +@SerialName(NamiComiConstants.imageData) +class PageListDataDto( + override val attributes: AttributesDto? = null, + val baseUrl: String, + val hash: String, + val source: List, + val high: List, + val medium: List, + val low: List, +) : EntityDto() + +@Serializable +class PageImageDto( + val size: Int?, + val filename: String, + val resolution: String?, +) diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ResponseDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ResponseDto.kt new file mode 100644 index 000000000..3aa5c99f4 --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ResponseDto.kt @@ -0,0 +1,27 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import kotlinx.serialization.Serializable + +@Serializable +class PaginatedResponseDto( + val result: String, + val data: List = emptyList(), + val meta: PaginationStateDto, +) + +@Serializable +class ResponseDto( + val result: String, + val type: String, + val data: T? = null, +) + +@Serializable +class PaginationStateDto( + val limit: Int = 0, + val offset: Int = 0, + val total: Int = 0, +) { + val hasNextPage: Boolean + get() = limit + offset < total +}