Clean up Komga (#1012)

* Clean up Komga

* le version bump
This commit is contained in:
beerpsi 2024-02-06 12:45:59 +07:00 committed by Draff
parent cdc6d67473
commit 9237c48aca
5 changed files with 377 additions and 334 deletions

View File

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

View File

@ -2,16 +2,17 @@ package eu.kanade.tachiyomi.extension.all.komga
import android.app.Application
import android.content.SharedPreferences
import android.text.Editable
import android.text.InputType
import android.text.TextWatcher
import android.util.Log
import android.widget.Button
import android.widget.Toast
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
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
@ -30,8 +31,6 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Credentials
import okhttp3.Dns
import okhttp3.HttpUrl.Companion.toHttpUrl
@ -39,14 +38,15 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.apache.commons.text.StringSubstitutor
import rx.Single
import rx.schedulers.Schedulers
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() {
@ -65,8 +65,6 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
}
private val json: Json by injectLazy()
internal val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
@ -100,7 +98,7 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
)
override fun popularMangaParse(response: Response): MangasPage =
processSeriesPage(response)
KomgaUtils.processSeriesPage(response, baseUrl)
override fun latestUpdatesRequest(page: Int): Request =
searchMangaRequest(
@ -110,13 +108,13 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
)
override fun latestUpdatesParse(response: Response): MangasPage =
processSeriesPage(response)
KomgaUtils.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.values[it.state].id
it.collections[it.state].id
}
val type = when {
@ -129,75 +127,14 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
filters.forEach { filter ->
when (filter) {
is UnreadFilter -> {
if (filter.state) {
url.addQueryParameter("read_status", "UNREAD")
url.addQueryParameter("read_status", "IN_PROGRESS")
}
}
is InProgressFilter -> {
if (filter.state) {
url.addQueryParameter("read_status", "IN_PROGRESS")
}
}
is ReadFilter -> {
if (filter.state) {
url.addQueryParameter("read_status", "READ")
}
}
is LibraryGroup -> {
val libraryToInclude = filter.state.filter { it.state }.map { it.id }
if (libraryToInclude.isNotEmpty()) {
url.addQueryParameter("library_id", libraryToInclude.joinToString(","))
}
}
is StatusGroup -> {
val statusToInclude = filter.state.filter { it.state }.map { it.name.uppercase(Locale.ROOT) }
if (statusToInclude.isNotEmpty()) {
url.addQueryParameter("status", statusToInclude.joinToString(","))
}
}
is GenreGroup -> {
val genreToInclude = filter.state.filter { it.state }.map { it.name }
if (genreToInclude.isNotEmpty()) {
url.addQueryParameter("genre", genreToInclude.joinToString(","))
}
}
is TagGroup -> {
val tagToInclude = filter.state.filter { it.state }.map { it.name }
if (tagToInclude.isNotEmpty()) {
url.addQueryParameter("tag", tagToInclude.joinToString(","))
}
}
is PublisherGroup -> {
val publisherToInclude = mutableListOf<String>()
filter.state.forEach { content ->
if (content.state) {
publisherToInclude.add(content.name)
}
}
if (publisherToInclude.isNotEmpty()) {
url.addQueryParameter("publisher", publisherToInclude.joinToString(","))
}
}
is AuthorGroup -> {
val authorToInclude = filter.state.filter { it.state }.map { it.author }
authorToInclude.forEach {
url.addQueryParameter("author", "${it.name},${it.role}")
}
}
is UriFilter -> filter.addToUri(url)
is Filter.Sort -> {
val state = filter.state ?: return@forEach
val sortCriteria = when (state.index) {
0 -> if (type == "series") "metadata.titleSort" else "name"
1 -> "createdDate"
2 -> "lastModifiedDate"
1 -> if (type == "series") "metadata.titleSort" else "name"
2 -> "createdDate"
3 -> "lastModifiedDate"
else -> return@forEach
} + "," + if (state.ascending) "asc" else "desc"
@ -211,17 +148,17 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
}
override fun searchMangaParse(response: Response): MangasPage =
processSeriesPage(response)
KomgaUtils.processSeriesPage(response, baseUrl)
override fun getMangaUrl(manga: SManga) = manga.url.replace("/api/v1", "")
override fun mangaDetailsRequest(manga: SManga) = GET(manga.url)
override fun mangaDetailsParse(response: Response): SManga {
return if (response.fromReadList()) {
response.parseAs<ReadListDto>().toSManga()
return if (response.isFromReadList()) {
response.parseAs<ReadListDto>().toSManga(baseUrl)
} else {
response.parseAs<SeriesDto>().toSManga()
response.parseAs<SeriesDto>().toSManga(baseUrl)
}
}
@ -236,34 +173,22 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
override fun chapterListParse(response: Response): List<SChapter> {
val page = response.parseAs<PageWrapperDto<BookDto>>().content
val isFromReadList = response.isFromReadList()
val r = page.mapIndexed { index, book ->
SChapter.create().apply {
chapter_number = if (!response.fromReadList()) book.metadata.numberSort else index + 1F
chapter_number = if (!isFromReadList) book.metadata.numberSort else index + 1F
url = "$baseUrl/api/v1/books/${book.id}"
scanlator = book.metadata.authors.groupBy({ it.role }, { it.name })["translator"]?.joinToString()
date_upload = book.metadata.releaseDate?.let { parseDate(it) }
?: parseDateTime(book.fileLastModified)
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, "{", "}")
name = (if (!response.fromReadList()) "" else "${book.seriesTitle} ") + sub.replace(chapterNameTemplate)
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.fileLastModified)
}
}
return r.sortedByDescending { it.chapter_number }
}
override fun pageListRequest(chapter: SChapter): Request =
GET("${chapter.url}/pages")
override fun pageListRequest(chapter: SChapter) = GET("${chapter.url}/pages")
override fun pageListParse(response: Response): List<Page> {
val pages = response.parseAs<List<PageDto>>()
@ -275,47 +200,69 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
} else {
""
}
Page(
index = it.number,
imageUrl = url,
)
Page(it.number, imageUrl = url)
}
}
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
override fun getFilterList(): FilterList {
val filters = try {
mutableListOf<Filter<*>>(
UnreadFilter(),
InProgressFilter(),
ReadFilter(),
TypeSelect(),
CollectionSelect(listOf(CollectionFilterEntry("None")) + collections.map { CollectionFilterEntry(it.name, it.id) }),
LibraryGroup(libraries.map { LibraryFilter(it.id, it.name) }.sortedBy { it.name.lowercase(Locale.ROOT) }),
StatusGroup(listOf("Ongoing", "Ended", "Abandoned", "Hiatus").map { StatusFilter(it) }),
GenreGroup(genres.map { GenreFilter(it) }),
TagGroup(tags.map { TagFilter(it) }),
PublisherGroup(publishers.map { PublisherFilter(it) }),
).also { list ->
if (collections.isEmpty() && libraries.isEmpty() && genres.isEmpty() && tags.isEmpty() && publishers.isEmpty()) {
list.add(0, Filter.Header("Press 'Reset' to show filtering options"))
list.add(1, Filter.Separator())
}
list.addAll(authors.map { (role, authors) -> AuthorGroup(role, authors.map { AuthorFilter(it) }) })
list.add(SeriesSort())
val filters = mutableListOf<Filter<*>>(
UnreadFilter(),
InProgressFilter(),
ReadFilter(),
TypeSelect(),
CollectionSelect(
buildList {
add(CollectionFilterEntry("None"))
collections.forEach {
add(CollectionFilterEntry(it.name, it.id))
}
},
),
UriMultiSelectFilter(
"Libraries",
"library_id",
libraries.map { UriMultiSelectOption(it.name, it.id) },
),
UriMultiSelectFilter(
"Status",
"status",
listOf("Ongoing", "Ended", "Abandoned", "Hiatus").map {
UriMultiSelectOption(it, it.uppercase(Locale.ROOT))
},
),
UriMultiSelectFilter(
"Genres",
"genre",
genres.map { UriMultiSelectOption(it) },
),
UriMultiSelectFilter(
"Tags",
"tag",
tags.map { UriMultiSelectOption(it) },
),
UriMultiSelectFilter(
"Publishers",
"publisher",
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"))
add(1, Filter.Separator())
}
} catch (e: Exception) {
Log.e(logTag, "error while creating filter list", e)
emptyList()
addAll(authors.map { (role, authors) -> AuthorGroup(role, authors.map { AuthorFilter(it) }) })
add(SeriesSort())
}
return FilterList(filters)
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
if (suffix.isBlank()) {
if (suffix.isEmpty()) {
ListPreference(screen.context).apply {
key = PREF_EXTRA_SOURCES_COUNT
title = "Number of extra sources"
@ -324,15 +271,9 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
entryValues = PREF_EXTRA_SOURCES_ENTRIES
setDefaultValue(PREF_EXTRA_SOURCES_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
try {
val setting = preferences.edit().putString(PREF_EXTRA_SOURCES_COUNT, newValue as String).commit()
Toast.makeText(screen.context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
setting
} catch (e: Exception) {
e.printStackTrace()
false
}
setOnPreferenceChangeListener { _, _ ->
Toast.makeText(screen.context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
true
}
}.also(screen::addPreference)
}
@ -383,14 +324,8 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
setDefaultValue(PREF_CHAPTER_NAME_TEMPLATE_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
try {
val setting = preferences.edit().putString(PREF_CHAPTER_NAME_TEMPLATE, newValue as String).commit()
Toast.makeText(screen.context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
setting
} catch (e: Exception) {
e.printStackTrace()
false
}
Toast.makeText(screen.context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
true
}
}.also(screen::addPreference)
}
@ -402,196 +337,54 @@ 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 class TypeSelect : Filter.Select<String>("Search for", arrayOf(TYPE_SERIES, TYPE_READLISTS))
private class LibraryFilter(val id: String, name: String) : Filter.CheckBox(name, false)
private class LibraryGroup(libraries: List<LibraryFilter>) : Filter.Group<LibraryFilter>("Libraries", libraries)
private class CollectionSelect(collections: List<CollectionFilterEntry>) : Filter.Select<CollectionFilterEntry>("Collection", collections.toTypedArray())
private class SeriesSort(selection: Selection? = null) : Filter.Sort("Sort", arrayOf("Alphabetically", "Date added", "Date updated"), selection ?: Selection(0, true))
private class StatusFilter(name: String) : Filter.CheckBox(name, false)
private class StatusGroup(filters: List<StatusFilter>) : Filter.Group<StatusFilter>("Status", filters)
private class UnreadFilter : Filter.CheckBox("Unread", false)
private class InProgressFilter : Filter.CheckBox("In Progress", false)
private class ReadFilter : Filter.CheckBox("Read", false)
private class GenreFilter(genre: String) : Filter.CheckBox(genre, false)
private class GenreGroup(genres: List<GenreFilter>) : Filter.Group<GenreFilter>("Genres", genres)
private class TagFilter(tag: String) : Filter.CheckBox(tag, false)
private class TagGroup(tags: List<TagFilter>) : Filter.Group<TagFilter>("Tags", tags)
private class PublisherFilter(publisher: String) : Filter.CheckBox(publisher, false)
private class PublisherGroup(publishers: List<PublisherFilter>) : Filter.Group<PublisherFilter>("Publishers", publishers)
private class AuthorFilter(val author: AuthorDto) : Filter.CheckBox(author.name, false)
private class AuthorGroup(role: String, authors: List<AuthorFilter>) : Filter.Group<AuthorFilter>(role.replaceFirstChar { it.titlecase() }, authors)
private data class CollectionFilterEntry(
val name: String,
val id: String? = null,
) {
override fun toString() = name
}
private fun PreferenceScreen.addEditTextPreference(
title: String,
default: String,
summary: String,
inputType: Int? = null,
validate: ((String) -> Boolean)? = null,
validationMessage: String? = null,
key: String = title,
) {
val preference = 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 { _, newValue ->
try {
val res = preferences.edit().putString(this.key, newValue as String).commit()
Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
res
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}
addPreference(preference)
}
private var fetchFiltersFailed = false
private var fetchFiltersAttempts = 0
private val fetchFiltersLock = ReentrantReadWriteLock()
private fun fetchFilterOptions() {
if (baseUrl.isBlank()) {
return
}
if (fetchFiltersAttempts > 3 || (fetchFiltersAttempts > 0 && !fetchFiltersFailed)) {
return
}
Single.fromCallable {
val result = runCatching {
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 }
fetchFiltersLock.read {
if (fetchFiltersAttempts > 3 || (fetchFiltersAttempts > 0 && !fetchFiltersFailed)) {
return@fromCallable
}
}
.onFailure {
Log.e(logTag, "Could not fetch filtering options", it)
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
}
fetchFiltersFailed = result.isFailure
fetchFiltersAttempts++
fetchFiltersAttempts++
}
}
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.subscribe()
}
private fun processSeriesPage(response: Response): MangasPage {
return if (response.fromReadList()) {
val data = response.parseAs<PageWrapperDto<ReadListDto>>()
MangasPage(data.content.map { it.toSManga() }, !data.last)
} else {
val data = response.parseAs<PageWrapperDto<SeriesDto>>()
MangasPage(data.content.map { it.toSManga() }, !data.last)
}
}
private fun SeriesDto.toSManga(): 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 }.let { map ->
author = map["writer"]?.map { it.name }?.distinct()?.joinToString()
artist = map["penciller"]?.map { it.name }?.distinct()?.joinToString()
}
}
private fun ReadListDto.toSManga(): SManga =
SManga.create().apply {
title = name
description = summary
url = "$baseUrl/api/v1/readlists/$id"
thumbnail_url = "$url/thumbnail"
status = SManga.UNKNOWN
}
private fun Response.fromReadList() = request.url.toString().contains("/api/v1/readlists")
private fun parseDate(date: String?): Long = runCatching {
KomgaHelper.formatterDate.parse(date!!)!!.time
}.getOrDefault(0L)
private fun parseDateTime(date: String?) = if (date == null) {
0L
} else {
runCatching {
KomgaHelper.formatterDateTime.parse(date)!!.time
}
.getOrElse {
KomgaHelper.formatterDateTimeMilli.parse(date)?.time ?: 0L
}
}
private inline fun <reified T> Response.parseAs(): T {
return json.decodeFromString(body.string())
}
private val logTag = "komga${if (suffix.isNotBlank()) ".$suffix" else ""}"
companion object {
@ -608,7 +401,7 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
private val supportedImageTypes = listOf("image/jpeg", "image/png", "image/gif", "image/webp", "image/jxl", "image/heif", "image/avif")
private const val TYPE_SERIES = "Series"
private const val TYPE_READLISTS = "Read lists"
internal const val TYPE_SERIES = "Series"
internal const val TYPE_READLISTS = "Read lists"
}
}

View File

@ -0,0 +1,89 @@
package eu.kanade.tachiyomi.extension.all.komga
import eu.kanade.tachiyomi.extension.all.komga.dto.AuthorDto
import eu.kanade.tachiyomi.source.model.Filter
import okhttp3.HttpUrl
interface UriFilter {
fun addToUri(builder: HttpUrl.Builder)
}
internal class TypeSelect : Filter.Select<String>(
"Search for",
arrayOf(
Komga.TYPE_SERIES,
Komga.TYPE_READLISTS,
),
)
internal class SeriesSort(selection: Selection? = null) : Filter.Sort(
"Sort",
arrayOf("None", "Alphabetically", "Date added", "Date updated"),
selection ?: Selection(0, true),
)
internal class UnreadFilter : Filter.CheckBox("Unread", false), UriFilter {
override fun addToUri(builder: HttpUrl.Builder) {
if (!state) {
return
}
builder.addQueryParameter("read_status", "UNREAD")
builder.addQueryParameter("read_status", "IN_PROGRESS")
}
}
internal class InProgressFilter : Filter.CheckBox("In Progress", false), UriFilter {
override fun addToUri(builder: HttpUrl.Builder) {
if (!state) {
return
}
builder.addQueryParameter("read_status", "IN_PROGRESS")
}
}
internal class ReadFilter : Filter.CheckBox("Read", false), UriFilter {
override fun addToUri(builder: HttpUrl.Builder) {
if (!state) {
return
}
builder.addQueryParameter("read_status", "READ")
}
}
internal class UriMultiSelectOption(name: String, val id: String = name) : Filter.CheckBox(name, false)
internal class UriMultiSelectFilter(
name: String,
private val param: String,
genres: List<UriMultiSelectOption>,
) : Filter.Group<UriMultiSelectOption>(name, genres), UriFilter {
override fun addToUri(builder: HttpUrl.Builder) {
val whatToInclude = state.filter { it.state }.map { it.id }
if (whatToInclude.isNotEmpty()) {
builder.addQueryParameter(param, whatToInclude.joinToString(","))
}
}
}
internal class AuthorFilter(val author: AuthorDto) : Filter.CheckBox(author.name, false)
internal class AuthorGroup(
role: String,
authors: List<AuthorFilter>,
) : Filter.Group<AuthorFilter>(role.replaceFirstChar { it.titlecase() }, authors), UriFilter {
override fun addToUri(builder: HttpUrl.Builder) {
val authorToInclude = state.filter { it.state }.map { it.author }
authorToInclude.forEach {
builder.addQueryParameter("author", "${it.name},${it.role}")
}
}
}
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

@ -1,14 +0,0 @@
package eu.kanade.tachiyomi.extension.all.komga
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
object KomgaHelper {
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") }
}

View File

@ -0,0 +1,175 @@
package eu.kanade.tachiyomi.extension.all.komga
import android.text.Editable
import android.text.TextWatcher
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.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 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 }.let { map ->
author = map["writer"]?.map { it.name }?.distinct()?.joinToString()
artist = map["penciller"]?.map { it.name }?.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,
) {
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 {
Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
text.isBlank() || validate?.invoke(text) ?: true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}.also(::addPreference)
}
inline fun <reified T> Response.parseAs(): T {
return json.decodeFromString(body.string())
}
}