From 7d62c045076ba4642f3f8ae1c60dee93515edce2 Mon Sep 17 00:00:00 2001 From: manti <133025162+manti-X@users.noreply.github.com> Date: Sat, 27 Sep 2025 14:22:51 +0200 Subject: [PATCH] Fix Ganma: use new API GraphQL (#10687) * api change to graphql * fix web entries * add afterword page, reduce requests * some review changes * serializable class templates --- src/ja/ganma/build.gradle | 2 +- .../tachiyomi/extension/ja/ganma/Ganma.kt | 372 ++++++++++++++---- .../tachiyomi/extension/ja/ganma/GanmaDto.kt | 359 ++++++++++------- 3 files changed, 521 insertions(+), 212 deletions(-) diff --git a/src/ja/ganma/build.gradle b/src/ja/ganma/build.gradle index e1a68c496..f639ba729 100644 --- a/src/ja/ganma/build.gradle +++ b/src/ja/ganma/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'GANMA!' extClass = '.Ganma' - extVersionCode = 2 + extVersionCode = 3 } apply from: "$rootDir/common.gradle" diff --git a/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/Ganma.kt b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/Ganma.kt index c928be218..a8d9141ea 100644 --- a/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/Ganma.kt +++ b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/Ganma.kt @@ -1,7 +1,7 @@ package eu.kanade.tachiyomi.extension.ja.ganma import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.asObservable +import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList @@ -10,12 +10,19 @@ 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.json.Json -import kotlinx.serialization.json.decodeFromStream +import eu.kanade.tachiyomi.util.asJsoup +import keiyoushi.utils.jsonInstance +import keiyoushi.utils.parseAs +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.serializer +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import okio.Buffer import rx.Observable -import uy.kohesive.injekt.injectLazy class Ganma : HttpSource() { override val name = "GANMA!" @@ -23,91 +30,314 @@ class Ganma : HttpSource() { override val baseUrl = "https://ganma.jp" override val supportsLatest = true - override fun headersBuilder() = super.headersBuilder().add("X-From", baseUrl) + override fun headersBuilder() = super.headersBuilder() + .add("X-From", "$baseUrl/web") + .add("Content-Type", "application/json;charset=UTF-8") + .add("Accept", "application/json, text/plain, */*") - override fun popularMangaRequest(page: Int) = - when (page) { - 1 -> GET("$baseUrl/api/1.0/ranking", headers) - else -> GET("$baseUrl/api/1.1/ranking?flag=Finish", headers) + private val apiUrl = "$baseUrl/api/graphql" + + private var operationsMap: Map? = null + private var lastSearchCursor: String? = null + private var lastFilterCursor: String? = null + + // https://ganma.jp/web/_next/static/chunks/app/layout-98772c0967d4bfb7.js + // Hashes to bypass OnlyPersistedQueryIsAllowed + private fun fetchAndParseHashes(): Map { + val mainPage = client.newCall(GET("$baseUrl/web", headers)).execute() + val document = mainPage.asJsoup() + + val mainScriptUrl = document.selectFirst("script[src*=/app/layout-]") + ?.attr("abs:src") + ?.ifEmpty { null } + ?: throw Exception("Could not find layout script") + + val scriptContent = client.newCall(GET(mainScriptUrl, headers)).execute().body.string() + + val manifestRegex = """operations:(\[.+?])\};""".toRegex() + val manifestMatch = manifestRegex.find(scriptContent) + ?: throw Exception("Could not find operations manifest in script") + + val manifestJson = manifestMatch.groupValues[1] + + val operationRegex = """id:"([a-f0-9]{64})",body:".*?",name:"(\w+)"""".toRegex() + return operationRegex.findAll(manifestJson).associate { + val (hash, name) = it.destructured + name to hash + }.also { operationsMap = it } + } + + private inline fun graphQlRequest(operationName: String, variables: T, useAppHeaders: Boolean = true): Request { + val hashes = operationsMap ?: fetchAndParseHashes() + val hash = hashes[operationName] ?: throw Exception("Could not find hash for operation: $operationName") + + val finalHeaders = headersBuilder().apply { + if (useAppHeaders) { + set("User-Agent", "GanmaReader/9.9.1 Android") + } else { + set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36") + } + }.build() + + val extensions = Payload.Extensions(Payload.Extensions.PersistedQuery(version = 1, sha256Hash = hash)) + val payload = Payload(operationName, variables, extensions) + val payloadSerializer = Payload.serializer(serializer()) + + val requestBody = jsonInstance.encodeToString(payloadSerializer, payload).toRequestBody("application/json; charset=utf-8".toMediaType()) + return POST(apiUrl, finalHeaders, requestBody) + } + + private fun updateImageUrlWidth(url: String?, width: Int = 4999): String? { + return url?.toHttpUrlOrNull() + ?.newBuilder() + ?.setQueryParameter("w", width.toString()) + ?.build() + ?.toString() + } + + private val cacheWebOnlyAliases: Set by lazy { + try { + fetchWebOnlyAliases() + } catch (e: Exception) { + emptySet() } + } + + private fun fetchWebOnlyAliases(): Set { + val response = client.newCall(GET("$baseUrl/web/magazineCategory/webOnly", headers)).execute() + val document = response.asJsoup() + return document.select("a[href*=/web/magazine/]") + .map { it.attr("href").substringAfterLast("/") } + .toSet() + } + + override fun popularMangaRequest(page: Int): Request { + return graphQlRequest("home", EmptyVariables, useAppHeaders = true) + } override fun popularMangaParse(response: Response): MangasPage { - val list: List = response.parseAs() - return MangasPage(list.map { it.toSManga() }, false) + val data = response.parseAs>().data + val mangas = data.ranking.totalRanking.map { it.toSManga() } + return MangasPage(mangas, false) } - override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/api/2.2/top", headers) + override fun latestUpdatesRequest(page: Int): Request { + return graphQlRequest("home", EmptyVariables, useAppHeaders = true) + } override fun latestUpdatesParse(response: Response): MangasPage { - val list = response.parseAs().boxes.flatMap { it.panels } - .filter { it.newestStoryItem != null } - .sortedByDescending { it.newestStoryItem!!.release } - return MangasPage(list.map { it.toSManga() }, false) + val data = response.parseAs>().data + val mangas = data.latestTotalRanking10.map { it.toSManga() } + return MangasPage(mangas, false) } - override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { - val pageNumber = when (filters.size) { - 0 -> 1 - else -> (filters[0] as TypeFilter).state + 1 + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (query.isNotBlank()) { + if (page == 1) { + lastSearchCursor = null + } + val variables = SearchVariables(keyword = query, first = 20, after = lastSearchCursor) + return graphQlRequest("magazinesByKeywordSearch", variables, useAppHeaders = false) } - return fetchPopularManga(pageNumber).map { mangasPage -> - MangasPage(mangasPage.mangas.filter { it.title.contains(query) }, false) + + if (page == 1) { + lastFilterCursor = null + } + val category = filters.filterIsInstance().first().selected + + return when (category.type) { + "day" -> { + val variables = DayOfWeekVariables(dayOfWeek = category.id, first = 20, after = lastFilterCursor) + graphQlRequest("serialMagazinesByDayOfWeek", variables) + } + "finished" -> { + val variables = FinishedVariables(first = 20, after = lastFilterCursor) + graphQlRequest("finishedMagazines", variables) + } + else -> graphQlRequest("home", EmptyVariables) } } - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = - throw UnsupportedOperationException() + override fun searchMangaParse(response: Response): MangasPage { + val operationName = Buffer().use { + response.request.body?.writeTo(it) + it.readUtf8().parseAs()["operationName"]?.jsonPrimitive?.content + } - override fun searchMangaParse(response: Response): MangasPage = - throw UnsupportedOperationException() + if (operationName == "magazinesByKeywordSearch") { + val data = response.parseAs>().data + lastSearchCursor = data.searchComic.pageInfo.endCursor + val mangas = data.searchComic.edges + .mapNotNull { it.node } + .map { it.toSManga() } + return MangasPage(mangas, data.searchComic.pageInfo.hasNextPage) + } - // navigate Webview to web page - override fun mangaDetailsRequest(manga: SManga) = - GET("$baseUrl/${manga.url.alias()}", headers) - - protected open fun realMangaDetailsRequest(manga: SManga) = - GET("$baseUrl/api/1.0/magazines/web/${manga.url.alias()}", headers) - - override fun chapterListRequest(manga: SManga) = realMangaDetailsRequest(manga) - - override fun fetchMangaDetails(manga: SManga): Observable = - client.newCall(realMangaDetailsRequest(manga)).asObservableSuccess() - .map { mangaDetailsParse(it) } - - override fun mangaDetailsParse(response: Response): SManga = - response.parseAs().toSMangaDetails() - - protected open fun List.sortedDescending() = this.asReversed() - - override fun chapterListParse(response: Response): List = - response.parseAs().getSChapterList().sortedDescending() - - override fun fetchPageList(chapter: SChapter): Observable> = - client.newCall(pageListRequest(chapter)).asObservable() - .map { pageListParse(chapter, it) } - - override fun pageListRequest(chapter: SChapter) = - GET("$baseUrl/api/1.0/magazines/web/${chapter.url.alias()}", headers) - - protected open fun pageListParse(chapter: SChapter, response: Response): List { - val manga: Magazine = response.parseAs() - val chapterId = chapter.url.substringAfter('/') - return manga.items.find { it.id == chapterId }!!.toPageList() + return when (operationName) { + "serialMagazinesByDayOfWeek" -> { + val data = response.parseAs>().data.serialPerDayOfWeek.panels + lastFilterCursor = data.pageInfo.endCursor + val mangas = data.edges + .map { it.node.storyInfo.magazine } + .map { it.toSManga() } + MangasPage(mangas, data.pageInfo.hasNextPage) + } + "finishedMagazines" -> { + val data = response.parseAs>().data.magazinesByCategory.magazines + lastFilterCursor = data.pageInfo.endCursor + val mangas = data.edges + .map { it.node } + .map { it.toSManga() } + MangasPage(mangas, data.pageInfo.hasNextPage) + } + "home" -> { + val data = response.parseAs>().data + val mangas = data.ranking.totalRanking + .map { it.toSManga() } + MangasPage(mangas, false) + } + else -> MangasPage(emptyList(), false) + } } - final override fun pageListParse(response: Response): List = - throw UnsupportedOperationException() - - override fun imageUrlParse(response: Response): String = - throw UnsupportedOperationException() - - protected open class TypeFilter : Filter.Select("Type", arrayOf("Popular", "Completed")) - - override fun getFilterList() = FilterList(TypeFilter()) - - protected inline fun Response.parseAs(): T = use { - json.decodeFromStream>(it.body.byteStream()).root + private fun MangaItemDto.toSManga(): SManga = SManga.create().apply { + url = this@toSManga.alias + title = this@toSManga.title + thumbnail_url = updateImageUrlWidth(this@toSManga.todaysJacketImageURL ?: this@toSManga.rectangleWithLogoImageURL) } - val json: Json by injectLazy() + override fun mangaDetailsRequest(manga: SManga): Request { + val variables = MagazineDetailVariables(magazineIdOrAlias = manga.url) + return graphQlRequest("magazineDetail", variables) + } + + override fun getMangaUrl(manga: SManga): String = "$baseUrl/web/magazine/${manga.url}" + + override fun mangaDetailsParse(response: Response): SManga { + val magazine = response.parseAs>().data.magazine + val manga = SManga.create().apply { + url = magazine.alias + title = magazine.title + author = magazine.authorName + description = magazine.description + status = if (magazine.isFinished) SManga.COMPLETED else SManga.ONGOING + thumbnail_url = updateImageUrlWidth(magazine.todaysJacketImageURL) + } + + if (magazine.todaysJacketImageURL == null) { + try { + val searchResponse = client.newCall(searchMangaRequest(1, manga.title, FilterList())).execute() + if (searchResponse.isSuccessful) { + val searchData = searchResponse.parseAs>().data + val searchResultUrl = searchData.searchComic.edges + .firstOrNull { it.node?.alias == manga.url } + ?.node?.todaysJacketImageURL + + if (searchResultUrl != null) { + manga.thumbnail_url = updateImageUrlWidth(searchResultUrl) + } + } + } catch (e: Exception) { + } + } + return manga + } + + override fun fetchChapterList(manga: SManga): Observable> { + return Observable.fromCallable { + val chapters = mutableListOf() + var hasNextPage = true + var cursor: String? = null + + while (hasNextPage) { + val variables = StoryInfoListVariables(magazineIdOrAlias = manga.url, first = 100, after = cursor) + val response = client.newCall(graphQlRequest("storyInfoList", variables, useAppHeaders = false)).execute() + val data = response.parseAs>().data.magazine.storyInfos + + chapters.addAll(data.edges.map { it.node }) + hasNextPage = data.pageInfo.hasNextPage + cursor = data.pageInfo.endCursor + } + + chapters.mapIndexed { index, chapter -> + SChapter.create().apply { + url = "${manga.url}/${chapter.storyId}" + name = (chapter.title + chapter.subtitle?.let { " $it" }.orEmpty()).trim() + date_upload = chapter.contentsRelease + chapter_number = (chapters.size - index).toFloat() + val accessCondition = chapter.contentsAccessCondition + val isPremiumType = accessCondition.typename != "FreeStoryContentsAccessCondition" + val isPurchasable = chapter.isSellByStory && (accessCondition.info?.coins ?: 0) > 0 + + if ((isPremiumType || isPurchasable) && !chapter.isPurchased) { + name = "\uD83E\uDE99 $name" + } + } + }.reversed() + } + } + + override fun fetchPageList(chapter: SChapter): Observable> { + val (alias, storyId) = chapter.url.split("/") + val isWebSeries = alias in cacheWebOnlyAliases + val variables = StoryReaderVariables(magazineIdOrAlias = alias, storyId = storyId) + val apiRequest = graphQlRequest("magazineStoryForReader", variables, useAppHeaders = !isWebSeries) + + return client.newCall(apiRequest).asObservableSuccess().map { response -> + val data = response.parseAs>().data + if (data.magazine.storyContents.error != null) { + throw Exception("This chapter is locked. Log in via WebView to read if you have premium or have purchased this chapter.") + } + val pageImages = data.magazine.storyContents.pageImages + ?: throw Exception("Could not find page images") + + val pages = (1..pageImages.pageCount).map { i -> + val imageUrl = "${pageImages.pageImageBaseURL}$i.jpg?${pageImages.pageImageSign}" + Page(i - 1, imageUrl = updateImageUrlWidth(imageUrl)) + }.toMutableList() + + data.magazine.storyContents.afterword?.imageURL?.let { + pages.add(Page(pages.size, imageUrl = updateImageUrlWidth(it))) + } + pages + } + } + + // Filter + override fun getFilterList(): FilterList { + val filters = mutableListOf>(Filter.Header("NOTE: Search query ignores filters")) + if (operationsMap != null) { + filters.add(CategoryFilter(getCategoryList())) + } else { + filters.add(Filter.Header("Press 'Reset' to load filters")) + } + return FilterList(filters) + } + + private class Category(val name: String, val id: String, val type: String) { + override fun toString(): String = name + } + + private fun getCategoryList() = listOf( + Category("人気", "popular", "popular"), + Category("完結", "finished", "finished"), + Category("月曜日", "MONDAY", "day"), + Category("火曜日", "TUESDAY", "day"), + Category("水曜日", "WEDNESDAY", "day"), + Category("木曜日", "THURSDAY", "day"), + Category("金曜日", "FRIDAY", "day"), + Category("土曜日", "SATURDAY", "day"), + Category("日曜日", "SUNDAY", "day"), + ) + + private class CategoryFilter(categories: List) : Filter.Select("カテゴリー", categories.toTypedArray()) { + val selected: Category + get() = values[state] + } + + // Unsupported + override fun chapterListRequest(manga: SManga): Request = throw UnsupportedOperationException() + override fun chapterListParse(response: Response): List = throw UnsupportedOperationException() + override fun pageListParse(response: Response): List = throw UnsupportedOperationException() + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() } diff --git a/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaDto.kt b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaDto.kt index ec78a2ff7..b023d3f4e 100644 --- a/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaDto.kt +++ b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaDto.kt @@ -1,168 +1,247 @@ package eu.kanade.tachiyomi.extension.ja.ganma -import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.source.model.SChapter -import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import java.text.DateFormat.getDateTimeInstance -import java.util.Date @Serializable -class Result(val root: T) +class GraphQLResponse(val data: T) -// Manga @Serializable -class Magazine( - val id: String, - val alias: String? = null, - val title: String, - val description: String? = null, - val squareImage: File? = null, -// val squareWithLogoImage: File? = null, - val author: Author? = null, - val newestStoryItem: Story? = null, - val flags: Flags? = null, - val announcement: Announcement? = null, - val items: List = emptyList(), +class Payload( + val operationName: String, + val variables: T, + val extensions: Extensions, ) { - fun toSManga() = SManga.create().apply { - url = "${alias!!}#$id" - title = this@Magazine.title - thumbnail_url = squareImage!!.url - } - - fun toSMangaDetails() = toSManga().apply { - author = this@Magazine.author?.penName - val flagsText = flags?.toText() - description = generateDescription(flagsText) - status = when { - flags?.isFinish == true -> SManga.COMPLETED - !flagsText.isNullOrEmpty() -> SManga.ONGOING - else -> SManga.UNKNOWN - } - initialized = true - } - - private fun generateDescription(flagsText: String?): String { - val result = mutableListOf() - if (!flagsText.isNullOrEmpty()) result.add("Updates: $flagsText") - if (announcement != null) result.add("Announcement: ${announcement.text}") - if (description != null) result.add(description) - return result.joinToString("\n\n") - } - - fun getSChapterList(): List { - val now = System.currentTimeMillis() - return items.map { - SChapter.create().apply { - url = "${alias!!}#$id/${it.id ?: it.storyId}" - name = buildString { - if (it.kind != "free") append("🔒 ") - append(it.title) - if (it.subtitle != null) append(' ').append(it.subtitle) - } - val time = it.releaseStart ?: -1 - date_upload = time - if (time > now) scanlator = getDateTimeInstance().format(Date(time)) + '~' - } - } + @Serializable + class Extensions( + val persistedQuery: PersistedQuery, + ) { + @Serializable + class PersistedQuery( + val version: Int, + val sha256Hash: String, + ) } } -fun String.alias() = this.substringBefore('#') -fun String.mangaId() = this.substringAfter('#') -fun String.chapterDir(): Pair = - with(this.substringAfter('#')) { - // this == [mangaId-UUID]/[chapterId-UUID] - Pair(substring(0, 36), substring(37, 37 + 36)) - } - -// Chapter @Serializable -class Story( - val id: String? = null, - val storyId: String? = null, +object EmptyVariables + +@Serializable +class SearchVariables( + val keyword: String, + val first: Int, + val after: String? = null, +) + +@Serializable +class DayOfWeekVariables( + val dayOfWeek: String, + val first: Int, + val after: String? = null, +) + +@Serializable +class FinishedVariables( + val first: Int, + val after: String? = null, +) + +@Serializable +class MagazineDetailVariables( + val magazineIdOrAlias: String, +) + +@Serializable +class StoryInfoListVariables( + val magazineIdOrAlias: String, + val first: Int, + val after: String? = null, +) + +@Serializable +class StoryReaderVariables( + val magazineIdOrAlias: String, + val storyId: String, +) + +@Serializable +class MangaItemDto( + val alias: String, + val title: String, + val todaysJacketImageURL: String? = null, + val rectangleWithLogoImageURL: String? = null, +) + +@Serializable +class HomeDto( + val ranking: Ranking, + val latestTotalRanking10: List, +) + +@Serializable +class Ranking( + val totalRanking: List, +) + +@Serializable +class SearchEdge( + val node: MangaItemDto?, +) + +@Serializable +class StoryInfoMagazine( + val magazine: MangaItemDto, +) + +@Serializable +class FinishedEdge( + val node: MangaItemDto, +) + +@Serializable +class SearchDto( + val searchComic: SearchResult, +) + +@Serializable +class SearchResult( + val edges: List, + val pageInfo: PageInfo, +) + +@Serializable +class SerialResponseDto( + val serialPerDayOfWeek: SerialPanel, +) + +@Serializable +class SerialPanel( + val panels: SerialConnection, +) + +@Serializable +class SerialConnection( + val edges: List, + val pageInfo: PageInfo, +) + +@Serializable +class SerialEdge( + val node: SerialNode, +) + +@Serializable +class SerialNode( + val storyInfo: StoryInfoMagazine, +) + +@Serializable +class FinishedResponseDto( + val magazinesByCategory: FinishedCategory, +) + +@Serializable +class FinishedCategory( + val magazines: FinishedConnection, +) + +@Serializable +class FinishedConnection( + val edges: List, + val pageInfo: PageInfo, +) + +@Serializable +class MagazineDetailDto( + val magazine: MagazineDetail, +) + +@Serializable +class MagazineDetail( + val title: String, + val alias: String, + val authorName: String? = null, + val description: String, + val isFinished: Boolean, + val todaysJacketImageURL: String? = null, +) + +@Serializable +class ChapterListDto( + val magazine: MagazineWithChapters, +) + +@Serializable +class MagazineWithChapters( + val storyInfos: StoryInfoConnection, +) + +@Serializable +class StoryInfoConnection( + val edges: List, + val pageInfo: PageInfo, +) + +@Serializable +class StoryInfoEdge( + val node: StoryInfoNode, +) + +@Serializable +class StoryInfoNode( + val storyId: String, val title: String, val subtitle: String? = null, - val release: Long = 0, - val releaseStart: Long? = null, - val page: Directory? = null, - val afterwordImage: File? = null, - val kind: String? = null, -) { - fun toPageList(): List { - val result = page!!.toPageList() - if (afterwordImage != null) { - result.add(Page(result.size, imageUrl = afterwordImage.url)) - } - return result - } -} + val contentsRelease: Long, + val isPurchased: Boolean, + val contentsAccessCondition: ContentsAccessCondition, + val isSellByStory: Boolean, +) @Serializable -class File(val url: String) +class ContentsAccessCondition( + @SerialName("__typename") + val typename: String, + val info: PurchaseInfo? = null, +) @Serializable -class Author(val penName: String? = null) +class PurchaseInfo( + val coins: Int, +) @Serializable -class Top(val boxes: List) +class PageInfo( + val hasNextPage: Boolean, + val endCursor: String? = null, +) @Serializable -class Box(val panels: List) +class PageListDto( + val magazine: MagazinePages, +) @Serializable -class Flags( - val isMonday: Boolean = false, - val isTuesday: Boolean = false, - val isWednesday: Boolean = false, - val isThursday: Boolean = false, - val isFriday: Boolean = false, - val isSaturday: Boolean = false, - val isSunday: Boolean = false, - - val isWeekly: Boolean = false, - val isEveryOtherWeek: Boolean = false, - val isThreeConsecutiveWeeks: Boolean = false, - val isMonthly: Boolean = false, - - val isFinish: Boolean = false, -// val isMGAward: Boolean = false, -// val isNew: Boolean = false, -) { - fun toText(): String { - val result = mutableListOf() - val days = mutableListOf() - arrayOf(isWeekly, isEveryOtherWeek, isThreeConsecutiveWeeks, isMonthly) - .forEachIndexed { i, value -> if (value) result.add(weekText[i]) } - arrayOf(isMonday, isTuesday, isWednesday, isThursday, isFriday, isSaturday, isSunday) - .forEachIndexed { i, value -> if (value) days.add(dayText[i] + "s") } - if (days.size == 7) { - result.add("every day") - } else if (days.size != 0) { - days[0] = "on " + days[0] - result += days - } - return result.joinToString(", ") - } - - companion object { - private val weekText = arrayOf("every week", "every other week", "three weeks in a row", "every month") - private val dayText = arrayOf("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday") - } -} +class MagazinePages( + val storyContents: StoryContents, +) @Serializable -class Announcement(val text: String) +class StoryContents( + val pageImages: PageImages? = null, + val error: String? = null, + val afterword: Afterword? = null, +) @Serializable -class Directory( - val baseUrl: String, - val token: String, - val files: List, -) { - fun toPageList(): MutableList = - files.mapIndexedTo(ArrayList(files.size + 1)) { i, file -> - Page(i, imageUrl = "$baseUrl$file?$token") - } -} +class PageImages( + val pageCount: Int, + val pageImageBaseURL: String, + val pageImageSign: String, +) + +@Serializable +class Afterword( + val imageURL: String? = null, +)