Komga: README, chapter timestamp shenanigans, refactor (#1313)

* Komga: README, chapter timestamp shenanigans, refactor

* Repeating by password length is probably fine

* Reuse the CoroutineScope
This commit is contained in:
beerpsi 2024-02-17 12:31:40 +07:00 committed by GitHub
parent bbeb5e0157
commit ca3589bb80
8 changed files with 347 additions and 269 deletions

41
src/all/komga/README.md Normal file
View File

@ -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.

View File

@ -1,7 +1,7 @@
ext {
extName = 'Komga'
extClass = '.KomgaFactory'
extVersionCode = 55
extVersionCode = 56
}
apply from: "$rootDir/common.gradle"

View File

@ -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<PageWrapperDto<ReadListDto>>()
} else {
response.parseAs<PageWrapperDto<SeriesDto>>()
}
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<Filter<*>>(
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<String>())
}.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<LibraryDto>()
@ -368,72 +426,72 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
private var publishers = emptySet<String>()
private var authors = emptyMap<String, List<AuthorDto>>() // 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<PageWrapperDto<CollectionDto>>()
.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<List<AuthorDto>>()
.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<PageWrapperDto<CollectionDto>>()
.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<List<AuthorDto>>()
.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 <reified T> 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")

View File

@ -6,9 +6,17 @@ import eu.kanade.tachiyomi.source.SourceFactory
class KomgaFactory : SourceFactory {
override fun createSources(): List<Source> {
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}"))
}
}
}
}

View File

@ -98,6 +98,8 @@ internal class AuthorGroup(
}
}
internal class CollectionSelect(val collections: List<CollectionFilterEntry>) : Filter.Select<String>("Collection", collections.map { it.name }.toTypedArray())
internal class CollectionSelect(
val collections: List<CollectionFilterEntry>,
) : Filter.Select<String>("Collection", collections.map { it.name }.toTypedArray())
internal data class CollectionFilterEntry(val name: String, val id: String? = null)

View File

@ -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<PageWrapperDto<ReadListDto>>()
MangasPage(data.content.map { it.toSManga(baseUrl) }, !data.last)
} else {
val data = response.parseAs<PageWrapperDto<SeriesDto>>()
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<Button>(android.R.id.button1)
?.isEnabled = editText.error == null
}
},
)
}
}
setOnPreferenceChangeListener { _, _ ->
try {
val result = text.isBlank() || validate?.invoke(text) ?: true
if (restartRequired && result) {
Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
}
result
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}.also(::addPreference)
}
inline fun <reified T> Response.parseAs(): T {
return json.decodeFromString(body.string())
}
fun parseDate(date: String): Long = try {
formatterDate.parse(date)!!.time
} catch (_: ParseException) {
0L
}
fun parseDateTime(date: String) = try {
formatterDateTime.parse(date)!!.time
} catch (_: ParseException) {
0L
}
fun PreferenceScreen.addEditTextPreference(
title: String,
default: String,
summary: String,
dialogMessage: String? = null,
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
this.dialogMessage = dialogMessage
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<Button>(android.R.id.button1)
?.isEnabled = editText.error == null
}
},
)
}
}
setOnPreferenceChangeListener { _, newValue ->
try {
val text = newValue as String
val result = text.isBlank() || validate?.invoke(text) ?: true
if (restartRequired && result) {
Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
}
result
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}.also(::addPreference)
}

View File

@ -1,15 +1,21 @@
package eu.kanade.tachiyomi.extension.all.komga.dto
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.Serializable
import org.apache.commons.text.StringSubstitutor
interface ConvertibleToSManga {
fun toSManga(baseUrl: String): SManga
}
@Serializable
data class LibraryDto(
class LibraryDto(
val id: String,
val name: String,
)
@Serializable
data class SeriesDto(
class SeriesDto(
val id: String,
val libraryId: String,
val name: String,
@ -19,10 +25,30 @@ data class SeriesDto(
val booksCount: Int,
val metadata: SeriesMetadataDto,
val booksMetadata: BookMetadataAggregationDto,
)
) : ConvertibleToSManga {
override fun toSManga(baseUrl: String) = 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()
}
}
}
@Serializable
data class SeriesMetadataDto(
class SeriesMetadataDto(
val status: String,
val created: String?,
val lastModified: String?,
@ -46,7 +72,7 @@ data class SeriesMetadataDto(
)
@Serializable
data class BookMetadataAggregationDto(
class BookMetadataAggregationDto(
val authors: List<AuthorDto> = emptyList(),
val tags: Set<String> = emptySet(),
val releaseDate: String?,
@ -58,7 +84,7 @@ data class BookMetadataAggregationDto(
)
@Serializable
data class BookDto(
class BookDto(
val id: String,
val seriesId: String,
val seriesTitle: String,
@ -71,10 +97,32 @@ data class BookDto(
val size: String,
val media: MediaDto,
val metadata: BookMetadataDto,
)
) {
fun getChapterName(template: String, isFromReadList: Boolean): String {
val values = hashMapOf(
"title" to metadata.title,
"seriesTitle" to seriesTitle,
"number" to metadata.number,
"createdDate" to created,
"releaseDate" to metadata.releaseDate,
"size" to size,
"sizeBytes" to sizeBytes.toString(),
)
val sub = StringSubstitutor(values, "{", "}")
return buildString {
if (isFromReadList) {
append(seriesTitle)
append(" ")
}
append(sub.replace(template))
}
}
}
@Serializable
data class MediaDto(
class MediaDto(
val status: String,
val mediaType: String,
val pagesCount: Int,
@ -83,14 +131,14 @@ data class MediaDto(
)
@Serializable
data class PageDto(
class PageDto(
val number: Int,
val fileName: String,
val mediaType: String,
)
@Serializable
data class BookMetadataDto(
class BookMetadataDto(
val title: String,
val titleLock: Boolean,
val summary: String,
@ -106,13 +154,13 @@ data class BookMetadataDto(
)
@Serializable
data class AuthorDto(
class AuthorDto(
val name: String,
val role: String,
)
@Serializable
data class CollectionDto(
class CollectionDto(
val id: String,
val name: String,
val ordered: Boolean,
@ -123,7 +171,7 @@ data class CollectionDto(
)
@Serializable
data class ReadListDto(
class ReadListDto(
val id: String,
val name: String,
val summary: String,
@ -131,4 +179,12 @@ data class ReadListDto(
val createdDate: String,
val lastModifiedDate: String,
val filtered: Boolean,
)
) : ConvertibleToSManga {
override fun toSManga(baseUrl: String) = SManga.create().apply {
title = name
description = summary
url = "$baseUrl/api/v1/readlists/$id"
thumbnail_url = "$url/thumbnail"
status = SManga.UNKNOWN
}
}

View File

@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.extension.all.komga.dto
import kotlinx.serialization.Serializable
@Serializable
data class PageWrapperDto<T>(
class PageWrapperDto<T>(
val content: List<T>,
val empty: Boolean,
val first: Boolean,