diff --git a/src/all/komga/README.md b/src/all/komga/README.md new file mode 100644 index 000000000..569b06142 --- /dev/null +++ b/src/all/komga/README.md @@ -0,0 +1,41 @@ +# Komga + +Table of Content +- [FAQ](#FAQ) + - [Why do I see no manga?](#why-do-i-see-no-manga) + - [Where can I get more information about Komga?](#where-can-i-get-more-information-about-komga) + - [The Komga extension stopped working?](#the-komga-extension-stopped-working) + - [Can I add more than one Komga server or user?](#can-i-add-more-than-one-komga-server-or-user) + - [Can I test the Komga extension before setting up my own server?](#can-i-test-the-komga-extension-before-setting-up-my-own-server) +- [Guides](#Guides) + - [How do I add my Komga server to the app?](#how-do-i-add-my-komga-server-to-the-app) + +## FAQ + +### Why do I see no manga? +Komga is a self-hosted comic/manga media server. + +### Where can I get more information about Komga? +You can visit the [Komga](https://komga.org/) website for for more information. + +### The Komga extension stopped working? +Make sure that your Komga server and extension are on the newest version. + +### Can I add more than one Komga server or user? +Yes, currently you can add up to 11 different Komga instances to the app. The number of instances +available can be customized in extension settings. + +### Can I test the Komga extension before setting up my own server? +Yes, you can try it out with the DEMO server `https://demo.komga.org`, username `demo@komga.org` and +password `komga-demo`. + +### Why are EPUB chapters/books missing? +Mihon/Tachiyomi does not support reading text. EPUB files containing only images will be available +if your Komga server version is [1.9.0](https://github.com/gotson/komga/releases/tag/1.9.0) +or newer. + +## Guides + +### How do I add my Komga server to the app? +Go into the settings of the Komga extension (under Browse -> Extensions) and fill in your server +address and login details. diff --git a/src/all/komga/build.gradle b/src/all/komga/build.gradle index e4a56a23f..bcbf88b2a 100644 --- a/src/all/komga/build.gradle +++ b/src/all/komga/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'Komga' extClass = '.KomgaFactory' - extVersionCode = 55 + extVersionCode = 56 } apply from: "$rootDir/common.gradle" diff --git a/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/Komga.kt b/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/Komga.kt index f49627c4a..3689d9679 100644 --- a/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/Komga.kt +++ b/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/Komga.kt @@ -5,15 +5,10 @@ import android.content.SharedPreferences import android.text.InputType import android.util.Log import android.widget.Toast -import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.AppInfo -import eu.kanade.tachiyomi.extension.all.komga.KomgaUtils.addEditTextPreference -import eu.kanade.tachiyomi.extension.all.komga.KomgaUtils.isFromReadList -import eu.kanade.tachiyomi.extension.all.komga.KomgaUtils.parseAs -import eu.kanade.tachiyomi.extension.all.komga.KomgaUtils.toSManga import eu.kanade.tachiyomi.extension.all.komga.dto.AuthorDto import eu.kanade.tachiyomi.extension.all.komga.dto.BookDto import eu.kanade.tachiyomi.extension.all.komga.dto.CollectionDto @@ -23,6 +18,7 @@ import eu.kanade.tachiyomi.extension.all.komga.dto.PageWrapperDto import eu.kanade.tachiyomi.extension.all.komga.dto.ReadListDto import eu.kanade.tachiyomi.extension.all.komga.dto.SeriesDto import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.UnmeteredSource import eu.kanade.tachiyomi.source.model.Filter @@ -32,6 +28,11 @@ 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.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import okhttp3.Credentials import okhttp3.Dns import okhttp3.HttpUrl.Companion.toHttpUrl @@ -39,15 +40,12 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import rx.Single -import rx.schedulers.Schedulers +import org.apache.commons.text.StringSubstitutor import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy import java.security.MessageDigest import java.util.Locale -import java.util.concurrent.locks.ReentrantReadWriteLock -import kotlin.concurrent.read -import kotlin.concurrent.write open class Komga(private val suffix: String = "") : ConfigurableSource, UnmeteredSource, HttpSource() { @@ -57,7 +55,13 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere private val displayName by lazy { preferences.getString(PREF_DISPLAY_NAME, "")!! } - override val name by lazy { "Komga${displayName.ifBlank { suffix }.let { if (it.isNotBlank()) " ($it)" else "" }}" } + override val name by lazy { + val displayNameSuffix = displayName + .ifBlank { suffix } + .let { if (it.isNotBlank()) " ($it)" else "" } + + "Komga$displayNameSuffix" + } override val lang = "all" @@ -79,6 +83,8 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere private val defaultLibraries get() = preferences.getStringSet(PREF_DEFAULT_LIBRARIES, emptySet())!! + private val json: Json by injectLazy() + override fun headersBuilder() = super.headersBuilder() .set("User-Agent", "TachiyomiKomga/${AppInfo.getVersionName()}") @@ -106,7 +112,7 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere ) override fun popularMangaParse(response: Response): MangasPage = - KomgaUtils.processSeriesPage(response, baseUrl) + processSeriesPage(response, baseUrl) override fun latestUpdatesRequest(page: Int): Request = searchMangaRequest( @@ -118,11 +124,9 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere ) override fun latestUpdatesParse(response: Response): MangasPage = - KomgaUtils.processSeriesPage(response, baseUrl) + processSeriesPage(response, baseUrl) override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - runCatching { fetchFilterOptions() } - val collectionId = (filters.find { it is CollectionSelect } as? CollectionSelect)?.let { it.collections[it.state].id } @@ -164,7 +168,17 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere } override fun searchMangaParse(response: Response): MangasPage = - KomgaUtils.processSeriesPage(response, baseUrl) + processSeriesPage(response, baseUrl) + + private fun processSeriesPage(response: Response, baseUrl: String): MangasPage { + val data = if (response.isFromReadList()) { + response.parseAs>() + } else { + response.parseAs>() + } + + return MangasPage(data.content.map { it.toSManga(baseUrl) }, !data.last) + } override fun getMangaUrl(manga: SManga) = manga.url.replace("/api/v1", "") @@ -199,10 +213,19 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere SChapter.create().apply { chapter_number = if (!isFromReadList) book.metadata.numberSort else index + 1F url = "$baseUrl/api/v1/books/${book.id}" - name = KomgaUtils.formatChapterName(book, chapterNameTemplate, isFromReadList) - scanlator = book.metadata.authors.filter { it.role == "translator" }.joinToString { it.name } - date_upload = book.metadata.releaseDate?.let { KomgaUtils.parseDate(it) } - ?: KomgaUtils.parseDateTime(book.lastModified) + name = book.getChapterName(chapterNameTemplate, isFromReadList) + scanlator = book.metadata.authors + .filter { it.role == "translator" } + .joinToString { it.name } + date_upload = when { + book.metadata.releaseDate != null -> parseDate(book.metadata.releaseDate) + book.created != null -> parseDateTime(book.created) + + // XXX: `Book.fileLastModified` actually uses the server's running timezone, + // not UTC, even if the timestamp ends with a Z! We cannot determine the + // server's timezone, which is why this is a last resort option. + else -> parseDateTime(book.fileLastModified) + } } } .sortedByDescending { it.chapter_number } @@ -215,7 +238,7 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere return pages.map { val url = "${response.request.url}/${it.number}" + - if (!supportedImageTypes.contains(it.mediaType)) { + if (!SUPPORTED_IMAGE_TYPES.contains(it.mediaType)) { "?convert=png" } else { "" @@ -228,6 +251,8 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() override fun getFilterList(): FilterList { + fetchFilterOptions() + val filters = mutableListOf>( UnreadFilter(), InProgressFilter(), @@ -265,8 +290,14 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere publishers.map { UriMultiSelectOption(it) }, ), ).apply { - if (collections.isEmpty() && libraries.isEmpty() && genres.isEmpty() && tags.isEmpty() && publishers.isEmpty()) { - add(0, Filter.Header("Press 'Reset' to show filtering options")) + if (fetchFilterStatus != FetchFilterStatus.FETCHED) { + val message = if (fetchFilterStatus == FetchFilterStatus.NOT_FETCHED && fetchFiltersAttempts >= 3) { + "Failed to fetch filtering options from the server" + } else { + "Press 'Reset' to show filtering options" + } + + add(0, Filter.Header(message)) add(1, Filter.Separator()) } @@ -278,6 +309,8 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere } override fun setupPreferenceScreen(screen: PreferenceScreen) { + fetchFilterOptions() + if (suffix.isEmpty()) { ListPreference(screen.context).apply { key = PREF_EXTRA_SOURCES_COUNT @@ -305,9 +338,10 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere title = "Address", default = "", summary = baseUrl.ifBlank { "The server address" }, + dialogMessage = "The address must not end with a forward slash.", inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI, - validate = { it.toHttpUrlOrNull() != null }, - validationMessage = "The URL is invalid or malformed", + validate = { it.toHttpUrlOrNull() != null && !it.endsWith("/") }, + validationMessage = "The URL is invalid, malformed, or ends with a slash", key = PREF_ADDRESS, restartRequired = true, ) @@ -334,7 +368,7 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere append("Show content from selected libraries by default.") if (libraries.isEmpty()) { - append(" Browse the source to load available options.") + append(" Exit and enter the settings menu to load options.") } } entries = libraries.map { it.name }.toTypedArray() @@ -342,10 +376,25 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere setDefaultValue(emptySet()) }.also(screen::addPreference) - EditTextPreference(screen.context).apply { - key = PREF_CHAPTER_NAME_TEMPLATE - title = "Chapter title format" - summary = "Customize how chapter names appear. Chapters in read lists will always be prefixed by the series' name." + val values = hashMapOf( + "title" to "", + "seriesTitle" to "", + "number" to "", + "createdDate" to "", + "releaseDate" to "", + "size" to "", + "sizeBytes" to "", + ) + val stringSubstitutor = StringSubstitutor(values, "{", "}").apply { + isEnableUndefinedVariableException = true + } + + screen.addEditTextPreference( + key = PREF_CHAPTER_NAME_TEMPLATE, + title = "Chapter title format", + summary = "Customize how chapter names appear. Chapters in read lists will always be prefixed by the series' name.", + inputType = InputType.TYPE_CLASS_TEXT, + default = PREF_CHAPTER_NAME_TEMPLATE_DEFAULT, dialogMessage = """ |Supported placeholders: |- {title}: Chapter name @@ -355,10 +404,19 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere |- {releaseDate}: Chapter release date |- {size}: Chapter file size (formatted) |- {sizeBytes}: Chapter file size (in bytes) - """.trimMargin() - - setDefaultValue(PREF_CHAPTER_NAME_TEMPLATE_DEFAULT) - }.also(screen::addPreference) + |If you wish to place some text between curly brackets, place the escape character "$" + |before the opening curly bracket, e.g. ${'$'}{series}. + """.trimMargin(), + validate = { + try { + stringSubstitutor.replace(it) + true + } catch (e: IllegalArgumentException) { + false + } + }, + validationMessage = "Invalid chapter title format", + ) } private var libraries = emptyList() @@ -368,72 +426,72 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere private var publishers = emptySet() private var authors = emptyMap>() // roles to list of authors - private var fetchFiltersFailed = false - + private var fetchFilterStatus = FetchFilterStatus.NOT_FETCHED private var fetchFiltersAttempts = 0 - - private val fetchFiltersLock = ReentrantReadWriteLock() + private val scope = CoroutineScope(Dispatchers.IO) private fun fetchFilterOptions() { - if (baseUrl.isBlank()) { + if (baseUrl.isBlank() || fetchFilterStatus != FetchFilterStatus.NOT_FETCHED || fetchFiltersAttempts >= 3) { return } - Single.fromCallable { - fetchFiltersLock.read { - if (fetchFiltersAttempts > 3 || (fetchFiltersAttempts > 0 && !fetchFiltersFailed)) { - return@fromCallable - } - } + fetchFilterStatus = FetchFilterStatus.FETCHING + fetchFiltersAttempts++ - fetchFiltersLock.write { - fetchFiltersFailed = try { - libraries = client.newCall(GET("$baseUrl/api/v1/libraries")).execute().parseAs() - collections = client - .newCall(GET("$baseUrl/api/v1/collections?unpaged=true")) - .execute() - .parseAs>() - .content - genres = client.newCall(GET("$baseUrl/api/v1/genres")).execute().parseAs() - tags = client.newCall(GET("$baseUrl/api/v1/tags")).execute().parseAs() - publishers = client.newCall(GET("$baseUrl/api/v1/publishers")).execute().parseAs() - authors = client - .newCall(GET("$baseUrl/api/v1/authors")) - .execute() - .parseAs>() - .groupBy { it.role } - false - } catch (e: Exception) { - Log.e(logTag, "Could not fetch filter options", e) - true - } - - fetchFiltersAttempts++ + scope.launch { + try { + libraries = client.newCall(GET("$baseUrl/api/v1/libraries")).await().parseAs() + collections = client + .newCall(GET("$baseUrl/api/v1/collections?unpaged=true")) + .await() + .parseAs>() + .content + genres = client.newCall(GET("$baseUrl/api/v1/genres")).await().parseAs() + tags = client.newCall(GET("$baseUrl/api/v1/tags")).await().parseAs() + publishers = client.newCall(GET("$baseUrl/api/v1/publishers")).await().parseAs() + authors = client + .newCall(GET("$baseUrl/api/v1/authors")) + .await() + .parseAs>() + .groupBy { it.role } + fetchFilterStatus = FetchFilterStatus.FETCHED + } catch (e: Exception) { + fetchFilterStatus = FetchFilterStatus.NOT_FETCHED + Log.e(logTag, "Failed to fetch filtering options", e) } } - .subscribeOn(Schedulers.io()) - .observeOn(Schedulers.io()) - .subscribe() } - private val logTag = "komga${if (suffix.isNotBlank()) ".$suffix" else ""}" + fun Response.isFromReadList() = request.url.toString().contains("/api/v1/readlists") + + private inline fun Response.parseAs(): T = + json.decodeFromString(body.string()) + + private val logTag by lazy { "komga${if (suffix.isNotBlank()) ".$suffix" else ""}" } companion object { internal const val PREF_EXTRA_SOURCES_COUNT = "Number of extra sources" internal const val PREF_EXTRA_SOURCES_DEFAULT = "2" - private val PREF_EXTRA_SOURCES_ENTRIES = (0..10).map { it.toString() }.toTypedArray() - - private const val PREF_DISPLAY_NAME = "Source display name" - private const val PREF_ADDRESS = "Address" - private const val PREF_USERNAME = "Username" - private const val PREF_PASSWORD = "Password" - private const val PREF_DEFAULT_LIBRARIES = "Default libraries" - private const val PREF_CHAPTER_NAME_TEMPLATE = "Chapter name template" - private const val PREF_CHAPTER_NAME_TEMPLATE_DEFAULT = "{number} - {title} ({size})" - - private val supportedImageTypes = listOf("image/jpeg", "image/png", "image/gif", "image/webp", "image/jxl", "image/heif", "image/avif") internal const val TYPE_SERIES = "Series" internal const val TYPE_READLISTS = "Read lists" } } + +private enum class FetchFilterStatus { + NOT_FETCHED, + FETCHING, + FETCHED, +} + +private val PREF_EXTRA_SOURCES_ENTRIES = (0..10).map { it.toString() }.toTypedArray() + +private const val PREF_DISPLAY_NAME = "Source display name" +private const val PREF_ADDRESS = "Address" +private const val PREF_USERNAME = "Username" +private const val PREF_PASSWORD = "Password" +private const val PREF_DEFAULT_LIBRARIES = "Default libraries" +private const val PREF_CHAPTER_NAME_TEMPLATE = "Chapter name template" +private const val PREF_CHAPTER_NAME_TEMPLATE_DEFAULT = "{number} - {title} ({size})" + +private val SUPPORTED_IMAGE_TYPES = listOf("image/jpeg", "image/png", "image/gif", "image/webp", "image/jxl", "image/heif", "image/avif") diff --git a/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/KomgaFactory.kt b/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/KomgaFactory.kt index 479c8d221..34d977ef2 100644 --- a/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/KomgaFactory.kt +++ b/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/KomgaFactory.kt @@ -6,9 +6,17 @@ import eu.kanade.tachiyomi.source.SourceFactory class KomgaFactory : SourceFactory { override fun createSources(): List { val firstKomga = Komga("") - val komgaCount = firstKomga.preferences.getString(Komga.PREF_EXTRA_SOURCES_COUNT, Komga.PREF_EXTRA_SOURCES_DEFAULT)!!.toInt() + val komgaCount = firstKomga.preferences + .getString(Komga.PREF_EXTRA_SOURCES_COUNT, Komga.PREF_EXTRA_SOURCES_DEFAULT)!! + .toInt() // Komga(""), Komga("2"), Komga("3"), ... - return listOf(firstKomga) + (0 until komgaCount).map { Komga("${it + 2}") } + return buildList(komgaCount) { + add(firstKomga) + + for (i in 0 until komgaCount) { + add(Komga("${i + 2}")) + } + } } } diff --git a/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/KomgaFilters.kt b/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/KomgaFilters.kt index 8803775b6..7a4cdcb2e 100644 --- a/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/KomgaFilters.kt +++ b/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/KomgaFilters.kt @@ -98,6 +98,8 @@ internal class AuthorGroup( } } -internal class CollectionSelect(val collections: List) : Filter.Select("Collection", collections.map { it.name }.toTypedArray()) +internal class CollectionSelect( + val collections: List, +) : Filter.Select("Collection", collections.map { it.name }.toTypedArray()) internal data class CollectionFilterEntry(val name: String, val id: String? = null) diff --git a/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/KomgaUtils.kt b/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/KomgaUtils.kt index def2dbecd..00a203674 100644 --- a/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/KomgaUtils.kt +++ b/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/KomgaUtils.kt @@ -6,176 +6,89 @@ import android.widget.Button import android.widget.Toast import androidx.preference.EditTextPreference import androidx.preference.PreferenceScreen -import eu.kanade.tachiyomi.extension.all.komga.KomgaUtils.toSManga -import eu.kanade.tachiyomi.extension.all.komga.dto.BookDto -import eu.kanade.tachiyomi.extension.all.komga.dto.PageWrapperDto -import eu.kanade.tachiyomi.extension.all.komga.dto.ReadListDto -import eu.kanade.tachiyomi.extension.all.komga.dto.SeriesDto -import eu.kanade.tachiyomi.source.model.MangasPage -import eu.kanade.tachiyomi.source.model.SManga -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import okhttp3.Response -import org.apache.commons.text.StringSubstitutor -import uy.kohesive.injekt.injectLazy +import java.text.ParseException import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone -internal object KomgaUtils { - private val json: Json by injectLazy() +val formatterDate = SimpleDateFormat("yyyy-MM-dd", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } +val formatterDateTime = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } - val formatterDate = SimpleDateFormat("yyyy-MM-dd", Locale.US) - .apply { timeZone = TimeZone.getTimeZone("UTC") } - val formatterDateTime = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US) - .apply { timeZone = TimeZone.getTimeZone("UTC") } - val formatterDateTimeMilli = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.S", Locale.US) - .apply { timeZone = TimeZone.getTimeZone("UTC") } - - fun parseDate(date: String?): Long = runCatching { - formatterDate.parse(date!!)!!.time - }.getOrDefault(0L) - - fun parseDateTime(date: String?) = if (date == null) { - 0L - } else { - runCatching { - formatterDateTime.parse(date)!!.time - } - .getOrElse { - formatterDateTimeMilli.parse(date)?.time ?: 0L - } - } - - fun Response.isFromReadList() = request.url.toString().contains("/api/v1/readlists") - - fun processSeriesPage(response: Response, baseUrl: String): MangasPage { - return if (response.isFromReadList()) { - val data = response.parseAs>() - - MangasPage(data.content.map { it.toSManga(baseUrl) }, !data.last) - } else { - val data = response.parseAs>() - - MangasPage(data.content.map { it.toSManga(baseUrl) }, !data.last) - } - } - - fun formatChapterName(book: BookDto, chapterNameTemplate: String, isFromReadList: Boolean): String { - val values = hashMapOf( - "title" to book.metadata.title, - "seriesTitle" to book.seriesTitle, - "number" to book.metadata.number, - "createdDate" to book.created, - "releaseDate" to book.metadata.releaseDate, - "size" to book.size, - "sizeBytes" to book.sizeBytes.toString(), - ) - val sub = StringSubstitutor(values, "{", "}") - - return buildString { - if (isFromReadList) { - append(book.seriesTitle) - append(" ") - } - - append(sub.replace(chapterNameTemplate)) - } - } - - fun SeriesDto.toSManga(baseUrl: String): SManga = - SManga.create().apply { - title = metadata.title - url = "$baseUrl/api/v1/series/$id" - thumbnail_url = "$url/thumbnail" - status = when { - metadata.status == "ENDED" && metadata.totalBookCount != null && booksCount < metadata.totalBookCount -> SManga.PUBLISHING_FINISHED - metadata.status == "ENDED" -> SManga.COMPLETED - metadata.status == "ONGOING" -> SManga.ONGOING - metadata.status == "ABANDONED" -> SManga.CANCELLED - metadata.status == "HIATUS" -> SManga.ON_HIATUS - else -> SManga.UNKNOWN - } - genre = (metadata.genres + metadata.tags + booksMetadata.tags).distinct().joinToString(", ") - description = metadata.summary.ifBlank { booksMetadata.summary } - booksMetadata.authors.groupBy({ it.role }, { it.name }).let { map -> - author = map["writer"]?.distinct()?.joinToString() - artist = map["penciller"]?.distinct()?.joinToString() - } - } - - fun ReadListDto.toSManga(baseUrl: String): SManga = - SManga.create().apply { - title = name - description = summary - url = "$baseUrl/api/v1/readlists/$id" - thumbnail_url = "$url/thumbnail" - status = SManga.UNKNOWN - } - - fun PreferenceScreen.addEditTextPreference( - title: String, - default: String, - summary: String, - inputType: Int? = null, - validate: ((String) -> Boolean)? = null, - validationMessage: String? = null, - key: String = title, - restartRequired: Boolean = false, - ) { - EditTextPreference(context).apply { - this.key = key - this.title = title - this.summary = summary - this.setDefaultValue(default) - dialogTitle = title - - setOnBindEditTextListener { editText -> - if (inputType != null) { - editText.inputType = inputType - } - - if (validate != null) { - editText.addTextChangedListener( - object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} - - override fun afterTextChanged(editable: Editable?) { - requireNotNull(editable) - - val text = editable.toString() - - val isValid = text.isBlank() || validate(text) - - editText.error = if (!isValid) validationMessage else null - editText.rootView.findViewById