diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 47c3b91..9d167ab 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -15,6 +15,7 @@ compileKotlin.kotlinOptions { repositories { mavenCentral() + jcenter() } dependencies { @@ -69,6 +70,16 @@ dependencies { implementation("org.slf4j:slf4j-api:1.8.0-beta4") implementation("com.fasterxml.jackson.core:jackson-databind:2.10.3") + // to get application content root + implementation("net.harawata:appdirs:1.2.0") + + // Exposed ORM + val exposed_version = "0.28.1" + implementation ("org.jetbrains.exposed:exposed-core:$exposed_version") + implementation ("org.jetbrains.exposed:exposed-dao:$exposed_version") + implementation ("org.jetbrains.exposed:exposed-jdbc:$exposed_version") + implementation ("org.xerial:sqlite-jdbc:3.30.1") + testImplementation("org.jetbrains.kotlin:kotlin-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit") diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt index 8f41ef1..71593f2 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt @@ -7,6 +7,7 @@ import com.github.salomonbrys.kotson.int import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.util.ExtensionLoader +import ir.armor.tachidesk.database.model.ExtensionDataClass //import kotlinx.coroutines.Dispatchers //import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonArray @@ -75,6 +76,10 @@ internal class ExtensionGithubApi { return "$REPO_URL_PREFIX/apk/${extension.apkName}" } + fun getApkUrl(extension: ExtensionDataClass): String { + return "$REPO_URL_PREFIX/apk/${extension.apkName}" + } + companion object { const val BASE_URL = "https://raw.githubusercontent.com/" const val REPO_URL_PREFIX = "${BASE_URL}inorichi/tachiyomi-extensions/repo" diff --git a/server/src/main/kotlin/ir/armor/tachidesk/Config.kt b/server/src/main/kotlin/ir/armor/tachidesk/Config.kt new file mode 100644 index 0000000..c3822e5 --- /dev/null +++ b/server/src/main/kotlin/ir/armor/tachidesk/Config.kt @@ -0,0 +1,8 @@ +package ir.armor.tachidesk + +import net.harawata.appdirs.AppDirsFactory + +object Config { + val dataRoot = AppDirsFactory.getInstance().getUserDataDir("Tachidesk",null, null) + val extensionsRoot = "$dataRoot/extensions" +} \ No newline at end of file diff --git a/server/src/main/kotlin/ir/armor/tachidesk/Main.kt b/server/src/main/kotlin/ir/armor/tachidesk/Main.kt index 3dab39e..dbd71ca 100644 --- a/server/src/main/kotlin/ir/armor/tachidesk/Main.kt +++ b/server/src/main/kotlin/ir/armor/tachidesk/Main.kt @@ -2,26 +2,31 @@ package ir.armor.tachidesk import com.googlecode.dex2jar.tools.Dex2jarCmd import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi +import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.online.HttpSource import io.javalin.Javalin -import io.javalin.http.Context +import ir.armor.tachidesk.database.makeDataBaseTables +import ir.armor.tachidesk.database.model.ExtensionDataClass +import ir.armor.tachidesk.database.model.ExtensionsTable import kotlinx.coroutines.runBlocking import okhttp3.Request import okio.buffer import okio.sink +import org.jetbrains.exposed.sql.* import java.io.File import java.net.URL import java.net.URLClassLoader - +import org.jetbrains.exposed.sql.transactions.transaction class Main { companion object { - const val contentRoot = "/tmp/tachidesk" + var lastExtensionCheck: Long = 0 + @JvmStatic - fun downloadAPK(url: String, apkPath: String){ + fun downloadAPKFile(url: String, apkPath: String) { val request = Request.Builder().url(url).build() val response = NetworkHelper().client.newCall(request).execute(); @@ -32,8 +37,8 @@ class Main { } @JvmStatic - fun testExtensionExecution(){ - File(contentRoot).mkdirs() + fun testExtensionExecution() { + File(Config.extensionsRoot).mkdirs() var sourcePkg = "" // get list of extensions @@ -48,20 +53,20 @@ class Main { } val apkFileName = apkToDownload.split("/").last() - val apkFilePath = "$contentRoot/$apkFileName" + val apkFilePath = "${Config.extensionsRoot}/$apkFileName" val zipDirPath = apkFilePath.substringBefore(".apk") val jarFilePath = "$zipDirPath.jar" val dexFilePath = "$zipDirPath.dex" // download apk file - downloadAPK(apkToDownload, apkFilePath) + downloadAPKFile(apkToDownload, apkFilePath) val className = APKExtractor.extract_dex_and_read_className(apkFilePath, dexFilePath) // dex -> jar Dex2jarCmd.main(dexFilePath, "-o", jarFilePath, "--force") - val child = URLClassLoader(arrayOf(URL("file:$jarFilePath")), this.javaClass.classLoader) + val child = URLClassLoader(arrayOf(URL("file:$jarFilePath")), this::class.java.classLoader) val classToLoad = Class.forName(className, true, child) val instance = classToLoad.newInstance() as HttpSource val result = instance.fetchPopularManga(1) @@ -69,25 +74,145 @@ class Main { mangasPage.mangas.forEach { println(it.title) } -// exitProcess(0) + } + + fun extensionDatabaseIsEmtpy(): Boolean { + return transaction { + return@transaction ExtensionsTable.selectAll().count() == 0L + } + } + + fun getExtensionList(offline: Boolean = false): List { + // update if 60 seconds has passed or requested offline and database is empty + if (lastExtensionCheck + 60 * 1000 < System.currentTimeMillis() || (offline && extensionDatabaseIsEmtpy())) { + println("Getting extensions list from the internet") + lastExtensionCheck = System.currentTimeMillis() + var foundExtensions: List + runBlocking { + val api = ExtensionGithubApi() + foundExtensions = api.findExtensions() + transaction { + foundExtensions.forEach { foundExtension -> + val extensionRecord = ExtensionsTable.select { ExtensionsTable.name eq foundExtension.name }.firstOrNull() + if (extensionRecord != null) { + // update the record + ExtensionsTable.update({ ExtensionsTable.name eq foundExtension.name }) { + it[name] = foundExtension.name + it[pkgName] = foundExtension.pkgName + it[versionName] = foundExtension.versionName + it[versionCode] = foundExtension.versionCode + it[lang] = foundExtension.lang + it[isNsfw] = foundExtension.isNsfw + it[apkName] = foundExtension.apkName + it[iconUrl] = foundExtension.iconUrl + } + } else { + // insert new record + ExtensionsTable.insert { + it[name] = foundExtension.name + it[pkgName] = foundExtension.pkgName + it[versionName] = foundExtension.versionName + it[versionCode] = foundExtension.versionCode + it[lang] = foundExtension.lang + it[isNsfw] = foundExtension.isNsfw + it[apkName] = foundExtension.apkName + it[iconUrl] = foundExtension.iconUrl + } + } + } + } + } + } + + return transaction { + return@transaction ExtensionsTable.selectAll().map { + ExtensionDataClass( + it[ExtensionsTable.name], + it[ExtensionsTable.pkgName], + it[ExtensionsTable.versionName], + it[ExtensionsTable.versionCode], + it[ExtensionsTable.lang], + it[ExtensionsTable.isNsfw], + it[ExtensionsTable.apkName], + it[ExtensionsTable.iconUrl], + it[ExtensionsTable.installed], + it[ExtensionsTable.classFQName] + ) + } + + } + } + + fun downloadApk(apkName: String): Int { + val extension = getExtensionList(true).first { it.apkName == apkName } + val fileNameWithoutType = apkName.substringBefore(".apk") + val dirPathWithoutType = "${Config.extensionsRoot}/$apkName" + + // check if we don't have the dex file already downloaded + val dexPath = "${Config.extensionsRoot}/$fileNameWithoutType.jar" + if (!File(dexPath).exists()) { + runBlocking { + val api = ExtensionGithubApi() + val apkToDownload = api.getApkUrl(extension) + + val apkFilePath = "$dirPathWithoutType.apk" + val jarFilePath = "$dirPathWithoutType.jar" + val dexFilePath = "$dirPathWithoutType.dex" + + // download apk file + downloadAPKFile(apkToDownload, apkFilePath) + + + val className: String = APKExtractor.extract_dex_and_read_className(apkFilePath, dexFilePath) + + // dex -> jar + Dex2jarCmd.main(dexFilePath, "-o", jarFilePath, "--force") + + File(apkFilePath).delete() + + transaction { + ExtensionsTable.update({ ExtensionsTable.name eq extension.name }) { + it[installed] = true + it[classFQName] = className + } + } + + } + return 201 // we downloaded successfully + } + else { + return 302 + } } @JvmStatic fun main(args: Array) { + // make sure everything we need exists + File(Config.dataRoot).mkdirs() + File(Config.extensionsRoot).mkdirs() + makeDataBaseTables() + + val app = Javalin.create().start(4567) app.before() { ctx -> - ctx.header("Access-Control-Allow-Origin", "*") + ctx.header("Access-Control-Allow-Origin", "*") // allow the client which is running on another port } app.get("/api/v1/extensions") { ctx -> - runBlocking { - val api = ExtensionGithubApi() - val sources = api.findExtensions() - ctx.json(sources) - } + ctx.json(getExtensionList()) } + + + app.get("/api/v1/extensions/install/:apkName") { ctx -> + val apkName = ctx.pathParam("apkName") + println(apkName) + ctx.status( + downloadApk(apkName) + ) + } + } } } diff --git a/server/src/main/kotlin/ir/armor/tachidesk/database/DBMangaer.kt b/server/src/main/kotlin/ir/armor/tachidesk/database/DBMangaer.kt new file mode 100644 index 0000000..807490a --- /dev/null +++ b/server/src/main/kotlin/ir/armor/tachidesk/database/DBMangaer.kt @@ -0,0 +1,24 @@ +package ir.armor.tachidesk.database + +import ir.armor.tachidesk.Config +import ir.armor.tachidesk.database.model.ExtensionsTable +import ir.armor.tachidesk.database.model.SourcesTable +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.transactions.transaction + +object DBMangaer { + val db by lazy { + Database.connect("jdbc:sqlite:${Config.dataRoot}/database.db", "org.sqlite.JDBC") + } +} + +fun makeDataBaseTables() { + // mention db object to connect + DBMangaer.db + + transaction { + SchemaUtils.create(ExtensionsTable) + SchemaUtils.create(SourcesTable) + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/ir/armor/tachidesk/database/model/ExtensionTable.kt b/server/src/main/kotlin/ir/armor/tachidesk/database/model/ExtensionTable.kt new file mode 100644 index 0000000..9cd3a05 --- /dev/null +++ b/server/src/main/kotlin/ir/armor/tachidesk/database/model/ExtensionTable.kt @@ -0,0 +1,37 @@ +package ir.armor.tachidesk.database.model + +import eu.kanade.tachiyomi.source.Source +import org.jetbrains.exposed.dao.IntEntity +import org.jetbrains.exposed.dao.IntEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.Table + + +object ExtensionsTable : IntIdTable() { + val name = varchar("name", 128) + val pkgName = varchar("pkg_name", 128) + val versionName = varchar("version_name", 16) + val versionCode = integer("version_code") + val lang = varchar("lang", 5) + val isNsfw = bool("is_nsfw") + val apkName = varchar("apk_name", 1024) + val iconUrl = varchar("icon_url", 2048) + + val installed = bool("installed").default(false) + val classFQName = varchar("class_name", 256).default("") // fully qualified name +} + +data class ExtensionDataClass( + val name: String, + val pkgName: String, + val versionName: String, + val versionCode: Int, + val lang: String, + val isNsfw: Boolean, + val apkName: String, + val iconUrl : String, + val installed: Boolean, + val classFQName: String, +) diff --git a/server/src/main/kotlin/ir/armor/tachidesk/database/model/SourcesTable.kt b/server/src/main/kotlin/ir/armor/tachidesk/database/model/SourcesTable.kt new file mode 100644 index 0000000..c83cbac --- /dev/null +++ b/server/src/main/kotlin/ir/armor/tachidesk/database/model/SourcesTable.kt @@ -0,0 +1,26 @@ +package ir.armor.tachidesk.database.model + +import org.jetbrains.exposed.dao.Entity +import org.jetbrains.exposed.dao.IntEntity +import org.jetbrains.exposed.dao.IntEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.Table + +object SourcesTable : Table() { + val id: Column = long("id") + val name: Column = varchar("name", 128) + val extension = reference("extension", ExtensionsTable) + override val primaryKey = PrimaryKey(id) +} + +//class Source : Entity() { +// companion object : Entity(SourcesTable) +// +// val name by ExtensionsTable.name +// val pkgName by ExtensionsTable.pkgName +// val versionName by ExtensionsTable.versionName +// val versionCode by ExtensionsTable.versionCode +// val lang by ExtensionsTable.lang +// val isNsfw by ExtensionsTable.isNsfw +//} \ No newline at end of file diff --git a/webUI/react/src/components/ExtensionCard.tsx b/webUI/react/src/components/ExtensionCard.tsx index 0b0a03c..d8005da 100644 --- a/webUI/react/src/components/ExtensionCard.tsx +++ b/webUI/react/src/components/ExtensionCard.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { makeStyles } from '@material-ui/core/styles'; import Card from '@material-ui/core/Card'; import CardContent from '@material-ui/core/CardContent'; @@ -36,16 +36,24 @@ interface IProps { extension: IExtension } -export default function ExtensionCard({ - extension: { - name, lang, versionName, iconUrl, - }, -}: IProps) { +export default function ExtensionCard(props: IProps) { + const { + extension: { + name, lang, versionName, iconUrl, installed, apkName, + }, + } = props; + const [installedState, setInstalledState] = useState((installed ? 'installed' : 'install')); + const classes = useStyles(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const bull = ; const langPress = lang === 'all' ? 'All' : lang.toUpperCase(); + function install() { + setInstalledState('installing'); + fetch(`http://127.0.0.1:4567/api/v1/extensions/install/${apkName}`).then(() => { + setInstalledState('installed'); + }); + } + return ( @@ -68,7 +76,7 @@ export default function ExtensionCard({ - + ); diff --git a/webUI/react/src/typings.d.ts b/webUI/react/src/typings.d.ts index dcc4045..1a21c32 100644 --- a/webUI/react/src/typings.d.ts +++ b/webUI/react/src/typings.d.ts @@ -3,4 +3,6 @@ interface IExtension { lang: string versionName: string iconUrl: string + installed: boolean + apkName: string }