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
This commit is contained in:
manti 2025-09-27 14:22:51 +02:00 committed by Draff
parent 33f4d5f8c0
commit 7d62c04507
Signed by: Draff
GPG Key ID: E8A89F3211677653
3 changed files with 521 additions and 212 deletions

View File

@ -1,7 +1,7 @@
ext {
extName = 'GANMA!'
extClass = '.Ganma'
extVersionCode = 2
extVersionCode = 3
}
apply from: "$rootDir/common.gradle"

View File

@ -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<String, String>? = 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<String, String> {
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 <reified T> 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<T>())
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<String> by lazy {
try {
fetchWebOnlyAliases()
} catch (e: Exception) {
emptySet()
}
}
private fun fetchWebOnlyAliases(): Set<String> {
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<Magazine> = response.parseAs()
return MangasPage(list.map { it.toSManga() }, false)
val data = response.parseAs<GraphQLResponse<HomeDto>>().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<Top>().boxes.flatMap { it.panels }
.filter { it.newestStoryItem != null }
.sortedByDescending { it.newestStoryItem!!.release }
return MangasPage(list.map { it.toSManga() }, false)
val data = response.parseAs<GraphQLResponse<HomeDto>>().data
val mangas = data.latestTotalRanking10.map { it.toSManga() }
return MangasPage(mangas, false)
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
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<CategoryFilter>().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<JsonObject>()["operationName"]?.jsonPrimitive?.content
}
override fun searchMangaParse(response: Response): MangasPage =
throw UnsupportedOperationException()
if (operationName == "magazinesByKeywordSearch") {
val data = response.parseAs<GraphQLResponse<SearchDto>>().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<SManga> =
client.newCall(realMangaDetailsRequest(manga)).asObservableSuccess()
.map { mangaDetailsParse(it) }
override fun mangaDetailsParse(response: Response): SManga =
response.parseAs<Magazine>().toSMangaDetails()
protected open fun List<SChapter>.sortedDescending() = this.asReversed()
override fun chapterListParse(response: Response): List<SChapter> =
response.parseAs<Magazine>().getSChapterList().sortedDescending()
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> =
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<Page> {
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<GraphQLResponse<SerialResponseDto>>().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<GraphQLResponse<FinishedResponseDto>>().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<GraphQLResponse<HomeDto>>().data
val mangas = data.ranking.totalRanking
.map { it.toSManga() }
MangasPage(mangas, false)
}
else -> MangasPage(emptyList(), false)
}
}
final override fun pageListParse(response: Response): List<Page> =
throw UnsupportedOperationException()
override fun imageUrlParse(response: Response): String =
throw UnsupportedOperationException()
protected open class TypeFilter : Filter.Select<String>("Type", arrayOf("Popular", "Completed"))
override fun getFilterList() = FilterList(TypeFilter())
protected inline fun <reified T> Response.parseAs(): T = use {
json.decodeFromStream<Result<T>>(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<GraphQLResponse<MagazineDetailDto>>().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<GraphQLResponse<SearchDto>>().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<List<SChapter>> {
return Observable.fromCallable {
val chapters = mutableListOf<StoryInfoNode>()
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<GraphQLResponse<ChapterListDto>>().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<List<Page>> {
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<GraphQLResponse<PageListDto>>().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<*>>(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<Category>) : Filter.Select<Category>("カテゴリー", categories.toTypedArray()) {
val selected: Category
get() = values[state]
}
// Unsupported
override fun chapterListRequest(manga: SManga): Request = throw UnsupportedOperationException()
override fun chapterListParse(response: Response): List<SChapter> = throw UnsupportedOperationException()
override fun pageListParse(response: Response): List<Page> = throw UnsupportedOperationException()
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
}

View File

@ -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<T>(val root: T)
class GraphQLResponse<T>(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<Story> = emptyList(),
class Payload<T>(
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<String>()
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<SChapter> {
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<String, String> =
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<MangaItemDto>,
)
@Serializable
class Ranking(
val totalRanking: List<MangaItemDto>,
)
@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<SearchEdge>,
val pageInfo: PageInfo,
)
@Serializable
class SerialResponseDto(
val serialPerDayOfWeek: SerialPanel,
)
@Serializable
class SerialPanel(
val panels: SerialConnection,
)
@Serializable
class SerialConnection(
val edges: List<SerialEdge>,
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<FinishedEdge>,
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<StoryInfoEdge>,
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<Page> {
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<Box>)
class PageInfo(
val hasNextPage: Boolean,
val endCursor: String? = null,
)
@Serializable
class Box(val panels: List<Magazine>)
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<String>()
val days = mutableListOf<String>()
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<String>,
) {
fun toPageList(): MutableList<Page> =
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,
)