From 5a05fd18c67e47967bc956569a980a6935c40509 Mon Sep 17 00:00:00 2001 From: ThePromidius Date: Thu, 21 Dec 2023 13:51:11 +0100 Subject: [PATCH] [Kavita] Filters update, smart filters and localization (#19329) * Finish migration to filters v2 * Implementation for smart filters * Subtle cleanup * Localization tests * Fix 404 for latest update * Filter out epubs * Fixed Filter out epubs and optimization of 20 results per page * Changelog and bump version * Localization implementation * Add localization keys * Fix pub status not displaying. Closes #16318 Co-authored-by: FYannK * Hande exceptions and add version requirements * Make fetch implementation with error handler. Added/improved some comments Added some more translation * Update changelog * Updated localization --------- Co-authored-by: FYannK --- src/all/kavita/CHANGELOG.md | 11 + .../kavita/assets/i18n/messages_en.properties | 18 + .../assets/i18n/messages_es_es.properties | 18 + .../assets/i18n/messages_fr_fr.properties | 20 + .../assets/i18n/messages_nb_no.properties | 21 + src/all/kavita/build.gradle | 3 +- .../tachiyomi/extension/all/kavita/Filters.kt | 112 ++++ .../tachiyomi/extension/all/kavita/Kavita.kt | 619 ++++++++---------- .../extension/all/kavita/KavitaConstants.kt | 1 + .../extension/all/kavita/KavitaHelper.kt | 95 ++- .../extension/all/kavita/KavitaInt.kt | 17 + .../extension/all/kavita/dto/FilterDto.kt | 124 ++++ .../extension/all/kavita/dto/MangaDto.kt | 2 +- .../extension/all/kavita/dto/MetadataDto.kt | 29 +- 14 files changed, 750 insertions(+), 340 deletions(-) create mode 100644 src/all/kavita/assets/i18n/messages_en.properties create mode 100644 src/all/kavita/assets/i18n/messages_es_es.properties create mode 100644 src/all/kavita/assets/i18n/messages_fr_fr.properties create mode 100644 src/all/kavita/assets/i18n/messages_nb_no.properties create mode 100644 src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Filters.kt create mode 100644 src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaInt.kt create mode 100644 src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/FilterDto.kt diff --git a/src/all/kavita/CHANGELOG.md b/src/all/kavita/CHANGELOG.md index b35d70e64..333fef450 100644 --- a/src/all/kavita/CHANGELOG.md +++ b/src/all/kavita/CHANGELOG.md @@ -1,3 +1,14 @@ +## 1.3.12 + +## Features + +* Migrate filters to v2 +* Implemented smartFilters +* Added localization support + +## Fixed +* Fixed publication status not showing + ## 1.3.10 ### Features diff --git a/src/all/kavita/assets/i18n/messages_en.properties b/src/all/kavita/assets/i18n/messages_en.properties new file mode 100644 index 000000000..52f341d65 --- /dev/null +++ b/src/all/kavita/assets/i18n/messages_en.properties @@ -0,0 +1,18 @@ +login_errors_failed_login=Login failed. Something went wrong +login_errors_header_token_empty="Error: The JSON Web Token is empty.\nTry opening the extension first." +login_errors_invalid_url=Invalid URL: +login_errors_parse_tokendto=There was an error parsing the auth token +pref_customsource_title=Displayed name for source +pref_edit_customsource_summary=Here you can change this source name.\nYou can write a descriptive name to identify this OPDS URL. +pref_filters_summary=Show these filters in the filter list +pref_filters_title=Default filters shown +pref_opds_badformed_url=Incorrect OPDS address. Please copy it from User settings \u2192 3rd party apps \u2192 OPDS URL +pref_opds_duplicated_source_url=The URL is configured in a different source -> +pref_opds_must_setup_address=You must set up the address to communicate with Kavita +pref_opds_summary=The OPDS URL copied from User Settings. This should include address and end with the API key. +restartapp_settings=Restart Tachiyomi to apply new setting. +version_exceptions_chapters_parse=Unhandled exception parsing chapters. Send your logs to the Kavita devs. +check_version=Ensure you have the newest version of the extension and Kavita. (0.7.8 or newer.)\nIf the issue persists, report it to the Kavita developers with the accompanying logs. +version_exceptions_smart_filter=Could not decode SmartFilter. Ensure you are using Kavita version 0.7.11 or later. +http_errors_500=Something went wrong +http_errors_401=There was an error logging in. Try again or reload the app diff --git a/src/all/kavita/assets/i18n/messages_es_es.properties b/src/all/kavita/assets/i18n/messages_es_es.properties new file mode 100644 index 000000000..8f6d3058c --- /dev/null +++ b/src/all/kavita/assets/i18n/messages_es_es.properties @@ -0,0 +1,18 @@ +pref_customsource_title=Nombre de la instancia +pref_edit_customsource_summary=Aqui puedes cambiar el nombre de la instancia.\nPuedes escribir un nombre descriptivo que identifique esta url/instancia +restartapp_settings=Reinicia la aplicación para aplicar los cambios +version_exceptions_chapters_parse=Algo ha ido mal al procesar los capitulos. Envia los registros de fallo a los desarrolladores de Kavita +check_version=Comprueba que tienes tanto Kavita como la extension actualizada. (Version minima: 0.7.8)\nSi el problema persiste, reportalo a los desarrolladores de Kavita aportando los registros de fallo. +version_exceptions_smart_filter=Fallo al decodificar los filtros inteligentes. Aseg\u00FArate que estas al menos en la version 0.7.11 de Kavita +http_errors_500=Algo ha ido mal +http_errors_401=Ha habido un error al iniciar sesi\u00F3n. Prueba otra vez o reinicia la aplicaci\u00F3n +pref_opds_summary=La url del OPDS copiada de la configuraci\u00F3n del usuario. Debe incluir la direcci\u00F3n y la clave api al final. +pref_filters_summary=Mostrar estos filtros en la lista de filtros +pref_filters_title=Filtros por defecto +pref_opds_badformed_url=La direcci\u00F3n OPDS no es correcta. Por favor, c\u00F3piela desde la Configuraci\u00F3n de usuario-> aplicaciones de terceros -> url de OPDS +login_errors_parse_tokendto=Se ha producido un error al procesar el token de autenticaci\u00F3n +pref_opds_duplicated_source_url=Url est\u00E1 configurado en una fuente diferente -> +pref_opds_must_setup_address=Debe configurar la direcci\u00F3n para comunicarse con Kavita +login_errors_failed_login=Error en el inicio de sesi\u00F3n. Algo ha ido mal +login_errors_header_token_empty="Error: el token jwt est\u00E1 vac\u00EDo.\nIntente abrir primero la extensi\u00F3n" +login_errors_invalid_url=URL no v\u00E1lida: diff --git a/src/all/kavita/assets/i18n/messages_fr_fr.properties b/src/all/kavita/assets/i18n/messages_fr_fr.properties new file mode 100644 index 000000000..bd29b962a --- /dev/null +++ b/src/all/kavita/assets/i18n/messages_fr_fr.properties @@ -0,0 +1,20 @@ + + +version_exceptions_chapters_parse=Exception non trait\u00E9e durant l'analyse des chapitres. Envoyez les journaux aux d\u00E9velopeurs de Kavita +pref_customsource_title=Nom d'affichage pour la source +version_exceptions_smart_filter=\u00C9chec du d\u00E9codage de SmartFilter. Assurez-vous que vous utilisez au moins Kavita version 0.7.11 +pref_opds_summary=L'URL OPDS a \u00E9t\u00E9 copi\u00E9e \u00E0 partir des param\u00E8tres de l'utilisateur. Ceci devrait inclure l'adresse et la cl\u00E9 API. +pref_filters_summary=Afficher ces filtres dans la liste des filtres +check_version=Assurez-vous que vous avez l'extension et Kavita mises \u00E0 jour. (version Mini\u202F: 0.7.8)\nSi le probl\u00E8me persiste, signalez-le aux d\u00E9veloppeurs de Kavita en fournissant des journaux +pref_filters_title=Filtres par d\u00E9faut affich\u00E9s +pref_edit_customsource_summary=Ici vous pouvez changer ce nom source.\nVous pouvez \u00E9crire un nom descriptif pour identifier cette URL opds +pref_opds_badformed_url=L'adresse OPDS n'est pas correcte. Veuillez la copiez \u00E0 partir des param\u00E8tres de l'utilisateur - > Applis tierces -> URL OPDS +login_errors_parse_tokendto=Il y a eu une erreur pendant l'analyse du jeton d'authentification +restartapp_settings=Red\u00E9marrez Tachiyomi pour appliquer le nouveau r\u00E9glage. +pref_opds_duplicated_source_url=L'URL est configur\u00E9e dans une autre source -> +pref_opds_must_setup_address=Vous devez configurer l'adresse pour communiquer avec Kavita +login_errors_failed_login=\u00C9chec de la connexion. Quelque chose s'est mal pass\u00E9 +http_errors_500=Quelque chose s'est mal pass\u00E9 +login_errors_header_token_empty=\u00AB\u00A0Erreur\u202F: le jeton jwt est vide.\nEssayez d'abord d'ouvrir l'extension\u00A0\u00BB +login_errors_invalid_url=URL invalide\u202F: +http_errors_401=Il y a eu une erreur. Essayez de nouveau ou rechargez l'application diff --git a/src/all/kavita/assets/i18n/messages_nb_no.properties b/src/all/kavita/assets/i18n/messages_nb_no.properties new file mode 100644 index 000000000..c2e0a530d --- /dev/null +++ b/src/all/kavita/assets/i18n/messages_nb_no.properties @@ -0,0 +1,21 @@ + + +pref_customsource_title=Vist kildenavn +pref_edit_customsource_summary=Her kan du endre dette kildenavnet.\nDu kan skrive et beskrivende navn for \u00E5 identifisere denne OPDS-nettadressen. +restartapp_settings=Ny innstilling trer i kraft n\u00E5r du starter Tachiyomi p\u00E5 ny. +duplicated_source_url=Nettadressen er satt opp i en annen Kavita-instans +pref_filters_summary=Vis disse filterne i filterlisten +pref_filters_title=Forvalgte filtre valgt +login_errors_parse_tokendto=Kunne ikke tolke identifiseringssymbolet +login_errors_failed_login=Innlogging mislyktes. Noe gikk galt. +http_errors_500=Noe gikk galt +login_errors_header_token_empty="Feil: JSON-nettsymbol er tomt.\nPr\u00F8v \u00E5 \u00E5pne utvidelsen f\u00F8rst." +login_errors_invalid_url=Ugyldig nettadresse: +version_exceptions_chapters_parse=Uh\u00E5ndtert unntak i tolking av kapitler. Send loggene dine til Kavita-utviklerne. +version_exceptions_smart_filter=Kunne ikke dekode smartfilter. Forsikre deg om at du bruker Kavita versjon 0.7.11 eller nyere. +pref_opds_summary=OPDS-nettadressen kopiert fra brukerinnstillingene. Denne skal inkludere med adressen og slutte med API-n\u00F8kkelen. +check_version=Forsikre deg om at b\u00E5de utvidelsen og Kavita er av nyeste versjon. (Ihvertfall 0.7.8)\nHvis problemet vedvarer kan du rapportere det til Kavita-utviklerne med tilh\u00F8rende loggf\u00F8ring. +pref_opds_badformed_url=OPDS-adressen er ikke riktig. Kopier den fra brukerinnstillinger -> tredjepartsprogrammer -> OPDS-nettadresse +pref_opds_duplicated_source_url=Nettadressen er satt opp i en annen instans -> +pref_opds_must_setup_address=Du m\u00E5 sette opp adressen som skal kommunisere med Kavita +http_errors_401=Feil med innlogging. Pr\u00F8v \u00E5 laste inn p\u00E5 ny, eller start programmet p\u00E5 ny. diff --git a/src/all/kavita/build.gradle b/src/all/kavita/build.gradle index d0beabf32..4c45c9f12 100644 --- a/src/all/kavita/build.gradle +++ b/src/all/kavita/build.gradle @@ -6,11 +6,12 @@ ext { extName = 'Kavita' pkgNameSuffix = 'all.kavita' extClass = '.KavitaFactory' - extVersionCode = 11 + extVersionCode = 12 } dependencies { implementation 'info.debatty:java-string-similarity:2.0.0' + implementation(project(':lib-i18n')) } apply from: "$rootDir/common.gradle" diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Filters.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Filters.kt new file mode 100644 index 000000000..80d0e4075 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Filters.kt @@ -0,0 +1,112 @@ +package eu.kanade.tachiyomi.extension.all.kavita + +import eu.kanade.tachiyomi.extension.all.kavita.KavitaConstants.noSmartFilterSelected +import eu.kanade.tachiyomi.source.model.Filter + +class UserRating : + Filter.Select( + "Minimum Rating", + arrayOf( + "Any", + "1 star", + "2 stars", + "3 stars", + "4 stars", + "5 stars", + ), + ) +class SmartFiltersFilter(smartFilters: Array) : + Filter.Select("Smart Filters", arrayOf(noSmartFilterSelected) + smartFilters) +class SortFilter(sortables: Array) : Filter.Sort("Sort by", sortables, Selection(0, true)) + +val sortableList = listOf( + Pair("Sort name", 1), + Pair("Created", 2), + Pair("Last modified", 3), + Pair("Item added", 4), + Pair("Time to Read", 5), + Pair("Release year", 6), +) + +class StatusFilter(name: String) : Filter.CheckBox(name, false) +class StatusFilterGroup(filters: List) : + Filter.Group("Status", filters) + +class ReleaseYearRange(name: String) : Filter.Text(name) +class ReleaseYearRangeGroup(filters: List) : + Filter.Group("Release Year", filters) +class GenreFilter(name: String) : Filter.TriState(name) +class GenreFilterGroup(genres: List) : + Filter.Group("Genres", genres) + +class TagFilter(name: String) : Filter.TriState(name) +class TagFilterGroup(tags: List) : Filter.Group("Tags", tags) + +class AgeRatingFilter(name: String) : Filter.TriState(name) +class AgeRatingFilterGroup(ageRatings: List) : + Filter.Group("Age Rating", ageRatings) + +class FormatFilter(name: String) : Filter.CheckBox(name, false) +class FormatsFilterGroup(formats: List) : + Filter.Group("Formats", formats) + +class CollectionFilter(name: String) : Filter.TriState(name) +class CollectionFilterGroup(collections: List) : + Filter.Group("Collection", collections) + +class LanguageFilter(name: String) : Filter.TriState(name) +class LanguageFilterGroup(languages: List) : + Filter.Group("Language", languages) + +class LibraryFilter(library: String) : Filter.TriState(library) +class LibrariesFilterGroup(libraries: List) : + Filter.Group("Libraries", libraries) + +class PubStatusFilter(name: String) : Filter.CheckBox(name, false) +class PubStatusFilterGroup(status: List) : + Filter.Group("Publication Status", status) + +class PeopleHeaderFilter(name: String) : + Filter.Header(name) +class PeopleSeparatorFilter : + Filter.Separator() + +class WriterPeopleFilter(name: String) : Filter.CheckBox(name, false) +class WriterPeopleFilterGroup(peoples: List) : + Filter.Group("Writer", peoples) + +class PencillerPeopleFilter(name: String) : Filter.CheckBox(name, false) +class PencillerPeopleFilterGroup(peoples: List) : + Filter.Group("Penciller", peoples) + +class InkerPeopleFilter(name: String) : Filter.CheckBox(name, false) +class InkerPeopleFilterGroup(peoples: List) : + Filter.Group("Inker", peoples) + +class ColoristPeopleFilter(name: String) : Filter.CheckBox(name, false) +class ColoristPeopleFilterGroup(peoples: List) : + Filter.Group("Colorist", peoples) + +class LettererPeopleFilter(name: String) : Filter.CheckBox(name, false) +class LettererPeopleFilterGroup(peoples: List) : + Filter.Group("Letterer", peoples) + +class CoverArtistPeopleFilter(name: String) : Filter.CheckBox(name, false) +class CoverArtistPeopleFilterGroup(peoples: List) : + Filter.Group("Cover Artist", peoples) + +class EditorPeopleFilter(name: String) : Filter.CheckBox(name, false) +class EditorPeopleFilterGroup(peoples: List) : + Filter.Group("Editor", peoples) + +class PublisherPeopleFilter(name: String) : Filter.CheckBox(name, false) +class PublisherPeopleFilterGroup(peoples: List) : + Filter.Group("Publisher", peoples) + +class CharacterPeopleFilter(name: String) : Filter.CheckBox(name, false) +class CharacterPeopleFilterGroup(peoples: List) : + Filter.Group("Character", peoples) + +class TranslatorPeopleFilter(name: String) : Filter.CheckBox(name, false) +class TranslatorPeopleFilterGroup(peoples: List) : + Filter.Group("Translator", peoples) diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Kavita.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Kavita.kt index a06a1b9bc..7482e6cd6 100644 --- a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Kavita.kt +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Kavita.kt @@ -9,7 +9,10 @@ import androidx.preference.EditTextPreference import androidx.preference.MultiSelectListPreference import eu.kanade.tachiyomi.AppInfo import eu.kanade.tachiyomi.extension.all.kavita.dto.AuthenticationDto -import eu.kanade.tachiyomi.extension.all.kavita.dto.ChapterDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.FilterComparison +import eu.kanade.tachiyomi.extension.all.kavita.dto.FilterField +import eu.kanade.tachiyomi.extension.all.kavita.dto.FilterStatementDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.FilterV2Dto import eu.kanade.tachiyomi.extension.all.kavita.dto.MangaFormat import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataAgeRatings import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataCollections @@ -24,6 +27,9 @@ import eu.kanade.tachiyomi.extension.all.kavita.dto.PersonRole import eu.kanade.tachiyomi.extension.all.kavita.dto.SeriesDto import eu.kanade.tachiyomi.extension.all.kavita.dto.SeriesMetadataDto import eu.kanade.tachiyomi.extension.all.kavita.dto.ServerInfoDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.SmartFilter +import eu.kanade.tachiyomi.extension.all.kavita.dto.SortFieldEnum +import eu.kanade.tachiyomi.extension.all.kavita.dto.SortOptions import eu.kanade.tachiyomi.extension.all.kavita.dto.VolumeDto import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST @@ -31,6 +37,8 @@ import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.UnmeteredSource import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.Filter.TriState.Companion.STATE_EXCLUDE +import eu.kanade.tachiyomi.source.model.Filter.TriState.Companion.STATE_INCLUDE import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.Page @@ -39,10 +47,8 @@ import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.add -import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put import okhttp3.Dns import okhttp3.Headers @@ -50,7 +56,6 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import rx.Observable @@ -62,32 +67,10 @@ import uy.kohesive.injekt.injectLazy import java.io.IOException import java.net.ConnectException import java.security.MessageDigest +import java.util.Locale class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSource, HttpSource() { - class CompareChapters { - companion object : Comparator { - override fun compare(a: SChapter, b: SChapter): Int { - if (a.chapter_number < 1.0 && b.chapter_number < 1.0) { - // Both are volumes, multiply by 100 and do normal sort - return if ((a.chapter_number * 100) < (b.chapter_number * 100)) { - 1 - } else { - -1 - } - } else { - if (a.chapter_number < 1.0 && b.chapter_number >= 1.0) { - // A is volume, b is not. A should sort first - return 1 - } else if (a.chapter_number >= 1.0 && b.chapter_number < 1.0) { - return -1 - } - } - if (a.chapter_number < b.chapter_number) return 1 - if (a.chapter_number > b.chapter_number) return -1 - return 0 - } - } - } + private val helper = KavitaHelper() override val client: OkHttpClient = network.client.newBuilder() .dns(Dns.SYSTEM) @@ -100,7 +83,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou private val preferences: SharedPreferences by lazy { Injekt.get().getSharedPreferences("source_$id", 0x0000) } - override val name = "Kavita (${preferences.getString(KavitaConstants.customSourceNamePref,suffix)})" + override val name = "${KavitaInt.KAVITA_NAME} (${preferences.getString(KavitaConstants.customSourceNamePref, suffix)})" override val lang = "all" override val supportsLatest = true private val apiUrl by lazy { getPrefApiUrl() } @@ -108,11 +91,12 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou private val address by lazy { getPrefAddress() } // Address for the Kavita OPDS url. Should be http(s)://host:(port)/api/opds/api-key private val apiKey by lazy { getPrefApiKey() } private var jwtToken = "" // * JWT Token for authentication with the server. Stored in memory. - private val LOG_TAG = """extension.all.kavita_${"[$suffix]_" + preferences.getString(KavitaConstants.customSourceNamePref,"[$suffix]")!!.replace(' ','_')}""" + private val LOG_TAG = """Kavita_${"[$suffix]_" + preferences.getString(KavitaConstants.customSourceNamePref, "[$suffix]")!!.replace(' ', '_')}""" private var isLogged = false // Used to know if login was correct and not send login requests anymore - private val json: Json by injectLazy() - private val helper = KavitaHelper() + + private var series = emptyList() // Acts as a cache + private inline fun Response.parseAs(): T = use { if (it.code == 401) { @@ -129,22 +113,38 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } json.decodeFromString(it.body.string()) } - private inline fun > safeValueOf(type: String): T { - return java.lang.Enum.valueOf(T::class.java, type) + + /** + * Custom implementation for fetch popular, latest and search + * Handles and logs errors to provide a more detailed exception to the users. + */ + private fun fetch(request: Request): Observable { + return client.newCall(request) + .asObservableSuccess() + .onErrorResumeNext { throwable -> + // Get Http code + val field = throwable.javaClass.getDeclaredField("code") + field.isAccessible = true // Make the field accessible + var code = field.get(throwable) // Get the value of the code property + Log.e(LOG_TAG, "Error fetching manga: ${throwable.message}", throwable) + if (code as Int !in intArrayOf(401, 201, 500)) { + code = 500 + } + return@onErrorResumeNext Observable.error(IOException("Http Error: $code\n ${helper.intl["http_errors_$code"]}\n${helper.intl["check_version"]}")) + } + .map { response -> + popularMangaParse(response) + } } - private var series = emptyList() // Acts as a cache + override fun fetchPopularManga(page: Int) = + fetch(popularMangaRequest(page)) - override fun popularMangaRequest(page: Int): Request { - if (!isLogged) { - doLogin() - } - return POST( - "$apiUrl/series/all?pageNumber=$page&libraryId=0&pageSize=20", - headersBuilder().build(), - buildFilterBody(currentFilter), - ) - } + override fun fetchLatestUpdates(page: Int) = + fetch(latestUpdatesRequest(page)) + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable = + fetch(searchMangaRequest(page, query, filters)) override fun popularMangaParse(response: Response): MangasPage { try { @@ -153,33 +153,75 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou val mangaList = result.map { item -> helper.createSeriesDto(item, apiUrl, apiKey) } return MangasPage(mangaList, helper.hasNextPage(response)) } catch (e: Exception) { - Log.e(LOG_TAG, "Possible outdated kavita", e) - throw IOException("Please check your kavita version.\nv0.5+ is required for the extension to work properly") + Log.e(LOG_TAG, "Unhandled exception", e) + throw IOException(helper.intl["check_version"]) } } + override fun popularMangaRequest(page: Int): Request { + if (!isLogged) { + doLogin() + } + val payload = buildFilterBody(currentFilter) + return POST( + "$apiUrl/series/all-v2?pageNumber=$page&pageSize=20", + headersBuilder().build(), + payload.toRequestBody(JSON_MEDIA_TYPE), + ) + } + override fun latestUpdatesRequest(page: Int): Request { if (!isLogged) { doLogin() } + // Hardcode exclude epubs + val filter = FilterV2Dto(sortOptions = SortOptions(SortFieldEnum.LastChapterAdded.type, false)) + filter.statements.add(FilterStatementDto(FilterComparison.NotContains.type, FilterField.Formats.type, "3")) + val payload = json.encodeToJsonElement(filter).toString() return POST( - "$apiUrl/series/all?pageNumber=$page&libraryId=0&pageSize=20", + "$apiUrl/series/all-v2?pageNumber=$page&pageSize=20", headersBuilder().build(), - buildFilterBody(MetadataPayload(sorting = 4, sorting_asc = false, forceUseMetadataPayload = true)), + payload.toRequestBody(JSON_MEDIA_TYPE), ) } - override fun latestUpdatesParse(response: Response): MangasPage { - return popularMangaParse(response) - } - - /** - * SEARCH MANGA - * **/ - - private var currentFilter: MetadataPayload = MetadataPayload() override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { val newFilter = MetadataPayload() // need to reset it or will double + val smartFilterFilter = filters.find { it is SmartFiltersFilter } + + // If a SmartFilter selected, apply its filter and return that + if (smartFilterFilter?.state != 0) { + val index = smartFilterFilter?.state as Int - 1 + val filter: SmartFilter = smartFilters[index] + val payload = buildJsonObject { + put("EncodedFilter", filter.filter) + } + // Decode selected filters + val request = POST( + "$apiUrl/filter/decode", + headersBuilder().build(), + payload.toString().toRequestBody(JSON_MEDIA_TYPE), + ) + client.newCall(request).execute().use { + if (it.code == 200) { + // Hardcode exclude epub + val decoded_filter = json.decodeFromString(it.body.string()) + decoded_filter.statements.add(FilterStatementDto(FilterComparison.NotContains.type, FilterField.Formats.type, "3")) + + // Make request with selected filters + return POST( + "$apiUrl/series/all-v2?pageNumber=$page&pageSize=20", + headersBuilder().build(), + json.encodeToJsonElement(decoded_filter).toString().toRequestBody(JSON_MEDIA_TYPE), + ) + } else { + Log.e(LOG_TAG, "Failed to decode SmartFilter: ${it.code}\n" + it.message) + throw IOException(helper.intl["version_exceptions_smart_filter"]) + } + } + } + // Else apply user filters + filters.forEach { filter -> when (filter) { is SortFilter -> { @@ -188,6 +230,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou newFilter.sorting_asc = filter.state!!.ascending } } + is StatusFilterGroup -> { filter.state.forEach { content -> if (content.state) { @@ -195,6 +238,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } } + is ReleaseYearRangeGroup -> { filter.state.forEach { content -> if (content.state.isNotEmpty()) { @@ -207,30 +251,41 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } } + is GenreFilterGroup -> { filter.state.forEach { content -> - if (content.state) { - newFilter.genres.add(genresListMeta.find { it.title == content.name }!!.id) + if (content.state == STATE_INCLUDE) { + newFilter.genres_i.add(genresListMeta.find { it.title == content.name }!!.id) + } else if (content.state == STATE_EXCLUDE) { + newFilter.genres_e.add(genresListMeta.find { it.title == content.name }!!.id) } } } + is UserRating -> { newFilter.userRating = filter.state } + is TagFilterGroup -> { filter.state.forEach { content -> - if (content.state) { - newFilter.tags.add(tagsListMeta.find { it.title == content.name }!!.id) + if (content.state == STATE_INCLUDE) { + newFilter.tags_i.add(tagsListMeta.find { it.title == content.name }!!.id) + } else if (content.state == STATE_EXCLUDE) { + newFilter.tags_e.add(tagsListMeta.find { it.title == content.name }!!.id) } } } + is AgeRatingFilterGroup -> { filter.state.forEach { content -> - if (content.state) { - newFilter.ageRating.add(ageRatingsListMeta.find { it.title == content.name }!!.value) + if (content.state == STATE_INCLUDE) { + newFilter.ageRating_i.add(ageRatingsListMeta.find { it.title == content.name }!!.value) + } else if (content.state == STATE_EXCLUDE) { + newFilter.ageRating_e.add(ageRatingsListMeta.find { it.title == content.name }!!.value) } } } + is FormatsFilterGroup -> { filter.state.forEach { content -> if (content.state) { @@ -238,25 +293,33 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } } + is CollectionFilterGroup -> { filter.state.forEach { content -> - if (content.state) { - newFilter.collections.add(collectionsListMeta.find { it.title == content.name }!!.id) + if (content.state == STATE_INCLUDE) { + newFilter.collections_i.add(collectionsListMeta.find { it.title == content.name }!!.id) + } else if (content.state == STATE_EXCLUDE) { + newFilter.collections_e.add(collectionsListMeta.find { it.title == content.name }!!.id) } } } is LanguageFilterGroup -> { filter.state.forEach { content -> - if (content.state) { - newFilter.language.add(languagesListMeta.find { it.title == content.name }!!.isoCode) + if (content.state == STATE_INCLUDE) { + newFilter.language_i.add(languagesListMeta.find { it.title == content.name }!!.isoCode) + } else if (content.state == STATE_EXCLUDE) { + newFilter.language_e.add(languagesListMeta.find { it.title == content.name }!!.isoCode) } } } + is LibrariesFilterGroup -> { filter.state.forEach { content -> - if (content.state) { - newFilter.libraries.add(libraryListMeta.find { it.name == content.name }!!.id) + if (content.state == STATE_INCLUDE) { + newFilter.libraries_i.add(libraryListMeta.find { it.name == content.name }!!.id) + } else if (content.state == STATE_EXCLUDE) { + newFilter.libraries_e.add(libraryListMeta.find { it.name == content.name }!!.id) } } } @@ -276,6 +339,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } } + is PencillerPeopleFilterGroup -> { filter.state.forEach { content -> if (content.state) { @@ -283,6 +347,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } } + is InkerPeopleFilterGroup -> { filter.state.forEach { content -> if (content.state) { @@ -290,6 +355,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } } + is ColoristPeopleFilterGroup -> { filter.state.forEach { content -> if (content.state) { @@ -297,6 +363,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } } + is LettererPeopleFilterGroup -> { filter.state.forEach { content -> if (content.state) { @@ -304,6 +371,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } } + is CoverArtistPeopleFilterGroup -> { filter.state.forEach { content -> if (content.state) { @@ -311,6 +379,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } } + is EditorPeopleFilterGroup -> { filter.state.forEach { content -> if (content.state) { @@ -318,6 +387,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } } + is PublisherPeopleFilterGroup -> { filter.state.forEach { content -> if (content.state) { @@ -325,6 +395,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } } + is CharacterPeopleFilterGroup -> { filter.state.forEach { content -> if (content.state) { @@ -332,6 +403,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } } + is TranslatorPeopleFilterGroup -> { filter.state.forEach { content -> if (content.state) { @@ -339,20 +411,16 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } } + else -> {} } } - newFilter.seriesNameQuery = query currentFilter = newFilter return popularMangaRequest(page) } - override fun searchMangaParse(response: Response): MangasPage { - return popularMangaParse(response) - } - - /** + /* * MANGA DETAILS (metadata about series) * **/ @@ -400,10 +468,19 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou author = result.writers.joinToString { it.name } genre = result.genres.joinToString { it.title } title = serieDto.name + thumbnail_url = "$apiUrl/image/series-cover?seriesId=${result.seriesId}&apiKey=$apiKey" + status = when (result.publicationStatus) { + 4 -> SManga.PUBLISHING_FINISHED + 2 -> SManga.COMPLETED + 0 -> SManga.ONGOING + 3 -> SManga.CANCELLED + 1 -> SManga.ON_HIATUS + else -> SManga.UNKNOWN + } } } - /** + /* * CHAPTER LIST * **/ override fun chapterListRequest(manga: SManga): Request { @@ -411,57 +488,6 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou return GET(url, headersBuilder().build()) } - private fun chapterFromObject(obj: ChapterDto): SChapter = SChapter.create().apply { - url = obj.id.toString() - name = if (obj.number == "0" && obj.isSpecial) { - // This is a special. Chapter name is special name - obj.range - } else { - val cleanedName = obj.title.replaceFirst("^0+(?!$)".toRegex(), "") - "Chapter $cleanedName" - } - date_upload = helper.parseDate(obj.created) - chapter_number = obj.number.toFloat() - scanlator = "${obj.pages} pages" - } - - private fun chapterFromVolume(obj: ChapterDto, volume: VolumeDto): SChapter = - SChapter.create().apply { - // If there are multiple chapters to this volume, then prefix with Volume number - if (volume.chapters.isNotEmpty() && obj.number != "0") { - // This volume is not volume 0, hence they are not loose chapters - // We just add a nice Volume X to the chapter title - // Chapter-based Volume - name = "Volume ${volume.number} Chapter ${obj.number}" - chapter_number = obj.number.toFloat() - } else if (obj.number == "0") { - // Both specials and volume has chapter number 0 - if (volume.number == 0) { - // Treat as special - // Special is not in a volume - if (obj.range == "") { - // Special does not have any Title - name = "Chapter 0" - chapter_number = obj.number.toFloat() - } else { - // We use it's own special tile - name = obj.range - chapter_number = obj.number.toFloat() - } - } else { - // Is a single-file volume - // We encode the chapter number to support tracking - name = "Volume ${volume.number}" - chapter_number = volume.number.toFloat() / 10000 - } - } else { - name = "Unhandled Else Volume ${volume.number}" - } - url = obj.id.toString() - date_upload = helper.parseDate(obj.created) - - scanlator = "${obj.pages} pages" - } override fun chapterListParse(response: Response): List { try { val volumes = response.parseAs>() @@ -471,22 +497,22 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou if (volume.number == 0) { // Regular chapters volume.chapters.map { - allChapterList.add(chapterFromObject(it)) + allChapterList.add(helper.chapterFromObject(it)) } } else { // Volume chapter volume.chapters.map { - allChapterList.add(chapterFromVolume(it, volume)) + allChapterList.add(helper.chapterFromVolume(it, volume)) } } } } - allChapterList.sortWith(CompareChapters) + allChapterList.sortWith(KavitaHelper.CompareChapters) return allChapterList } catch (e: Exception) { Log.e(LOG_TAG, "Unhandled exception parsing chapters. Send logs to kavita devs", e) - throw IOException("Unhandled exception parsing chapters. Send logs to kavita devs") + throw IOException(helper.intl["version_exceptions_chapters_parse"]) } } @@ -496,6 +522,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou override fun pageListRequest(chapter: SChapter): Request { return GET("$apiUrl/${chapter.url}", headersBuilder().build()) } + override fun fetchPageList(chapter: SChapter): Observable> { val chapterId = chapter.url val numPages = chapter.scanlator?.replace(" pages", "")?.toInt() @@ -512,15 +539,23 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou return Observable.just(pages) } + override fun latestUpdatesParse(response: Response): MangasPage = + throw UnsupportedOperationException("Not used") + override fun pageListParse(response: Response): List = throw UnsupportedOperationException("Not used") + override fun searchMangaParse(response: Response): MangasPage = + throw UnsupportedOperationException("Not used") + override fun imageUrlParse(response: Response): String = "" - /** + /* * FILTERING **/ + private var currentFilter: MetadataPayload = MetadataPayload() + /** Some variable names already exist. im not good at naming add Meta suffix */ private var genresListMeta = emptyList() private var tagsListMeta = emptyList() @@ -530,6 +565,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou private var languagesListMeta = emptyList() private var libraryListMeta = emptyList() private var collectionsListMeta = emptyList() + private var smartFilters = emptyList() private val personRoles = listOf( "Writer", "Penciller", @@ -543,112 +579,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou "Translator", ) - private class UserRating : - Filter.Select( - "Minimum Rating", - arrayOf( - "Any", - "1 star", - "2 stars", - "3 stars", - "4 stars", - "5 stars", - ), - ) - - private class SortFilter(sortables: Array) : Filter.Sort("Sort by", sortables, Selection(0, true)) - - private val sortableList = listOf( - Pair("Sort name", 1), - Pair("Created", 2), - Pair("Last modified", 3), - Pair("Item added", 4), - Pair("Time to Read", 5), - ) - - private class StatusFilter(name: String) : Filter.CheckBox(name, false) - private class StatusFilterGroup(filters: List) : - Filter.Group("Status", filters) - - private class ReleaseYearRange(name: String) : Filter.Text(name) - private class ReleaseYearRangeGroup(filters: List) : - Filter.Group("Release Year", filters) - private class GenreFilter(name: String) : Filter.CheckBox(name, false) - private class GenreFilterGroup(genres: List) : - Filter.Group("Genres", genres) - - private class TagFilter(name: String) : Filter.CheckBox(name, false) - private class TagFilterGroup(tags: List) : Filter.Group("Tags", tags) - - private class AgeRatingFilter(name: String) : Filter.CheckBox(name, false) - private class AgeRatingFilterGroup(ageRatings: List) : - Filter.Group("Age Rating", ageRatings) - - private class FormatFilter(name: String) : Filter.CheckBox(name, false) - private class FormatsFilterGroup(formats: List) : - Filter.Group("Formats", formats) - - private class CollectionFilter(name: String) : Filter.CheckBox(name, false) - private class CollectionFilterGroup(collections: List) : - Filter.Group("Collection", collections) - - private class LanguageFilter(name: String) : Filter.CheckBox(name, false) - private class LanguageFilterGroup(languages: List) : - Filter.Group("Language", languages) - - private class LibraryFilter(library: String) : Filter.CheckBox(library, false) - private class LibrariesFilterGroup(libraries: List) : - Filter.Group("Libraries", libraries) - - private class PubStatusFilter(name: String) : Filter.CheckBox(name, false) - private class PubStatusFilterGroup(status: List) : - Filter.Group("Publication Status", status) - - private class PeopleHeaderFilter(name: String) : - Filter.Header(name) - private class PeopleSeparatorFilter : - Filter.Separator() - - private class WriterPeopleFilter(name: String) : Filter.CheckBox(name, false) - private class WriterPeopleFilterGroup(peoples: List) : - Filter.Group("Writer", peoples) - - private class PencillerPeopleFilter(name: String) : Filter.CheckBox(name, false) - private class PencillerPeopleFilterGroup(peoples: List) : - Filter.Group("Penciller", peoples) - - private class InkerPeopleFilter(name: String) : Filter.CheckBox(name, false) - private class InkerPeopleFilterGroup(peoples: List) : - Filter.Group("Inker", peoples) - - private class ColoristPeopleFilter(name: String) : Filter.CheckBox(name, false) - private class ColoristPeopleFilterGroup(peoples: List) : - Filter.Group("Colorist", peoples) - - private class LettererPeopleFilter(name: String) : Filter.CheckBox(name, false) - private class LettererPeopleFilterGroup(peoples: List) : - Filter.Group("Letterer", peoples) - - private class CoverArtistPeopleFilter(name: String) : Filter.CheckBox(name, false) - private class CoverArtistPeopleFilterGroup(peoples: List) : - Filter.Group("Cover Artist", peoples) - - private class EditorPeopleFilter(name: String) : Filter.CheckBox(name, false) - private class EditorPeopleFilterGroup(peoples: List) : - Filter.Group("Editor", peoples) - - private class PublisherPeopleFilter(name: String) : Filter.CheckBox(name, false) - private class PublisherPeopleFilterGroup(peoples: List) : - Filter.Group("Publisher", peoples) - - private class CharacterPeopleFilter(name: String) : Filter.CheckBox(name, false) - private class CharacterPeopleFilterGroup(peoples: List) : - Filter.Group("Character", peoples) - - private class TranslatorPeopleFilter(name: String) : Filter.CheckBox(name, false) - private class TranslatorPeopleFilterGroup(peoples: List) : - Filter.Group("Translator", peoples) - + /** + * Loads the enabled filters if they are not empty so tachiyomi can show them to the user + */ override fun getFilterList(): FilterList { val toggledFilters = getToggledFilters() @@ -657,7 +590,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou personRoles.map { role -> val peoplesWithRole = mutableListOf() peopleListMeta.map { - if (it.role == safeValueOf(role).role) { + if (it.role == helper.safeValueOf(role).role) { peoplesWithRole.add(it) } } @@ -670,6 +603,12 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou filtersLoaded.add( SortFilter(sortableList.map { it.first }.toTypedArray()), ) + if (smartFilters.isNotEmpty()) { + filtersLoaded.add( + SmartFiltersFilter(smartFilters.map { it.name }.toTypedArray()), + + ) + } } if (toggledFilters.contains("Read Status")) { filtersLoaded.add( @@ -826,7 +765,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } else { filtersLoaded } - } else { filtersLoaded } + } else { + filtersLoaded + } } catch (e: Exception) { Log.e(LOG_TAG, "[FILTERS] Error while creating filter list", e) emptyList() @@ -835,19 +776,82 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } /** - * - * Finished filtering - * - * */ + * Returns a FilterV2Dto encoded as a json string with values taken from filter + */ + private fun buildFilterBody(filter: MetadataPayload): String { + val filter_dto = FilterV2Dto() + filter_dto.sortOptions.sortField = filter.sorting + filter_dto.sortOptions.isAscending = filter.sorting_asc + + // Fields that support contains and not contains statements + val containsAndNotTriplets = listOf( + Triple(FilterField.Libraries, filter.libraries_i, filter.libraries_e), + Triple(FilterField.Tags, filter.tags_i, filter.tags_e), + Triple(FilterField.Languages, filter.language_i, filter.genres_e), + Triple(FilterField.AgeRating, filter.ageRating_i, filter.ageRating_e), + Triple(FilterField.Genres, filter.genres_i, filter.genres_e), + Triple(FilterField.CollectionTags, filter.collections_i, filter.collections_e), + ) + filter_dto.addContainsNotTriple(containsAndNotTriplets) + // Fields that have must contains statements + val peoplePairs = listOf( + + Pair(FilterField.Writers, filter.peopleWriters), + Pair(FilterField.Penciller, filter.peoplePenciller), + Pair(FilterField.Inker, filter.peopleInker), + Pair(FilterField.Colorist, filter.peopleCharacter), + Pair(FilterField.Letterer, filter.peopleLetterer), + Pair(FilterField.CoverArtist, filter.peopleCoverArtist), + Pair(FilterField.Editor, filter.peopleEditor), + Pair(FilterField.Publisher, filter.peoplePublisher), + Pair(FilterField.Characters, filter.peopleCharacter), + Pair(FilterField.Translators, filter.peopleTranslator), + + Pair(FilterField.PublicationStatus, filter.pubStatus), + ) + filter_dto.addPeople(peoplePairs) + + // Customized statements + filter_dto.addStatement(FilterComparison.Contains, FilterField.Formats, filter.formats) + filter_dto.addStatement(FilterComparison.Matches, FilterField.SeriesName, filter.seriesNameQuery) + // Hardcoded statement to filter out epubs: + filter_dto.addStatement(FilterComparison.NotContains, FilterField.Formats, "3") + if (filter.readStatus.isNotEmpty()) { + filter.readStatus.forEach { + if (it == "notRead") { + filter_dto.addStatement(FilterComparison.Equal, FilterField.ReadProgress, "0") + } else if (it == "inProgress") { + filter_dto.addStatement(FilterComparison.GreaterThan, FilterField.ReadProgress, "0") + filter_dto.addStatement(FilterComparison.LessThan, FilterField.ReadProgress, "100") + } else if (it == "read") { + filter_dto.addStatement(FilterComparison.Equal, FilterField.ReadProgress, "100") + } + } + } + // todo: check statement + // filter_dto.addStatement(FilterComparison.GreaterThanEqual, FilterField.UserRating, filter.userRating.toString()) + if (filter.releaseYearRangeMin != 0) { + filter_dto.addStatement(FilterComparison.GreaterThan, FilterField.ReleaseYear, filter.releaseYearRangeMin.toString()) + } + + if (filter.releaseYearRangeMax != 0) { + filter_dto.addStatement(FilterComparison.LessThan, FilterField.ReleaseYear, filter.releaseYearRangeMax.toString()) + } + return json.encodeToJsonElement(filter_dto).toString() + } + class LoginErrorException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { constructor(cause: Throwable) : this(null, cause) } + class OpdsurlExistsInPref(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { constructor(cause: Throwable) : this(null, cause) } + class EmptyRequestBody(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { constructor(cause: Throwable) : this(null, cause) } + class LoadingFilterFailed(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { constructor(cause: Throwable) : this(null, cause) } @@ -855,93 +859,32 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou override fun headersBuilder(): Headers.Builder { if (jwtToken.isEmpty()) { doLogin() - if (jwtToken.isEmpty()) throw LoginErrorException("Error: jwt token is empty.\nTry opening the extension first") + if (jwtToken.isEmpty()) throw LoginErrorException(helper.intl["login_errors_header_token_empty"]) } return Headers.Builder() .add("User-Agent", "Tachiyomi Kavita v${AppInfo.getVersionName()}") .add("Content-Type", "application/json") .add("Authorization", "Bearer $jwtToken") } + private fun setupLoginHeaders(): Headers.Builder { return Headers.Builder() .add("User-Agent", "Tachiyomi Kavita v${AppInfo.getVersionName()}") .add("Content-Type", "application/json") .add("Authorization", "Bearer $jwtToken") } - private fun buildFilterBody(filter: MetadataPayload): RequestBody { - val formats = if (filter.formats.isEmpty()) { - buildJsonArray { - add(MangaFormat.Archive.ordinal) - add(MangaFormat.Image.ordinal) - add(MangaFormat.Pdf.ordinal) - } - } else { - buildJsonArray { filter.formats.map { add(it) } } - } - - val payload = buildJsonObject { - put("formats", formats) - put("libraries", buildJsonArray { filter.libraries.map { add(it) } }) - put( - "readStatus", - buildJsonObject { - if (filter.readStatus.isNotEmpty()) { - filter.readStatusList - .forEach { status -> put(status, JsonPrimitive(status in filter.readStatus)) } - } else { - put("notRead", JsonPrimitive(true)) - put("inProgress", JsonPrimitive(true)) - put("read", JsonPrimitive(true)) - } - }, - ) - put("genres", buildJsonArray { filter.genres.map { add(it) } }) - put("writers", buildJsonArray { filter.peopleWriters.map { add(it) } }) - put("penciller", buildJsonArray { filter.peoplePenciller.map { add(it) } }) - put("inker", buildJsonArray { filter.peopleInker.map { add(it) } }) - put("colorist", buildJsonArray { filter.peoplePeoplecolorist.map { add(it) } }) - put("letterer", buildJsonArray { filter.peopleLetterer.map { add(it) } }) - put("coverArtist", buildJsonArray { filter.peopleCoverArtist.map { add(it) } }) - put("editor", buildJsonArray { filter.peopleEditor.map { add(it) } }) - put("publisher", buildJsonArray { filter.peoplePublisher.map { add(it) } }) - put("character", buildJsonArray { filter.peopleCharacter.map { add(it) } }) - put("translators", buildJsonArray { filter.peopleTranslator.map { add(it) } }) - put("collectionTags", buildJsonArray { filter.collections.map { add(it) } }) - put("languages", buildJsonArray { filter.language.map { add(it) } }) - put("publicationStatus", buildJsonArray { filter.pubStatus.map { add(it) } }) - put("tags", buildJsonArray { filter.tags.map { add(it) } }) - put("rating", filter.userRating) - put("ageRating", buildJsonArray { filter.ageRating.map { add(it) } }) - put( - "sortOptions", - buildJsonObject { - put("sortField", filter.sorting) - put("isAscending", JsonPrimitive(filter.sorting_asc)) - }, - ) - put("seriesNameQuery", filter.seriesNameQuery) - put( - "releaseYearRange", - buildJsonObject { - put("min", filter.releaseYearRangeMin) - put("max", filter.releaseYearRangeMax) - }, - ) - } - return payload.toString().toRequestBody(JSON_MEDIA_TYPE) - } override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) { val opdsAddressPref = screen.editTextPreference( ADDRESS_TITLE, "OPDS url", "", - "The OPDS url copied from User Settings. This should include address and the api key on end.", + helper.intl["pref_opds_summary"], ) val enabledFiltersPref = MultiSelectListPreference(screen.context).apply { key = KavitaConstants.toggledFiltersPref - title = "Default filters shown" - summary = "Show these filters in the filter list" + title = helper.intl["pref_filters_title"] + summary = helper.intl["pref_filters_summary"] entries = KavitaConstants.filterPrefEntries entryValues = KavitaConstants.filterPrefEntriesValue setDefaultValue(KavitaConstants.defaultFilterPrefEntries) @@ -955,16 +898,15 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } val customSourceNamePref = EditTextPreference(screen.context).apply { key = KavitaConstants.customSourceNamePref - title = "Displayed name for source" - summary = "Here you can change this source name.\n" + - "You can write a descriptive name to identify this opds URL" + title = helper.intl["pref_customsource_title"] + summary = helper.intl["pref_edit_customsource_summary"] setOnPreferenceChangeListener { _, newValue -> val res = preferences.edit() .putString(KavitaConstants.customSourceNamePref, newValue.toString()) .commit() Toast.makeText( screen.context, - "Restart Tachiyomi to apply new setting.", + helper.intl["restartapp_settings"], Toast.LENGTH_LONG, ).show() Log.v(LOG_TAG, "[Preferences] Successfully modified custom source name: $newValue") @@ -1006,16 +948,16 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou Toast.makeText( context, - "URL exists in a different source -> $opdsUrlInPref", + helper.intl["pref_opds_duplicated_source_url"] + ": " + opdsUrlInPref, Toast.LENGTH_LONG, ).show() - throw OpdsurlExistsInPref("Url exists in a different source -> $opdsUrlInPref") + throw OpdsurlExistsInPref(helper.intl["pref_opds_duplicated_source_url"] + opdsUrlInPref) } val res = preferences.edit().putString(title, newValue as String).commit() Toast.makeText( context, - "Restart Tachiyomi to apply new setting.", + helper.intl["restartapp_settings"], Toast.LENGTH_LONG, ).show() setupLogin(newValue) @@ -1048,7 +990,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou private fun getPrefApiKey(): String { // http(s)://host:(port)/api/opds/api-key - var existingKey = preferences.getString("APIKEY", "") + val existingKey = preferences.getString("APIKEY", "") return existingKey!!.ifEmpty { preferences.getString(ADDRESS_TITLE, "")!!.split("/opds/")[1] } } @@ -1057,14 +999,16 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull() } - /** + /* * LOGIN **/ - + /** + * Used to check if a url is configured already in any of the sources + * This is a limitation needed for tracking. + * **/ private fun opdsUrlInPreferences(url: String): String { fun getCleanedApiUrl(url: String): String = "${url.split("/api/").first()}/api" - /**Used to check if a url already exists in preference in any source - * This is a limitation needed for tracking.**/ + for (sourceId in 1..3) { // There's 3 sources so 3 preferences to check val sourceSuffixID by lazy { val key = "${"kavita_$sourceId"}/all/1" // Hardcoded versionID to 1 @@ -1097,7 +1041,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou if (baseUrlSetup.toHttpUrlOrNull() == null) { Log.e(LOG_TAG, "Invalid URL $baseUrlSetup") - throw Exception("""Invalid URL: $baseUrlSetup""") + throw Exception("""${helper.intl["login_errors_invalid_url"]}: $baseUrlSetup""") } preferences.edit().putString("BASEURL", baseUrlSetup).apply() preferences.edit().putString("APIKEY", apiKey).apply() @@ -1108,10 +1052,10 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou private fun doLogin() { if (address.isEmpty()) { Log.e(LOG_TAG, "OPDS URL is empty or null") - throw IOException("You must setup the Address to communicate with Kavita") + throw IOException(helper.intl["pref_opds_must_setup_address"]) } if (address.split("/opds/").size != 2) { - throw IOException("Address is not correct. Please copy from User settings -> OPDS Url") + throw IOException(helper.intl["pref_opds_badformed_url"]) } if (jwtToken.isEmpty()) setupLogin() Log.v(LOG_TAG, "[Login] Starting login") @@ -1129,15 +1073,15 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou isLogged = true } catch (e: Exception) { Log.e(LOG_TAG, "Possible outdated kavita", e) - throw IOException("Please check your kavita version.\nv0.5+ is required for the extension to work properly") + throw IOException(helper.intl["login_errors_parse_tokendto"]) } } else { if (it.code == 500) { Log.e(LOG_TAG, "[LOGIN] login failed. There was some error -> Code: ${it.code}.Response message: ${it.message} Response body: $peekbody.") - throw LoginErrorException("[LOGIN] login failed. Something went wrong") + throw LoginErrorException(helper.intl["login_errors_failed_login"]) } else { Log.e(LOG_TAG, "[LOGIN] login failed. Authentication was not successful -> Code: ${it.code}.Response message: ${it.message} Response body: $peekbody.") - throw LoginErrorException("[LOGIN] login failed. Something went wrong") + throw LoginErrorException(helper.intl["login_errors_failed_login"]) } } } @@ -1157,7 +1101,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou Log.e( LOG_TAG, "Extension version: code=${AppInfo.getVersionCode()} name=${AppInfo.getVersionName()}" + - " - - Kavita version: ${serverInfoDto.kavitaVersion}", + " - - Kavita version: ${serverInfoDto.kavitaVersion} - - Lang:${Locale.getDefault()}", ) // this is not a real error. Using this so it gets printed in dump logs if there's any error } catch (e: EmptyRequestBody) { Log.e(LOG_TAG, "Extension version: code=${AppInfo.getVersionCode()} - name=${AppInfo.getVersionName()}") @@ -1270,6 +1214,19 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou emptyList() } } + client.newCall(GET("$apiUrl/filter", headersBuilder().build())) + .execute().use { response -> + smartFilters = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e( + LOG_TAG, + "error while decoding JSON for smartfilters", + e, + ) + emptyList() + } + } Log.v(LOG_TAG, "[Filter] Successfully loaded metadata tags from server") } catch (e: Exception) { throw LoadingFilterFailed("Failed Loading Filters", e.cause) diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaConstants.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaConstants.kt index 036c35b75..24f284410 100644 --- a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaConstants.kt +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaConstants.kt @@ -77,4 +77,5 @@ object KavitaConstants { ) const val customSourceNamePref = "customSourceName" + const val noSmartFilterSelected = "No smart filter loaded" } diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaHelper.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaHelper.kt index 1e06e5760..f588f684b 100644 --- a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaHelper.kt +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaHelper.kt @@ -1,7 +1,11 @@ package eu.kanade.tachiyomi.extension.all.kavita +import eu.kanade.tachiyomi.extension.all.kavita.dto.ChapterDto import eu.kanade.tachiyomi.extension.all.kavita.dto.PaginationInfo import eu.kanade.tachiyomi.extension.all.kavita.dto.SeriesDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.VolumeDto +import eu.kanade.tachiyomi.lib.i18n.Intl +import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json @@ -18,7 +22,9 @@ class KavitaHelper { useArrayPolymorphism = true prettyPrint = true } - + inline fun > safeValueOf(type: String): T { + return java.lang.Enum.valueOf(T::class.java, type) + } val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSS", Locale.US) .apply { timeZone = TimeZone.getTimeZone("UTC") } fun parseDate(dateAsString: String): Long = @@ -45,4 +51,91 @@ class KavitaHelper { // Deprecated: description = obj.summary thumbnail_url = "$baseUrl/image/series-cover?seriesId=${obj.id}&apiKey=$apiKey" } + class CompareChapters { + companion object : Comparator { + override fun compare(a: SChapter, b: SChapter): Int { + if (a.chapter_number < 1.0 && b.chapter_number < 1.0) { + // Both are volumes, multiply by 100 and do normal sort + return if ((a.chapter_number * 100) < (b.chapter_number * 100)) { + 1 + } else { + -1 + } + } else { + if (a.chapter_number < 1.0 && b.chapter_number >= 1.0) { + // A is volume, b is not. A should sort first + return 1 + } else if (a.chapter_number >= 1.0 && b.chapter_number < 1.0) { + return -1 + } + } + if (a.chapter_number < b.chapter_number) return 1 + if (a.chapter_number > b.chapter_number) return -1 + return 0 + } + } + } + fun chapterFromObject(obj: ChapterDto): SChapter = SChapter.create().apply { + url = obj.id.toString() + name = if (obj.number == "0" && obj.isSpecial) { + // This is a special. Chapter name is special name + obj.range + } else { + val cleanedName = obj.title.replaceFirst("^0+(?!$)".toRegex(), "") + "Chapter $cleanedName" + } + date_upload = parseDate(obj.created) + chapter_number = obj.number.toFloat() + scanlator = "${obj.pages} pages" + } + + fun chapterFromVolume(obj: ChapterDto, volume: VolumeDto): SChapter = + SChapter.create().apply { + // If there are multiple chapters to this volume, then prefix with Volume number + if (volume.chapters.isNotEmpty() && obj.number != "0") { + // This volume is not volume 0, hence they are not loose chapters + // We just add a nice Volume X to the chapter title + // Chapter-based Volume + name = "Volume ${volume.number} Chapter ${obj.number}" + chapter_number = obj.number.toFloat() + } else if (obj.number == "0") { + // Both specials and volume has chapter number 0 + if (volume.number == 0) { + // Treat as special + // Special is not in a volume + if (obj.range == "") { + // Special does not have any Title + name = "Chapter 0" + chapter_number = obj.number.toFloat() + } else { + // We use it's own special tile + name = obj.range + chapter_number = obj.number.toFloat() + } + } else { + // Is a single-file volume + // We encode the chapter number to support tracking + name = "Volume ${volume.number}" + chapter_number = volume.number.toFloat() / 10000 + } + } else { + name = "Unhandled Else Volume ${volume.number}" + } + url = obj.id.toString() + date_upload = parseDate(obj.created) + + scanlator = "${obj.pages} pages" + } + val intl = Intl( + language = Locale.getDefault().toString(), + baseLanguage = "en", + availableLanguages = KavitaInt.AVAILABLE_LANGS, + classLoader = this::class.java.classLoader!!, + createMessageFileName = { lang -> + when (lang) { + KavitaInt.SPANISH_LATAM -> Intl.createDefaultMessageFileName(KavitaInt.SPANISH) + else -> Intl.createDefaultMessageFileName(lang) + } + }, + ) } diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaInt.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaInt.kt new file mode 100644 index 000000000..ed09e9769 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaInt.kt @@ -0,0 +1,17 @@ +package eu.kanade.tachiyomi.extension.all.kavita + +object KavitaInt { + const val ENGLISH = "en" + const val SPANISH = "es_ES" + const val SPANISH_LATAM = "es-419" + const val FRENCH = "fr_FR" + const val NORWEGIAN = "nb_NO" + val AVAILABLE_LANGS = setOf( + ENGLISH, + SPANISH, + SPANISH_LATAM, + NORWEGIAN, + FRENCH + ) + const val KAVITA_NAME = "Kavita" +} diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/FilterDto.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/FilterDto.kt new file mode 100644 index 000000000..77b669e51 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/FilterDto.kt @@ -0,0 +1,124 @@ +package eu.kanade.tachiyomi.extension.all.kavita.dto + +import kotlinx.serialization.Serializable +import kotlin.Triple + +@Serializable +data class FilterV2Dto( + val id: Int? = null, + val name: String? = null, + val statements: MutableList = mutableListOf(), + val combination: Int = 0, // FilterCombination = FilterCombination.And, + val sortOptions: SortOptions = SortOptions(), + val limitTo: Int = 0, +) { + fun addStatement(comparison: FilterComparison, field: FilterField, value: String) { + if (value.isNotBlank()) { + statements.add(FilterStatementDto(comparison.type, field.type, value)) + } + } + fun addStatement(comparison: FilterComparison, field: FilterField, values: java.util.ArrayList) { + if (values.isNotEmpty()) { + statements.add(FilterStatementDto(comparison.type, field.type, values.joinToString(","))) + } + } + + fun addContainsNotTriple(list: List, ArrayList>>) { + list.map { + addStatement(FilterComparison.Contains, it.first, it.second) + addStatement(FilterComparison.NotContains, it.first, it.third) + } + } + fun addPeople(list: List>>) { + list.map { + addStatement(FilterComparison.MustContains, it.first, it.second) + } + } +} + +@Serializable +data class FilterStatementDto( + // todo: Create custom serializator for comparison and field and remove .type extension in Kavita.kt + val comparison: Int, + val field: Int, + val value: String, + +) + +@Serializable +enum class SortFieldEnum(val type: Int) { + SortName(1), + CreatedDate(2), + LastModifiedDate(3), + LastChapterAdded(4), + TimeToRead(5), + ReleaseYear(6), + ; + + companion object { + private val map = SortFieldEnum.values().associateBy(SortFieldEnum::type) + fun fromInt(type: Int) = map[type] + } +} + +@Serializable +data class SortOptions( + var sortField: Int = SortFieldEnum.SortName.type, + var isAscending: Boolean = true, +) + +@Serializable +enum class FilterCombination { + Or, + And, +} + +@Serializable +enum class FilterField(val type: Int) { + Summary(0), + SeriesName(1), + PublicationStatus(2), + Languages(3), + AgeRating(4), + UserRating(5), + Tags(6), + CollectionTags(7), + Translators(8), + Characters(9), + Publisher(10), + Editor(11), + CoverArtist(12), + Letterer(13), + Colorist(14), + Inker(15), + Penciller(16), + Writers(17), + Genres(18), + Libraries(19), + ReadProgress(20), + Formats(21), + ReleaseYear(22), + ReadTime(23), + Path(24), + FilePath(25), +} + +@Serializable +enum class FilterComparison(val type: Int) { + Equal(0), + GreaterThan(1), + GreaterThanEqual(2), + LessThan(3), + LessThanEqual(4), + Contains(5), + MustContains(6), + Matches(7), + NotContains(8), + NotEqual(9), + BeginsWith(10), + EndsWith(11), + IsBefore(12), + IsAfter(13), + IsInLast(14), + IsNotInLast(15), +} diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MangaDto.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MangaDto.kt index 703d0a99a..1c09db11d 100644 --- a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MangaDto.kt +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MangaDto.kt @@ -62,7 +62,7 @@ data class SeriesMetadataDto( val genres: List = emptyList(), val seriesId: Int, val ageRating: Int, - + val publicationStatus: Int, ) @Serializable diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MetadataDto.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MetadataDto.kt index 73d492835..f14256719 100644 --- a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MetadataDto.kt +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MetadataDto.kt @@ -60,15 +60,25 @@ data class MetadataPayload( var sorting_asc: Boolean = true, var readStatus: ArrayList = arrayListOf(), val readStatusList: List = listOf("notRead", "inProgress", "read"), - var genres: ArrayList = arrayListOf(), - var tags: ArrayList = arrayListOf(), - var ageRating: ArrayList = arrayListOf(), + // _i = included, _e = excluded + var genres_i: ArrayList = arrayListOf(), + var genres_e: ArrayList = arrayListOf(), + var tags_i: ArrayList = arrayListOf(), + var tags_e: ArrayList = arrayListOf(), + var ageRating_i: ArrayList = arrayListOf(), + var ageRating_e: ArrayList = arrayListOf(), + var formats: ArrayList = arrayListOf(), - var collections: ArrayList = arrayListOf(), + var collections_i: ArrayList = arrayListOf(), + var collections_e: ArrayList = arrayListOf(), var userRating: Int = 0, var people: ArrayList = arrayListOf(), - var language: ArrayList = arrayListOf(), - var libraries: ArrayList = arrayListOf(), + // _i = included, _e = excluded + var language_i: ArrayList = arrayListOf(), + var language_e: ArrayList = arrayListOf(), + + var libraries_i: ArrayList = arrayListOf(), + var libraries_e: ArrayList = arrayListOf(), var pubStatus: ArrayList = arrayListOf(), var seriesNameQuery: String = "", var releaseYearRangeMin: Int = 0, @@ -85,3 +95,10 @@ data class MetadataPayload( var peopleCharacter: ArrayList = arrayListOf(), var peopleTranslator: ArrayList = arrayListOf(), ) + +@Serializable +data class SmartFilter( + val id: Int, + val name: String, + val filter: String, +)