diff --git a/src/all/tachidesk/AndroidManifest.xml b/src/all/tachidesk/AndroidManifest.xml new file mode 100644 index 000000000..30deb7f79 --- /dev/null +++ b/src/all/tachidesk/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/all/tachidesk/build.gradle b/src/all/tachidesk/build.gradle new file mode 100644 index 000000000..f62575335 --- /dev/null +++ b/src/all/tachidesk/build.gradle @@ -0,0 +1,14 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'Tachidesk' + pkgNameSuffix = 'all.tachidesk' + extClass = '.Tachidesk' + extVersionCode = 1 + libVersion = '1.2' + containsNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/tachidesk/res/mipmap-hdpi/ic_launcher.png b/src/all/tachidesk/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..0fdfde363 Binary files /dev/null and b/src/all/tachidesk/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/tachidesk/res/mipmap-mdpi/ic_launcher.png b/src/all/tachidesk/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..5de574929 Binary files /dev/null and b/src/all/tachidesk/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/tachidesk/res/mipmap-xhdpi/ic_launcher.png b/src/all/tachidesk/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..7247e859a Binary files /dev/null and b/src/all/tachidesk/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/tachidesk/res/mipmap-xxhdpi/ic_launcher.png b/src/all/tachidesk/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..70fa84350 Binary files /dev/null and b/src/all/tachidesk/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/tachidesk/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/tachidesk/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..ebfad819e Binary files /dev/null and b/src/all/tachidesk/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/tachidesk/res/web_hi_res_512.png b/src/all/tachidesk/res/web_hi_res_512.png new file mode 100644 index 000000000..e138f8ca9 Binary files /dev/null and b/src/all/tachidesk/res/web_hi_res_512.png differ diff --git a/src/all/tachidesk/src/eu/kanade/tachiyomi/extension/all/tachidesk/Dto.kt b/src/all/tachidesk/src/eu/kanade/tachiyomi/extension/all/tachidesk/Dto.kt new file mode 100644 index 000000000..b121475cd --- /dev/null +++ b/src/all/tachidesk/src/eu/kanade/tachiyomi/extension/all/tachidesk/Dto.kt @@ -0,0 +1,73 @@ +package eu.kanade.tachiyomi.extension.all.tachidesk + +import kotlinx.serialization.Serializable + +@Serializable +data class SourceDataClass( + val id: String, + val name: String?, + val lang: String?, + val iconUrl: String?, + val supportsLatest: Boolean?, + val isConfigurable: Boolean? +) + +@Serializable +data class MangaDataClass( + val id: Int, + val sourceId: String, + + val url: String, + val title: String, + val thumbnailUrl: String? = null, + + val initialized: Boolean = false, + + val artist: String? = null, + val author: String? = null, + val description: String? = null, + val genre: String? = null, + val status: String = "UNKNOWN", + val inLibrary: Boolean = false, + val source: SourceDataClass? = null, + val meta: Map = emptyMap(), + + val freshData: Boolean = false +) + +@Serializable +data class ChapterDataClass( + val url: String, + val name: String, + val uploadDate: Long, + val chapterNumber: Float, + val scanlator: String?, + val mangaId: Int, + + /** chapter is read */ + val read: Boolean, + + /** chapter is bookmarked */ + val bookmarked: Boolean, + + /** last read page, zero means not read/no data */ + val lastPageRead: Int, + + /** last read page, zero means not read/no data */ + val lastReadAt: Long, + + /** this chapter's index, starts with 1 */ + val index: Int, + + /** is chapter downloaded */ + val downloaded: Boolean, + + /** used to construct pages in the front-end */ + val pageCount: Int = -1, + + /** total chapter count, used to calculate if there's a next and prev chapter */ + val chapterCount: Int? = null, + + /** used to store client specific values */ + val meta: Map = emptyMap(), +) diff --git a/src/all/tachidesk/src/eu/kanade/tachiyomi/extension/all/tachidesk/Tachidesk.kt b/src/all/tachidesk/src/eu/kanade/tachiyomi/extension/all/tachidesk/Tachidesk.kt new file mode 100644 index 000000000..7c7161ec2 --- /dev/null +++ b/src/all/tachidesk/src/eu/kanade/tachiyomi/extension/all/tachidesk/Tachidesk.kt @@ -0,0 +1,180 @@ +package eu.kanade.tachiyomi.extension.all.tachidesk + +import android.app.Application +import android.content.SharedPreferences +import android.text.InputType +import android.util.Log +import android.widget.Toast +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +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.Request +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import java.lang.RuntimeException + +class Tachidesk : ConfigurableSource, HttpSource() { + override val name = "Tachidesk" + override val baseUrl by lazy { getPrefBaseUrl() } + override val lang = "en" + override val supportsLatest = false + + private val json: Json by injectLazy() + + // ------------- Popular Manga ------------- + + override fun popularMangaRequest(page: Int): Request = + GET("$checkedBaseUrl/api/v1/library") + + override fun popularMangaParse(response: Response): MangasPage = + MangasPage( + json.decodeFromString>(response.body!!.string()).map { + it.toSManga() + }, + false + ) + // ------------- Manga Details ------------- + + override fun mangaDetailsRequest(manga: SManga) = + GET("$checkedBaseUrl/api/v1/manga/${manga.url}/?onlineFetch=true") + + override fun mangaDetailsParse(response: Response): SManga = + json.decodeFromString(response.body!!.string()).let { it.toSManga() } + + // ------------- Chapter ------------- + + override fun chapterListRequest(manga: SManga): Request = + GET("$checkedBaseUrl/api/v1/manga/${manga.url}/chapters?onlineFetch=true", headers) + + override fun chapterListParse(response: Response): List = + json.decodeFromString>(response.body!!.string()).map { + it.toSChapter() + } + + // ------------- Page List ------------- + + override fun fetchPageList(chapter: SChapter): Observable> { + return client.newCall(pageListRequest(chapter)) + .asObservableSuccess() + .map { response -> + pageListParse(response, chapter) + } + } + + override fun pageListRequest(chapter: SChapter): Request { + val mangaId = chapter.url.split(" ").first() + val chapterIndex = chapter.url.split(" ").last() + + return GET("$checkedBaseUrl/api/v1/manga/$mangaId/chapter/$chapterIndex/?onlineFetch=True", headers) + } + + fun pageListParse(response: Response, sChapter: SChapter): List { + val mangaId = sChapter.url.split(" ").first() + val chapterIndex = sChapter.url.split(" ").last() + + val chapter = json.decodeFromString(response.body!!.string()) + + return List(chapter.pageCount) { + Page(it + 1, "", "$checkedBaseUrl/api/v1/manga/$mangaId/chapter/$chapterIndex/page/$it/") + } + } + + // ------------- Preferences ------------- + override fun setupPreferenceScreen(screen: PreferenceScreen) { + screen.addPreference(screen.editTextPreference(ADDRESS_TITLE, ADDRESS_DEFAULT, baseUrl)) + } + + /** boilerplate for [EditTextPreference] */ + private fun PreferenceScreen.editTextPreference(title: String, default: String, value: String, isPassword: Boolean = false): EditTextPreference { + return EditTextPreference(context).apply { + key = title + this.title = title + summary = value + this.setDefaultValue(default) + dialogTitle = title + + if (isPassword) { + setOnBindEditTextListener { + it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + + setOnPreferenceChangeListener { _, newValue -> + try { + val res = preferences.edit().putString(title, newValue as String).commit() + Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show() + res + } catch (e: Exception) { + Log.e("Tachidesk", "Exception while setting text preference", e) + false + } + } + } + } + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + private fun getPrefBaseUrl(): String = preferences.getString(ADDRESS_TITLE, ADDRESS_DEFAULT)!! + + companion object { + private const val ADDRESS_TITLE = "Address" + private const val ADDRESS_DEFAULT = "" + } + + // ------------- Not Used ------------- + + override fun latestUpdatesRequest(page: Int): Request = throw Exception("Not used") + + override fun latestUpdatesParse(response: Response): MangasPage = throw Exception("Not used") + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw Exception("Not used") + + override fun searchMangaParse(response: Response): MangasPage = throw Exception("Not used") + + override fun pageListParse(response: Response): List = throw Exception("Not used") + + override fun imageUrlParse(response: Response): String = throw Exception("Not used") + + // ------------- Util ------------- + + private fun MangaDataClass.toSManga() = SManga.create().also { + it.title = title + it.url = id.toString() + it.thumbnail_url = "$baseUrl$thumbnailUrl" + it.artist = artist + it.author = author + it.description = description + it.status = when (status) { + "ONGOING" -> SManga.ONGOING + "COMPLETED" -> SManga.COMPLETED + "LICENSED" -> SManga.LICENSED + else -> SManga.UNKNOWN // covers "UNKNOWN" and other Impossible cases + } + } + + private fun ChapterDataClass.toSChapter() = SChapter.create().also { + it.url = "$mangaId $index" + it.name = name + it.date_upload = uploadDate + it.scanlator = scanlator + } + + private val checkedBaseUrl: String + get(): String = if (baseUrl.isNotEmpty()) baseUrl + else throw RuntimeException("Set Tachidesk server url in extension settings") +}