Compare commits

...

118 Commits

Author SHA1 Message Date
Aria Moradi 63a078cf7d Add migrations (#76)
CI Publish / Validate Gradle Wrapper (push) Successful in 11s
CI Publish / Build FatJar (push) Failing after 16s
2021-05-06 18:46:12 +04:30
Aria Moradi 5304917e53 fix multiple version warning 2021-05-06 14:14:04 +04:30
Aria Moradi 831b74d2ec bintray is dead 2021-05-06 13:07:40 +04:30
Aria Moradi 1bad9dcd69 [SKIP CI] 2021-05-05 15:54:05 +04:30
Aria Moradi dd43716851 [SKIP CI] more info 2021-05-05 15:26:52 +04:30
Aria Moradi f2e55e95a2 reaffirmation of what Tachidesk is for some Twitter users... 2021-05-05 15:22:46 +04:30
Aria Moradi 14658a0c4d be fancy about the manifest info 😎 2021-05-04 00:15:31 +04:30
Aria Moradi 4195e7056b fix windowsPackge not building the jar 2021-05-03 23:51:48 +04:30
Aria Moradi 1d29e8b248 fix webUI not working with gradle 7.0 2021-05-03 23:21:37 +04:30
Syer10 b718c718df Download directly to file instead of a dir (#70) 2021-05-03 23:00:18 +04:30
Aria Moradi a3601cf1b5 fix pull request build 2021-05-03 22:57:54 +04:30
Aria Moradi 0236a9639b rename vals, comments 2021-05-03 22:30:43 +04:30
Syer10 5f4c7454ee Update everything (#68)
* Update everything, cleanup build.gradle.kts's

* Make requested changes
2021-05-03 22:19:09 +04:30
Aria Moradi 773120c96a make the app build 2021-05-03 20:50:09 +04:30
Aria Moradi 4b273c6bf9 add a bit of docs 2021-05-03 20:48:29 +04:30
Aria Moradi b626aa66ba Merge branch 'master' of github.com:Suwayomi/Tachidesk 2021-05-03 20:41:46 +04:30
Syer10 1dd029559e Stop Javalin properly on shutdown (#69) 2021-05-03 20:40:02 +04:30
Aria Moradi 59cbe5d5bc [SKIP CI] Yes, we do use git 2021-04-30 06:15:56 +04:30
Aria Moradi 40d1173653 put DBManager where it should be 2021-04-30 06:10:41 +04:30
Aria Moradi bf6a0aba5d Rework the version endpoint 2021-04-30 06:07:29 +04:30
Kolby Moroz Liebl 34d8feacdd Add version api endpoint (#66) 2021-04-30 05:47:23 +04:30
Kolby Moroz Liebl 1ea821584c [SKIP CI] Remove IE Requirement in ps1 script (#65) 2021-04-29 01:52:24 +04:30
Aria Moradi 3d2fee19bb refactor 2021-04-28 10:25:01 +04:30
Forgenn 449d12779a Truncate manga description if it's too long (#63)
* Manga description changed from 4096 to 8192

* Check that the description of a manga is not longer than 4096, trim otherwise

* Revert description length changes
2021-04-28 09:50:56 +04:30
Aria Moradi 6fb6a251e7 [SKIP CI] latest pointer link 2021-04-18 20:52:59 +04:30
Aria Moradi 4d6220f894 dir pointer 2021-04-18 20:45:15 +04:30
Aria Moradi fe747bfc52 [SKIP CI] fix link 2021-04-18 20:14:17 +04:30
Aria Moradi 0c2d038870 [SKIP CI] add logo back 2021-04-18 20:05:58 +04:30
Aria Moradi 4e3f73af75 fix string 2021-04-18 19:58:07 +04:30
Aria Moradi 63e5e1b45f add indexer 2021-04-18 19:50:26 +04:30
Aria Moradi 2e1558bd96 [SKIP CI] fix discord links, again 2021-04-18 19:40:25 +04:30
Aria Moradi 0671dee8b2 [SKIP CI] fix discord link 2021-04-18 19:38:43 +04:30
Aria Moradi 8f91b8089a [SKIP CI] fix preview 2021-04-18 19:38:05 +04:30
Aria Moradi 009b45f676 [SKIP CI] discord? 2021-04-18 19:26:28 +04:30
Aria Moradi 8f7d5eb311 [SKIP CI] badges 2021-04-18 19:22:25 +04:30
Aria Moradi f3de835ef3 Update build_push.yml 2021-04-18 18:51:58 +04:30
Aria Moradi fd6662f428 bring back the old licenses 2021-04-18 17:48:42 +04:30
Aria Moradi fde137b3ed restore categories 2021-04-18 12:40:09 +04:30
Aria Moradi a1349aa0e3 Merge branch 'master' of github.com:Suwayomi/Tachidesk 2021-04-18 11:54:44 +04:30
Aria Moradi c9ef5f9b9d lint meh 2021-04-18 11:50:02 +04:30
Aria Moradi 8fbf564177 also backup chapter and category data 2021-04-18 11:48:14 +04:30
Aria Moradi ae0b1a818c [SKIP CI] update troubleshooting guide 2021-04-15 15:57:42 +04:30
Aria Moradi c04cc780b7 update discord invite
CI Publish / Validate Gradle Wrapper (push) Successful in 12s
CI Publish / Build FatJar (push) Failing after 17s
2021-04-15 14:15:49 +04:30
Aria Moradi 71ad1bb6e3 Merge branch 'master' of github.com:Suwayomi/Tachidesk 2021-04-13 10:54:50 +04:30
Aria Moradi c1be77ee9b Merge pull request #60 from Arias800/patch-1
Fix white screen if the manga doesn't have genre.
2021-04-13 10:54:08 +04:30
Aria Moradi d1fa857ffb avoid creating the jar befre the front-end is copied 2021-04-13 10:53:23 +04:30
Aria Moradi 93fd81b38b [SKIP CI] fix the glob 2021-04-13 10:36:34 +04:30
Aria Moradi 2f116b40b2 fix chapter reading not working 2021-04-13 01:23:09 +04:30
Arias800 b884d34bdf Fix white screen if the manga doesn't have genre. 2021-04-10 19:42:05 +02:00
Aria Moradi 309803368b [SKIP CI] rename workflow 2021-04-10 10:39:47 +04:30
Aria Moradi 19fc5be8f3 [SKIP CI] rename workflow 2021-04-10 10:35:28 +04:30
Aria Moradi c28fac14c0 Merge branch 'master' of github.com:Suwayomi/Tachidesk 2021-04-10 10:30:47 +04:30
Aria Moradi 66e38de29f check for deps 2021-04-10 10:28:54 +04:30
Aria Moradi 282cb1d3be [CI SKIP] account for windows 2021-04-10 10:16:24 +04:30
Aria Moradi b741ded595 [SKIP CI] improve wording 2021-04-10 10:13:56 +04:30
Aria Moradi 6b290695fc [SKIP CI] update for preview builds 2021-04-10 10:09:25 +04:30
Aria Moradi 4e43c554c0 fix triggers 2021-04-10 10:01:39 +04:30
Aria Moradi 090a72b35f rename repo to preview 2021-04-10 09:57:02 +04:30
Aria Moradi 3fcc269df3 now we can deploy repo too 2021-04-10 09:54:03 +04:30
Aria Moradi 9958e0eb34 bump version 2021-04-10 09:21:42 +04:30
Aria Moradi c5269002a2 Update README.md 2021-04-10 01:18:15 +04:30
Aria Moradi 455a35f8ae front-end UI done
Publish / Validate Gradle Wrapper (push) Successful in 12s
Publish / Build FatJar (push) Failing after 16s
2021-04-10 00:44:13 +04:30
Aria Moradi 0c79f207c3 fist version of a working backup system 2021-04-09 22:59:13 +04:30
Aria Moradi cd16d32a35 first instance of legacy import 2021-04-09 19:21:04 +04:30
Aria Moradi 1989c1eb48 add copyright notice 2021-04-09 18:18:40 +04:30
Aria Moradi f56856529f Merge pull request #55 from txtsd/master
Fix blatant typo
2021-04-06 22:27:53 +04:30
txtsd 52e27a3e39 Fix typo 2021-04-06 14:52:26 +05:30
Aria Moradi 177c971b52 This is better. 2021-04-04 03:37:00 +04:30
Aria Moradi 7a52e19235 Better way of setting it maybe? 2021-04-04 03:23:50 +04:30
Aria Moradi 5171e509a5 remove TOOD 2021-04-04 03:16:02 +04:30
Aria Moradi 975a3b1828 Merge pull request #53 from Syer10/testing_suit
Add initial testing suit
2021-04-04 03:14:27 +04:30
Syer10 c11887fada Allow rootdir to be used as a argument 2021-04-03 18:35:30 -04:00
Syer10 e043cb5690 Use properties to set rootDir so that ConfigManager can use it 2021-04-03 18:12:01 -04:00
Syer10 b2d5354798 dirs -> applicationDirs 2021-04-03 17:25:53 -04:00
Syer10 a211a4143b Revert source id to long 2021-04-03 16:57:03 -04:00
Syer10 c0df7d314b Add initial testing suit 2021-04-03 16:42:13 -04:00
Aria Moradi c8a8ce07e2 fix windows path 2021-04-04 00:52:58 +04:30
Aria Moradi e0e474dfce fixes from inspector 2021-04-03 22:40:14 +04:30
Aria Moradi 7591748811 fixes from inspector 2021-04-03 20:30:28 +04:30
Aria Moradi 884308690f fixes from inspector 2021-04-03 20:08:50 +04:30
Aria Moradi 15bd5b4b7a the new and improved apk installer 2021-04-03 19:47:31 +04:30
Aria Moradi abc3a16ee3 fix typo 2021-04-03 15:40:23 +04:30
Aria Moradi bb09ccf3c0 lint 2021-04-03 15:26:23 +04:30
Aria Moradi ad2ea8095b fixes from the inspector project 2021-04-03 15:09:48 +04:30
Aria Moradi 760d1116a1 prepare to install apk from any source 2021-04-03 13:20:14 +04:30
Aria Moradi 47fcf7eb97 export extensions 2021-04-02 18:10:02 +04:30
Aria Moradi b0e90c2f63 use the correct endpoint 2021-04-02 18:05:42 +04:30
Aria Moradi f502884fdd a partially working legacy import... 2021-04-02 17:57:29 +04:30
Aria Moradi 5ed79523d2 Move getHttpSource to util as it is a util 2021-04-02 14:17:37 +04:30
Aria Moradi da5dd70969 use future properly 2021-04-02 14:06:41 +04:30
Aria Moradi 68e69085df flatten the code 2021-04-02 13:56:38 +04:30
Aria Moradi 640ce8f5d7 add future shorthand 2021-04-02 04:09:48 +04:30
Aria Moradi c960cc1ee5 update comment 2021-04-02 03:24:12 +04:30
Aria Moradi 2b2601aa4a move coroutines to root 2021-04-02 03:14:40 +04:30
Aria Moradi 99a10ec7db improve logging 2021-04-02 03:14:19 +04:30
Aria Moradi 035105adf0 refactor and more 2021-04-02 02:56:16 +04:30
Aria Moradi f983f0e359 Merge pull request #46 from Syer10/future
Implement coroutines
2021-04-02 02:53:59 +04:30
Syer10 769472b24c Implement coroutines 2021-04-01 16:07:35 -04:00
Aria Moradi 8c80ad7575 fix time between extensions list checks 2021-04-01 23:39:00 +04:30
Aria Moradi 63db2e6695 fix HNI-Scantard not installing correctly 2021-04-01 23:34:52 +04:30
Aria Moradi d6d5e97fbd Merge pull request #45 from Syer10/getRepo
Fix getRepo function
2021-04-01 23:15:45 +04:30
Syer10 1ae0a8326e Fix getRepo function 2021-04-01 14:42:13 -04:00
Aria Moradi 57693fef7b clean up 2021-03-30 21:48:05 +04:30
Aria Moradi 5656016700 let's not polute the namespace together 2021-03-30 21:10:41 +04:30
Aria Moradi 90ae180b3e let's not polute the namespace 2021-03-30 21:04:06 +04:30
Aria Moradi 2a3c78d43e refactor 2021-03-30 20:49:54 +04:30
Aria Moradi 11000af718 get gz insead of big big json 2021-03-30 20:41:20 +04:30
Aria Moradi b808121f1d Merge branch 'master' of github.com:Suwayomi/Tachidesk 2021-03-30 20:39:56 +04:30
Aria Moradi addadefeb1 some refactor and comments 2021-03-30 20:39:40 +04:30
Aria Moradi 838cd20e57 new build scripts 2021-03-30 20:21:58 +04:30
Aria Moradi 5b9219522d separate jar task from webUI copy task 2021-03-30 20:14:25 +04:30
Aria Moradi caeb4d273d refactor 2021-03-30 17:34:15 +04:30
Aria Moradi 77cf87c989 refactor 2021-03-30 17:21:41 +04:30
Aria Moradi 50c2dbed5d refactor 2021-03-30 16:52:10 +04:30
Aria Moradi 71a9396952 starts of legacy backup support 2021-03-30 01:18:57 +04:30
Aria Moradi bc3ad75328 finished Update support: webUI side 2021-03-29 02:47:51 +04:30
Aria Moradi 077bbc3c38 refactor & support for extension update: Backend 2021-03-29 00:35:58 +04:30
Aria Moradi b1b1abad1d refactor proxy 2021-03-28 20:41:39 +04:30
116 changed files with 4512 additions and 2084 deletions
+9 -8
View File
@@ -1,24 +1,25 @@
#!/bin/bash #!/bin/bash
git lfs install cp master/server/build/Tachidesk-*.jar preview
#git lfs track "*.zip" cd preview
cp ../master/repo/* .
new_jar_build=$(ls *.jar| tail -1) new_jar_build=$(ls *.jar| tail -1)
echo "last jar build file name: $new_jar_build" echo "last jar build file name: $new_jar_build"
new_win32_build=$(ls *.zip| tail -1)
echo "last win32 build file name: $new_win32_build"
cp -f $new_jar_build Tachidesk-latest.jar cp -f $new_jar_build Tachidesk-latest.jar
cp -f $new_win32_build Tachidesk-latest-win32.zip
rm -rf latest_pointer/*
cp $new_jar_build latest_pointer
latest=$(ls *.jar | tail -n1 | cut -d"-" -f3 | cut -d"." -f1)
echo "{ \"latest\": \"$latest\" }" > index.json
git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]" git config --global user.name "github-actions[bot]"
git status git status
if [ -n "$(git status --porcelain)" ]; then if [ -n "$(git status --porcelain)" ]; then
git add . git add .
git commit -m "Update repo" git commit -m "Update preview repository"
git push git push
else else
echo "No changes to commit" echo "No changes to commit"
-20
View File
@@ -1,20 +0,0 @@
#!/bin/bash
# Get last commit message
#last_commit_log=$(git log -1 --pretty=format:"%s")
#echo "last commit log: $last_commit_log"
#
#filter_count=$(echo "$last_commit_log" | grep -e '\[RELEASE CI\]' -e '\[CI RELEASE\]' | wc -c)
#echo "count is: $filter_count"
mkdir -p repo/
cp server/build/Tachidesk-*.jar repo/
cp server/build/Tachidesk-*.zip repo/
ls repo
pwd
#if [ "$filter_count" -gt 0 ]; then
# cp server/build/Tachidesk-*.jar repo/
# cp server/build/Tachidesk-*.zip repo/
#fi
@@ -1,9 +1,6 @@
name: CI name: CI Pull Request
on: on:
push:
branches:
- master
pull_request: pull_request:
jobs: jobs:
@@ -33,7 +30,7 @@ jobs:
- name: Checkout master branch - name: Checkout master branch
uses: actions/checkout@v2 uses: actions/checkout@v2
with: with:
ref: master ref: ${{ github.event.pull_request.head.sha }}
path: master path: master
fetch-depth: 0 fetch-depth: 0
@@ -60,12 +57,12 @@ jobs:
**/react/node_modules **/react/node_modules
key: ${{ runner.os }}-${{ hashFiles('**/react/yarn.lock') }} key: ${{ runner.os }}-${{ hashFiles('**/react/yarn.lock') }}
- name: Build Jar and launch4j - name: Build and copy webUI, Build Jar and launch4j
uses: eskatos/gradle-command-action@v1 uses: eskatos/gradle-command-action@v1
with: with:
build-root-directory: master build-root-directory: master
wrapper-directory: master wrapper-directory: master
arguments: :server:windowsPackage --stacktrace arguments: :webUI:copyBuild :server:windowsPackage --stacktrace
wrapper-cache-enabled: true wrapper-cache-enabled: true
dependencies-cache-enabled: true dependencies-cache-enabled: true
configuration-cache-enabled: true configuration-cache-enabled: true
+80
View File
@@ -0,0 +1,80 @@
name: CI build
on:
push:
branches:
- master
jobs:
check_wrapper:
name: Validate Gradle Wrapper
runs-on: ubuntu-latest
steps:
- name: Clone repo
uses: actions/checkout@v2
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
build:
name: Build FatJar
needs: check_wrapper
if: "!startsWith(github.event.head_commit.message, '[SKIP CI]')"
runs-on: ubuntu-latest
steps:
- name: Cancel previous runs
uses: styfle/cancel-workflow-action@0.5.0
with:
access_token: ${{ github.token }}
- name: Checkout master branch
uses: actions/checkout@v2
with:
ref: master
path: master
fetch-depth: 0
- name: Set up JDK 1.8
uses: actions/setup-java@v1
with:
java-version: 1.8
- name: Copy CI gradle.properties
run: |
cd master
mkdir -p ~/.gradle
cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties
- name: Download android.jar
run: |
cd master
curl https://raw.githubusercontent.com/AriaMoradi/Tachidesk/android-jar/android.jar -o AndroidCompat/lib/android.jar
- name: Cache node_modules
uses: actions/cache@v2
with:
path: |
**/react/node_modules
key: ${{ runner.os }}-${{ hashFiles('**/react/yarn.lock') }}
- name: Build and copy webUI, Build Jar and launch4j
uses: eskatos/gradle-command-action@v1
with:
build-root-directory: master
wrapper-directory: master
arguments: :webUI:copyBuild :server:windowsPackage --stacktrace
wrapper-cache-enabled: true
dependencies-cache-enabled: true
configuration-cache-enabled: true
- name: Checkout preview branch
uses: actions/checkout@v2
with:
ref: preview
path: preview
- name: Deploy preview
run: |
./master/.github/scripts/commit-repo.sh
+4 -10
View File
@@ -1,4 +1,4 @@
name: Publish name: CI Publish
on: on:
push: push:
@@ -58,28 +58,22 @@ jobs:
**/react/node_modules **/react/node_modules
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
- name: Build Jar and launch4j - name: Build and copy webUI, Build Jar and launch4j
uses: eskatos/gradle-command-action@v1 uses: eskatos/gradle-command-action@v1
with: with:
build-root-directory: master build-root-directory: master
wrapper-directory: master wrapper-directory: master
arguments: :server:windowsPackage --stacktrace arguments: :webUI:copyBuild :server:windowsPackage --stacktrace
wrapper-cache-enabled: true wrapper-cache-enabled: true
dependencies-cache-enabled: true dependencies-cache-enabled: true
configuration-cache-enabled: true configuration-cache-enabled: true
- name: Create repo artifacts
run: |
cd master
./.github/scripts/create-repo.sh
- name: Upload Release - name: Upload Release
uses: xresloader/upload-to-github-release@master uses: xresloader/upload-to-github-release@master
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
file: "master/repo/*" file: "master/server/build/*.jar;master/server/build/*-win32.zip"
tags: true tags: true
draft: true draft: true
verbose: true verbose: true
+3
View File
@@ -1,8 +1,11 @@
# Ignore Gradle project-specific cache directory # Ignore Gradle project-specific cache directory
.gradle .gradle
.idea .idea
gradle.properties
# Ignore Gradle build output directory # Ignore Gradle build output directory
build build
server/src/main/resources/react server/src/main/resources/react
server/tmp/
server/tachiserver-data/
@@ -0,0 +1,18 @@
package xyz.nulldev.ts.config
import net.harawata.appdirs.AppDirsFactory
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
val ApplicationRootDir: String
get(): String {
return System.getProperty(
"ir.armor.tachidesk.rootDir",
AppDirsFactory.getInstance().getUserDataDir("Tachidesk", null, null)
)
}
@@ -11,15 +11,12 @@ import com.typesafe.config.Config
import com.typesafe.config.ConfigFactory import com.typesafe.config.ConfigFactory
import com.typesafe.config.ConfigRenderOptions import com.typesafe.config.ConfigRenderOptions
import mu.KotlinLogging import mu.KotlinLogging
import net.harawata.appdirs.AppDirsFactory
import java.io.File import java.io.File
/** /**
* Manages app config. * Manages app config.
*/ */
open class ConfigManager { open class ConfigManager {
private val dataRoot by lazy { AppDirsFactory.getInstance().getUserDataDir("Tachidesk", null, null)!! }
private val generatedModules = mutableMapOf<Class<out ConfigModule>, ConfigModule>() private val generatedModules = mutableMapOf<Class<out ConfigModule>, ConfigModule>()
val config by lazy { loadConfigs() } val config by lazy { loadConfigs() }
@@ -27,8 +24,6 @@ open class ConfigManager {
val loadedModules: Map<Class<out ConfigModule>, ConfigModule> val loadedModules: Map<Class<out ConfigModule>, ConfigModule>
get() = generatedModules get() = generatedModules
open val appConfigFile: String = "$dataRoot/server.conf"
val logger = KotlinLogging.logger {} val logger = KotlinLogging.logger {}
/** /**
@@ -51,8 +46,8 @@ open class ConfigManager {
//Load user config //Load user config
val userConfig = val userConfig =
File(appConfigFile).let{ File(ApplicationRootDir, "server.conf").let {
ConfigFactory.parseFile(it) ConfigFactory.parseFile(it)
} }
val config = ConfigFactory.empty() val config = ConfigFactory.empty()
@@ -69,7 +64,7 @@ open class ConfigManager {
} }
fun registerModule(module: ConfigModule) { fun registerModule(module: ConfigModule) {
generatedModules.put(module.javaClass, module) generatedModules[module.javaClass] = module
} }
fun registerModules(vararg modules: ConfigModule) { fun registerModules(vararg modules: ConfigModule) {
-12
View File
@@ -6,7 +6,6 @@ plugins {
repositories { repositories {
mavenCentral() mavenCentral()
jcenter()
maven { maven {
url = uri("https://jitpack.io") url = uri("https://jitpack.io")
} }
@@ -30,23 +29,12 @@ dependencies {
// Javassist // Javassist
compileOnly( "org.javassist:javassist:3.27.0-GA") compileOnly( "org.javassist:javassist:3.27.0-GA")
// Coroutines
val kotlinx_coroutines_version = "1.4.2"
compileOnly( "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version")
compileOnly( "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$kotlinx_coroutines_version")
// XML // XML
compileOnly( group= "xmlpull", name= "xmlpull", version= "1.1.3.1") compileOnly( group= "xmlpull", name= "xmlpull", version= "1.1.3.1")
// Config API // Config API
implementation(project(":AndroidCompat:Config")) implementation(project(":AndroidCompat:Config"))
// dex2jar: https://github.com/DexPatcher/dex2jar/releases/tag/v2.1-20190905-lanchon
compileOnly("com.github.DexPatcher.dex2jar:dex-tools:v2.1-20190905-lanchon")
// APK parser
compileOnly("net.dongliu:apk-parser:2.6.10")
// APK sig verifier // APK sig verifier
compileOnly("com.android.tools.build:apksig:4.2.0-alpha13") compileOnly("com.android.tools.build:apksig:4.2.0-alpha13")
+1 -1
View File
@@ -15,7 +15,7 @@ Write-Output "Getting required Android.jar..."
Remove-Item -Recurse -Force "tmp" -ErrorAction SilentlyContinue | Out-Null Remove-Item -Recurse -Force "tmp" -ErrorAction SilentlyContinue | Out-Null
New-Item -ItemType Directory -Force -Path "tmp" | Out-Null New-Item -ItemType Directory -Force -Path "tmp" | Out-Null
$androidEncoded = (Invoke-WebRequest -Uri "https://android.googlesource.com/platform/prebuilts/sdk/+/3b8a524d25fa6c3d795afb1eece3f24870c60988/27/public/android.jar?format=TEXT").content $androidEncoded = (Invoke-WebRequest -Uri "https://android.googlesource.com/platform/prebuilts/sdk/+/3b8a524d25fa6c3d795afb1eece3f24870c60988/27/public/android.jar?format=TEXT" -UseBasicParsing).content
$android_jar = (Get-Location).Path + "\tmp\android.jar" $android_jar = (Get-Location).Path + "\tmp\android.jar"
+11
View File
@@ -8,6 +8,17 @@
# This is a bash script to create android.jar stubs # This is a bash script to create android.jar stubs
for dep in "curl" "base64" "zip"
do
which $dep >/dev/null 2>&1 || { echo >&2 "Error: This script needs $dep installed."; abort=yes; }
done
if [ $abort = yes ]; then
echo "Some of the dependencies didn't exist. Aborting."
exit 1
fi
# foolproof against running from AndroidCompat dir instead of running from project root # foolproof against running from AndroidCompat dir instead of running from project root
if [ "$(basename $(pwd))" = "AndroidCompat" ]; then if [ "$(basename $(pwd))" = "AndroidCompat" ]; then
cd .. cd ..
@@ -1,11 +1,20 @@
package com.squareup.duktape;
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) 2015 Square, Inc.
* *
* This Source Code Form is subject to the terms of the Mozilla Public * Licensed under the Apache License, Version 2.0 (the "License");
* License, v. 2.0. If a copy of the MPL was not distributed with this * you may not use this file except in compliance with the License.
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.squareup.duktape;
import kotlin.NotImplementedError; import kotlin.NotImplementedError;
@@ -64,18 +73,18 @@ public final class Duktape implements Closeable, AutoCloseable {
throw new NotImplementedError("Not implemented!"); throw new NotImplementedError("Not implemented!");
} }
// /** /**
// * Attaches to a global JavaScript object called {@code name} that implements {@code type}. * Attaches to a global JavaScript object called {@code name} that implements {@code type}.
// * {@code type} defines the interface implemented in JavaScript that will be accessible to Java. * {@code type} defines the interface implemented in JavaScript that will be accessible to Java.
// * {@code type} must be an interface that does not extend any other interfaces, and cannot define * {@code type} must be an interface that does not extend any other interfaces, and cannot define
// * any overloaded methods. * any overloaded methods.
// * <p>Methods of the interface may return {@code void} or any of the following supported argument * <p>Methods of the interface may return {@code void} or any of the following supported argument
// * types: {@code boolean}, {@link Boolean}, {@code int}, {@link Integer}, {@code double}, * types: {@code boolean}, {@link Boolean}, {@code int}, {@link Integer}, {@code double},
// * {@link Double}, {@link String}. * {@link Double}, {@link String}.
// */ */
// public synchronized <T> T get(final String name, final Class<T> type) { public synchronized <T> T get(final String name, final Class<T> type) {
// throw new NotImplementedError("Not implemented!"); throw new NotImplementedError("Not implemented!");
// } }
/** /**
* Release the native resources associated with this object. You <strong>must</strong> call this * Release the native resources associated with this object. You <strong>must</strong> call this
@@ -1,13 +1,6 @@
package com.squareup.duktape; package com.squareup.duktape;
/* /* part of tachiyomi-extensions which is licensed under Apache License Version 2.0 */
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
// part of tachiyomi-extensions which was originally licensed under Apache License Version 2.0
import java.io.Closeable; import java.io.Closeable;
import java.io.IOException; import java.io.IOException;
@@ -22,7 +22,7 @@ data class InstalledPackage(val root: File) {
val icon = File(root, "icon.png") val icon = File(root, "icon.png")
val info: PackageInfo val info: PackageInfo
get() = ApkParsers.getMetaInfo(apk).toPackageInfo(root, apk).also { get() = ApkParsers.getMetaInfo(apk).toPackageInfo(apk).also {
val parsed = ApkFile(apk) val parsed = ApkFile(apk)
val dbFactory = DocumentBuilderFactory.newInstance() val dbFactory = DocumentBuilderFactory.newInstance()
val dBuilder = dbFactory.newDocumentBuilder() val dBuilder = dbFactory.newDocumentBuilder()
@@ -82,12 +82,14 @@ data class InstalledPackage(val root: File) {
} }
} }
private fun NodeList.toList(): List<Node> { companion object {
val out = mutableListOf<Node>() fun NodeList.toList(): List<Node> {
val out = mutableListOf<Node>()
for(i in 0 until length) for (i in 0 until length)
out += item(i) out += item(i)
return out return out
}
} }
} }
@@ -6,7 +6,7 @@ import android.content.pm.PackageInfo
import net.dongliu.apk.parser.bean.ApkMeta import net.dongliu.apk.parser.bean.ApkMeta
import java.io.File import java.io.File
fun ApkMeta.toPackageInfo(root: File, apk: File): PackageInfo { fun ApkMeta.toPackageInfo(apk: File): PackageInfo {
return PackageInfo().also { return PackageInfo().also {
it.packageName = packageName it.packageName = packageName
it.versionCode = versionCode.toInt() it.versionCode = versionCode.toInt()
+22 -13
View File
@@ -1,9 +1,16 @@
![image](https://github.com/Suwayomi/Tachidesk/raw/master/server/src/main/resources/icon/faviconlogo.png) | Build | Stable | Preview | Support Server |
|-------|----------|---------|---------|
| ![CI](https://github.com/Suwayomi/Tachidesk/actions/workflows/build_push.yml/badge.svg) | [![stable release](https://img.shields.io/github/release/Suwayomi/Tachidesk.svg?maxAge=3600&label=download)](https://github.com/Suwayomi/Tachidesk/releases) | [![preview](https://img.shields.io/badge/dynamic/json?url=https://github.com/Suwayomi/Tachidesk/raw/preview/index.json&label=download&query=$.latest&color=blue)](https://github.com/Suwayomi/Tachidesk/tree/preview/latest_pointer) | [![Discord](https://img.shields.io/discord/801021177333940224.svg?label=discord&labelColor=7289da&color=2c2f33&style=flat)](https://discord.gg/DDZdqZWaHA) |
# Tachidesk # Tachidesk
<img src="https://github.com/Suwayomi/Tachidesk/raw/master/server/src/main/resources/icon/faviconlogo.png" alt="drawing" width="200"/>
A free and open source manga reader that runs extensions built for [Tachiyomi](https://tachiyomi.org/). A free and open source manga reader that runs extensions built for [Tachiyomi](https://tachiyomi.org/).
Tachidesk is as multi-platform as you can get. Any platform that runs java and/or has a modern browser can run it. Tachidesk is an independent Tachiyomi compatible software made by [@AriaMoradi AKA ArMor](https://github.com/AriaMoradi) and contributors and is **not a Fork of** Tachiyomi.
Tachidesk is as multi-platform as you can get. Any platform that runs java and/or has a modern browser can run it.
Ability to read and write Tachiyomi compatible backups and syncing is a planned feature. Ability to read and write Tachiyomi compatible backups and syncing is a planned feature.
@@ -15,6 +22,7 @@ Here is a list of current features:
- Searching and browsing installed sources. - Searching and browsing installed sources.
- A decent chapter reader. - A decent chapter reader.
- Ability to download Mangas for offline read(This partially works) - Ability to download Mangas for offline read(This partially works)
- Backup and restore support powered by Tachiyomi Legacy Backups
**Note:** Keep in mind that Tachidesk is alpha software and can break rarely and/or with each update, so you may have to delete your data to fix it. See [General troubleshooting](#general-troubleshooting) and [Support and help](#support-and-help) if it happens. **Note:** Keep in mind that Tachidesk is alpha software and can break rarely and/or with each update, so you may have to delete your data to fix it. See [General troubleshooting](#general-troubleshooting) and [Support and help](#support-and-help) if it happens.
@@ -24,9 +32,9 @@ Anyways, for more info checkout [finished milestone #1](https://github.com/Suway
### All Operating Systems ### All Operating Systems
You should have The Java Runtime Environment(JRE) 8 or newer and a modern browser installed. Also an internet connection is required as almost everything this app does is downloading stuff. You should have The Java Runtime Environment(JRE) 8 or newer and a modern browser installed. Also an internet connection is required as almost everything this app does is downloading stuff.
Download the latest jar release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases). Download the latest "Stable" jar release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview jar build from [the preview branch](https://github.com/Suwayomi/Tachidesk/tree/preview).
Double click on the jar file or run `java -jar Tachidesk-vX.Y.Z-rxxx.jar` from a Terminal/Command Prompt window to run the app which will open a new browser window automatically. Also the System Tray Icon is your friend if you need to open the browser window again or close Tachidesk. Double click on the jar file or run `java -jar Tachidesk-vX.Y.Z-rxxx.jar` (or `java -jar Tachidesk-latest.jar` if you have the latest preview) from a Terminal/Command Prompt window to run the app which will open a new browser window automatically. Also the System Tray Icon is your friend if you need to open the browser window again or close Tachidesk.
### Windows ### Windows
Download the latest win32 release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases). Download the latest win32 release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases).
@@ -43,7 +51,7 @@ yay -S tachidesk
Check [arbuilder's repo](https://github.com/arbuilder/Tachidesk-docker) out for more details and the dockerfile. Check [arbuilder's repo](https://github.com/arbuilder/Tachidesk-docker) out for more details and the dockerfile.
## General troubleshooting ## General troubleshooting
If the app breaks try deleting the directory below and re-running the app (**This will delete all your data!**) and if the problem persists open an issue. If the app breaks, make sure that it's not running(right click on tray icon and quit or kill it through the way your Operating System provides), delete the directory below and re-run the app (**This procedure will delete all your data!**) and if the problem persists open an issue or ask for help on discord.
On Mac OS X : `/Users/<Account>/Library/Application Support/Tachidesk` On Mac OS X : `/Users/<Account>/Library/Application Support/Tachidesk`
@@ -54,7 +62,7 @@ On Windows 7 and later : `C:\Users\<Account>\AppData\Local\Tachidesk`
On Unix/Linux : `/home/<account>/.local/share/Tachidesk` On Unix/Linux : `/home/<account>/.local/share/Tachidesk`
## Support and help ## Support and help
Join Tachidesk's [discord server](https://discord.gg/wgPyb7hE5d) to hang out with the community and receive support and help. Join Tachidesk's [discord server](https://discord.gg/DDZdqZWaHA) to hang out with the community and to receive support and help.
## How does it work? ## How does it work?
This project has two components: This project has two components:
@@ -65,26 +73,27 @@ This project has two components:
### Prerequisite: Get Android stubs jar ### Prerequisite: Get Android stubs jar
#### Manual download #### Manual download
Download [android.jar](https://raw.githubusercontent.com/Suwayomi/Tachidesk/android-jar/android.jar) and put it under `AndroidCompat/lib`. Download [android.jar](https://raw.githubusercontent.com/Suwayomi/Tachidesk/android-jar/android.jar) and put it under `AndroidCompat/lib`.
#### Automated download(needs `bash`, `curl`, `base64`, `zip` to work) #### Automated download
Run `AndroidCompat/getAndroid.sh`(MacOS/Linux) or `AndroidCompat/getAndroid.ps1`(Windows) from project's root directory to download and rebuild the jar file from Google's repository. Run `AndroidCompat/getAndroid.sh`(MacOS/Linux) or `AndroidCompat/getAndroid.ps1`(Windows) from project's root directory to download and rebuild the jar file from Google's repository.
### Prerequisite: Software dependencies ### Prerequisite: Software dependencies
You need this software packages installed in order to build this project: You need this software packages installed in order to build this project:
- Java Development Kit and Java Runtime Environment version 8 or newer(both Oracle JDK and OpenJDK works) - Java Development Kit and Java Runtime Environment version 8 or newer(both Oracle JDK and OpenJDK works)
- Nodejs LTS or latest - Nodejs LTS or latest
- Yarn - Yarn
- Git
### building the full-blown jar ### building the full-blown jar
Run `./gradlew server:shadowJar`, the resulting built jar file will be `server/build/Tachidesk-vX.Y.Z-rxxx.jar`. Run `./gradlew :webUI:copyBuild server:shadowJar`, the resulting built jar file will be `server/build/Tachidesk-vX.Y.Z-rxxx.jar`.
### building without `webUI` bundled ### building without `webUI` bundled(server only)
Delete the `server/src/main/resources/react` directory if exists from previous runs, then run `./gradlew server:shadowJar -x :webUI:copyBuild`, the resulting built jar file will be `server/build/Tachidesk-vX.Y.Z-rxxx.jar`. Delete the `server/src/main/resources/react` directory if exists from previous runs, then run `./gradlew server:shadowJar`, the resulting built jar file will be `server/build/Tachidesk-vX.Y.Z-rxxx.jar`.
### building the Windows package ### building the Windows package
Run `./gradlew windowsPackage`, the resulting built zip package file will be `server/build/Tachidesk-vX.Y.Z-rxxx-win32.zip`. Run `./gradlew :server:windowsPackage` to build a server only bundle and `./gradlew :webUI:copyBuild :server:windowsPackage` to get a full bundle , the resulting built zip package file will be `server/build/Tachidesk-vX.Y.Z-rxxx-win32.zip`.
## Running for development purposes ## Running for development purposes
### `server` module ### `server` module
Follow [Get Android stubs jar](#prerequisite-get-android-stubs-jar) then run `./gradlew :server:run -x :webUI:copyBuild --stacktrace` to run the server Follow [Get Android stubs jar](#prerequisite-get-android-stubs-jar) then run `./gradlew :server:run --stacktrace` to run the server
### `webUI` module ### `webUI` module
How to do it is described in `webUI/react/README.md` but for short, How to do it is described in `webUI/react/README.md` but for short,
first cd into `webUI/react` then run `yarn` to install the node modules(do this only once) first cd into `webUI/react` then run `yarn` to install the node modules(do this only once)
then `yarn start` to start the client if a new browser window doesn't start automatically, then `yarn start` to start the development server, if a new browser window doesn't get opned automatically,
then open `http://127.0.0.1:3000` in a modern browser. This is a `create-react-app` project then open `http://127.0.0.1:3000` in a modern browser. This is a `create-react-app` project
and supports HMR and all the other goodies you'll need. and supports HMR and all the other goodies you'll need.
+27 -29
View File
@@ -1,33 +1,30 @@
import org.jetbrains.kotlin.config.KotlinCompilerVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
id("org.jetbrains.kotlin.jvm") version "1.4.21" apply false // Also in buildSrc Config.kt kotlin("jvm") version "1.4.32"
id("java")
} }
allprojects { allprojects {
group = "xyz.nulldev.ts" group = "ir.armor.tachidesk"
version = "1.0" version = "1.0"
repositories { repositories {
jcenter()
mavenCentral() mavenCentral()
maven("https://maven.google.com/")
maven("https://jitpack.io") maven("https://jitpack.io")
maven("https://oss.sonatype.org/content/repositories/snapshots/") maven("https://oss.sonatype.org/content/repositories/snapshots/")
maven("https://dl.bintray.com/inorichi/maven")
maven("https://dl.google.com/dl/android/maven2/") maven("https://dl.google.com/dl/android/maven2/")
} }
} }
val javaProjects = listOf( val projects = listOf(
project(":AndroidCompat"), project(":AndroidCompat"),
project(":AndroidCompat:Config"), project(":AndroidCompat:Config"),
project(":server") project(":server")
) )
configure(javaProjects) { configure(projects) {
apply(plugin = "java")
apply(plugin = "org.jetbrains.kotlin.jvm") apply(plugin = "org.jetbrains.kotlin.jvm")
java { java {
@@ -35,34 +32,32 @@ configure(javaProjects) {
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8
} }
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> { tasks.withType<KotlinCompile> {
kotlinOptions { kotlinOptions {
jvmTarget = "1.8" jvmTarget = JavaVersion.VERSION_1_8.toString()
} }
} }
dependencies { dependencies {
// Kotlin // Kotlin
implementation(kotlin("stdlib", KotlinCompilerVersion.VERSION)) implementation(kotlin("stdlib-jdk8"))
implementation(kotlin("stdlib", KotlinCompilerVersion.VERSION)) implementation(kotlin("reflect"))
testImplementation(kotlin("test", version = "1.4.21")) testImplementation(kotlin("test"))
}
} // coroutines
val coroutinesVersion = "1.4.3"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
configure(listOf(
project(":AndroidCompat"),
project(":server"),
project(":AndroidCompat:Config")
)) {
dependencies {
// Dependency Injection // Dependency Injection
implementation("org.kodein.di:kodein-di-conf-jvm:7.1.0") implementation("org.kodein.di:kodein-di-conf-jvm:7.5.0")
// Logging // Logging
implementation("org.slf4j:slf4j-api:1.7.30") implementation("org.slf4j:slf4j-api:1.7.30")
implementation("ch.qos.logback:logback-classic:1.2.3") implementation("ch.qos.logback:logback-classic:1.2.3")
implementation("io.github.microutils:kotlin-logging:2.0.3") implementation("io.github.microutils:kotlin-logging:2.0.6")
// RxJava // RxJava
implementation("io.reactivex:rxjava:1.3.8") implementation("io.reactivex:rxjava:1.3.8")
@@ -71,15 +66,18 @@ configure(listOf(
// JSoup // JSoup
implementation("org.jsoup:jsoup:1.13.1") implementation("org.jsoup:jsoup:1.13.1")
// Kotlin
implementation(kotlin("reflect", version = "1.4.21"))
// dependency of :AndroidCompat:Config // dependency of :AndroidCompat:Config
implementation("com.typesafe:config:1.4.0") implementation("com.typesafe:config:1.4.1")
implementation("io.github.config4k:config4k:0.4.2") implementation("io.github.config4k:config4k:0.4.2")
// to get application content root // to get application content root
implementation("net.harawata:appdirs:1.2.0") implementation("net.harawata:appdirs:1.2.1")
// dex2jar: https://github.com/DexPatcher/dex2jar/releases/tag/v2.1-20190905-lanchon
implementation("com.github.DexPatcher.dex2jar:dex-tools:v2.1-20190905-lanchon")
// APK parser
implementation("net.dongliu:apk-parser:2.6.10")
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-all.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
+159 -125
View File
@@ -1,57 +1,50 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jmailen.gradle.kotlinter.tasks.FormatTask
import org.jmailen.gradle.kotlinter.tasks.LintTask
import java.io.BufferedReader import java.io.BufferedReader
plugins { plugins {
// id("org.jetbrains.kotlin.jvm") version "1.4.21"
application application
id("com.github.johnrengelman.shadow") version "6.1.0" id("com.github.johnrengelman.shadow") version "7.0.0"
id("org.jmailen.kotlinter") version "3.3.0" id("org.jmailen.kotlinter") version "3.4.3"
id("edu.sc.seis.launch4j") version "2.4.9" id("edu.sc.seis.launch4j") version "2.5.0"
id("de.fuerstenau.buildconfig") version "1.1.8"
} }
val TachideskVersion = "v0.2.7"
repositories { repositories {
mavenCentral() mavenCentral()
jcenter()
maven { maven {
url = uri("https://jitpack.io") url = uri("https://jitpack.io")
} }
} }
dependencies { dependencies {
// implementation(platform("org.jetbrains.kotlin:kotlin-bom"))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
// Source models and interfaces from Tachiyomi 1.x // Source models and interfaces from Tachiyomi 1.x
// using source class from tachiyomi commit 9493577de27c40ce8b2b6122cc447d025e34c477 to not depend on tachiyomi.sourceapi // using source class from tachiyomi commit 9493577de27c40ce8b2b6122cc447d025e34c477 to not depend on tachiyomi.sourceapi
// implementation("tachiyomi.sourceapi:source-api:1.1") // implementation("tachiyomi.sourceapi:source-api:1.1")
implementation("com.github.inorichi.injekt:injekt-core:65b0440") implementation("com.github.inorichi.injekt:injekt-core:65b0440")
val okhttp_version = "4.10.0-RC1" val okhttpVersion = "4.10.0-RC1"
implementation("com.squareup.okhttp3:okhttp:$okhttp_version") implementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
implementation("com.squareup.okhttp3:logging-interceptor:$okhttp_version") implementation("com.squareup.okhttp3:logging-interceptor:$okhttpVersion")
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttp_version") implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttpVersion")
implementation("com.squareup.okio:okio:2.9.0") implementation("com.squareup.okio:okio:2.10.0")
// retrofit // Retrofit
val retrofit_version = "2.9.0" val retrofitVersion = "2.9.0"
implementation("com.squareup.retrofit2:retrofit:$retrofit_version") implementation("com.squareup.retrofit2:retrofit:$retrofitVersion")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0") implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0")
implementation("com.squareup.retrofit2:converter-gson:$retrofit_version") implementation("com.squareup.retrofit2:converter-gson:$retrofitVersion")
implementation("com.squareup.retrofit2:adapter-rxjava:$retrofit_version") implementation("com.squareup.retrofit2:adapter-rxjava:$retrofitVersion")
// reactivex // Reactivex
implementation("io.reactivex:rxjava:1.3.8") implementation("io.reactivex:rxjava:1.3.8")
// implementation("io.reactivex:rxandroid:1.2.1")
// implementation("com.jakewharton.rxrelay:rxrelay:1.2.0")
// implementation("com.github.pwittchen:reactivenetwork:0.13.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.1.0")
implementation("com.google.code.gson:gson:2.8.6") implementation("com.google.code.gson:gson:2.8.6")
implementation("com.github.salomonbrys.kotson:kotson:2.5.0") implementation("com.github.salomonbrys.kotson:kotson:2.5.0")
@@ -59,27 +52,25 @@ dependencies {
implementation("com.github.salomonbrys.kotson:kotson:2.5.0") implementation("com.github.salomonbrys.kotson:kotson:2.5.0")
val coroutinesVersion = "1.3.9"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
// dex2jar: https://github.com/DexPatcher/dex2jar/releases/tag/v2.1-20190905-lanchon
implementation("com.github.DexPatcher.dex2jar:dex-tools:v2.1-20190905-lanchon")
// api // api
implementation("io.javalin:javalin:3.12.0") implementation("io.javalin:javalin:3.13.6")
implementation("com.fasterxml.jackson.core:jackson-databind:2.10.3") implementation("com.fasterxml.jackson.core:jackson-databind:2.12.3")
// Exposed ORM // Exposed ORM
val exposed_version = "0.28.1" val exposedVersion = "0.31.1"
implementation("org.jetbrains.exposed:exposed-core:$exposed_version") implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-dao:$exposed_version") implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version") implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
implementation("com.h2database:h2:1.4.199") implementation("org.jetbrains.exposed:exposed-java-time:$exposedVersion")
// current database driver
implementation("com.h2database:h2:1.4.200")
// tray icon // tray icon
implementation("com.dorkbox:SystemTray:3.17") implementation("com.dorkbox:SystemTray:4.1")
implementation("com.dorkbox:Utilities:1.9")
implementation("com.google.guava:guava:30.1.1-jre")
// AndroidCompat // AndroidCompat
implementation(project(":AndroidCompat")) implementation(project(":AndroidCompat"))
@@ -87,14 +78,14 @@ dependencies {
// uncomment to test extensions directly // uncomment to test extensions directly
// implementation(fileTree("lib/")) // implementation(fileTree("lib/"))
// Testing
testImplementation(kotlin("test-junit5"))
} }
val name = "ir.armor.tachidesk.Main" val MainClass = "ir.armor.tachidesk.Main"
application { application {
mainClass.set(name) mainClass.set(MainClass)
// Required by ShadowJar.
mainClassName = name
} }
sourceSets { sourceSets {
@@ -105,7 +96,11 @@ sourceSets {
} }
} }
val TachideskRevision = Runtime // should be bumped with each stable release
val tachideskVersion = "v0.3.0"
// counts commit count on master
val tachideskRevision = Runtime
.getRuntime() .getRuntime()
.exec("git rev-list master --count") .exec("git rev-list master --count")
.let { process -> .let { process ->
@@ -118,94 +113,133 @@ val TachideskRevision = Runtime
} }
buildConfig {
appName = rootProject.name
clsName = "BuildConfig"
packageName = "ir.armor.tachidesk.server"
version = tachideskVersion
buildConfigField("String", "name", rootProject.name) // alias for BuildConfig.NAME
buildConfigField("String", "version", tachideskVersion) // alias for BuildConfig.VERSION
buildConfigField("String", "revision", tachideskRevision)
buildConfigField("boolean", "debug", project.hasProperty("debugApp").toString())
}
launch4j { //used for windows
mainClassName = MainClass
bundledJrePath = "jre"
bundledJre64Bit = true
jreMinVersion = "8"
outputDir = "${rootProject.name}-$tachideskVersion-$tachideskRevision-win32"
icon = "${projectDir}/src/main/resources/icon/faviconlogo.ico"
jar = "${projectDir}/build/${rootProject.name}-$tachideskVersion-$tachideskRevision.jar"
}
tasks { tasks {
jar { jar {
manifest { manifest {
attributes( attributes(
mapOf( mapOf(
"Main-Class" to "com.example.MainKt", //will make your jar (produced by jar task) runnable "Main-Class" to MainClass,
"ImplementationTitle" to project.name, "Implementation-Title" to rootProject.name,
"Implementation-Version" to project.version) "Implementation-Vendor" to "The Suwayomi Project",
"Specification-Version" to tachideskVersion,
"Implementation-Version" to tachideskRevision
)
) )
} }
} }
shadowJar { shadowJar {
manifest.inheritFrom(jar.get().manifest) //will make your shadowJar (produced by jar task) runnable manifest.inheritFrom(jar.get().manifest) //will make your shadowJar (produced by jar task) runnable
archiveBaseName.set("Tachidesk") archiveBaseName.set(rootProject.name)
archiveVersion.set(TachideskVersion) archiveVersion.set(tachideskVersion)
archiveClassifier.set(TachideskRevision) archiveClassifier.set(tachideskRevision)
}
withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf(
"-Xopt-in=kotlin.RequiresOptIn",
"-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-Xopt-in=kotlinx.coroutines.InternalCoroutinesApi"
)
}
}
test {
useJUnit()
}
register<Zip>("windowsPackage") {
from(fileTree("$buildDir/${rootProject.name}-$tachideskVersion-$tachideskRevision-win32"))
destinationDirectory.set(File("$buildDir"))
archiveFileName.set("${rootProject.name}-$tachideskVersion-$tachideskRevision-win32.zip")
dependsOn("windowsPackageWorkaround2")
}
register<Delete>("windowsPackageWorkaround2") {
delete(
"$buildDir/${rootProject.name}-$tachideskVersion-$tachideskRevision-win32/jre",
"$buildDir/${rootProject.name}-$tachideskVersion-$tachideskRevision-win32/lib",
"$buildDir/${rootProject.name}-$tachideskVersion-$tachideskRevision-win32/server.exe",
"$buildDir/${rootProject.name}-$tachideskVersion-$tachideskRevision-win32/Tachidesk-$tachideskVersion-$tachideskRevision-win32/Tachidesk-$tachideskVersion-$tachideskRevision-win32"
)
dependsOn("windowsPackageWorkaround")
}
register<Copy>("windowsPackageWorkaround") {
from("$buildDir/${rootProject.name}-$tachideskVersion-$tachideskRevision-win32")
into("$buildDir/${rootProject.name}-$tachideskVersion-$tachideskRevision-win32/${rootProject.name}-$tachideskVersion-$tachideskRevision-win32")
dependsOn("deleteUnwantedJreDir")
}
register<Delete>("deleteUnwantedJreDir") {
delete(
"$buildDir/${rootProject.name}-$tachideskVersion-$tachideskRevision-win32/jdk8u282-b08-jre"
)
dependsOn("addJreToDistributable")
}
register<Copy>("addJreToDistributable") {
from(zipTree("$buildDir/OpenJDK8U-jre_x86-32_windows_hotspot_8u282b08.zip"))
into("$buildDir/${rootProject.name}-$tachideskVersion-$tachideskRevision-win32")
eachFile {
path = path.replace(".*-jre".toRegex(), "jre")
}
dependsOn("downloadJre")
dependsOn("createExe")
}
named("createExe") {
dependsOn("shadowJar")
}
register<de.undercouch.gradle.tasks.download.Download>("downloadJre") {
src("https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/jdk8u282-b08/OpenJDK8U-jre_x86-32_windows_hotspot_8u282b08.zip")
dest("$buildDir/OpenJDK8U-jre_x86-32_windows_hotspot_8u282b08.zip")
overwrite(false)
onlyIfModified(true)
}
withType<ShadowJar> {
destinationDirectory.set(File("$rootDir/server/build"))
dependsOn("formatKotlin", "lintKotlin")
}
named("run") {
dependsOn("formatKotlin", "lintKotlin")
}
named<Copy>("processResources") {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
mustRunAfter(":webUI:copyBuild")
}
withType<LintTask> {
source(files("src"))
}
withType<FormatTask> {
source(files("src"))
} }
} }
launch4j { //used for windows
mainClassName = name
bundledJrePath = "jre"
bundledJre64Bit = true
jreMinVersion = "8"
outputDir = "Tachidesk-$TachideskVersion-$TachideskRevision-win32"
icon = "${projectDir}/src/main/resources/icon/faviconlogo.ico"
jar = "${projectDir}/build/Tachidesk-$TachideskVersion-$TachideskRevision.jar"
}
tasks.register<Zip>("windowsPackage") {
from(fileTree("$buildDir/Tachidesk-$TachideskVersion-$TachideskRevision-win32"))
destinationDirectory.set(File("$buildDir"))
archiveFileName.set("Tachidesk-$TachideskVersion-$TachideskRevision-win32.zip")
dependsOn("windowsPackageWorkaround2")
}
tasks.register<Delete>("windowsPackageWorkaround2") {
delete(
"$buildDir/Tachidesk-$TachideskVersion-$TachideskRevision-win32/jre",
"$buildDir/Tachidesk-$TachideskVersion-$TachideskRevision-win32/lib",
"$buildDir/Tachidesk-$TachideskVersion-$TachideskRevision-win32/server.exe",
"$buildDir/Tachidesk-$TachideskVersion-$TachideskRevision-win32/Tachidesk-$TachideskVersion-$TachideskRevision-win32/Tachidesk-$TachideskVersion-$TachideskRevision-win32"
)
dependsOn("windowsPackageWorkaround")
}
tasks.register<Copy>("windowsPackageWorkaround") {
from("$buildDir/Tachidesk-$TachideskVersion-$TachideskRevision-win32")
into("$buildDir/Tachidesk-$TachideskVersion-$TachideskRevision-win32/Tachidesk-$TachideskVersion-$TachideskRevision-win32")
dependsOn("deleteUnwantedJreDir")
}
tasks.register<Delete>("deleteUnwantedJreDir") {
delete(
"$buildDir/Tachidesk-$TachideskVersion-$TachideskRevision-win32/jdk8u282-b08-jre"
)
dependsOn("addJreToDistributable")
}
tasks.register<Copy>("addJreToDistributable") {
from(zipTree("$buildDir/OpenJDK8U-jre_x86-32_windows_hotspot_8u282b08.zip"))
into("$buildDir/Tachidesk-$TachideskVersion-$TachideskRevision-win32")
eachFile {
path = path.replace(".*-jre".toRegex(),"jre")
}
dependsOn("downloadJre")
dependsOn("createExe")
}
tasks.register<de.undercouch.gradle.tasks.download.Download>("downloadJre") {
src("https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/jdk8u282-b08/OpenJDK8U-jre_x86-32_windows_hotspot_8u282b08.zip")
dest(buildDir)
overwrite(false)
onlyIfModified(true)
}
tasks.withType<ShadowJar> {
destinationDir = File("$rootDir/server/build")
dependsOn("lintKotlin")
}
tasks.named("processResources") {
dependsOn(":webUI:copyBuild")
}
tasks.named("run") {
dependsOn("formatKotlin", "lintKotlin")
}
@@ -7,52 +7,17 @@ package eu.kanade.tachiyomi.extension.api
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
// import android.content.Context
// import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import ir.armor.tachidesk.database.dataclass.ExtensionDataClass import ir.armor.tachidesk.model.dataclass.ExtensionDataClass
// import kotlinx.coroutines.Dispatchers
// import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.int import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
// import uy.kohesive.injekt.injectLazy
internal class ExtensionGithubApi { object ExtensionGithubApi {
const val BASE_URL = "https://raw.githubusercontent.com"
// private val preferences: PreferencesHelper by injectLazy() const val REPO_URL_PREFIX = "$BASE_URL/tachiyomiorg/tachiyomi-extensions/repo"
suspend fun findExtensions(): List<Extension.Available> {
val service: ExtensionGithubService = ExtensionGithubService.create()
val response = service.getRepo()
return parseResponse(response)
}
// suspend fun checkForUpdates(): List<Extension.Installed> {
// val extensions = fin dExtensions()
//
// // preferences.lastExtCheck().set(Date().time)
//
// val installedExtensions = ExtensionLoader.loadExtensions(context)
// .filterIsInstance<LoadResult.Success>()
// .map { it.extension }
//
// val extensionsWithUpdate = mutableListOf<Extension.Installed>()
// for (installedExt in installedExtensions) {
// val pkgName = installedExt.pkgName
// val availableExt = extensions.find { it.pkgName == pkgName } ?: continue
//
// val hasUpdate = availableExt.versionCode > installedExt.versionCode
// if (hasUpdate) {
// extensionsWithUpdate.add(installedExt)
// }
// }
//
// return extensionsWithUpdate
// }
private fun parseResponse(json: JsonArray): List<Extension.Available> { private fun parseResponse(json: JsonArray): List<Extension.Available> {
return json return json
@@ -75,16 +40,14 @@ internal class ExtensionGithubApi {
} }
} }
fun getApkUrl(extension: Extension.Available): String { suspend fun findExtensions(): List<Extension.Available> {
return "$REPO_URL_PREFIX/apk/${extension.apkName}" val service: ExtensionGithubService = ExtensionGithubService.create()
val response = service.getRepo()
return parseResponse(response)
} }
fun getApkUrl(extension: ExtensionDataClass): String { fun getApkUrl(extension: ExtensionDataClass): String {
return "$REPO_URL_PREFIX/apk/${extension.apkName}" 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"
}
} }
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension.api
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -9,8 +10,6 @@ import retrofit2.Retrofit
import retrofit2.http.GET import retrofit2.http.GET
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
// import uy.kohesive.injekt.injectLazy
/** /**
* Used to get the extension repo listing from GitHub. * Used to get the extension repo listing from GitHub.
*/ */
@@ -30,6 +29,7 @@ interface ExtensionGithubService {
.build() .build()
} }
@ExperimentalSerializationApi
fun create(): ExtensionGithubService { fun create(): ExtensionGithubService {
val adapter = Retrofit.Builder() val adapter = Retrofit.Builder()
.baseUrl(ExtensionGithubApi.BASE_URL) .baseUrl(ExtensionGithubApi.BASE_URL)
@@ -0,0 +1,358 @@
package eu.kanade.tachiyomi.source
import android.content.Context
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 rx.Observable
// import com.github.junrar.Archive
// import com.google.gson.JsonParser
// import eu.kanade.tachiyomi.R
// import eu.kanade.tachiyomi.source.model.Filter
// 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.util.chapter.ChapterRecognition
// import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
// import eu.kanade.tachiyomi.util.storage.DiskUtil
// import eu.kanade.tachiyomi.util.storage.EpubFile
// import eu.kanade.tachiyomi.util.system.ImageUtil
// import rx.Observable
// import timber.log.Timber
// import java.io.File
// import java.io.FileInputStream
// import java.io.InputStream
// import java.util.Locale
// import java.util.concurrent.TimeUnit
// import java.util.zip.ZipFile
class LocalSource(private val context: Context) : CatalogueSource {
companion object {
const val ID = 0L
// const val HELP_URL = "https://tachiyomi.org/help/guides/reading-local-manga/"
//
// private const val COVER_NAME = "cover.jpg"
// private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub")
//
// private val POPULAR_FILTERS = FilterList(OrderBy())
// private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) })
// private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
//
// fun updateCover(context: Context, manga: SManga, input: InputStream): File? {
// val dir = getBaseDirectories(context).firstOrNull()
// if (dir == null) {
// input.close()
// return null
// }
// val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME)
//
// // It might not exist if using the external SD card
// cover.parentFile?.mkdirs()
// input.use {
// cover.outputStream().use {
// input.copyTo(it)
// }
// }
// return cover
// }
//
// private fun getBaseDirectories(context: Context): List<File> {
// val c = context.getString(R.string.app_name) + File.separator + "local"
// return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) }
// }
}
override val id = ID
override val name = "Local source"
override val lang = ""
override val supportsLatest = true
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
TODO("Not yet implemented")
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
TODO("Not yet implemented")
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
TODO("Not yet implemented")
}
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
TODO("Not yet implemented")
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
TODO("Not yet implemented")
}
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
TODO("Not yet implemented")
}
override fun getFilterList(): FilterList {
TODO("Not yet implemented")
}
//
// override fun toString() = context.getString(R.string.local_source)
//
// override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
//
// override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
// val baseDirs = getBaseDirectories(context)
//
// val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
// var mangaDirs = baseDirs
// .asSequence()
// .mapNotNull { it.listFiles()?.toList() }
// .flatten()
// .filter { it.isDirectory }
// .filterNot { it.name.startsWith('.') }
// .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
// .distinctBy { it.name }
//
// val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state
// when (state?.index) {
// 0 -> {
// mangaDirs = if (state.ascending) {
// mangaDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) }
// } else {
// mangaDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) }
// }
// }
// 1 -> {
// mangaDirs = if (state.ascending) {
// mangaDirs.sortedBy(File::lastModified)
// } else {
// mangaDirs.sortedByDescending(File::lastModified)
// }
// }
// }
//
// val mangas = mangaDirs.map { mangaDir ->
// SManga.create().apply {
// title = mangaDir.name
// url = mangaDir.name
//
// // Try to find the cover
// for (dir in baseDirs) {
// val cover = File("${dir.absolutePath}/$url", COVER_NAME)
// if (cover.exists()) {
// thumbnail_url = cover.absolutePath
// break
// }
// }
//
// val chapters = fetchChapterList(this).toBlocking().first()
// if (chapters.isNotEmpty()) {
// val chapter = chapters.last()
// val format = getFormat(chapter)
// if (format is Format.Epub) {
// EpubFile(format.file).use { epub ->
// epub.fillMangaMetadata(this)
// }
// }
//
// // Copy the cover from the first chapter found.
// if (thumbnail_url == null) {
// try {
// val dest = updateCover(chapter, this)
// thumbnail_url = dest?.absolutePath
// } catch (e: Exception) {
// Timber.e(e)
// }
// }
// }
// }
// }
//
// return Observable.just(MangasPage(mangas.toList(), false))
// }
//
// override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
//
// override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
// getBaseDirectories(context)
// .asSequence()
// .mapNotNull { File(it, manga.url).listFiles()?.toList() }
// .flatten()
// .firstOrNull { it.extension == "json" }
// ?.apply {
// val reader = this.inputStream().bufferedReader()
// val json = JsonParser.parseReader(reader).asJsonObject
//
// manga.title = json["title"]?.asString ?: manga.title
// manga.author = json["author"]?.asString ?: manga.author
// manga.artist = json["artist"]?.asString ?: manga.artist
// manga.description = json["description"]?.asString ?: manga.description
// manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString }
// ?: manga.genre
// manga.status = json["status"]?.asInt ?: manga.status
// }
//
// return Observable.just(manga)
// }
//
// override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
// val chapters = getBaseDirectories(context)
// .asSequence()
// .mapNotNull { File(it, manga.url).listFiles()?.toList() }
// .flatten()
// .filter { it.isDirectory || isSupportedFile(it.extension) }
// .map { chapterFile ->
// SChapter.create().apply {
// url = "${manga.url}/${chapterFile.name}"
// name = if (chapterFile.isDirectory) {
// chapterFile.name
// } else {
// chapterFile.nameWithoutExtension
// }
// date_upload = chapterFile.lastModified()
//
// val format = getFormat(this)
// if (format is Format.Epub) {
// EpubFile(format.file).use { epub ->
// epub.fillChapterMetadata(this)
// }
// }
//
// val chapNameCut = stripMangaTitle(name, manga.title)
// if (chapNameCut.isNotEmpty()) name = chapNameCut
// ChapterRecognition.parseChapterNumber(this, manga)
// }
// }
// .sortedWith(
// Comparator { c1, c2 ->
// val c = c2.chapter_number.compareTo(c1.chapter_number)
// if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
// }
// )
// .toList()
//
// return Observable.just(chapters)
// }
//
// /**
// * Strips the manga title from a chapter name, matching only based on alphanumeric and whitespace
// * characters.
// */
// private fun stripMangaTitle(chapterName: String, mangaTitle: String): String {
// var chapterNameIndex = 0
// var mangaTitleIndex = 0
// while (chapterNameIndex < chapterName.length && mangaTitleIndex < mangaTitle.length) {
// val chapterChar = chapterName[chapterNameIndex]
// val mangaChar = mangaTitle[mangaTitleIndex]
// if (!chapterChar.equals(mangaChar, true)) {
// val invalidChapterChar = !chapterChar.isLetterOrDigit() && !chapterChar.isWhitespace()
// val invalidMangaChar = !mangaChar.isLetterOrDigit() && !mangaChar.isWhitespace()
//
// if (!invalidChapterChar && !invalidMangaChar) {
// return chapterName
// }
//
// if (invalidChapterChar) {
// chapterNameIndex++
// }
//
// if (invalidMangaChar) {
// mangaTitleIndex++
// }
// } else {
// chapterNameIndex++
// mangaTitleIndex++
// }
// }
//
// return chapterName.substring(chapterNameIndex).trimStart(' ', '-', '_', ',', ':')
// }
//
// override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
// return Observable.error(Exception("Unused"))
// }
//
// private fun isSupportedFile(extension: String): Boolean {
// return extension.toLowerCase() in SUPPORTED_ARCHIVE_TYPES
// }
//
// fun getFormat(chapter: SChapter): Format {
// val baseDirs = getBaseDirectories(context)
//
// for (dir in baseDirs) {
// val chapFile = File(dir, chapter.url)
// if (!chapFile.exists()) continue
//
// return getFormat(chapFile)
// }
// throw Exception("Chapter not found")
// }
//
// private fun getFormat(file: File): Format {
// val extension = file.extension
// return if (file.isDirectory) {
// Format.Directory(file)
// } else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
// Format.Zip(file)
// } else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
// Format.Rar(file)
// } else if (extension.equals("epub", true)) {
// Format.Epub(file)
// } else {
// throw Exception("Invalid chapter format")
// }
// }
//
// private fun updateCover(chapter: SChapter, manga: SManga): File? {
// return when (val format = getFormat(chapter)) {
// is Format.Directory -> {
// val entry = format.file.listFiles()
// ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
// ?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
//
// entry?.let { updateCover(context, manga, it.inputStream()) }
// }
// is Format.Zip -> {
// ZipFile(format.file).use { zip ->
// val entry = zip.entries().toList()
// .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
// .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
//
// entry?.let { updateCover(context, manga, zip.getInputStream(it)) }
// }
// }
// is Format.Rar -> {
// Archive(format.file).use { archive ->
// val entry = archive.fileHeaders
// .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
// .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
//
// entry?.let { updateCover(context, manga, archive.getInputStream(it)) }
// }
// }
// is Format.Epub -> {
// EpubFile(format.file).use { epub ->
// val entry = epub.getImagesFromPages()
// .firstOrNull()
// ?.let { epub.getEntry(it) }
//
// entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
// }
// }
// }
// }
//
// private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Selection(0, true))
//
// override fun getFilterList() = FilterList(OrderBy())
//
// sealed class Format {
// data class Directory(val file: File) : Format()
// data class Zip(val file: File) : Format()
// data class Rar(val file: File) : Format()
// data class Epub(val file: File) : Format()
// }
}
@@ -0,0 +1,44 @@
package eu.kanade.tachiyomi.util.lang
import java.security.MessageDigest
object Hash {
private val chars = charArrayOf(
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'a', 'b', 'c', 'd', 'e', 'f'
)
private val MD5 get() = MessageDigest.getInstance("MD5")
private val SHA256 get() = MessageDigest.getInstance("SHA-256")
fun sha256(bytes: ByteArray): String {
return encodeHex(SHA256.digest(bytes))
}
fun sha256(string: String): String {
return sha256(string.toByteArray())
}
fun md5(bytes: ByteArray): String {
return encodeHex(MD5.digest(bytes))
}
fun md5(string: String): String {
return md5(string.toByteArray())
}
private fun encodeHex(data: ByteArray): String {
val l = data.size
val out = CharArray(l shl 1)
var i = 0
var j = 0
while (i < l) {
out[j++] = chars[(240 and data[i].toInt()).ushr(4)]
out[j++] = chars[15 and data[i].toInt()]
i++
}
return String(out)
}
}
@@ -7,8 +7,8 @@ package ir.armor.tachidesk
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import ir.armor.tachidesk.server.JavalinSetup.javalinSetup
import ir.armor.tachidesk.server.applicationSetup import ir.armor.tachidesk.server.applicationSetup
import ir.armor.tachidesk.server.javalinSetup
class Main { class Main {
companion object { companion object {
@@ -1,44 +0,0 @@
package ir.armor.tachidesk.database
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import ir.armor.tachidesk.database.table.CategoryMangaTable
import ir.armor.tachidesk.database.table.CategoryTable
import ir.armor.tachidesk.database.table.ChapterTable
import ir.armor.tachidesk.database.table.ExtensionTable
import ir.armor.tachidesk.database.table.MangaTable
import ir.armor.tachidesk.database.table.PageTable
import ir.armor.tachidesk.database.table.SourceTable
import ir.armor.tachidesk.server.applicationDirs
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:h2:${applicationDirs.dataRoot}/database", "org.h2.Driver")
}
}
fun makeDataBaseTables() {
// must mention db object so the lazy block executes
val db = DBMangaer.db
db.useNestedTransactions = true
transaction {
SchemaUtils.createMissingTablesAndColumns(
ExtensionTable,
SourceTable,
MangaTable,
ChapterTable,
PageTable,
CategoryTable,
CategoryMangaTable,
)
}
}
@@ -1,28 +0,0 @@
package ir.armor.tachidesk.database.entity
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import ir.armor.tachidesk.database.table.ExtensionTable
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
class ExtensionEntity(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<ExtensionEntity>(ExtensionTable)
var name by ExtensionTable.name
var pkgName by ExtensionTable.pkgName
var versionName by ExtensionTable.versionName
var versionCode by ExtensionTable.versionCode
var lang by ExtensionTable.lang
var isNsfw by ExtensionTable.isNsfw
var apkName by ExtensionTable.apkName
var iconUrl by ExtensionTable.iconUrl
var installed by ExtensionTable.installed
var classFQName by ExtensionTable.classFQName
}
@@ -1,30 +0,0 @@
package ir.armor.tachidesk.database.entity
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import ir.armor.tachidesk.database.table.MangaTable
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
class MangaEntity(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<MangaEntity>(MangaTable)
var url by MangaTable.url
var title by MangaTable.title
var initialized by MangaTable.initialized
var artist by MangaTable.artist
var author by MangaTable.author
var description by MangaTable.description
var genre by MangaTable.genre
var status by MangaTable.status
var thumbnail_url by MangaTable.thumbnail_url
var sourceReference by MangaEntity referencedOn MangaTable.sourceReference
}
@@ -1,24 +0,0 @@
package ir.armor.tachidesk.database.entity
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import ir.armor.tachidesk.database.table.SourceTable
import org.jetbrains.exposed.dao.EntityClass
import org.jetbrains.exposed.dao.LongEntity
import org.jetbrains.exposed.dao.id.EntityID
class SourceEntity(id: EntityID<Long>) : LongEntity(id) {
companion object : EntityClass<Long, SourceEntity>(SourceTable, null)
var sourceId by SourceTable.id
var name by SourceTable.name
var lang by SourceTable.lang
var extension by ExtensionEntity referencedOn SourceTable.extension
var partOfFactorySource by SourceTable.partOfFactorySource
var positionInFactorySource by SourceTable.positionInFactorySource
}
@@ -7,10 +7,11 @@ package ir.armor.tachidesk.impl
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import ir.armor.tachidesk.database.dataclass.CategoryDataClass import ir.armor.tachidesk.impl.CategoryManga.removeMangaFromCategory
import ir.armor.tachidesk.database.table.CategoryMangaTable import ir.armor.tachidesk.model.database.table.CategoryMangaTable
import ir.armor.tachidesk.database.table.CategoryTable import ir.armor.tachidesk.model.database.table.CategoryTable
import ir.armor.tachidesk.database.table.toDataClass import ir.armor.tachidesk.model.database.table.toDataClass
import ir.armor.tachidesk.model.dataclass.CategoryDataClass
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
@@ -19,51 +20,59 @@ import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
fun createCategory(name: String) { object Category {
transaction { /**
val count = CategoryTable.selectAll().count() * The new category will be placed at the end of the list
if (CategoryTable.select { CategoryTable.name eq name }.firstOrNull() == null) */
CategoryTable.insert { fun createCategory(name: String) {
it[CategoryTable.name] = name transaction {
it[CategoryTable.order] = count.toInt() + 1 val count = CategoryTable.selectAll().count()
} if (CategoryTable.select { CategoryTable.name eq name }.firstOrNull() == null)
} CategoryTable.insert {
} it[CategoryTable.name] = name
it[CategoryTable.order] = count.toInt() + 1
fun updateCategory(categoryId: Int, name: String?, isLanding: Boolean?) { }
transaction {
CategoryTable.update({ CategoryTable.id eq categoryId }) {
if (name != null) it[CategoryTable.name] = name
if (isLanding != null) it[CategoryTable.isLanding] = isLanding
} }
} }
}
fun reorderCategory(categoryId: Int, from: Int, to: Int) { fun updateCategory(categoryId: Int, name: String?, isLanding: Boolean?) {
transaction { transaction {
val categories = CategoryTable.selectAll().orderBy(CategoryTable.order to SortOrder.ASC).toMutableList() CategoryTable.update({ CategoryTable.id eq categoryId }) {
categories.add(to - 1, categories.removeAt(from - 1)) if (name != null) it[CategoryTable.name] = name
categories.forEachIndexed { index, cat -> if (isLanding != null) it[CategoryTable.isLanding] = isLanding
CategoryTable.update({ CategoryTable.id eq cat[CategoryTable.id].value }) { }
it[CategoryTable.order] = index + 1 }
}
/**
* Move the category from position `from` to `to`
*/
fun reorderCategory(categoryId: Int, from: Int, to: Int) {
transaction {
val categories = CategoryTable.selectAll().orderBy(CategoryTable.order to SortOrder.ASC).toMutableList()
categories.add(to - 1, categories.removeAt(from - 1))
categories.forEachIndexed { index, cat ->
CategoryTable.update({ CategoryTable.id eq cat[CategoryTable.id].value }) {
it[CategoryTable.order] = index + 1
}
}
}
}
fun removeCategory(categoryId: Int) {
transaction {
CategoryMangaTable.select { CategoryMangaTable.category eq categoryId }.forEach {
removeMangaFromCategory(it[CategoryMangaTable.manga].value, categoryId)
}
CategoryTable.deleteWhere { CategoryTable.id eq categoryId }
}
}
fun getCategoryList(): List<CategoryDataClass> {
return transaction {
CategoryTable.selectAll().orderBy(CategoryTable.order to SortOrder.ASC).map {
CategoryTable.toDataClass(it)
} }
} }
} }
} }
fun removeCategory(categoryId: Int) {
transaction {
CategoryMangaTable.select { CategoryMangaTable.category eq categoryId }.forEach {
removeMangaFromCategory(it[CategoryMangaTable.manga].value, categoryId)
}
CategoryTable.deleteWhere { CategoryTable.id eq categoryId }
}
}
fun getCategoryList(): List<CategoryDataClass> {
return transaction {
CategoryTable.selectAll().orderBy(CategoryTable.order to SortOrder.ASC).map {
CategoryTable.toDataClass(it)
}
}
}
@@ -7,12 +7,12 @@ package ir.armor.tachidesk.impl
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import ir.armor.tachidesk.database.dataclass.CategoryDataClass import ir.armor.tachidesk.model.database.table.CategoryMangaTable
import ir.armor.tachidesk.database.dataclass.MangaDataClass import ir.armor.tachidesk.model.database.table.CategoryTable
import ir.armor.tachidesk.database.table.CategoryMangaTable import ir.armor.tachidesk.model.database.table.MangaTable
import ir.armor.tachidesk.database.table.CategoryTable import ir.armor.tachidesk.model.database.table.toDataClass
import ir.armor.tachidesk.database.table.MangaTable import ir.armor.tachidesk.model.dataclass.CategoryDataClass
import ir.armor.tachidesk.database.table.toDataClass import ir.armor.tachidesk.model.dataclass.MangaDataClass
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.deleteWhere
@@ -21,44 +21,52 @@ import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
fun addMangaToCategory(mangaId: Int, categoryId: Int) { object CategoryManga {
transaction { fun addMangaToCategory(mangaId: Int, categoryId: Int) {
if (CategoryMangaTable.select { (CategoryMangaTable.category eq categoryId) and (CategoryMangaTable.manga eq mangaId) }.firstOrNull() == null) { transaction {
CategoryMangaTable.insert { if (CategoryMangaTable.select { (CategoryMangaTable.category eq categoryId) and (CategoryMangaTable.manga eq mangaId) }.firstOrNull() == null) {
it[CategoryMangaTable.category] = categoryId CategoryMangaTable.insert {
it[CategoryMangaTable.manga] = mangaId it[CategoryMangaTable.category] = categoryId
} it[CategoryMangaTable.manga] = mangaId
}
MangaTable.update({ MangaTable.id eq mangaId }) { MangaTable.update({ MangaTable.id eq mangaId }) {
it[MangaTable.defaultCategory] = false it[MangaTable.defaultCategory] = false
}
}
}
}
fun removeMangaFromCategory(mangaId: Int, categoryId: Int) {
transaction {
CategoryMangaTable.deleteWhere { (CategoryMangaTable.category eq categoryId) and (CategoryMangaTable.manga eq mangaId) }
if (CategoryMangaTable.select { CategoryMangaTable.manga eq mangaId }.count() == 0L) {
MangaTable.update({ MangaTable.id eq mangaId }) {
it[MangaTable.defaultCategory] = true
}
}
}
}
/**
* list of mangas that belong to a category
*/
fun getCategoryMangaList(categoryId: Int): List<MangaDataClass> {
return transaction {
CategoryMangaTable.innerJoin(MangaTable).select { CategoryMangaTable.category eq categoryId }.map {
MangaTable.toDataClass(it)
}
}
}
/**
* list of categories that a manga belongs to
*/
fun getMangaCategories(mangaId: Int): List<CategoryDataClass> {
return transaction {
CategoryMangaTable.innerJoin(CategoryTable).select { CategoryMangaTable.manga eq mangaId }.orderBy(CategoryTable.order to SortOrder.ASC).map {
CategoryTable.toDataClass(it)
} }
} }
} }
} }
fun removeMangaFromCategory(mangaId: Int, categoryId: Int) {
transaction {
CategoryMangaTable.deleteWhere { (CategoryMangaTable.category eq categoryId) and (CategoryMangaTable.manga eq mangaId) }
if (CategoryMangaTable.select { CategoryMangaTable.manga eq mangaId }.count() == 0L) {
MangaTable.update({ MangaTable.id eq mangaId }) {
it[MangaTable.defaultCategory] = true
}
}
}
}
fun getCategoryMangaList(categoryId: Int): List<MangaDataClass> {
return transaction {
CategoryMangaTable.innerJoin(MangaTable).select { CategoryMangaTable.category eq categoryId }.map {
MangaTable.toDataClass(it)
}
}
}
fun getMangaCategories(mangaId: Int): List<CategoryDataClass> {
return transaction {
CategoryMangaTable.innerJoin(CategoryTable).select { CategoryMangaTable.manga eq mangaId }.orderBy(CategoryTable.order to SortOrder.ASC).map {
CategoryTable.toDataClass(it)
}
}
}
@@ -9,86 +9,90 @@ package ir.armor.tachidesk.impl
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import ir.armor.tachidesk.database.dataclass.ChapterDataClass import ir.armor.tachidesk.impl.Manga.getManga
import ir.armor.tachidesk.database.table.ChapterTable import ir.armor.tachidesk.impl.util.GetHttpSource.getHttpSource
import ir.armor.tachidesk.database.table.MangaTable import ir.armor.tachidesk.impl.util.awaitSingle
import ir.armor.tachidesk.database.table.PageTable import ir.armor.tachidesk.model.database.table.ChapterTable
import ir.armor.tachidesk.model.database.table.MangaTable
import ir.armor.tachidesk.model.database.table.PageTable
import ir.armor.tachidesk.model.dataclass.ChapterDataClass
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
fun getChapterList(mangaId: Int): List<ChapterDataClass> { object Chapter {
val mangaDetails = getManga(mangaId) /** get chapter list when showing a manga */
val source = getHttpSource(mangaDetails.sourceId.toLong()) suspend fun getChapterList(mangaId: Int): List<ChapterDataClass> {
val mangaDetails = getManga(mangaId)
val source = getHttpSource(mangaDetails.sourceId.toLong())
val chapterList = source.fetchChapterList( val chapterList = source.fetchChapterList(
SManga.create().apply { SManga.create().apply {
title = mangaDetails.title title = mangaDetails.title
url = mangaDetails.url url = mangaDetails.url
} }
).toBlocking().first() ).awaitSingle()
val chapterCount = chapterList.count() val chapterCount = chapterList.count()
return transaction { return transaction {
chapterList.reversed().forEachIndexed { index, fetchedChapter -> chapterList.reversed().forEachIndexed { index, fetchedChapter ->
val chapterEntry = ChapterTable.select { ChapterTable.url eq fetchedChapter.url }.firstOrNull() val chapterEntry = ChapterTable.select { ChapterTable.url eq fetchedChapter.url }.firstOrNull()
if (chapterEntry == null) { if (chapterEntry == null) {
ChapterTable.insertAndGetId { ChapterTable.insert {
it[url] = fetchedChapter.url it[url] = fetchedChapter.url
it[name] = fetchedChapter.name it[name] = fetchedChapter.name
it[date_upload] = fetchedChapter.date_upload it[date_upload] = fetchedChapter.date_upload
it[chapter_number] = fetchedChapter.chapter_number it[chapter_number] = fetchedChapter.chapter_number
it[scanlator] = fetchedChapter.scanlator it[scanlator] = fetchedChapter.scanlator
it[chapterIndex] = index + 1 it[chapterIndex] = index + 1
it[manga] = mangaId it[manga] = mangaId
} }
} else { } else {
ChapterTable.update({ ChapterTable.url eq fetchedChapter.url }) { ChapterTable.update({ ChapterTable.url eq fetchedChapter.url }) {
it[name] = fetchedChapter.name it[name] = fetchedChapter.name
it[date_upload] = fetchedChapter.date_upload it[date_upload] = fetchedChapter.date_upload
it[chapter_number] = fetchedChapter.chapter_number it[chapter_number] = fetchedChapter.chapter_number
it[scanlator] = fetchedChapter.scanlator it[scanlator] = fetchedChapter.scanlator
it[chapterIndex] = index + 1 it[chapterIndex] = index + 1
it[manga] = mangaId it[manga] = mangaId
}
} }
} }
}
// clear any orphaned chapters // clear any orphaned chapters that are in the db but not in `chapterList`
val dbChapterCount = transaction { ChapterTable.selectAll().count() } val dbChapterCount = transaction { ChapterTable.selectAll().count() }
if (dbChapterCount > chapterCount) { // we got some clean up due if (dbChapterCount > chapterCount) { // we got some clean up due
// TODO // TODO: delete orphan chapters
} }
return@transaction chapterList.mapIndexed { index, it -> chapterList.mapIndexed { index, it ->
ChapterDataClass( ChapterDataClass(
ChapterTable.select { ChapterTable.url eq it.url }.firstOrNull()!![ChapterTable.id].value, it.url,
it.url, it.name,
it.name, it.date_upload,
it.date_upload, it.chapter_number,
it.chapter_number, it.scanlator,
it.scanlator, mangaId,
mangaId, chapterCount - index,
chapterCount - index, )
chapterCount }
)
} }
} }
}
fun getChapter(chapterIndex: Int, mangaId: Int): ChapterDataClass { /** used to display a chapter, get a chapter in order to show it's pages */
return transaction { suspend fun getChapter(chapterIndex: Int, mangaId: Int): ChapterDataClass {
val chapterEntry = ChapterTable.select { val chapterEntry = transaction {
ChapterTable.chapterIndex eq chapterIndex and (ChapterTable.manga eq mangaId) ChapterTable.select {
}.firstOrNull()!! (ChapterTable.chapterIndex eq chapterIndex) and (ChapterTable.manga eq mangaId)
val mangaEntry = MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }.firstOrNull()!!
}
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
val source = getHttpSource(mangaEntry[MangaTable.sourceReference]) val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
val pageList = source.fetchPageList( val pageList = source.fetchPageList(
@@ -96,38 +100,23 @@ fun getChapter(chapterIndex: Int, mangaId: Int): ChapterDataClass {
url = chapterEntry[ChapterTable.url] url = chapterEntry[ChapterTable.url]
name = chapterEntry[ChapterTable.name] name = chapterEntry[ChapterTable.name]
} }
).toBlocking().first() ).awaitSingle()
val chapterId = chapterEntry[ChapterTable.id].value val chapterId = chapterEntry[ChapterTable.id].value
val chapterCount = transaction { ChapterTable.selectAll().count() } val chapterCount = transaction { ChapterTable.selectAll().count() }
val chapter = ChapterDataClass( // update page list for this chapter
chapterId, transaction {
chapterEntry[ChapterTable.url], pageList.forEach { page ->
chapterEntry[ChapterTable.name], val pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq page.index) }.firstOrNull() }
chapterEntry[ChapterTable.date_upload], if (pageEntry == null) {
chapterEntry[ChapterTable.chapter_number],
chapterEntry[ChapterTable.scanlator],
mangaId,
chapterEntry[ChapterTable.chapterIndex],
chapterCount.toInt(),
pageList.count()
)
pageList.forEach { page ->
val pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq page.index) }.firstOrNull() }
if (pageEntry == null) {
transaction {
PageTable.insert { PageTable.insert {
it[index] = page.index it[index] = page.index
it[url] = page.url it[url] = page.url
it[imageUrl] = page.imageUrl it[imageUrl] = page.imageUrl
it[this.chapter] = chapterId it[chapter] = chapterId
} }
} } else {
} else {
transaction {
PageTable.update({ (PageTable.chapter eq chapterId) and (PageTable.index eq page.index) }) { PageTable.update({ (PageTable.chapter eq chapterId) and (PageTable.index eq page.index) }) {
it[url] = page.url it[url] = page.url
it[imageUrl] = page.imageUrl it[imageUrl] = page.imageUrl
@@ -136,6 +125,16 @@ fun getChapter(chapterIndex: Int, mangaId: Int): ChapterDataClass {
} }
} }
return@transaction chapter return ChapterDataClass(
chapterEntry[ChapterTable.url],
chapterEntry[ChapterTable.name],
chapterEntry[ChapterTable.date_upload],
chapterEntry[ChapterTable.chapter_number],
chapterEntry[ChapterTable.scanlator],
mangaId,
chapterEntry[ChapterTable.chapterIndex],
chapterCount.toInt(),
pageList.count()
)
} }
} }
@@ -7,19 +7,29 @@ package ir.armor.tachidesk.impl
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import com.googlecode.d2j.dex.Dex2jar import android.net.Uri
import com.googlecode.d2j.reader.MultiDexFileReader
import com.googlecode.dex2jar.tools.BaksmaliBaseDexExceptionHandler
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.source.online.HttpSource import ir.armor.tachidesk.impl.ExtensionsList.extensionTableAsDataClass
import ir.armor.tachidesk.database.table.ExtensionTable import ir.armor.tachidesk.impl.util.CachedImageResponse.getCachedImageResponse
import ir.armor.tachidesk.database.table.SourceTable import ir.armor.tachidesk.impl.util.PackageTools.EXTENSION_FEATURE
import ir.armor.tachidesk.impl.util.APKExtractor import ir.armor.tachidesk.impl.util.PackageTools.LIB_VERSION_MAX
import ir.armor.tachidesk.server.applicationDirs import ir.armor.tachidesk.impl.util.PackageTools.LIB_VERSION_MIN
import kotlinx.coroutines.runBlocking import ir.armor.tachidesk.impl.util.PackageTools.METADATA_NSFW
import ir.armor.tachidesk.impl.util.PackageTools.METADATA_SOURCE_CLASS
import ir.armor.tachidesk.impl.util.PackageTools.dex2jar
import ir.armor.tachidesk.impl.util.PackageTools.getPackageInfo
import ir.armor.tachidesk.impl.util.PackageTools.getSignatureHash
import ir.armor.tachidesk.impl.util.PackageTools.loadExtensionSources
import ir.armor.tachidesk.impl.util.PackageTools.trustedSignatures
import ir.armor.tachidesk.impl.util.await
import ir.armor.tachidesk.model.database.table.ExtensionTable
import ir.armor.tachidesk.model.database.table.SourceTable
import ir.armor.tachidesk.server.ApplicationDirs
import mu.KotlinLogging import mu.KotlinLogging
import okhttp3.Request import okhttp3.Request
import okio.buffer import okio.buffer
@@ -29,179 +39,213 @@ import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import java.net.URL
import java.net.URLClassLoader
import java.nio.file.Files
import java.nio.file.Path
private val logger = KotlinLogging.logger {} object Extension {
private val logger = KotlinLogging.logger {}
private val applicationDirs by DI.global.instance<ApplicationDirs>()
private fun dex2jar(dexFile: String, jarFile: String, fileNameWithoutType: String) { data class InstallableAPK(
// adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine val apkFilePath: String,
// source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java val pkgName: String
)
val jarFilePath = File(jarFile).toPath() suspend fun installExtension(pkgName: String): Int {
val reader = MultiDexFileReader.open(Files.readAllBytes(File(dexFile).toPath())) logger.debug("Installing $pkgName")
val handler = BaksmaliBaseDexExceptionHandler() val extensionRecord = extensionTableAsDataClass().first { it.pkgName == pkgName }
Dex2jar
.from(reader) return installAPK {
.withExceptionHandler(handler) val apkURL = ExtensionGithubApi.getApkUrl(extensionRecord)
.reUseReg(false) val apkName = Uri.parse(apkURL).lastPathSegment!!
.topoLogicalSort() val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
.skipDebug(true) // download apk file
.optimizeSynchronized(false) downloadAPKFile(apkURL, apkSavePath)
.printIR(false)
.noCode(false) apkSavePath
.skipExceptions(false) }
.to(jarFilePath)
if (handler.hasException()) {
val errorFile: Path = File(applicationDirs.extensionsRoot).toPath().resolve("$fileNameWithoutType-error.txt")
logger.error(
"Detail Error Information in File $errorFile\n" +
"Please report this file to one of following link if possible (any one).\n" +
" https://sourceforge.net/p/dex2jar/tickets/\n" +
" https://bitbucket.org/pxb1988/dex2jar/issues\n" +
" https://github.com/pxb1988/dex2jar/issues\n" +
" dex2jar@googlegroups.com"
)
handler.dump(errorFile, emptyArray<String>())
} }
}
fun installAPK(apkName: String): Int { suspend fun installAPK(fetcher: suspend () -> String): Int {
logger.debug("Installing $apkName") val apkFilePath = fetcher()
val extensionRecord = getExtensionList(true).first { it.apkName == apkName } val apkName = File(apkFilePath).name
val fileNameWithoutType = apkName.substringBefore(".apk")
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
// check if we don't have the dex file already downloaded // check if we don't have the extension already installed
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar" // if it's installed and we want to update, it first has to be uninstalled
if (!File(jarPath).exists()) { val isInstalled = transaction {
runBlocking { ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull()
val api = ExtensionGithubApi() }?.get(ExtensionTable.isInstalled) ?: false
val apkToDownload = api.getApkUrl(extensionRecord)
val apkFilePath = "$dirPathWithoutType.apk" if (!isInstalled) {
val fileNameWithoutType = apkName.substringBefore(".apk")
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
val jarFilePath = "$dirPathWithoutType.jar" val jarFilePath = "$dirPathWithoutType.jar"
val dexFilePath = "$dirPathWithoutType.dex" val dexFilePath = "$dirPathWithoutType.dex"
// download apk file val packageInfo = getPackageInfo(apkFilePath)
downloadAPKFile(apkToDownload, apkFilePath) val pkgName = packageInfo.packageName
val className: String = APKExtractor.extract_dex_and_read_className(apkFilePath, dexFilePath) if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) {
logger.debug(className) throw Exception("This apk is not a Tachiyomi extension")
// dex -> jar }
dex2jar(dexFilePath, jarFilePath, fileNameWithoutType)
// Validate lib version
val libVersion = packageInfo.versionName.substringBeforeLast('.').toDouble()
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
throw Exception(
"Lib version is $libVersion, while only versions " +
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed"
)
}
val signatureHash = getSignatureHash(packageInfo)
if (signatureHash == null) {
throw Exception("Package $pkgName isn't signed")
} else if (signatureHash !in trustedSignatures) {
// TODO: allow trusting keys
throw Exception("This apk is not a signed with the official tachiyomi signature")
}
val isNsfw = packageInfo.applicationInfo.metaData.getString(METADATA_NSFW) == "1"
val className = packageInfo.packageName + packageInfo.applicationInfo.metaData.getString(METADATA_SOURCE_CLASS)
logger.debug("Main class for extension is $className")
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
// clean up // clean up
File(apkFilePath).delete() // File(apkFilePath).delete()
File(dexFilePath).delete() File(dexFilePath).delete()
// update sources of the extension // collect sources from the extension
val child = URLClassLoader(arrayOf<URL>(URL("file:$jarFilePath")), this::class.java.classLoader) val sources: List<CatalogueSource> = when (val instance = loadExtensionSources(jarFilePath, className)) {
val classToLoad = Class.forName(className, true, child) is Source -> listOf(instance)
val instance = classToLoad.newInstance() is SourceFactory -> instance.createSources()
else -> throw RuntimeException("Unknown source class type! ${instance.javaClass}")
}.map { it as CatalogueSource }
val extensionId = transaction { val langs = sources.map { it.lang }.toSet()
return@transaction ExtensionTable.select { ExtensionTable.name eq extensionRecord.name }.first()[ExtensionTable.id] val extensionLang = when (langs.size) {
0 -> ""
1 -> langs.first()
else -> "all"
} }
if (instance is HttpSource) { // single source val extensionName = packageInfo.applicationInfo.nonLocalizedLabel.toString().substringAfter("Tachiyomi: ")
val httpSource = instance as HttpSource
transaction {
if (SourceTable.select { SourceTable.id eq httpSource.id }.count() == 0L) {
SourceTable.insert {
it[this.id] = httpSource.id
it[name] = httpSource.name
it[this.lang] = httpSource.lang
it[extension] = extensionId
}
}
logger.debug("Installed source ${httpSource.name} with id ${httpSource.id}")
}
} else { // multi source
val sourceFactory = instance as SourceFactory
transaction {
sourceFactory.createSources().forEachIndexed { index, source ->
val httpSource = source as HttpSource
if (SourceTable.select { SourceTable.id eq httpSource.id }.count() == 0L) {
SourceTable.insert {
it[this.id] = httpSource.id
it[name] = httpSource.name
it[this.lang] = httpSource.lang
it[extension] = extensionId
it[partOfFactorySource] = true
it[positionInFactorySource] = index
}
}
logger.debug("Installed source ${httpSource.name} with id:${httpSource.id}")
}
}
}
// update extension info // update extension info
transaction { transaction {
ExtensionTable.update({ ExtensionTable.name eq extensionRecord.name }) { if (ExtensionTable.select { ExtensionTable.pkgName eq pkgName }.firstOrNull() == null) {
it[installed] = true ExtensionTable.insert {
it[classFQName] = className it[this.apkName] = apkName
it[name] = extensionName
it[this.pkgName] = packageInfo.packageName
it[versionName] = packageInfo.versionName
it[versionCode] = packageInfo.versionCode
it[lang] = extensionLang
it[this.isNsfw] = isNsfw
}
}
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
it[this.isInstalled] = true
it[this.classFQName] = className
}
val extensionId = ExtensionTable.select { ExtensionTable.pkgName eq pkgName }.firstOrNull()!![ExtensionTable.id].value
sources.forEach { httpSource ->
SourceTable.insert {
it[id] = httpSource.id
it[name] = httpSource.name
it[lang] = httpSource.lang
it[extension] = extensionId
}
logger.debug("Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}")
} }
} }
} return 201 // we installed successfully
return 201 // we downloaded successfully } else {
} else { return 302 // extension was already installed
return 302
}
}
val networkHelper: NetworkHelper by injectLazy()
private fun downloadAPKFile(url: String, apkPath: String) {
val request = Request.Builder().url(url).build()
val response = networkHelper.client.newCall(request).execute()
val downloadedFile = File(apkPath)
val sink = downloadedFile.sink().buffer()
sink.writeAll(response.body!!.source())
sink.close()
}
fun removeExtension(apkName: String) {
logger.debug("Uninstalling $apkName")
val extensionRecord = getExtensionList(true).first { it.apkName == apkName }
val fileNameWithoutType = apkName.substringBefore(".apk")
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
transaction {
val extensionId = ExtensionTable.select { ExtensionTable.name eq extensionRecord.name }.first()[ExtensionTable.id]
SourceTable.deleteWhere { SourceTable.extension eq extensionId }
ExtensionTable.update({ ExtensionTable.name eq extensionRecord.name }) {
it[ExtensionTable.installed] = false
} }
} }
if (File(jarPath).exists()) { private val network: NetworkHelper by injectLazy()
File(jarPath).delete()
private suspend fun downloadAPKFile(url: String, savePath: String) {
val request = Request.Builder().url(url).build()
val response = network.client.newCall(request).await()
val downloadedFile = File(savePath)
downloadedFile.sink().buffer().use { sink ->
response.body!!.source().use { source ->
sink.writeAll(source)
sink.flush()
}
}
}
fun uninstallExtension(pkgName: String) {
logger.debug("Uninstalling $pkgName")
val extensionRecord = transaction { ExtensionTable.select { ExtensionTable.pkgName eq pkgName }.firstOrNull()!! }
val fileNameWithoutType = extensionRecord[ExtensionTable.apkName].substringBefore(".apk")
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
transaction {
val extensionId = extensionRecord[ExtensionTable.id].value
SourceTable.deleteWhere { SourceTable.extension eq extensionId }
if (extensionRecord[ExtensionTable.isObsolete])
ExtensionTable.deleteWhere { ExtensionTable.pkgName eq pkgName }
else
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
it[isInstalled] = false
}
}
if (File(jarPath).exists()) {
File(jarPath).delete()
}
}
suspend fun updateExtension(pkgName: String): Int {
val targetExtension = ExtensionsList.updateMap.remove(pkgName)!!
uninstallExtension(pkgName)
transaction {
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
it[name] = targetExtension.name
it[versionName] = targetExtension.versionName
it[versionCode] = targetExtension.versionCode
it[lang] = targetExtension.lang
it[isNsfw] = targetExtension.isNsfw
it[apkName] = targetExtension.apkName
it[iconUrl] = targetExtension.iconUrl
it[hasUpdate] = false
}
}
return installExtension(pkgName)
}
suspend fun getExtensionIcon(apkName: String): Pair<InputStream, String> {
val iconUrl = transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull()!! }[ExtensionTable.iconUrl]
val saveDir = "${applicationDirs.extensionsRoot}/icon"
return getCachedImageResponse(saveDir, apkName) {
network.client.newCall(
GET(iconUrl)
).await()
}
}
fun getExtensionIconUrl(apkName: String): String {
return "/api/v1/extension/icon/$apkName"
} }
} }
val network: NetworkHelper by injectLazy()
fun getExtensionIcon(apkName: String): Pair<InputStream, String> {
val iconUrl = transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull()!! }[ExtensionTable.iconUrl]
val saveDir = "${applicationDirs.extensionsRoot}/icon"
return getCachedImageResponse(saveDir, apkName) {
network.client.newCall(
GET(iconUrl)
).execute()
}
}
fun getExtensionIconUrl(apkName: String): String {
return "/api/v1/extension/icon/$apkName"
}
@@ -9,57 +9,85 @@ package ir.armor.tachidesk.impl
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import ir.armor.tachidesk.database.dataclass.ExtensionDataClass import ir.armor.tachidesk.impl.Extension.getExtensionIconUrl
import ir.armor.tachidesk.database.table.ExtensionTable import ir.armor.tachidesk.model.database.table.ExtensionTable
import kotlinx.coroutines.runBlocking import ir.armor.tachidesk.model.dataclass.ExtensionDataClass
import mu.KotlinLogging import mu.KotlinLogging
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import java.util.concurrent.ConcurrentHashMap
private val logger = KotlinLogging.logger {} object ExtensionsList {
private val logger = KotlinLogging.logger {}
private object Data { var lastUpdateCheck: Long = 0
var lastExtensionCheck: Long = 0 var updateMap = ConcurrentHashMap<String, Extension.Available>()
}
private fun extensionDatabaseIsEmtpy(): Boolean { /** 60,000 milliseconds = 60 seconds */
return transaction { private const val ExtensionUpdateDelayTime = 60 * 1000
return@transaction ExtensionTable.selectAll().count() == 0L
suspend fun getExtensionList(): List<ExtensionDataClass> {
// update if {ExtensionUpdateDelayTime} seconds has passed or requested offline and database is empty
if (lastUpdateCheck + ExtensionUpdateDelayTime < System.currentTimeMillis()) {
logger.debug("Getting extensions list from the internet")
lastUpdateCheck = System.currentTimeMillis()
val foundExtensions = ExtensionGithubApi.findExtensions()
updateExtensionDatabase(foundExtensions)
} else {
logger.debug("used cached extension list")
}
return extensionTableAsDataClass()
} }
}
fun getExtensionList(offline: Boolean = false): List<ExtensionDataClass> { fun extensionTableAsDataClass() = transaction {
// update if 60 seconds has passed or requested offline and database is empty ExtensionTable.selectAll().map {
if (Data.lastExtensionCheck + 60 * 1000 < System.currentTimeMillis() || (offline && extensionDatabaseIsEmtpy())) { ExtensionDataClass(
logger.debug("Getting extensions list from the internet") it[ExtensionTable.apkName],
Data.lastExtensionCheck = System.currentTimeMillis() getExtensionIconUrl(it[ExtensionTable.apkName]),
var foundExtensions: List<Extension.Available> it[ExtensionTable.name],
runBlocking { it[ExtensionTable.pkgName],
val api = ExtensionGithubApi() it[ExtensionTable.versionName],
foundExtensions = api.findExtensions() it[ExtensionTable.versionCode],
transaction { it[ExtensionTable.lang],
foundExtensions.forEach { foundExtension -> it[ExtensionTable.isNsfw],
val extensionRecord = ExtensionTable.select { ExtensionTable.name eq foundExtension.name }.firstOrNull() it[ExtensionTable.isInstalled],
if (extensionRecord != null) { it[ExtensionTable.hasUpdate],
// update the record it[ExtensionTable.isObsolete],
ExtensionTable.update({ ExtensionTable.name eq foundExtension.name }) { )
it[name] = foundExtension.name }
it[pkgName] = foundExtension.pkgName }
it[versionName] = foundExtension.versionName
it[versionCode] = foundExtension.versionCode private fun updateExtensionDatabase(foundExtensions: List<Extension.Available>) {
it[lang] = foundExtension.lang transaction {
it[isNsfw] = foundExtension.isNsfw foundExtensions.forEach { foundExtension ->
it[apkName] = foundExtension.apkName val extensionRecord = ExtensionTable.select { ExtensionTable.pkgName eq foundExtension.pkgName }.firstOrNull()
it[iconUrl] = foundExtension.iconUrl if (extensionRecord != null) {
if (extensionRecord[ExtensionTable.isInstalled]) {
when {
foundExtension.versionCode > extensionRecord[ExtensionTable.versionCode] -> {
// there is an update
ExtensionTable.update({ ExtensionTable.pkgName eq foundExtension.pkgName }) {
it[hasUpdate] = true
}
updateMap.putIfAbsent(foundExtension.pkgName, foundExtension)
}
foundExtension.versionCode < extensionRecord[ExtensionTable.versionCode] -> {
// some how the user installed an invalid version
ExtensionTable.update({ ExtensionTable.pkgName eq foundExtension.pkgName }) {
it[isObsolete] = true
}
}
} }
} else { } else {
// insert new record // extension is not installed so we can overwrite the data without a care
ExtensionTable.insert { ExtensionTable.update({ ExtensionTable.pkgName eq foundExtension.pkgName }) {
it[name] = foundExtension.name it[name] = foundExtension.name
it[pkgName] = foundExtension.pkgName
it[versionName] = foundExtension.versionName it[versionName] = foundExtension.versionName
it[versionCode] = foundExtension.versionCode it[versionCode] = foundExtension.versionCode
it[lang] = foundExtension.lang it[lang] = foundExtension.lang
@@ -68,27 +96,37 @@ fun getExtensionList(offline: Boolean = false): List<ExtensionDataClass> {
it[iconUrl] = foundExtension.iconUrl it[iconUrl] = foundExtension.iconUrl
} }
} }
} else {
// insert new record
ExtensionTable.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
}
}
}
// deal with obsolete extensions
ExtensionTable.selectAll().forEach { extensionRecord ->
val foundExtension = foundExtensions.find { it.pkgName == extensionRecord[ExtensionTable.pkgName] }
if (foundExtension == null) {
// not in the repo, so this extensions is obsolete
if (extensionRecord[ExtensionTable.isInstalled]) {
// is installed so we should mark it as obsolete
ExtensionTable.update({ ExtensionTable.pkgName eq extensionRecord[ExtensionTable.pkgName] }) {
it[isObsolete] = true
}
} else {
// is not installed so we can remove the record without a care
ExtensionTable.deleteWhere { ExtensionTable.pkgName eq extensionRecord[ExtensionTable.pkgName] }
}
} }
} }
} }
} else {
logger.debug("used cached extension list")
}
return transaction {
return@transaction ExtensionTable.selectAll().map {
ExtensionDataClass(
it[ExtensionTable.name],
it[ExtensionTable.pkgName],
it[ExtensionTable.versionName],
it[ExtensionTable.versionCode],
it[ExtensionTable.lang],
it[ExtensionTable.isNsfw],
it[ExtensionTable.apkName],
getExtensionIconUrl(it[ExtensionTable.apkName]),
it[ExtensionTable.installed],
it[ExtensionTable.classFQName]
)
}
} }
} }
@@ -7,44 +7,50 @@ package ir.armor.tachidesk.impl
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import ir.armor.tachidesk.database.dataclass.MangaDataClass import ir.armor.tachidesk.impl.Manga.getManga
import ir.armor.tachidesk.database.table.CategoryMangaTable import ir.armor.tachidesk.model.database.table.CategoryMangaTable
import ir.armor.tachidesk.database.table.MangaTable import ir.armor.tachidesk.model.database.table.MangaTable
import ir.armor.tachidesk.database.table.toDataClass import ir.armor.tachidesk.model.database.table.toDataClass
import ir.armor.tachidesk.model.dataclass.MangaDataClass
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
fun addMangaToLibrary(mangaId: Int) { object Library {
val manga = getManga(mangaId) // TODO: `Category.isLanding` is to handle the default categories a new library manga gets,
if (!manga.inLibrary) { // ..implement that shit at some time...
transaction { // ..also Consider to rename it to `isDefault`
MangaTable.update({ MangaTable.id eq manga.id }) { suspend fun addMangaToLibrary(mangaId: Int) {
it[inLibrary] = true val manga = getManga(mangaId)
if (!manga.inLibrary) {
transaction {
MangaTable.update({ MangaTable.id eq manga.id }) {
it[inLibrary] = true
}
}
}
}
suspend fun removeMangaFromLibrary(mangaId: Int) {
val manga = getManga(mangaId)
if (manga.inLibrary) {
transaction {
MangaTable.update({ MangaTable.id eq manga.id }) {
it[inLibrary] = false
it[defaultCategory] = true
}
CategoryMangaTable.deleteWhere { CategoryMangaTable.manga eq mangaId }
}
}
}
fun getLibraryMangas(): List<MangaDataClass> {
return transaction {
MangaTable.select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }.map {
MangaTable.toDataClass(it)
} }
} }
} }
} }
fun removeMangaFromLibrary(mangaId: Int) {
val manga = getManga(mangaId)
if (manga.inLibrary) {
transaction {
MangaTable.update({ MangaTable.id eq manga.id }) {
it[inLibrary] = false
it[defaultCategory] = true
}
CategoryMangaTable.deleteWhere { CategoryMangaTable.manga eq mangaId }
}
}
}
fun getLibraryMangas(): List<MangaDataClass> {
return transaction {
MangaTable.select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }.map {
MangaTable.toDataClass(it)
}
}
}
@@ -9,100 +9,119 @@ package ir.armor.tachidesk.impl
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import ir.armor.tachidesk.database.dataclass.MangaDataClass import ir.armor.tachidesk.impl.MangaList.proxyThumbnailUrl
import ir.armor.tachidesk.database.table.MangaStatus import ir.armor.tachidesk.impl.Source.getSource
import ir.armor.tachidesk.database.table.MangaTable import ir.armor.tachidesk.impl.util.CachedImageResponse.getCachedImageResponse
import ir.armor.tachidesk.server.applicationDirs import ir.armor.tachidesk.impl.util.GetHttpSource.getHttpSource
import ir.armor.tachidesk.impl.util.await
import ir.armor.tachidesk.impl.util.awaitSingle
import ir.armor.tachidesk.model.database.table.MangaStatus
import ir.armor.tachidesk.model.database.table.MangaTable
import ir.armor.tachidesk.model.dataclass.MangaDataClass
import ir.armor.tachidesk.server.ApplicationDirs
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import java.io.InputStream import java.io.InputStream
fun getManga(mangaId: Int, proxyThumbnail: Boolean = true): MangaDataClass { object Manga {
var mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! } private fun truncate(text: String?, maxLength: Int): String? {
return if (text?.length ?: 0 > maxLength)
text?.take(maxLength - 3) + "..."
else
text
}
return if (mangaEntry[MangaTable.initialized]) { suspend fun getManga(mangaId: Int, proxyThumbnail: Boolean = true): MangaDataClass {
MangaDataClass( var mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
mangaId,
mangaEntry[MangaTable.sourceReference].toString(),
mangaEntry[MangaTable.url], return if (mangaEntry[MangaTable.initialized]) {
mangaEntry[MangaTable.title], MangaDataClass(
if (proxyThumbnail) proxyThumbnailUrl(mangaId) else mangaEntry[MangaTable.thumbnail_url], mangaId,
mangaEntry[MangaTable.sourceReference].toString(),
true, mangaEntry[MangaTable.url],
mangaEntry[MangaTable.title],
if (proxyThumbnail) proxyThumbnailUrl(mangaId) else mangaEntry[MangaTable.thumbnail_url],
mangaEntry[MangaTable.artist], true,
mangaEntry[MangaTable.author],
mangaEntry[MangaTable.description], mangaEntry[MangaTable.artist],
mangaEntry[MangaTable.genre], mangaEntry[MangaTable.author],
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name, mangaEntry[MangaTable.description],
mangaEntry[MangaTable.inLibrary], mangaEntry[MangaTable.genre],
getSource(mangaEntry[MangaTable.sourceReference]) MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
) mangaEntry[MangaTable.inLibrary],
} else { // initialize manga getSource(mangaEntry[MangaTable.sourceReference])
val source = getHttpSource(mangaEntry[MangaTable.sourceReference]) )
val fetchedManga = source.fetchMangaDetails( } else { // initialize manga
SManga.create().apply { val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
url = mangaEntry[MangaTable.url] val fetchedManga = source.fetchMangaDetails(
title = mangaEntry[MangaTable.title] SManga.create().apply {
url = mangaEntry[MangaTable.url]
title = mangaEntry[MangaTable.title]
}
).awaitSingle()
transaction {
MangaTable.update({ MangaTable.id eq mangaId }) {
it[MangaTable.initialized] = true
it[MangaTable.artist] = fetchedManga.artist
it[MangaTable.author] = fetchedManga.author
it[MangaTable.description] = truncate(fetchedManga.description, 4096)
it[MangaTable.genre] = fetchedManga.genre
it[MangaTable.status] = fetchedManga.status
if (fetchedManga.thumbnail_url != null && fetchedManga.thumbnail_url!!.isNotEmpty())
it[MangaTable.thumbnail_url] = fetchedManga.thumbnail_url
}
} }
).toBlocking().first()
transaction { mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
MangaTable.update({ MangaTable.id eq mangaId }) { val newThumbnail = mangaEntry[MangaTable.thumbnail_url]
it[MangaTable.initialized] = true MangaDataClass(
mangaId,
mangaEntry[MangaTable.sourceReference].toString(),
it[MangaTable.artist] = fetchedManga.artist mangaEntry[MangaTable.url],
it[MangaTable.author] = fetchedManga.author mangaEntry[MangaTable.title],
it[MangaTable.description] = fetchedManga.description if (proxyThumbnail) proxyThumbnailUrl(mangaId) else newThumbnail,
it[MangaTable.genre] = fetchedManga.genre
it[MangaTable.status] = fetchedManga.status true,
if (fetchedManga.thumbnail_url != null && fetchedManga.thumbnail_url!!.isNotEmpty())
it[MangaTable.thumbnail_url] = fetchedManga.thumbnail_url fetchedManga.artist,
} fetchedManga.author,
fetchedManga.description,
fetchedManga.genre,
MangaStatus.valueOf(fetchedManga.status).name,
false,
getSource(mangaEntry[MangaTable.sourceReference])
)
} }
}
mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! } private val applicationDirs by DI.global.instance<ApplicationDirs>()
val newThumbnail = mangaEntry[MangaTable.thumbnail_url] suspend fun getMangaThumbnail(mangaId: Int): Pair<InputStream, String> {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
val saveDir = applicationDirs.thumbnailsRoot
val fileName = mangaId.toString()
MangaDataClass( return getCachedImageResponse(saveDir, fileName) {
mangaId, val sourceId = mangaEntry[MangaTable.sourceReference]
mangaEntry[MangaTable.sourceReference].toString(), val source = getHttpSource(sourceId)
var thumbnailUrl = mangaEntry[MangaTable.thumbnail_url]
if (thumbnailUrl == null || thumbnailUrl.isEmpty()) {
thumbnailUrl = getManga(mangaId, proxyThumbnail = false).thumbnailUrl!!
}
mangaEntry[MangaTable.url], source.client.newCall(
mangaEntry[MangaTable.title], GET(thumbnailUrl, source.headers)
if (proxyThumbnail) proxyThumbnailUrl(mangaId) else newThumbnail, ).await()
}
true,
fetchedManga.artist,
fetchedManga.author,
fetchedManga.description,
fetchedManga.genre,
MangaStatus.valueOf(fetchedManga.status).name,
false,
getSource(mangaEntry[MangaTable.sourceReference])
)
}
}
fun getThumbnail(mangaId: Int): Pair<InputStream, String> {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
val saveDir = applicationDirs.thumbnailsRoot
val fileName = mangaId.toString()
return getCachedImageResponse(saveDir, fileName) {
val sourceId = mangaEntry[MangaTable.sourceReference]
val source = getHttpSource(sourceId)
var thumbnailUrl = mangaEntry[MangaTable.thumbnail_url]
if (thumbnailUrl == null || thumbnailUrl.isEmpty()) {
thumbnailUrl = getManga(mangaId, proxyThumbnail = false).thumbnailUrl!!
}
source.client.newCall(
GET(thumbnailUrl, source.headers)
).execute()
} }
} }
@@ -8,91 +8,95 @@ package ir.armor.tachidesk.impl
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import ir.armor.tachidesk.database.dataclass.MangaDataClass import ir.armor.tachidesk.impl.util.GetHttpSource.getHttpSource
import ir.armor.tachidesk.database.dataclass.PagedMangaListDataClass import ir.armor.tachidesk.impl.util.awaitSingle
import ir.armor.tachidesk.database.table.MangaStatus import ir.armor.tachidesk.model.database.table.MangaStatus
import ir.armor.tachidesk.database.table.MangaTable import ir.armor.tachidesk.model.database.table.MangaTable
import ir.armor.tachidesk.model.dataclass.MangaDataClass
import ir.armor.tachidesk.model.dataclass.PagedMangaListDataClass
import org.jetbrains.exposed.sql.insertAndGetId import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
fun proxyThumbnailUrl(mangaId: Int): String { object MangaList {
return "/api/v1/manga/$mangaId/thumbnail" fun proxyThumbnailUrl(mangaId: Int): String {
} return "/api/v1/manga/$mangaId/thumbnail"
fun getMangaList(sourceId: Long, pageNum: Int = 1, popular: Boolean): PagedMangaListDataClass {
val source = getHttpSource(sourceId.toLong())
val mangasPage = if (popular) {
source.fetchPopularManga(pageNum).toBlocking().first()
} else {
if (source.supportsLatest)
source.fetchLatestUpdates(pageNum).toBlocking().first()
else
throw Exception("Source $source doesn't support latest")
} }
return mangasPage.processEntries(sourceId)
}
fun MangasPage.processEntries(sourceId: Long): PagedMangaListDataClass { suspend fun getMangaList(sourceId: Long, pageNum: Int = 1, popular: Boolean): PagedMangaListDataClass {
val mangasPage = this val source = getHttpSource(sourceId)
val mangaList = transaction { val mangasPage = if (popular) {
return@transaction mangasPage.mangas.map { manga -> source.fetchPopularManga(pageNum).awaitSingle()
var mangaEntry = MangaTable.select { MangaTable.url eq manga.url }.firstOrNull() } else {
if (mangaEntry == null) { // create manga entry if (source.supportsLatest)
val mangaId = MangaTable.insertAndGetId { source.fetchLatestUpdates(pageNum).awaitSingle()
it[url] = manga.url else
it[title] = manga.title throw Exception("Source $source doesn't support latest")
}
return mangasPage.processEntries(sourceId)
}
it[artist] = manga.artist fun MangasPage.processEntries(sourceId: Long): PagedMangaListDataClass {
it[author] = manga.author val mangasPage = this
it[description] = manga.description val mangaList = transaction {
it[genre] = manga.genre return@transaction mangasPage.mangas.map { manga ->
it[status] = manga.status var mangaEntry = MangaTable.select { MangaTable.url eq manga.url }.firstOrNull()
it[thumbnail_url] = manga.thumbnail_url if (mangaEntry == null) { // create manga entry
val mangaId = MangaTable.insertAndGetId {
it[url] = manga.url
it[title] = manga.title
it[sourceReference] = sourceId it[artist] = manga.artist
}.value it[author] = manga.author
it[description] = manga.description
it[genre] = manga.genre
it[status] = manga.status
it[thumbnail_url] = manga.thumbnail_url
MangaDataClass( it[sourceReference] = sourceId
mangaId, }.value
sourceId.toString(),
manga.url, MangaDataClass(
manga.title, mangaId,
proxyThumbnailUrl(mangaId), sourceId.toString(),
manga.initialized, manga.url,
manga.title,
proxyThumbnailUrl(mangaId),
manga.artist, manga.initialized,
manga.author,
manga.description,
manga.genre,
MangaStatus.valueOf(manga.status).name
)
} else {
val mangaId = mangaEntry[MangaTable.id].value
MangaDataClass(
mangaId,
sourceId.toString(),
manga.url, manga.artist,
manga.title, manga.author,
proxyThumbnailUrl(mangaId), manga.description,
manga.genre,
MangaStatus.valueOf(manga.status).name
)
} else {
val mangaId = mangaEntry[MangaTable.id].value
MangaDataClass(
mangaId,
sourceId.toString(),
true, manga.url,
manga.title,
proxyThumbnailUrl(mangaId),
mangaEntry[MangaTable.artist], true,
mangaEntry[MangaTable.author],
mangaEntry[MangaTable.description], mangaEntry[MangaTable.artist],
mangaEntry[MangaTable.genre], mangaEntry[MangaTable.author],
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name, mangaEntry[MangaTable.description],
mangaEntry[MangaTable.inLibrary] mangaEntry[MangaTable.genre],
) MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
mangaEntry[MangaTable.inLibrary]
)
}
} }
} }
return PagedMangaListDataClass(
mangaList,
mangasPage.hasNextPage
)
} }
return PagedMangaListDataClass(
mangaList,
mangasPage.hasNextPage
)
} }
@@ -9,77 +9,92 @@ package ir.armor.tachidesk.impl
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import ir.armor.tachidesk.database.table.ChapterTable import ir.armor.tachidesk.impl.util.CachedImageResponse.getCachedImageResponse
import ir.armor.tachidesk.database.table.MangaTable import ir.armor.tachidesk.impl.util.GetHttpSource.getHttpSource
import ir.armor.tachidesk.database.table.PageTable import ir.armor.tachidesk.impl.util.awaitSingle
import ir.armor.tachidesk.database.table.SourceTable import ir.armor.tachidesk.model.database.table.ChapterTable
import ir.armor.tachidesk.server.applicationDirs import ir.armor.tachidesk.model.database.table.MangaTable
import ir.armor.tachidesk.model.database.table.PageTable
import ir.armor.tachidesk.model.database.table.SourceTable
import ir.armor.tachidesk.server.ApplicationDirs
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
fun getTrueImageUrl(page: Page, source: HttpSource): String { object Page {
if (page.imageUrl == null) { /**
page.imageUrl = source.fetchImageUrl(page).toBlocking().first()!! * A page might have a imageUrl ready from the get go, or we might need to
* go an extra step and call fetchImageUrl to get it.
*/
suspend fun getTrueImageUrl(page: Page, source: HttpSource): String {
if (page.imageUrl == null) {
page.imageUrl = source.fetchImageUrl(page).awaitSingle()
}
return page.imageUrl!!
} }
return page.imageUrl!!
}
fun getPageImage(mangaId: Int, chapterIndex: Int, index: Int): Pair<InputStream, String> { suspend fun getPageImage(mangaId: Int, chapterIndex: Int, index: Int): Pair<InputStream, String> {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! } val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
val source = getHttpSource(mangaEntry[MangaTable.sourceReference]) val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
val chapterEntry = transaction { val chapterEntry = transaction {
ChapterTable.select { ChapterTable.select {
(ChapterTable.chapterIndex eq chapterIndex) and (ChapterTable.manga eq mangaId) (ChapterTable.chapterIndex eq chapterIndex) and (ChapterTable.manga eq mangaId)
}.firstOrNull()!! }.firstOrNull()!!
} }
val chapterId = chapterEntry[ChapterTable.id].value val chapterId = chapterEntry[ChapterTable.id].value
val pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq index) }.firstOrNull()!! } val pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq index) }.firstOrNull()!! }
val tachiPage = Page( val tachiPage = Page(
pageEntry[PageTable.index], pageEntry[PageTable.index],
pageEntry[PageTable.url], pageEntry[PageTable.url],
pageEntry[PageTable.imageUrl] pageEntry[PageTable.imageUrl]
) )
if (pageEntry[PageTable.imageUrl] == null) { if (pageEntry[PageTable.imageUrl] == null) {
transaction { val trueImageUrl = getTrueImageUrl(tachiPage, source)
PageTable.update({ (PageTable.chapter eq chapterId) and (PageTable.index eq index) }) { transaction {
it[imageUrl] = getTrueImageUrl(tachiPage, source) PageTable.update({ (PageTable.chapter eq chapterId) and (PageTable.index eq index) }) {
it[imageUrl] = trueImageUrl
}
} }
} }
val saveDir = getChapterDir(mangaId, chapterId)
File(saveDir).mkdirs()
val fileName = index.toString()
return getCachedImageResponse(saveDir, fileName) {
source.fetchImage(tachiPage).awaitSingle()
}
} }
val saveDir = getChapterDir(mangaId, chapterId) // TODO: rewrite this to match tachiyomi
File(saveDir).mkdirs() private val applicationDirs by DI.global.instance<ApplicationDirs>()
val fileName = index.toString() fun getChapterDir(mangaId: Int, chapterId: Int): String {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
val sourceId = mangaEntry[MangaTable.sourceReference]
val source = getHttpSource(sourceId)
val sourceEntry = transaction { SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()!! }
val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.firstOrNull()!! }
return getCachedImageResponse(saveDir, fileName) { val chapterDir = when {
source.fetchImage(tachiPage).toBlocking().first() chapterEntry[ChapterTable.scanlator] != null -> "${chapterEntry[ChapterTable.scanlator]}_${chapterEntry[ChapterTable.name]}"
else -> chapterEntry[ChapterTable.name]
}
val mangaTitle = mangaEntry[MangaTable.title]
val sourceName = source.toString()
val mangaDir = "${applicationDirs.mangaRoot}/$sourceName/$mangaTitle/$chapterDir"
// make sure dirs exist
File(mangaDir).mkdirs()
return mangaDir
} }
} }
fun getChapterDir(mangaId: Int, chapterId: Int): String {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
val sourceId = mangaEntry[MangaTable.sourceReference]
val source = getHttpSource(sourceId)
val sourceEntry = transaction { SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()!! }
val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.firstOrNull()!! }
val chapterDir = when {
chapterEntry[ChapterTable.scanlator] != null -> "${chapterEntry[ChapterTable.scanlator]}_${chapterEntry[ChapterTable.name]}"
else -> chapterEntry[ChapterTable.name]
}
val mangaTitle = mangaEntry[MangaTable.title]
val sourceName = source.toString()
val mangaDir = "${applicationDirs.mangaRoot}/$sourceName/$mangaTitle/$chapterDir"
// make sure dirs exist
File(mangaDir).mkdirs()
return mangaDir
}
@@ -7,28 +7,36 @@ package ir.armor.tachidesk.impl
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import ir.armor.tachidesk.database.dataclass.PagedMangaListDataClass import ir.armor.tachidesk.impl.MangaList.processEntries
import ir.armor.tachidesk.impl.util.GetHttpSource.getHttpSource
import ir.armor.tachidesk.impl.util.awaitSingle
import ir.armor.tachidesk.model.dataclass.PagedMangaListDataClass
fun sourceFilters(sourceId: Long) { object Search {
val source = getHttpSource(sourceId) // TODO
// source.getFilterList().toItems() fun sourceFilters(sourceId: Long) {
} val source = getHttpSource(sourceId)
// source.getFilterList().toItems()
}
fun sourceSearch(sourceId: Long, searchTerm: String, pageNum: Int): PagedMangaListDataClass { suspend fun sourceSearch(sourceId: Long, searchTerm: String, pageNum: Int): PagedMangaListDataClass {
val source = getHttpSource(sourceId) val source = getHttpSource(sourceId)
val searchManga = source.fetchSearchManga(pageNum, searchTerm, source.getFilterList()).toBlocking().first() val searchManga = source.fetchSearchManga(pageNum, searchTerm, source.getFilterList()).awaitSingle()
return searchManga.processEntries(sourceId) return searchManga.processEntries(sourceId)
} }
fun sourceGlobalSearch(searchTerm: String) { fun sourceGlobalSearch(searchTerm: String) {
// TODO // TODO
} }
data class FilterWrapper( data class FilterWrapper(
val type: String, val type: String,
val filter: Any val filter: Any
) )
/**
* Note: Exhentai had a filter serializer (now in SY) that we might be able to steal
*/
// private fun FilterList.toFilterWrapper(): List<FilterWrapper> { // private fun FilterList.toFilterWrapper(): List<FilterWrapper> {
// return mapNotNull { filter -> // return mapNotNull { filter ->
// when (filter) { // when (filter) {
@@ -64,3 +72,4 @@ data class FilterWrapper(
// } // }
// } // }
// } // }
}
@@ -0,0 +1,50 @@
package ir.armor.tachidesk.impl
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import ir.armor.tachidesk.impl.Extension.getExtensionIconUrl
import ir.armor.tachidesk.impl.util.GetHttpSource.getHttpSource
import ir.armor.tachidesk.model.database.table.ExtensionTable
import ir.armor.tachidesk.model.database.table.SourceTable
import ir.armor.tachidesk.model.dataclass.SourceDataClass
import mu.KotlinLogging
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
object Source {
private val logger = KotlinLogging.logger {}
fun getSourceList(): List<SourceDataClass> {
return transaction {
SourceTable.selectAll().map {
SourceDataClass(
it[SourceTable.id].value.toString(),
it[SourceTable.name],
it[SourceTable.lang],
getExtensionIconUrl(ExtensionTable.select { ExtensionTable.id eq it[SourceTable.extension] }.first()[ExtensionTable.apkName]),
getHttpSource(it[SourceTable.id].value).supportsLatest
)
}
}
}
fun getSource(sourceId: Long): SourceDataClass {
return transaction {
val source = SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()
SourceDataClass(
sourceId.toString(),
source?.get(SourceTable.name),
source?.get(SourceTable.lang),
source?.let { ExtensionTable.select { ExtensionTable.id eq source[SourceTable.extension] }.first()[ExtensionTable.iconUrl] },
source?.let { getHttpSource(sourceId).supportsLatest }
)
}
}
}
@@ -1,107 +0,0 @@
package ir.armor.tachidesk.impl
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.source.online.HttpSource
import ir.armor.tachidesk.database.dataclass.SourceDataClass
import ir.armor.tachidesk.database.entity.ExtensionEntity
import ir.armor.tachidesk.database.entity.SourceEntity
import ir.armor.tachidesk.database.table.ExtensionTable
import ir.armor.tachidesk.database.table.SourceTable
import ir.armor.tachidesk.server.applicationDirs
import mu.KotlinLogging
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import java.lang.NullPointerException
import java.net.URL
import java.net.URLClassLoader
private val logger = KotlinLogging.logger {}
private val sourceCache = mutableListOf<Pair<Long, HttpSource>>()
private val extensionCache = mutableListOf<Pair<String, Any>>()
fun getHttpSource(sourceId: Long): HttpSource {
val sourceRecord = transaction {
SourceEntity.findById(sourceId)
} ?: throw NullPointerException("Source with id $sourceId is not installed")
val cachedResult: Pair<Long, HttpSource>? = sourceCache.firstOrNull { it.first == sourceId }
if (cachedResult != null) {
logger.debug("used cached HttpSource: ${cachedResult.second.name}")
return cachedResult.second
}
val result: HttpSource = transaction {
val extensionId = sourceRecord.extension.id.value
val extensionRecord = ExtensionEntity.findById(extensionId)!!
val apkName = extensionRecord.apkName
val className = extensionRecord.classFQName
val jarName = apkName.substringBefore(".apk") + ".jar"
val jarPath = "${applicationDirs.extensionsRoot}/$jarName"
val cachedExtensionPair = extensionCache.firstOrNull { it.first == jarPath }
var usedCached = false
val instance =
if (cachedExtensionPair != null) {
usedCached = true
logger.debug("Used cached Extension")
cachedExtensionPair.second
} else {
logger.debug("No Extension cache")
val child = URLClassLoader(arrayOf<URL>(URL("file:$jarPath")), this::class.java.classLoader)
val classToLoad = Class.forName(className, true, child)
classToLoad.newInstance()
}
if (sourceRecord.partOfFactorySource) {
return@transaction if (usedCached) {
(instance as List<HttpSource>)[sourceRecord.positionInFactorySource!!]
} else {
val list = (instance as SourceFactory).createSources()
extensionCache.add(Pair(jarPath, list))
list[sourceRecord.positionInFactorySource!!] as HttpSource
}
} else {
if (!usedCached)
extensionCache.add(Pair(jarPath, instance))
return@transaction instance as HttpSource
}
}
sourceCache.add(Pair(sourceId, result))
return result
}
fun getSourceList(): List<SourceDataClass> {
return transaction {
return@transaction SourceTable.selectAll().map {
SourceDataClass(
it[SourceTable.id].value.toString(),
it[SourceTable.name],
it[SourceTable.lang],
getExtensionIconUrl(ExtensionTable.select { ExtensionTable.id eq it[SourceTable.extension] }.first()[ExtensionTable.apkName]),
getHttpSource(it[SourceTable.id].value).supportsLatest
)
}
}
}
fun getSource(sourceId: Long): SourceDataClass {
return transaction {
val source = SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()
return@transaction SourceDataClass(
sourceId.toString(),
source?.get(SourceTable.name),
source?.get(SourceTable.lang),
source?.let { ExtensionTable.select { ExtensionTable.id eq source[SourceTable.extension] }.first()[ExtensionTable.iconUrl] },
source?.let { getHttpSource(sourceId).supportsLatest }
)
}
}
@@ -0,0 +1,16 @@
package ir.armor.tachidesk.impl.backup
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
data class BackupFlags(
val includeManga: Boolean,
val includeCategories: Boolean,
val includeChapters: Boolean,
val includeTracking: Boolean,
val includeHistory: Boolean,
)
@@ -0,0 +1,45 @@
package ir.armor.tachidesk.impl.backup.legacy
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import com.github.salomonbrys.kotson.registerTypeAdapter
import com.github.salomonbrys.kotson.registerTypeHierarchyAdapter
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import ir.armor.tachidesk.impl.backup.legacy.models.DHistory
import ir.armor.tachidesk.impl.backup.legacy.serializer.CategoryTypeAdapter
import ir.armor.tachidesk.impl.backup.legacy.serializer.ChapterTypeAdapter
import ir.armor.tachidesk.impl.backup.legacy.serializer.HistoryTypeAdapter
import ir.armor.tachidesk.impl.backup.legacy.serializer.MangaTypeAdapter
import ir.armor.tachidesk.impl.backup.legacy.serializer.TrackTypeAdapter
import ir.armor.tachidesk.impl.backup.models.CategoryImpl
import ir.armor.tachidesk.impl.backup.models.ChapterImpl
import ir.armor.tachidesk.impl.backup.models.MangaImpl
import ir.armor.tachidesk.impl.backup.models.TrackImpl
import java.util.Date
open class LegacyBackupBase {
protected val parser: Gson = when (version) {
2 -> GsonBuilder()
.registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build())
.registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build())
.registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build())
.registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
.registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build())
.create()
else -> throw Exception("Unknown backup version")
}
protected var sourceMapping: Map<Long, String> = emptyMap()
protected val errors = mutableListOf<Pair<Date, String>>()
companion object {
internal const val version = 2
}
}
@@ -0,0 +1,154 @@
package ir.armor.tachidesk.impl.backup.legacy
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import com.github.salomonbrys.kotson.set
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.source.LocalSource
import ir.armor.tachidesk.impl.Category.getCategoryList
import ir.armor.tachidesk.impl.CategoryManga.getMangaCategories
import ir.armor.tachidesk.impl.backup.BackupFlags
import ir.armor.tachidesk.impl.backup.legacy.models.Backup
import ir.armor.tachidesk.impl.backup.legacy.models.Backup.CURRENT_VERSION
import ir.armor.tachidesk.impl.backup.models.CategoryImpl
import ir.armor.tachidesk.impl.backup.models.ChapterImpl
import ir.armor.tachidesk.impl.backup.models.Manga
import ir.armor.tachidesk.impl.backup.models.MangaImpl
import ir.armor.tachidesk.impl.util.GetHttpSource.getHttpSource
import ir.armor.tachidesk.model.database.table.ChapterTable
import ir.armor.tachidesk.model.database.table.MangaTable
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
object LegacyBackupExport : LegacyBackupBase() {
suspend fun createLegacyBackup(flags: BackupFlags): String? {
// Create root object
val root = JsonObject()
// Create manga array
val mangaEntries = JsonArray()
// Create category array
val categoryEntries = JsonArray()
// Create extension ID/name mapping
val extensionEntries = JsonArray()
// Add values to root
root[Backup.VERSION] = CURRENT_VERSION
root[Backup.MANGAS] = mangaEntries
root[Backup.CATEGORIES] = categoryEntries
root[Backup.EXTENSIONS] = extensionEntries
transaction {
val mangas = MangaTable.select { (MangaTable.inLibrary eq true) }
val extensions: MutableSet<String> = mutableSetOf()
// Backup library manga and its dependencies
mangas.map {
MangaImpl.fromQuery(it)
}.forEach { manga ->
mangaEntries.add(backupMangaObject(manga, flags))
// Maintain set of extensions/sources used (excludes local source)
if (manga.source != LocalSource.ID) {
getHttpSource(manga.source).let {
extensions.add("${it.id}:${it.name}")
}
}
}
// Backup categories
if (flags.includeCategories) {
backupCategories(categoryEntries)
}
// Backup extension ID/name mapping
backupExtensionInfo(extensionEntries, extensions)
}
return parser.toJson(root)
}
private fun backupMangaObject(manga: Manga, options: BackupFlags): JsonElement {
// Entry for this manga
val entry = JsonObject()
// Backup manga fields
entry[Backup.MANGA] = parser.toJsonTree(manga)
val mangaId = manga.id!!.toInt()
// Check if user wants chapter information in backup
if (options.includeChapters) {
// Backup all the chapters
val chapters = ChapterTable.select { ChapterTable.manga eq mangaId }.map { ChapterImpl.fromQuery(it) }
if (chapters.count() > 0) {
val chaptersJson = parser.toJsonTree(chapters)
if (chaptersJson.asJsonArray.size() > 0) {
entry[Backup.CHAPTERS] = chaptersJson
}
}
}
// Check if user wants category information in backup
if (options.includeCategories) {
// Backup categories for this manga
val categoriesForManga = getMangaCategories(mangaId)
if (categoriesForManga.isNotEmpty()) {
val categoriesNames = categoriesForManga.map { it.name }
entry[Backup.CATEGORIES] = parser.toJsonTree(categoriesNames)
}
}
// Check if user wants track information in backup
if (options.includeTracking) { // TODO
// val tracks = databaseHelper.getTracks(manga).executeAsBlocking()
// if (tracks.isNotEmpty()) {
// entry[TRACK] = parser.toJsonTree(tracks)
// }
}
//
// // Check if user wants history information in backup
if (options.includeHistory) { // TODO
// val historyForManga = databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking()
// if (historyForManga.isNotEmpty()) {
// val historyData = historyForManga.mapNotNull { history ->
// val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url
// url?.let { DHistory(url, history.last_read) }
// }
// val historyJson = parser.toJsonTree(historyData)
// if (historyJson.asJsonArray.size() > 0) {
// entry[HISTORY] = historyJson
// }
// }
}
return entry
}
private fun backupCategories(root: JsonArray) {
val categories = getCategoryList().map {
CategoryImpl().apply {
name = it.name
order = it.order
}
}
categories.forEach { root.add(parser.toJsonTree(it)) }
}
private fun backupExtensionInfo(root: JsonArray, extensions: Set<String>) {
extensions.sorted().forEach {
root.add(it)
}
}
}
@@ -0,0 +1,212 @@
package ir.armor.tachidesk.impl.backup.legacy
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SManga
import ir.armor.tachidesk.impl.Category.createCategory
import ir.armor.tachidesk.impl.Category.getCategoryList
import ir.armor.tachidesk.impl.backup.legacy.LegacyBackupValidator.ValidationResult
import ir.armor.tachidesk.impl.backup.legacy.LegacyBackupValidator.validate
import ir.armor.tachidesk.impl.backup.legacy.models.Backup
import ir.armor.tachidesk.impl.backup.legacy.models.DHistory
import ir.armor.tachidesk.impl.backup.models.CategoryImpl
import ir.armor.tachidesk.impl.backup.models.Chapter
import ir.armor.tachidesk.impl.backup.models.ChapterImpl
import ir.armor.tachidesk.impl.backup.models.Manga
import ir.armor.tachidesk.impl.backup.models.MangaImpl
import ir.armor.tachidesk.impl.backup.models.Track
import ir.armor.tachidesk.impl.backup.models.TrackImpl
import ir.armor.tachidesk.impl.util.GetHttpSource.getHttpSource
import ir.armor.tachidesk.impl.util.awaitSingle
import ir.armor.tachidesk.model.database.table.MangaTable
import mu.KotlinLogging
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import java.io.InputStream
import java.util.Date
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
private val logger = KotlinLogging.logger {}
object LegacyBackupImport : LegacyBackupBase() {
suspend fun restoreLegacyBackup(sourceStream: InputStream): ValidationResult {
val reader = sourceStream.bufferedReader()
val json = JsonParser.parseReader(reader).asJsonObject
val validationResult = validate(json)
val mangasJson = json.get(Backup.MANGAS).asJsonArray
// Restore categories
json.get(Backup.CATEGORIES)?.let { restoreCategories(it) }
// Store source mapping for error messages
sourceMapping = LegacyBackupValidator.getSourceMapping(json)
// Restore individual manga
mangasJson.forEach {
restoreManga(it.asJsonObject)
}
logger.info {
"""
Restore Errors:
${
errors.map {
"${it.first} - ${it.second}"
}.joinToString("\n")
}
Restore Summary:
- Missing Sources:
${validationResult.missingSources.joinToString("\n")}
- Missing Trackers:
${validationResult.missingTrackers.joinToString("\n")}
""".trimIndent()
}
return validationResult
}
private fun restoreCategories(jsonCategories: JsonElement) { // TODO
val backupCategories = parser.fromJson<List<CategoryImpl>>(jsonCategories)
val dbCategories = getCategoryList()
// Iterate over them
backupCategories.forEach { category ->
if (dbCategories.none { it.name == category.name }) {
createCategory(category.name)
}
}
}
private suspend fun restoreManga(mangaJson: JsonObject) {
val manga = parser.fromJson<MangaImpl>(
mangaJson.get(
Backup.MANGA
)
)
val chapters = parser.fromJson<List<ChapterImpl>>(
mangaJson.get(Backup.CHAPTERS)
?: JsonArray()
)
val categories = parser.fromJson<List<String>>(
mangaJson.get(Backup.CATEGORIES)
?: JsonArray()
)
val history = parser.fromJson<List<DHistory>>(
mangaJson.get(Backup.HISTORY)
?: JsonArray()
)
val tracks = parser.fromJson<List<TrackImpl>>(
mangaJson.get(Backup.TRACK)
?: JsonArray()
)
val source = try {
getHttpSource(manga.source)
} catch (e: NullPointerException) {
null
}
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
logger.debug("Restoring Manga: ${manga.title} from $sourceName")
try {
if (source != null) {
restoreMangaData(manga, source, chapters, categories, history, tracks)
} else {
errors.add(Date() to "${manga.title} [$sourceName]: Source not found: $sourceName (${manga.source})")
}
} catch (e: Exception) {
errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}")
}
}
/**
* @param manga manga data from json
* @param source source to get manga data from
* @param chapters chapters data from json
* @param categories categories data from json
* @param history history data from json
* @param tracks tracking data from json
*/
private suspend fun restoreMangaData(
manga: Manga,
source: Source,
chapters: List<Chapter>,
categories: List<String>,
history: List<DHistory>,
tracks: List<Track>
) {
val fetchedManga = fetchManga(source, manga)
updateChapters(source, fetchedManga, chapters)
// TODO
// backupManager.restoreCategoriesForManga(manga, categories)
// backupManager.restoreHistoryForManga(history)
// backupManager.restoreTrackForManga(manga, tracks)
// updateTracking(fetchedManga, tracks)
}
/**
* Fetches manga information
*
* @param source source of manga
* @param manga manga that needs updating
* @return Updated manga.
*/
private suspend fun fetchManga(source: Source, manga: Manga): SManga {
// make sure we have the manga record in library
transaction {
if (MangaTable.select { (MangaTable.url eq manga.url) and (MangaTable.sourceReference eq manga.source) }.firstOrNull() == null) {
MangaTable.insert {
it[url] = manga.url
it[title] = manga.title
it[sourceReference] = manga.source
}
}
MangaTable.update({ (MangaTable.url eq manga.url) and (MangaTable.sourceReference eq manga.source) }) {
it[MangaTable.inLibrary] = true
}
}
// update manga details
val fetchedManga = source.fetchMangaDetails(manga).awaitSingle()
transaction {
MangaTable.update({ (MangaTable.url eq manga.url) and (MangaTable.sourceReference eq manga.source) }) {
it[artist] = fetchedManga.artist
it[author] = fetchedManga.author
it[description] = fetchedManga.description
it[genre] = fetchedManga.genre
it[status] = fetchedManga.status
if (fetchedManga.thumbnail_url != null && fetchedManga.thumbnail_url!!.isNotEmpty())
it[MangaTable.thumbnail_url] = fetchedManga.thumbnail_url
}
}
return fetchedManga
}
private fun updateChapters(source: Source, fetchedManga: SManga, chapters: List<Chapter>) {
// TODO("Not yet implemented")
}
}
@@ -0,0 +1,71 @@
package ir.armor.tachidesk.impl.backup.legacy
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import com.google.gson.JsonObject
import ir.armor.tachidesk.impl.backup.legacy.models.Backup
import ir.armor.tachidesk.model.database.table.SourceTable
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
object LegacyBackupValidator {
data class ValidationResult(val missingSources: List<String>, val missingTrackers: List<String>)
/**
* Checks for critical backup file data.
*
* @throws Exception if version or manga cannot be found.
* @return List of missing sources or missing trackers.
*/
fun validate(json: JsonObject): ValidationResult {
val version = json.get(Backup.VERSION)
val mangasJson = json.get(Backup.MANGAS)
if (version == null || mangasJson == null) {
throw Exception("File is missing data.")
}
val mangas = mangasJson.asJsonArray
if (mangas.size() == 0) {
throw Exception("Backup does not contain any manga.")
}
val sources = getSourceMapping(json)
val missingSources = transaction {
sources
.filter { SourceTable.select { SourceTable.id eq it.key }.firstOrNull() == null }
.map { "${it.value} (${it.key})" }
.sorted()
}
val trackers = mangas
.filter { it.asJsonObject.has("track") }
.flatMap { it.asJsonObject["track"].asJsonArray }
.map { it.asJsonObject["s"].asInt }
.distinct()
val missingTrackers = listOf("")
// val missingTrackers = trackers
// .mapNotNull { trackManager.getService(it) }
// .filter { !it.isLogged }
// .map { context.getString(it.nameRes()) }
// .sorted()
return ValidationResult(missingSources, missingTrackers)
}
fun getSourceMapping(json: JsonObject): Map<Long, String> {
val extensionsMapping = json.get(Backup.EXTENSIONS) ?: return emptyMap()
return extensionsMapping.asJsonArray
.map {
val items = it.asString.split(":")
items[0].toLong() to items[1]
}
.toMap()
}
}
@@ -0,0 +1,25 @@
package ir.armor.tachidesk.impl.backup.legacy.models
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Json values
*/
object Backup {
const val CURRENT_VERSION = 2
const val MANGA = "manga"
const val MANGAS = "mangas"
const val TRACK = "track"
const val CHAPTERS = "chapters"
const val CATEGORIES = "categories"
const val EXTENSIONS = "extensions"
const val HISTORY = "history"
const val VERSION = "version"
fun getDefaultFilename(): String {
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
return "tachiyomi_$date.json"
}
}
@@ -0,0 +1,3 @@
package ir.armor.tachidesk.impl.backup.legacy.models
data class DHistory(val url: String, val lastRead: Long)
@@ -0,0 +1,31 @@
package ir.armor.tachidesk.impl.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter
import ir.armor.tachidesk.impl.backup.models.CategoryImpl
/**
* JSON Serializer used to write / read [CategoryImpl] to / from json
*/
object CategoryTypeAdapter {
fun build(): TypeAdapter<CategoryImpl> {
return typeAdapter {
write {
beginArray()
value(it.name)
value(it.order)
endArray()
}
read {
beginArray()
val category = CategoryImpl()
category.name = nextString()
category.order = nextInt()
endArray()
category
}
}
}
}
@@ -0,0 +1,59 @@
package ir.armor.tachidesk.impl.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonToken
import ir.armor.tachidesk.impl.backup.models.ChapterImpl
/**
* JSON Serializer used to write / read [ChapterImpl] to / from json
*/
object ChapterTypeAdapter {
private const val URL = "u"
private const val READ = "r"
private const val BOOKMARK = "b"
private const val LAST_READ = "l"
fun build(): TypeAdapter<ChapterImpl> {
return typeAdapter {
write {
if (it.read || it.bookmark || it.last_page_read != 0) {
beginObject()
name(URL)
value(it.url)
if (it.read) {
name(READ)
value(1)
}
if (it.bookmark) {
name(BOOKMARK)
value(1)
}
if (it.last_page_read != 0) {
name(LAST_READ)
value(it.last_page_read)
}
endObject()
}
}
read {
val chapter = ChapterImpl()
beginObject()
while (hasNext()) {
if (peek() == JsonToken.NAME) {
when (nextName()) {
URL -> chapter.url = nextString()
READ -> chapter.read = nextInt() == 1
BOOKMARK -> chapter.bookmark = nextInt() == 1
LAST_READ -> chapter.last_page_read = nextInt()
}
}
}
endObject()
chapter
}
}
}
}
@@ -0,0 +1,32 @@
package ir.armor.tachidesk.impl.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter
import ir.armor.tachidesk.impl.backup.legacy.models.DHistory
/**
* JSON Serializer used to write / read [DHistory] to / from json
*/
object HistoryTypeAdapter {
fun build(): TypeAdapter<DHistory> {
return typeAdapter {
write {
if (it.lastRead != 0L) {
beginArray()
value(it.url)
value(it.lastRead)
endArray()
}
}
read {
beginArray()
val url = nextString()
val lastRead = nextLong()
endArray()
DHistory(url, lastRead)
}
}
}
}
@@ -0,0 +1,37 @@
package ir.armor.tachidesk.impl.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter
import ir.armor.tachidesk.impl.backup.models.MangaImpl
/**
* JSON Serializer used to write / read [MangaImpl] to / from json
*/
object MangaTypeAdapter {
fun build(): TypeAdapter<MangaImpl> {
return typeAdapter {
write {
beginArray()
value(it.url)
value(it.title)
value(it.source)
value(it.viewer)
value(it.chapter_flags)
endArray()
}
read {
beginArray()
val manga = MangaImpl()
manga.url = nextString()
manga.title = nextString()
manga.source = nextLong()
manga.viewer = nextInt()
manga.chapter_flags = nextInt()
endArray()
manga
}
}
}
}
@@ -0,0 +1,59 @@
package ir.armor.tachidesk.impl.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonToken
import ir.armor.tachidesk.impl.backup.models.TrackImpl
/**
* JSON Serializer used to write / read [TrackImpl] to / from json
*/
object TrackTypeAdapter {
private const val SYNC = "s"
private const val MEDIA = "r"
private const val LIBRARY = "ml"
private const val TITLE = "t"
private const val LAST_READ = "l"
private const val TRACKING_URL = "u"
fun build(): TypeAdapter<TrackImpl> {
return typeAdapter {
write {
beginObject()
name(TITLE)
value(it.title)
name(SYNC)
value(it.sync_id)
name(MEDIA)
value(it.media_id)
name(LIBRARY)
value(it.library_id)
name(LAST_READ)
value(it.last_chapter_read)
name(TRACKING_URL)
value(it.tracking_url)
endObject()
}
read {
val track = TrackImpl()
beginObject()
while (hasNext()) {
if (peek() == JsonToken.NAME) {
when (nextName()) {
TITLE -> track.title = nextString()
SYNC -> track.sync_id = nextInt()
MEDIA -> track.media_id = nextInt()
LIBRARY -> track.library_id = nextLong()
LAST_READ -> track.last_chapter_read = nextInt()
TRACKING_URL -> track.tracking_url = nextString()
}
}
}
endObject()
track
}
}
}
}
@@ -0,0 +1,23 @@
package ir.armor.tachidesk.impl.backup.models
import java.io.Serializable
interface Category : Serializable {
var id: Int?
var name: String
var order: Int
var flags: Int
companion object {
fun create(name: String): Category = CategoryImpl().apply {
this.name = name
}
fun createDefault(): Category = create("Default").apply { id = 0 }
}
}
@@ -0,0 +1,24 @@
package ir.armor.tachidesk.impl.backup.models
class CategoryImpl : Category {
override var id: Int? = null
override lateinit var name: String
override var order: Int = 0
override var flags: Int = 0
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val category = other as Category
return name == category.name
}
override fun hashCode(): Int {
return name.hashCode()
}
}
@@ -0,0 +1,31 @@
package ir.armor.tachidesk.impl.backup.models
import eu.kanade.tachiyomi.source.model.SChapter
import java.io.Serializable
interface Chapter : SChapter, Serializable {
var id: Long?
var manga_id: Long?
var read: Boolean
var bookmark: Boolean
var last_page_read: Int
var date_fetch: Long
var source_order: Int
val isRecognizedNumber: Boolean
get() = chapter_number >= 0f
companion object {
fun create(): Chapter = ChapterImpl().apply {
chapter_number = -1f
}
}
}
@@ -0,0 +1,57 @@
package ir.armor.tachidesk.impl.backup.models
import ir.armor.tachidesk.model.database.table.ChapterTable
import org.jetbrains.exposed.sql.ResultRow
class ChapterImpl : Chapter {
override var id: Long? = null
override var manga_id: Long? = null
override lateinit var url: String
override lateinit var name: String
override var scanlator: String? = null
override var read: Boolean = false
override var bookmark: Boolean = false
override var last_page_read: Int = 0
override var date_fetch: Long = 0
override var date_upload: Long = 0
override var chapter_number: Float = 0f
override var source_order: Int = 0
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val chapter = other as Chapter
if (url != chapter.url) return false
return id == chapter.id
}
override fun hashCode(): Int {
return url.hashCode() + id.hashCode()
}
// Tachidesk -->
companion object {
fun fromQuery(chapterRecord: ResultRow): ChapterImpl {
return ChapterImpl().apply {
url = chapterRecord[ChapterTable.url]
read = chapterRecord[ChapterTable.isRead]
bookmark = chapterRecord[ChapterTable.isBookmarked]
last_page_read = chapterRecord[ChapterTable.lastPageRead]
}
}
}
// Tachidesk <--
}
@@ -0,0 +1,42 @@
package ir.armor.tachidesk.impl.backup.models
import java.io.Serializable
/**
* Object containing the history statistics of a chapter
*/
interface History : Serializable {
/**
* Id of history object.
*/
var id: Long?
/**
* Chapter id of history object.
*/
var chapter_id: Long
/**
* Last time chapter was read in time long format
*/
var last_read: Long
/**
* Total time chapter was read - todo not yet implemented
*/
var time_read: Long
companion object {
/**
* History constructor
*
* @param chapter chapter object
* @return history object
*/
fun create(chapter: Chapter): History = HistoryImpl().apply {
this.chapter_id = chapter.id!!
}
}
}
@@ -0,0 +1,27 @@
package ir.armor.tachidesk.impl.backup.models
/**
* Object containing the history statistics of a chapter
*/
class HistoryImpl : History {
/**
* Id of history object.
*/
override var id: Long? = null
/**
* Chapter id of history object.
*/
override var chapter_id: Long = 0
/**
* Last time chapter was read in time long format
*/
override var last_read: Long = 0
/**
* Total time chapter was read - todo not yet implemented
*/
override var time_read: Long = 0
}
@@ -0,0 +1,8 @@
package ir.armor.tachidesk.impl.backup.models
class LibraryManga : MangaImpl() {
var unread: Int = 0
var category: Int = 0
}
@@ -0,0 +1,115 @@
package ir.armor.tachidesk.impl.backup.models
import eu.kanade.tachiyomi.source.model.SManga
// import tachiyomi.source.model.MangaInfo
interface Manga : SManga {
var id: Long?
var source: Long
/** is in library */
var favorite: Boolean
var last_update: Long
var date_added: Long
var viewer: Int
var chapter_flags: Int
var cover_last_modified: Long
fun setChapterOrder(order: Int) {
setFlags(order, SORT_MASK)
}
fun sortDescending(): Boolean {
return chapter_flags and SORT_MASK == SORT_DESC
}
fun getGenres(): List<String>? {
return genre?.split(", ")?.map { it.trim() }
}
private fun setFlags(flag: Int, mask: Int) {
chapter_flags = chapter_flags and mask.inv() or (flag and mask)
}
// Used to display the chapter's title one way or another
var displayMode: Int
get() = chapter_flags and DISPLAY_MASK
set(mode) = setFlags(mode, DISPLAY_MASK)
var readFilter: Int
get() = chapter_flags and READ_MASK
set(filter) = setFlags(filter, READ_MASK)
var downloadedFilter: Int
get() = chapter_flags and DOWNLOADED_MASK
set(filter) = setFlags(filter, DOWNLOADED_MASK)
var bookmarkedFilter: Int
get() = chapter_flags and BOOKMARKED_MASK
set(filter) = setFlags(filter, BOOKMARKED_MASK)
var sorting: Int
get() = chapter_flags and SORTING_MASK
set(sort) = setFlags(sort, SORTING_MASK)
companion object {
const val SORT_DESC = 0x00000000
const val SORT_ASC = 0x00000001
const val SORT_MASK = 0x00000001
// Generic filter that does not filter anything
const val SHOW_ALL = 0x00000000
const val SHOW_UNREAD = 0x00000002
const val SHOW_READ = 0x00000004
const val READ_MASK = 0x00000006
const val SHOW_DOWNLOADED = 0x00000008
const val SHOW_NOT_DOWNLOADED = 0x00000010
const val DOWNLOADED_MASK = 0x00000018
const val SHOW_BOOKMARKED = 0x00000020
const val SHOW_NOT_BOOKMARKED = 0x00000040
const val BOOKMARKED_MASK = 0x00000060
const val SORTING_SOURCE = 0x00000000
const val SORTING_NUMBER = 0x00000100
const val SORTING_UPLOAD_DATE = 0x00000200
const val SORTING_MASK = 0x00000300
const val DISPLAY_NAME = 0x00000000
const val DISPLAY_NUMBER = 0x00100000
const val DISPLAY_MASK = 0x00100000
fun create(source: Long): Manga = MangaImpl().apply {
this.source = source
}
fun create(pathUrl: String, title: String, source: Long = 0): Manga = MangaImpl().apply {
url = pathUrl
this.title = title
this.source = source
}
}
}
// fun Manga.toMangaInfo(): MangaInfo {
// return MangaInfo(
// artist = this.artist ?: "",
// author = this.author ?: "",
// cover = this.thumbnail_url ?: "",
// description = this.description ?: "",
// genres = this.getGenres() ?: emptyList(),
// key = this.url,
// status = this.status,
// title = this.title
// )
// }
@@ -0,0 +1,20 @@
package ir.armor.tachidesk.impl.backup.models
class MangaCategory {
var id: Long? = null
var manga_id: Long = 0
var category_id: Int = 0
companion object {
fun create(manga: Manga, category: Category): MangaCategory {
val mc = MangaCategory()
mc.manga_id = manga.id!!
mc.category_id = category.id!!
return mc
}
}
}
@@ -0,0 +1,3 @@
package ir.armor.tachidesk.impl.backup.models
class MangaChapter(val manga: Manga, val chapter: Chapter)
@@ -0,0 +1,10 @@
package ir.armor.tachidesk.impl.backup.models
/**
* Object containing manga, chapter and history
*
* @param manga object containing manga
* @param chapter object containing chater
* @param history object containing history
*/
data class MangaChapterHistory(val manga: Manga, val chapter: Chapter, val history: History)
@@ -0,0 +1,79 @@
package ir.armor.tachidesk.impl.backup.models
import ir.armor.tachidesk.model.database.table.MangaTable
import org.jetbrains.exposed.sql.ResultRow
open class MangaImpl : Manga {
override var id: Long? = 0
override var source: Long = -1
override lateinit var url: String
override lateinit var title: String
override var artist: String? = null
override var author: String? = null
override var description: String? = null
override var genre: String? = null
override var status: Int = 0
override var thumbnail_url: String? = null
override var favorite: Boolean = false
override var last_update: Long = 0
override var date_added: Long = 0
override var initialized: Boolean = false
/** Reader mode value
* ref: https://github.com/tachiyomiorg/tachiyomi/blob/ff369010074b058bb734ce24c66508300e6e9ac6/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReadingModeType.kt#L8
* 0 -> Default
* 1 -> Left to Right
* 2 -> Right to Left
* 3 -> Vertical
* 4 -> Webtoon
* 5 -> Continues Vertical
*/
override var viewer: Int = 0
/** Contains some useful info about
*/
override var chapter_flags: Int = 0
override var cover_last_modified: Long = 0
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val manga = other as Manga
if (url != manga.url) return false
return id == manga.id
}
override fun hashCode(): Int {
return url.hashCode() + id.hashCode()
}
// Tachidesk -->
companion object {
fun fromQuery(mangaRecord: ResultRow): MangaImpl {
return MangaImpl().apply {
url = mangaRecord[MangaTable.url]
title = mangaRecord[MangaTable.title]
source = mangaRecord[MangaTable.sourceReference]
viewer = 0 // TODO: implement
chapter_flags = 0 // TODO: implement
}
}
}
// Tachidesk <--
}
@@ -0,0 +1,46 @@
package ir.armor.tachidesk.impl.backup.models
import java.io.Serializable
interface Track : Serializable {
var id: Long?
var manga_id: Long
var sync_id: Int
var media_id: Int
var library_id: Long?
var title: String
var last_chapter_read: Int
var total_chapters: Int
var score: Float
var status: Int
var started_reading_date: Long
var finished_reading_date: Long
var tracking_url: String
fun copyPersonalFrom(other: Track) {
last_chapter_read = other.last_chapter_read
score = other.score
status = other.status
started_reading_date = other.started_reading_date
finished_reading_date = other.finished_reading_date
}
companion object {
fun create(serviceId: Int): Track = TrackImpl().apply {
sync_id = serviceId
}
}
}
@@ -0,0 +1,48 @@
package ir.armor.tachidesk.impl.backup.models
class TrackImpl : Track {
override var id: Long? = null
override var manga_id: Long = 0
override var sync_id: Int = 0
override var media_id: Int = 0
override var library_id: Long? = null
override lateinit var title: String
override var last_chapter_read: Int = 0
override var total_chapters: Int = 0
override var score: Float = 0f
override var status: Int = 0
override var started_reading_date: Long = 0
override var finished_reading_date: Long = 0
override var tracking_url: String = ""
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
other as Track
if (manga_id != other.manga_id) return false
if (sync_id != other.sync_id) return false
return media_id == other.media_id
}
override fun hashCode(): Int {
var result = (manga_id xor manga_id.ushr(32)).toInt()
result = 31 * result + sync_id
result = 31 * result + media_id
return result
}
}
@@ -1,510 +0,0 @@
package ir.armor.tachidesk.impl.util
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.w3c.dom.Document
import org.xml.sax.InputSource
import java.io.IOException
import java.io.StringReader
import java.nio.file.Files
import java.nio.file.Paths
import java.util.zip.ZipFile
import javax.xml.parsers.DocumentBuilderFactory
object APKExtractor {
// decompressXML -- Parse the 'compressed' binary form of Android XML docs
// such as for AndroidManifest.xml in .apk files
var endDocTag = 0x00100101
var startTag = 0x00100102
var endTag = 0x00100103
fun prt(str: String?) {
// System.err.print(str);
}
fun decompressXML(xml: ByteArray): String {
val finalXML = StringBuilder()
// Compressed XML file/bytes starts with 24x bytes of data,
// 9 32 bit words in little endian order (LSB first):
// 0th word is 03 00 08 00
// 3rd word SEEMS TO BE: Offset at then of StringTable
// 4th word is: Number of strings in string table
// WARNING: Sometime I indiscriminently display or refer to word in
// little endian storage format, or in integer format (ie MSB first).
val numbStrings = LEW(xml, 4 * 4)
// StringIndexTable starts at offset 24x, an array of 32 bit LE offsets
// of the length/string data in the StringTable.
val sitOff = 0x24 // Offset of start of StringIndexTable
// StringTable, each string is represented with a 16 bit little endian
// character count, followed by that number of 16 bit (LE) (Unicode)
// chars.
val stOff = sitOff + numbStrings * 4 // StringTable follows
// StrIndexTable
// XMLTags, The XML tag tree starts after some unknown content after the
// StringTable. There is some unknown data after the StringTable, scan
// forward from this point to the flag for the start of an XML start
// tag.
var xmlTagOff = LEW(xml, 3 * 4) // Start from the offset in the 3rd
// word.
// Scan forward until we find the bytes: 0x02011000(x00100102 in normal
// int)
var ii = xmlTagOff
while (ii < xml.size - 4) {
if (LEW(xml, ii) == startTag) {
xmlTagOff = ii
break
}
ii += 4
}
// XML tags and attributes:
// Every XML start and end tag consists of 6 32 bit words:
// 0th word: 02011000 for startTag and 03011000 for endTag
// 1st word: a flag?, like 38000000
// 2nd word: Line of where this tag appeared in the original source file
// 3rd word: FFFFFFFF ??
// 4th word: StringIndex of NameSpace name, or FFFFFFFF for default NS
// 5th word: StringIndex of Element Name
// (Note: 01011000 in 0th word means end of XML document, endDocTag)
// Start tags (not end tags) contain 3 more words:
// 6th word: 14001400 meaning??
// 7th word: Number of Attributes that follow this tag(follow word 8th)
// 8th word: 00000000 meaning??
// Attributes consist of 5 words:
// 0th word: StringIndex of Attribute Name's Namespace, or FFFFFFFF
// 1st word: StringIndex of Attribute Name
// 2nd word: StringIndex of Attribute Value, or FFFFFFF if ResourceId
// used
// 3rd word: Flags?
// 4th word: str ind of attr value again, or ResourceId of value
// TMP, dump string table to tr for debugging
// tr.addSelect("strings", null);
// for (int ii=0; ii<numbStrings; ii++) {
// // Length of string starts at StringTable plus offset in StrIndTable
// String str = compXmlString(xml, sitOff, stOff, ii);
// tr.add(String.valueOf(ii), str);
// }
// tr.parent();
// Step through the XML tree element tags and attributes
var off = xmlTagOff
var indent = 0
var startTagLineNo = -2
while (off < xml.size) {
val tag0 = LEW(xml, off)
// int tag1 = LEW(xml, off+1*4);
val lineNo = LEW(xml, off + 2 * 4)
// int tag3 = LEW(xml, off+3*4);
val nameNsSi = LEW(xml, off + 4 * 4)
val nameSi = LEW(xml, off + 5 * 4)
if (tag0 == startTag) { // XML START TAG
val tag6 = LEW(xml, off + 6 * 4) // Expected to be 14001400
val numbAttrs = LEW(xml, off + 7 * 4) // Number of Attributes
// to follow
// int tag8 = LEW(xml, off+8*4); // Expected to be 00000000
off += 9 * 4 // Skip over 6+3 words of startTag data
val name = compXmlString(xml, sitOff, stOff, nameSi)
// tr.addSelect(name, null);
startTagLineNo = lineNo
// Look for the Attributes
val sb = StringBuffer()
for (ii in 0 until numbAttrs) {
val attrNameNsSi = LEW(xml, off) // AttrName Namespace Str
// Ind, or FFFFFFFF
val attrNameSi = LEW(xml, off + 1 * 4) // AttrName String
// Index
val attrValueSi = LEW(xml, off + 2 * 4) // AttrValue Str
// Ind, or
// FFFFFFFF
val attrFlags = LEW(xml, off + 3 * 4)
val attrResId = LEW(xml, off + 4 * 4) // AttrValue
// ResourceId or dup
// AttrValue StrInd
off += 5 * 4 // Skip over the 5 words of an attribute
val attrName = compXmlString(
xml, sitOff, stOff,
attrNameSi
)
val attrValue = if (attrValueSi != -1) compXmlString(xml, sitOff, stOff, attrValueSi)
else "resourceID 0x ${Integer.toHexString(attrResId)}"
sb.append(" $attrName=\"$attrValue\"")
// tr.add(attrName, attrValue);
}
finalXML.append("<$name$sb>")
prtIndent(indent, "<$name$sb>")
indent++
} else if (tag0 == endTag) { // XML END TAG
indent--
off += 6 * 4 // Skip over 6 words of endTag data
val name = compXmlString(xml, sitOff, stOff, nameSi)
finalXML.append("</$name>")
prtIndent(
indent,
"</" + name + "> (line " + startTagLineNo +
"-" + lineNo + ")"
)
// tr.parent(); // Step back up the NobTree
} else if (tag0 == endDocTag) { // END OF XML DOC TAG
break
} else {
prt(
" Unrecognized tag code '" + Integer.toHexString(tag0) +
"' at offset " + off
)
break
}
} // end of while loop scanning tags and attributes of XML tree
// prt(" end at offset " + off);
return finalXML.toString()
} // end of decompressXML
fun compXmlString(xml: ByteArray, sitOff: Int, stOff: Int, strInd: Int): String? {
if (strInd < 0) return null
val strOff = stOff + LEW(xml, sitOff + strInd * 4)
return compXmlStringAt(xml, strOff)
}
var spaces = " "
fun prtIndent(indent: Int, str: String) {
prt(spaces.substring(0, Math.min(indent * 2, spaces.length)) + str)
}
// compXmlStringAt -- Return the string stored in StringTable format at
// offset strOff. This offset points to the 16 bit string length, which
// is followed by that number of 16 bit (Unicode) chars.
fun compXmlStringAt(arr: ByteArray, strOff: Int): String {
val strLen: Int = arr[strOff + 1].toInt() shl 8 and 0xff00 or arr[strOff].toInt() and 0xff
val chars = ByteArray(strLen)
for (ii in 0 until strLen) {
chars[ii] = arr[strOff + 2 + ii * 2]
}
return String(chars) // Hack, just use 8 byte chars
} // end of compXmlStringAt
// LEW -- Return value of a Little Endian 32 bit word from the byte array
// at offset off.
fun LEW(arr: ByteArray, off: Int): Int {
return (arr[off + 3].toInt() shl 24) and -0x1000000 or
(arr[off + 2].toInt() shl 16 and 0xff0000) or
(arr[off + 1].toInt() shl 8 and 0xff00) or
(arr[off].toInt() and 0xFF)
} // end of LEW
@Throws(Exception::class)
fun loadXMLFromString(xml: String?): Document {
val docBuilderFactory = DocumentBuilderFactory.newInstance()
val docBuilder = docBuilderFactory.newDocumentBuilder()
return docBuilder.parse(InputSource(StringReader(xml)))
}
@Throws(IOException::class)
fun extract_dex_and_read_className(filePath: String?, dexPath: String?): String {
var zip: ZipFile? = null
zip = ZipFile(filePath)
val androidManifest = zip.getEntry("AndroidManifest.xml")
val classesDex = zip.getEntry("classes.dex")
// write dex file
val dexStream = zip.getInputStream(classesDex)
Files.newOutputStream(Paths.get(dexPath)).use { os ->
val buffer = ByteArray(1024)
var len: Int
while (dexStream.read(buffer).also { len = it } > 0) {
os.write(buffer, 0, len)
}
}
// read xml file
val `is` = zip.getInputStream(androidManifest)
val buf = ByteArray(1024000) // 100 kb
`is`.read(buf)
`is`.close()
zip.close()
val xml = decompressXML(buf)
try {
val xmlDoc = loadXMLFromString(xml)
val pkg = xmlDoc.documentElement.getAttribute("package")
val nodes = xmlDoc.getElementsByTagName("meta-data")
for (i in 0 until nodes.length) {
val attributes = nodes.item(i).attributes
println(attributes.getNamedItem("name").nodeValue)
if (attributes.getNamedItem("name").nodeValue == "tachiyomi.extension.class") return pkg + attributes.getNamedItem("value").nodeValue
}
} catch (e: Exception) {
e.printStackTrace()
}
return ""
}
}
// original Java code
// package ir.armor.tachidesk.impl.util;
//
// /*
// * Copyright (C) Contributors to the Suwayomi project
// *
// * This Source Code Form is subject to the terms of the Mozilla Public
// * License, v. 2.0. If a copy of the MPL was not distributed with this
// * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
//
// import org.w3c.dom.Document;
// import org.w3c.dom.NamedNodeMap;
// import org.w3c.dom.NodeList;
// import org.xml.sax.InputSource;
//
// import javax.xml.parsers.DocumentBuilder;
// import javax.xml.parsers.DocumentBuilderFactory;
// import java.io.*;
// import java.nio.file.Files;
// import java.nio.file.Paths;
// import java.util.zip.ZipEntry;
// import java.util.zip.ZipFile;
//
// public class APKExtractor {
// // decompressXML -- Parse the 'compressed' binary form of Android XML docs
// // such as for AndroidManifest.xml in .apk files
// public static int endDocTag = 0x00100101;
// public static int startTag = 0x00100102;
// public static int endTag = 0x00100103;
//
// static void prt(String str) {
// //System.err.print(str);
// }
//
// public static String decompressXML(byte[] xml) {
//
// StringBuilder finalXML = new StringBuilder();
//
// // Compressed XML file/bytes starts with 24x bytes of data,
// // 9 32 bit words in little endian order (LSB first):
// // 0th word is 03 00 08 00
// // 3rd word SEEMS TO BE: Offset at then of StringTable
// // 4th word is: Number of strings in string table
// // WARNING: Sometime I indiscriminently display or refer to word in
// // little endian storage format, or in integer format (ie MSB first).
// int numbStrings = LEW(xml, 4 * 4);
//
// // StringIndexTable starts at offset 24x, an array of 32 bit LE offsets
// // of the length/string data in the StringTable.
// int sitOff = 0x24; // Offset of start of StringIndexTable
//
// // StringTable, each string is represented with a 16 bit little endian
// // character count, followed by that number of 16 bit (LE) (Unicode)
// // chars.
// int stOff = sitOff + numbStrings * 4; // StringTable follows
// // StrIndexTable
//
// // XMLTags, The XML tag tree starts after some unknown content after the
// // StringTable. There is some unknown data after the StringTable, scan
// // forward from this point to the flag for the start of an XML start
// // tag.
// int xmlTagOff = LEW(xml, 3 * 4); // Start from the offset in the 3rd
// // word.
// // Scan forward until we find the bytes: 0x02011000(x00100102 in normal
// // int)
// for (int ii = xmlTagOff; ii < xml.length - 4; ii += 4) {
// if (LEW(xml, ii) == startTag) {
// xmlTagOff = ii;
// break;
// }
// } // end of hack, scanning for start of first start tag
//
// // XML tags and attributes:
// // Every XML start and end tag consists of 6 32 bit words:
// // 0th word: 02011000 for startTag and 03011000 for endTag
// // 1st word: a flag?, like 38000000
// // 2nd word: Line of where this tag appeared in the original source file
// // 3rd word: FFFFFFFF ??
// // 4th word: StringIndex of NameSpace name, or FFFFFFFF for default NS
// // 5th word: StringIndex of Element Name
// // (Note: 01011000 in 0th word means end of XML document, endDocTag)
//
// // Start tags (not end tags) contain 3 more words:
// // 6th word: 14001400 meaning??
// // 7th word: Number of Attributes that follow this tag(follow word 8th)
// // 8th word: 00000000 meaning??
//
// // Attributes consist of 5 words:
// // 0th word: StringIndex of Attribute Name's Namespace, or FFFFFFFF
// // 1st word: StringIndex of Attribute Name
// // 2nd word: StringIndex of Attribute Value, or FFFFFFF if ResourceId
// // used
// // 3rd word: Flags?
// // 4th word: str ind of attr value again, or ResourceId of value
//
// // TMP, dump string table to tr for debugging
// // tr.addSelect("strings", null);
// // for (int ii=0; ii<numbStrings; ii++) {
// // // Length of string starts at StringTable plus offset in StrIndTable
// // String str = compXmlString(xml, sitOff, stOff, ii);
// // tr.add(String.valueOf(ii), str);
// // }
// // tr.parent();
//
// // Step through the XML tree element tags and attributes
// int off = xmlTagOff;
// int indent = 0;
// int startTagLineNo = -2;
// while (off < xml.length) {
// int tag0 = LEW(xml, off);
// // int tag1 = LEW(xml, off+1*4);
// int lineNo = LEW(xml, off + 2 * 4);
// // int tag3 = LEW(xml, off+3*4);
// int nameNsSi = LEW(xml, off + 4 * 4);
// int nameSi = LEW(xml, off + 5 * 4);
//
// if (tag0 == startTag) { // XML START TAG
// int tag6 = LEW(xml, off + 6 * 4); // Expected to be 14001400
// int numbAttrs = LEW(xml, off + 7 * 4); // Number of Attributes
// // to follow
// // int tag8 = LEW(xml, off+8*4); // Expected to be 00000000
// off += 9 * 4; // Skip over 6+3 words of startTag data
// String name = compXmlString(xml, sitOff, stOff, nameSi);
// // tr.addSelect(name, null);
// startTagLineNo = lineNo;
//
// // Look for the Attributes
// StringBuffer sb = new StringBuffer();
// for (int ii = 0; ii < numbAttrs; ii++) {
// int attrNameNsSi = LEW(xml, off); // AttrName Namespace Str
// // Ind, or FFFFFFFF
// int attrNameSi = LEW(xml, off + 1 * 4); // AttrName String
// // Index
// int attrValueSi = LEW(xml, off + 2 * 4); // AttrValue Str
// // Ind, or
// // FFFFFFFF
// int attrFlags = LEW(xml, off + 3 * 4);
// int attrResId = LEW(xml, off + 4 * 4); // AttrValue
// // ResourceId or dup
// // AttrValue StrInd
// off += 5 * 4; // Skip over the 5 words of an attribute
//
// String attrName = compXmlString(xml, sitOff, stOff,
// attrNameSi);
// String attrValue = attrValueSi != -1 ? compXmlString(xml,
// sitOff, stOff, attrValueSi) : "resourceID 0x"
// + Integer.toHexString(attrResId);
// sb.append(" " + attrName + "=\"" + attrValue + "\"");
// // tr.add(attrName, attrValue);
// }
// finalXML.append("<" + name + sb + ">");
// prtIndent(indent, "<" + name + sb + ">");
// indent++;
//
// } else if (tag0 == endTag) { // XML END TAG
// indent--;
// off += 6 * 4; // Skip over 6 words of endTag data
// String name = compXmlString(xml, sitOff, stOff, nameSi);
// finalXML.append("</" + name + ">");
// prtIndent(indent, "</" + name + "> (line " + startTagLineNo
// + "-" + lineNo + ")");
// // tr.parent(); // Step back up the NobTree
//
// } else if (tag0 == endDocTag) { // END OF XML DOC TAG
// break;
//
// } else {
// prt(" Unrecognized tag code '" + Integer.toHexString(tag0)
// + "' at offset " + off);
// break;
// }
// } // end of while loop scanning tags and attributes of XML tree
// //prt(" end at offset " + off);
// return finalXML.toString();
// } // end of decompressXML
//
// public static String compXmlString(byte[] xml, int sitOff, int stOff, int strInd) {
// if (strInd < 0)
// return null;
// int strOff = stOff + LEW(xml, sitOff + strInd * 4);
// return compXmlStringAt(xml, strOff);
// }
//
// public static String spaces = " ";
//
// public static void prtIndent(int indent, String str) {
// prt(spaces.substring(0, Math.min(indent * 2, spaces.length())) + str);
// }
//
// // compXmlStringAt -- Return the string stored in StringTable format at
// // offset strOff. This offset points to the 16 bit string length, which
// // is followed by that number of 16 bit (Unicode) chars.
// public static String compXmlStringAt(byte[] arr, int strOff) {
// int strLen = arr[strOff + 1] << 8 & 0xff00 | arr[strOff] & 0xff;
// byte[] chars = new byte[strLen];
// for (int ii = 0; ii < strLen; ii++) {
// chars[ii] = arr[strOff + 2 + ii * 2];
// }
// return new String(chars); // Hack, just use 8 byte chars
// } // end of compXmlStringAt
//
// // LEW -- Return value of a Little Endian 32 bit word from the byte array
// // at offset off.
// public static int LEW(byte[] arr, int off) {
// return ((arr[off + 3] << 24) & 0xff000000) |
// ((arr[off + 2] << 16) & 0xff0000) |
// ((arr[off + 1] << 8) & 0xff00) |
// (arr[off] & 0xFF);
// } // end of LEW
//
// public static Document loadXMLFromString(String xml) throws Exception {
// DocumentBuilderFactory docBuilderFactory = DocumentBuilderFactory.newInstance();
// DocumentBuilder docBuilder = docBuilderFactory.newDocumentBuilder();
// return docBuilder.parse(new InputSource(new StringReader(xml)));
// }
//
// public static String extract_dex_and_read_className(String filePath, String dexPath) throws IOException {
// ZipFile zip = null;
//
// zip = new ZipFile(filePath);
// ZipEntry androidManifest = zip.getEntry("AndroidManifest.xml");
// ZipEntry classesDex = zip.getEntry("classes.dex");
//
// // write dex file
// InputStream dexStream = zip.getInputStream(classesDex);
// try (OutputStream os = Files.newOutputStream(Paths.get(dexPath))) {
// byte[] buffer = new byte[1024];
// int len;
// while ((len = dexStream.read(buffer)) > 0) {
// os.write(buffer, 0, len);
// }
// }
//
// // read xml file
// InputStream is = zip.getInputStream(androidManifest);
// byte[] buf = new byte[1024000]; // 100 kb
// is.read(buf);
// is.close();
// zip.close();
//
// String xml = APKExtractor.decompressXML(buf);
// try {
// Document xmlDoc = loadXMLFromString(xml);
// String pkg = xmlDoc.getDocumentElement().getAttribute("package");
// NodeList nodes = xmlDoc.getElementsByTagName("meta-data");
// for (int i = 0; i < nodes.getLength(); i++) {
// NamedNodeMap attributes = nodes.item(i).getAttributes();
// System.out.println(attributes.getNamedItem("name").getNodeValue());
// if (attributes.getNamedItem("name").getNodeValue().equals("tachiyomi.extension.class"))
// return pkg + attributes.getNamedItem("value").getNodeValue();
// }
// } catch (Exception e) {
// e.printStackTrace();
// }
// return "";
// }
// }
@@ -0,0 +1,67 @@
package ir.armor.tachidesk.impl.util
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import okhttp3.Response
import okio.buffer
import okio.sink
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.nio.file.Files
import java.nio.file.Paths
object CachedImageResponse {
private fun pathToInputStream(path: String): InputStream {
return BufferedInputStream(FileInputStream(path))
}
private fun findFileNameStartingWith(directoryPath: String, fileName: String): String? {
File(directoryPath).listFiles().forEach { file ->
if (file.name.startsWith(fileName))
return "$directoryPath/${file.name}"
}
return null
}
/** fetch a cached image response, calls `fetcher` if cache fails */
suspend fun getCachedImageResponse(saveDir: String, fileName: String, fetcher: suspend () -> Response): Pair<InputStream, String> {
val cachedFile = findFileNameStartingWith(saveDir, fileName)
val filePath = "$saveDir/$fileName"
if (cachedFile != null) {
val fileType = cachedFile.substringAfter(filePath)
return Pair(
pathToInputStream(cachedFile),
"image/$fileType"
)
}
val response = fetcher()
if (response.code == 200) {
val contentType = response.headers["content-type"]!!
val fullPath = filePath + "." + contentType.substringAfter("image/")
Files.newOutputStream(Paths.get(fullPath)).use { output ->
response.body!!.source().use { input ->
output.sink().buffer().use {
it.writeAll(input)
it.flush()
}
}
}
return Pair(
pathToInputStream(fullPath),
contentType
)
} else {
throw Exception("request error! ${response.code}")
}
}
}
@@ -1,85 +0,0 @@
package ir.armor.tachidesk.impl
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import okhttp3.Response
import okio.BufferedSource
import okio.buffer
import okio.sink
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.io.OutputStream
import java.nio.file.Files
import java.nio.file.Paths
// fun writeStream(fileStream: InputStream, path: String) {
// Files.newOutputStream(Paths.get(path)).use { os ->
// val buffer = ByteArray(128 * 1024)
// var len: Int
// while (fileStream.read(buffer).also { len = it } > 0) {
// os.write(buffer, 0, len)
// }
// }
// }
fun pathToInputStream(path: String): InputStream {
return BufferedInputStream(FileInputStream(path))
}
fun findFileNameStartingWith(directoryPath: String, fileName: String): String? {
File(directoryPath).listFiles().forEach { file ->
if (file.name.startsWith(fileName))
return "$directoryPath/${file.name}"
}
return null
}
/**
* Saves the given source to an output stream and closes both resources.
*
* @param stream the stream where the source is copied.
*/
private fun BufferedSource.saveTo(stream: OutputStream) {
use { input ->
stream.sink().buffer().use {
it.writeAll(input)
it.flush()
}
}
}
fun getCachedImageResponse(saveDir: String, fileName: String, fetcher: () -> Response): Pair<InputStream, String> {
val cachedFile = findFileNameStartingWith(saveDir, fileName)
val filePath = "$saveDir/$fileName"
if (cachedFile != null) {
val fileType = cachedFile.substringAfter(filePath)
return Pair(
pathToInputStream(cachedFile),
"image/$fileType"
)
}
val response = fetcher()
if (response.code == 200) {
val contentType = response.headers["content-type"]!!
val fullPath = filePath + "." + contentType.substringAfter("image/")
Files.newOutputStream(Paths.get(fullPath)).use { os ->
response.body!!.source().saveTo(os)
}
return Pair(
pathToInputStream(fullPath),
contentType
)
} else {
throw Exception("request error! ${response.code}")
}
}
@@ -0,0 +1,57 @@
package ir.armor.tachidesk.impl.util
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.source.online.HttpSource
import ir.armor.tachidesk.impl.util.PackageTools.loadExtensionSources
import ir.armor.tachidesk.model.database.table.ExtensionTable
import ir.armor.tachidesk.model.database.table.SourceTable
import ir.armor.tachidesk.server.ApplicationDirs
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import java.util.concurrent.ConcurrentHashMap
object GetHttpSource {
private val sourceCache = ConcurrentHashMap<Long, HttpSource>()
private val applicationDirs by DI.global.instance<ApplicationDirs>()
fun getHttpSource(sourceId: Long): HttpSource {
val cachedResult: HttpSource? = sourceCache[sourceId]
if (cachedResult != null) {
return cachedResult
}
val sourceRecord = transaction {
SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()!!
}
val extensionId = sourceRecord[SourceTable.extension]
val extensionRecord = transaction {
ExtensionTable.select { ExtensionTable.id eq extensionId }.firstOrNull()!!
}
val apkName = extensionRecord[ExtensionTable.apkName]
val className = extensionRecord[ExtensionTable.classFQName]
val jarName = apkName.substringBefore(".apk") + ".jar"
val jarPath = "${applicationDirs.extensionsRoot}/$jarName"
when (val instance = loadExtensionSources(jarPath, className)) {
is Source -> listOf(instance)
is SourceFactory -> instance.createSources()
else -> throw Exception("Unknown source class type! ${instance.javaClass}")
}.forEach {
sourceCache[it.id] = it as HttpSource
}
return sourceCache[sourceId]!!
}
}
@@ -1,4 +1,4 @@
package ir.armor.tachidesk.impl package ir.armor.tachidesk.impl.util
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -13,6 +13,7 @@ import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.net.URLEncoder import java.net.URLEncoder
// TODO: finish MangaDex support
class MangaDexHelper(private val mangaDexSource: HttpSource) { class MangaDexHelper(private val mangaDexSource: HttpSource) {
private fun clientBuilder(): OkHttpClient = clientBuilder(0) private fun clientBuilder(): OkHttpClient = clientBuilder(0)
@@ -0,0 +1,48 @@
package ir.armor.tachidesk.impl.util
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import java.io.IOException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
suspend fun Call.await(): Response {
return suspendCancellableCoroutine { continuation ->
enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
if (!response.isSuccessful) {
continuation.resumeWithException(Exception("HTTP error ${response.code}"))
return
}
continuation.resume(response)
}
override fun onFailure(call: Call, e: IOException) {
// Don't bother with resuming the continuation if it is already cancelled.
if (continuation.isCancelled) return
continuation.resumeWithException(e)
}
}
)
continuation.invokeOnCancellation {
try {
cancel()
} catch (ex: Throwable) {
// Ignore cancel exception
}
}
}
}
@@ -0,0 +1,141 @@
package ir.armor.tachidesk.impl.util
import android.content.pm.PackageInfo
import android.content.pm.Signature
import android.os.Bundle
import com.googlecode.d2j.dex.Dex2jar
import com.googlecode.d2j.reader.MultiDexFileReader
import com.googlecode.dex2jar.tools.BaksmaliBaseDexExceptionHandler
import eu.kanade.tachiyomi.util.lang.Hash
import ir.armor.tachidesk.server.ApplicationDirs
import mu.KotlinLogging
import net.dongliu.apk.parser.ApkFile
import net.dongliu.apk.parser.ApkParsers
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import org.w3c.dom.Element
import org.w3c.dom.Node
import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList
import xyz.nulldev.androidcompat.pm.toPackageInfo
import java.io.File
import java.net.URL
import java.net.URLClassLoader
import java.nio.file.Files
import java.nio.file.Path
import javax.xml.parsers.DocumentBuilderFactory
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
object PackageTools {
private val logger = KotlinLogging.logger {}
private val applicationDirs by DI.global.instance<ApplicationDirs>()
const val EXTENSION_FEATURE = "tachiyomi.extension"
const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory"
const val METADATA_NSFW = "tachiyomi.extension.nsfw"
const val LIB_VERSION_MIN = 1.2
const val LIB_VERSION_MAX = 1.2
private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" // inorichi's key
private const val unofficialSignature = "64feb21075ba97ebc9cc981243645b331595c111cef1b0d084236a0403b00581" // ArMor's key
var trustedSignatures = mutableSetOf<String>() + officialSignature + unofficialSignature
/**
* Convert dex to jar, a wrapper for the dex2jar library
*/
fun dex2jar(dexFile: String, jarFile: String, fileNameWithoutType: String) {
// adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine
// source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java
val jarFilePath = File(jarFile).toPath()
val reader = MultiDexFileReader.open(Files.readAllBytes(File(dexFile).toPath()))
val handler = BaksmaliBaseDexExceptionHandler()
Dex2jar
.from(reader)
.withExceptionHandler(handler)
.reUseReg(false)
.topoLogicalSort()
.skipDebug(true)
.optimizeSynchronized(false)
.printIR(false)
.noCode(false)
.skipExceptions(false)
.to(jarFilePath)
if (handler.hasException()) {
val errorFile: Path = File(applicationDirs.extensionsRoot).toPath().resolve("$fileNameWithoutType-error.txt")
logger.error(
"Detail Error Information in File $errorFile\n" +
"Please report this file to one of following link if possible (any one).\n" +
" https://sourceforge.net/p/dex2jar/tickets/\n" +
" https://bitbucket.org/pxb1988/dex2jar/issues\n" +
" https://github.com/pxb1988/dex2jar/issues\n" +
" dex2jar@googlegroups.com"
)
handler.dump(errorFile, emptyArray<String>())
}
}
/** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */
fun getPackageInfo(apkFilePath: String): PackageInfo {
val apk = File(apkFilePath)
return ApkParsers.getMetaInfo(apk).toPackageInfo(apk).apply {
val parsed = ApkFile(apk)
val dbFactory = DocumentBuilderFactory.newInstance()
val dBuilder = dbFactory.newDocumentBuilder()
val doc = parsed.manifestXml.byteInputStream().use {
dBuilder.parse(it)
}
logger.debug(parsed.manifestXml)
applicationInfo.metaData = Bundle().apply {
val appTag = doc.getElementsByTagName("application").item(0)
appTag?.childNodes?.toList()?.filter {
it.nodeType == Node.ELEMENT_NODE
}?.map {
it as Element
}?.filter {
it.tagName == "meta-data"
}?.map {
putString(
it.attributes.getNamedItem("android:name").nodeValue,
it.attributes.getNamedItem("android:value").nodeValue
)
}
}
signatures = (
parsed.apkSingers.flatMap { it.certificateMetas }
/*+ parsed.apkV2Singers.flatMap { it.certificateMetas }*/
) // Blocked by: https://github.com/hsiafan/apk-parser/issues/72
.map { Signature(it.data) }.toTypedArray()
}
}
fun getSignatureHash(pkgInfo: PackageInfo): String? {
val signatures = pkgInfo.signatures
return if (signatures != null && signatures.isNotEmpty()) {
Hash.sha256(signatures.first().toByteArray())
} else {
null
}
}
/**
* loads the extension main class called $className from the jar located at $jarPath
* It may return an instance of HttpSource or SourceFactory depending on the extension.
*/
fun loadExtensionSources(jarPath: String, className: String): Any {
val classLoader = URLClassLoader(arrayOf<URL>(URL("file:$jarPath")))
val classToLoad = Class.forName(className, false, classLoader)
return classToLoad.getDeclaredConstructor().newInstance()
}
}
@@ -0,0 +1,62 @@
package ir.armor.tachidesk.impl.util
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import rx.Observable
import rx.Subscriber
import rx.Subscription
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
// source: https://github.com/jobobby04/TachiyomiSY/blob/9320221a4e8b118ef68deb60d8c4c32bcbb9e06f/app/src/main/java/eu/kanade/tachiyomi/util/lang/RxCoroutineBridge.kt
/*
* Util functions for bridging RxJava and coroutines. Taken from TachiyomiEH/SY.
*/
suspend fun <T> Observable<T>.awaitSingle(): T = single().awaitOne()
private suspend fun <T> Observable<T>.awaitOne(): T = suspendCancellableCoroutine { cont ->
cont.unsubscribeOnCancellation(
subscribe(
object : Subscriber<T>() {
override fun onStart() {
request(1)
}
override fun onNext(t: T) {
cont.resume(t)
}
override fun onCompleted() {
if (cont.isActive) cont.resumeWithException(
IllegalStateException(
"Should have invoked onNext"
)
)
}
override fun onError(e: Throwable) {
/*
* Rx1 observable throws NoSuchElementException if cancellation happened before
* element emission. To mitigate this we try to atomically resume continuation with exception:
* if resume failed, then we know that continuation successfully cancelled itself
*/
val token = cont.tryResumeWithException(e)
if (token != null) {
cont.completeResume(token)
}
}
}
)
)
}
private fun <T> CancellableContinuation<T>.unsubscribeOnCancellation(sub: Subscription) =
invokeOnCancellation { sub.unsubscribe() }
@@ -0,0 +1,32 @@
package ir.armor.tachidesk.model.database
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import ir.armor.tachidesk.model.database.migration.lib.loadMigrationsFrom
import ir.armor.tachidesk.model.database.migration.lib.runMigrations
import ir.armor.tachidesk.server.ApplicationDirs
import org.jetbrains.exposed.sql.Database
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
object DBMangaer {
val db by lazy {
val applicationDirs by DI.global.instance<ApplicationDirs>()
Database.connect("jdbc:h2:${applicationDirs.dataRoot}/database", "org.h2.Driver")
}
}
fun databaseUp() {
// must mention db object so the lazy block executes
val db = DBMangaer.db
db.useNestedTransactions = true
val migrations = loadMigrationsFrom("ir.armor.tachidesk.model.database.migration")
runMigrations(migrations)
}
@@ -0,0 +1,117 @@
package ir.armor.tachidesk.model.database.migration
import eu.kanade.tachiyomi.source.model.SManga
import ir.armor.tachidesk.model.database.migration.lib.Migration
import org.jetbrains.exposed.dao.id.IdTable
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
class M0001_Initial : Migration() {
private object ExtensionTable : IntIdTable() {
val apkName = varchar("apk_name", 1024)
// default is the local source icon from tachiyomi
val iconUrl = varchar("icon_url", 2048)
.default("https://raw.githubusercontent.com/tachiyomiorg/tachiyomi/64ba127e7d43b1d7e6d58a6f5c9b2bd5fe0543f7/app/src/main/res/mipmap-xxxhdpi/ic_local_source.webp")
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", 10)
val isNsfw = bool("is_nsfw")
val isInstalled = bool("is_installed").default(false)
val hasUpdate = bool("has_update").default(false)
val isObsolete = bool("is_obsolete").default(false)
val classFQName = varchar("class_name", 1024).default("") // fully qualified name
}
private object SourceTable : IdTable<Long>() {
override val id = long("id").entityId()
val name = varchar("name", 128)
val lang = varchar("lang", 10)
val extension = reference("extension", ExtensionTable)
val partOfFactorySource = bool("part_of_factory_source").default(false)
}
private object MangaTable : IntIdTable() {
val url = varchar("url", 2048)
val title = varchar("title", 512)
val initialized = bool("initialized").default(false)
val artist = varchar("artist", 64).nullable()
val author = varchar("author", 64).nullable()
val description = varchar("description", 4096).nullable()
val genre = varchar("genre", 1024).nullable()
// val status = enumeration("status", MangaStatus::class).default(MangaStatus.UNKNOWN)
val status = integer("status").default(SManga.UNKNOWN)
val thumbnail_url = varchar("thumbnail_url", 2048).nullable()
val inLibrary = bool("in_library").default(false)
val defaultCategory = bool("default_category").default(true)
// source is used by some ancestor of IntIdTable
val sourceReference = long("source")
}
private object ChapterTable : IntIdTable() {
val url = varchar("url", 2048)
val name = varchar("name", 512)
val date_upload = long("date_upload").default(0)
val chapter_number = float("chapter_number").default(-1f)
val scanlator = varchar("scanlator", 128).nullable()
val isRead = bool("read").default(false)
val isBookmarked = bool("bookmark").default(false)
val lastPageRead = integer("last_page_read").default(0)
val chapterIndex = integer("number_in_list")
val manga = reference("manga", MangaTable)
}
private object PageTable : IntIdTable() {
val index = integer("index")
val url = varchar("url", 2048)
val imageUrl = varchar("imageUrl", 2048).nullable()
val chapter = reference("chapter", ChapterTable)
}
private object CategoryTable : IntIdTable() {
val name = varchar("name", 64)
val isLanding = bool("is_landing").default(false)
val order = integer("order").default(0)
}
private object CategoryMangaTable : IntIdTable() {
val category = reference("category", ir.armor.tachidesk.model.database.table.CategoryTable)
val manga = reference("manga", ir.armor.tachidesk.model.database.table.MangaTable)
}
override fun run() {
transaction {
SchemaUtils.create(
ExtensionTable,
ExtensionTable,
SourceTable,
MangaTable,
ChapterTable,
PageTable,
CategoryTable,
CategoryMangaTable,
)
}
}
}
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Andreas Mausch
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,25 @@
package ir.armor.tachidesk.model.database.migration.lib
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
// originally licenced under MIT by Andreas Mausch, Changes are licenced under Mozilla Public License, v. 2.0.
// adopted from: https://gitlab.com/andreas-mausch/exposed-migrations/-/tree/4bf853c18a24d0170eda896ddbb899cb01233595
abstract class Migration {
val name: String
val version: Int
init {
val groups = Regex("^M(\\d+)_(.*)$").matchEntire(this::class.simpleName!!)?.groupValues
?: throw IllegalArgumentException("Migration class name doesn't match convention")
version = groups[1].toInt()
name = groups[2]
}
abstract fun run()
}
@@ -0,0 +1,37 @@
package ir.armor.tachidesk.model.database.migration.lib
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
// originally licenced under MIT by Andreas Mausch, Changes are licenced under Mozilla Public License, v. 2.0.
// adopted from: https://gitlab.com/andreas-mausch/exposed-migrations/-/tree/4bf853c18a24d0170eda896ddbb899cb01233595
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.IdTable
import org.jetbrains.exposed.sql.`java-time`.timestamp
object MigrationsTable : IdTable<Int>() {
override val id = integer("version").entityId()
override val primaryKey = PrimaryKey(id)
val name = varchar("name", length = 400)
val executedAt = timestamp("executed_at")
init {
index(true, name)
}
}
class MigrationEntity(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<MigrationEntity>(MigrationsTable)
var version by MigrationsTable.id
var name by MigrationsTable.name
var executedAt by MigrationsTable.executedAt
}
@@ -0,0 +1,97 @@
package ir.armor.tachidesk.model.database.migration.lib
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
// originally licenced under MIT by Andreas Mausch, Changes are licenced under Mozilla Public License, v. 2.0.
// adopted from: https://gitlab.com/andreas-mausch/exposed-migrations/-/tree/4bf853c18a24d0170eda896ddbb899cb01233595
import com.google.common.reflect.ClassPath
import mu.KotlinLogging
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils.create
import org.jetbrains.exposed.sql.exists
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.jetbrains.exposed.sql.transactions.transaction
import java.time.Clock
import java.time.Instant.now
private val logger = KotlinLogging.logger {}
fun runMigrations(migrations: List<Migration>, database: Database = TransactionManager.defaultDatabase!!, clock: Clock = Clock.systemUTC()) {
checkVersions(migrations)
logger.info { "Running migrations on database ${database.url}" }
val latestVersion = transaction(database) {
createTableIfNotExists(database)
MigrationEntity.all().maxByOrNull { it.version }?.version?.value
}
logger.info { "Database version before migrations: $latestVersion" }
migrations
.sortedBy { it.version }
.filter { shouldRun(latestVersion, it) }
.forEach {
logger.info { "Running migration version ${it.version}: ${it.name}" }
transaction(database) {
it.run()
MigrationEntity.new {
version = EntityID(it.version, MigrationsTable)
name = it.name
executedAt = now(clock)
}
}
}
logger.info { "Migrations finished successfully" }
}
fun loadMigrationsFrom(classPath: String): List<Migration> {
return ClassPath.from(Thread.currentThread().contextClassLoader)
.getTopLevelClasses(classPath)
.map {
logger.debug("found Migration class ${it.name}")
val clazz = it.load().getDeclaredConstructor().newInstance()
if (clazz is Migration)
clazz
else
throw RuntimeException("found a class that's not a Migration")
}
}
private fun checkVersions(migrations: List<Migration>) {
val sorted = migrations.map { it.version }.sorted()
if ((1..migrations.size).toList() != sorted) {
throw IllegalStateException("List of migrations version is not consecutive: $sorted")
}
}
private fun createTableIfNotExists(database: Database) {
if (MigrationsTable.exists()) {
return
}
val tableNames = database.dialect.allTablesNames()
when (tableNames.isEmpty()) {
true -> {
logger.info { "Empty database found, creating table for migrations" }
create(MigrationsTable)
}
false -> throw IllegalStateException("Tried to run migrations against a non-empty database without a Migrations table. This is not supported.")
}
}
private fun shouldRun(latestVersion: Int?, migration: Migration): Boolean {
val run = latestVersion?.let { migration.version > it } ?: true
if (!run) {
logger.debug { "Skipping migration version ${migration.version}: ${migration.name}" }
}
return run
}
@@ -1,4 +1,4 @@
package ir.armor.tachidesk.database.table package ir.armor.tachidesk.model.database.table
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -1,4 +1,4 @@
package ir.armor.tachidesk.database.table package ir.armor.tachidesk.model.database.table
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -7,7 +7,7 @@ package ir.armor.tachidesk.database.table
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import ir.armor.tachidesk.database.dataclass.CategoryDataClass import ir.armor.tachidesk.model.dataclass.CategoryDataClass
import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.ResultRow
@@ -1,4 +1,4 @@
package ir.armor.tachidesk.database.table package ir.armor.tachidesk.model.database.table
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -16,6 +16,10 @@ object ChapterTable : IntIdTable() {
val chapter_number = float("chapter_number").default(-1f) val chapter_number = float("chapter_number").default(-1f)
val scanlator = varchar("scanlator", 128).nullable() val scanlator = varchar("scanlator", 128).nullable()
val isRead = bool("read").default(false)
val isBookmarked = bool("bookmark").default(false)
val lastPageRead = integer("last_page_read").default(0)
val chapterIndex = integer("number_in_list") val chapterIndex = integer("number_in_list")
val manga = reference("manga", MangaTable) val manga = reference("manga", MangaTable)
@@ -1,4 +1,4 @@
package ir.armor.tachidesk.database.table package ir.armor.tachidesk.model.database.table
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -10,15 +10,22 @@ package ir.armor.tachidesk.database.table
import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.dao.id.IntIdTable
object ExtensionTable : IntIdTable() { object ExtensionTable : IntIdTable() {
val apkName = varchar("apk_name", 1024)
// default is the local source icon from tachiyomi
val iconUrl = varchar("icon_url", 2048)
.default("https://raw.githubusercontent.com/tachiyomiorg/tachiyomi/64ba127e7d43b1d7e6d58a6f5c9b2bd5fe0543f7/app/src/main/res/mipmap-xxxhdpi/ic_local_source.webp")
val name = varchar("name", 128) val name = varchar("name", 128)
val pkgName = varchar("pkg_name", 128) val pkgName = varchar("pkg_name", 128)
val versionName = varchar("version_name", 16) val versionName = varchar("version_name", 16)
val versionCode = integer("version_code") val versionCode = integer("version_code")
val lang = varchar("lang", 10) val lang = varchar("lang", 10)
val isNsfw = bool("is_nsfw") val isNsfw = bool("is_nsfw")
val apkName = varchar("apk_name", 1024)
val iconUrl = varchar("icon_url", 2048)
val installed = bool("installed").default(false) val isInstalled = bool("is_installed").default(false)
val classFQName = varchar("class_name", 256).default("") // fully qualified name val hasUpdate = bool("has_update").default(false)
val isObsolete = bool("is_obsolete").default(false)
val classFQName = varchar("class_name", 1024).default("") // fully qualified name
} }
@@ -1,4 +1,4 @@
package ir.armor.tachidesk.database.table package ir.armor.tachidesk.model.database.table
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -8,8 +8,8 @@ package ir.armor.tachidesk.database.table
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import ir.armor.tachidesk.database.dataclass.MangaDataClass import ir.armor.tachidesk.impl.MangaList.proxyThumbnailUrl
import ir.armor.tachidesk.impl.proxyThumbnailUrl import ir.armor.tachidesk.model.dataclass.MangaDataClass
import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.ResultRow
@@ -37,7 +37,7 @@ object MangaTable : IntIdTable() {
fun MangaTable.toDataClass(mangaEntry: ResultRow) = fun MangaTable.toDataClass(mangaEntry: ResultRow) =
MangaDataClass( MangaDataClass(
mangaEntry[MangaTable.id].value, mangaEntry[MangaTable.id].value,
mangaEntry[sourceReference].toString(), mangaEntry[MangaTable.sourceReference].toString(),
mangaEntry[MangaTable.url], mangaEntry[MangaTable.url],
mangaEntry[MangaTable.title], mangaEntry[MangaTable.title],
@@ -1,4 +1,4 @@
package ir.armor.tachidesk.database.table package ir.armor.tachidesk.model.database.table
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -1,4 +1,4 @@
package ir.armor.tachidesk.database.table package ir.armor.tachidesk.model.database.table
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -15,5 +15,4 @@ object SourceTable : IdTable<Long>() {
val lang = varchar("lang", 10) val lang = varchar("lang", 10)
val extension = reference("extension", ExtensionTable) val extension = reference("extension", ExtensionTable)
val partOfFactorySource = bool("part_of_factory_source").default(false) val partOfFactorySource = bool("part_of_factory_source").default(false)
val positionInFactorySource = integer("position_in_factory_source").nullable()
} }
@@ -1,4 +1,4 @@
package ir.armor.tachidesk.database.dataclass package ir.armor.tachidesk.model.dataclass
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -1,4 +1,4 @@
package ir.armor.tachidesk.database.dataclass package ir.armor.tachidesk.model.dataclass
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -8,14 +8,19 @@ package ir.armor.tachidesk.database.dataclass
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
data class ChapterDataClass( data class ChapterDataClass(
val id: Int,
val url: String, val url: String,
val name: String, val name: String,
val date_upload: Long, val date_upload: Long,
val chapter_number: Float, val chapter_number: Float,
val scanlator: String?, val scanlator: String?,
val mangaId: Int, val mangaId: Int,
val chapterIndex: Int,
val chapterCount: Int, /** this chapter's index */
val chapterIndex: Int? = null,
/** total chapter count, used to calculate if there's a next and prev chapter */
val chapterCount: Int? = null,
/** used to construct pages in the front-end */
val pageCount: Int? = null, val pageCount: Int? = null,
) )
@@ -1,4 +1,4 @@
package ir.armor.tachidesk.database.dataclass package ir.armor.tachidesk.model.dataclass
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -8,14 +8,17 @@ package ir.armor.tachidesk.database.dataclass
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
data class ExtensionDataClass( data class ExtensionDataClass(
val apkName: String,
val iconUrl: String,
val name: String, val name: String,
val pkgName: String, val pkgName: String,
val versionName: String, val versionName: String,
val versionCode: Int, val versionCode: Int,
val lang: String, val lang: String,
val isNsfw: Boolean, val isNsfw: Boolean,
val apkName: String,
val iconUrl: String,
val installed: Boolean, val installed: Boolean,
val classFQName: String, val hasUpdate: Boolean,
val obsolete: Boolean,
) )
@@ -1,4 +1,4 @@
package ir.armor.tachidesk.database.dataclass package ir.armor.tachidesk.model.dataclass
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -7,7 +7,7 @@ package ir.armor.tachidesk.database.dataclass
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import ir.armor.tachidesk.database.table.MangaStatus import ir.armor.tachidesk.model.database.table.MangaStatus
data class MangaDataClass( data class MangaDataClass(
val id: Int, val id: Int,
@@ -1,4 +1,4 @@
package ir.armor.tachidesk.database.dataclass package ir.armor.tachidesk.model.dataclass
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -1,4 +1,4 @@
package ir.armor.tachidesk.database.dataclass package ir.armor.tachidesk.model.dataclass
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -2,35 +2,49 @@ package ir.armor.tachidesk.server
import io.javalin.Javalin import io.javalin.Javalin
import ir.armor.tachidesk.Main import ir.armor.tachidesk.Main
import ir.armor.tachidesk.impl.addMangaToCategory import ir.armor.tachidesk.impl.Category.createCategory
import ir.armor.tachidesk.impl.addMangaToLibrary import ir.armor.tachidesk.impl.Category.getCategoryList
import ir.armor.tachidesk.impl.createCategory import ir.armor.tachidesk.impl.Category.removeCategory
import ir.armor.tachidesk.impl.getCategoryList import ir.armor.tachidesk.impl.Category.reorderCategory
import ir.armor.tachidesk.impl.getCategoryMangaList import ir.armor.tachidesk.impl.Category.updateCategory
import ir.armor.tachidesk.impl.getChapter import ir.armor.tachidesk.impl.CategoryManga.addMangaToCategory
import ir.armor.tachidesk.impl.getChapterList import ir.armor.tachidesk.impl.CategoryManga.getCategoryMangaList
import ir.armor.tachidesk.impl.getExtensionIcon import ir.armor.tachidesk.impl.CategoryManga.getMangaCategories
import ir.armor.tachidesk.impl.getExtensionList import ir.armor.tachidesk.impl.CategoryManga.removeMangaFromCategory
import ir.armor.tachidesk.impl.getLibraryMangas import ir.armor.tachidesk.impl.Chapter.getChapter
import ir.armor.tachidesk.impl.getManga import ir.armor.tachidesk.impl.Chapter.getChapterList
import ir.armor.tachidesk.impl.getMangaCategories import ir.armor.tachidesk.impl.Extension.getExtensionIcon
import ir.armor.tachidesk.impl.getMangaList import ir.armor.tachidesk.impl.Extension.installExtension
import ir.armor.tachidesk.impl.getPageImage import ir.armor.tachidesk.impl.Extension.uninstallExtension
import ir.armor.tachidesk.impl.getSource import ir.armor.tachidesk.impl.Extension.updateExtension
import ir.armor.tachidesk.impl.getSourceList import ir.armor.tachidesk.impl.ExtensionsList.getExtensionList
import ir.armor.tachidesk.impl.getThumbnail import ir.armor.tachidesk.impl.Library.addMangaToLibrary
import ir.armor.tachidesk.impl.installAPK import ir.armor.tachidesk.impl.Library.getLibraryMangas
import ir.armor.tachidesk.impl.removeCategory import ir.armor.tachidesk.impl.Library.removeMangaFromLibrary
import ir.armor.tachidesk.impl.removeExtension import ir.armor.tachidesk.impl.Manga.getManga
import ir.armor.tachidesk.impl.removeMangaFromCategory import ir.armor.tachidesk.impl.Manga.getMangaThumbnail
import ir.armor.tachidesk.impl.removeMangaFromLibrary import ir.armor.tachidesk.impl.MangaList.getMangaList
import ir.armor.tachidesk.impl.reorderCategory import ir.armor.tachidesk.impl.Page.getPageImage
import ir.armor.tachidesk.impl.sourceFilters import ir.armor.tachidesk.impl.Search.sourceFilters
import ir.armor.tachidesk.impl.sourceGlobalSearch import ir.armor.tachidesk.impl.Search.sourceGlobalSearch
import ir.armor.tachidesk.impl.sourceSearch import ir.armor.tachidesk.impl.Search.sourceSearch
import ir.armor.tachidesk.impl.updateCategory import ir.armor.tachidesk.impl.Source.getSource
import ir.armor.tachidesk.impl.Source.getSourceList
import ir.armor.tachidesk.impl.backup.BackupFlags
import ir.armor.tachidesk.impl.backup.legacy.LegacyBackupExport.createLegacyBackup
import ir.armor.tachidesk.impl.backup.legacy.LegacyBackupImport.restoreLegacyBackup
import ir.armor.tachidesk.server.internal.About.getAbout
import ir.armor.tachidesk.server.util.openInBrowser import ir.armor.tachidesk.server.util.openInBrowser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.future.future
import mu.KotlinLogging import mu.KotlinLogging
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.concurrent.CompletableFuture
import kotlin.concurrent.thread
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -39,222 +53,355 @@ import mu.KotlinLogging
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
private val logger = KotlinLogging.logger {} object JavalinSetup {
private val logger = KotlinLogging.logger {}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun javalinSetup() { private fun <T> future(block: suspend CoroutineScope.() -> T): CompletableFuture<T> {
var hasWebUiBundled = false return scope.future(block = block)
val app = Javalin.create { config ->
try {
Main::class.java.getResource("/react/index.html")
hasWebUiBundled = true
config.addStaticFiles("/react")
config.addSinglePageRoot("/", "/react/index.html")
} catch (e: RuntimeException) {
logger.warn("react build files are missing.")
hasWebUiBundled = false
}
config.enableCorsForAllOrigins()
}.start(serverConfig.ip, serverConfig.port)
if (hasWebUiBundled && serverConfig.initialOpenInBrowserEnabled) {
openInBrowser()
} }
app.exception(NullPointerException::class.java) { e, ctx -> fun javalinSetup() {
logger.error("NullPointerException while handling the request", e) var hasWebUiBundled = false
ctx.status(404)
}
app.get("/api/v1/extension/list") { ctx -> val app = Javalin.create { config ->
ctx.json(getExtensionList()) try {
} // if the bellow line throws an exception then webUI is not bundled
Main::class.java.getResource("/react/index.html")
app.get("/api/v1/extension/install/:apkName") { ctx -> // no exception so we can tell javalin to serve webUI
val apkName = ctx.pathParam("apkName") hasWebUiBundled = true
config.addStaticFiles("/react")
config.addSinglePageRoot("/", "/react/index.html")
} catch (e: RuntimeException) {
logger.warn("react build files are missing.")
hasWebUiBundled = false
}
config.enableCorsForAllOrigins()
}.start(serverConfig.ip, serverConfig.port)
ctx.status( // when JVM is prompted to shutdown, stop javalin gracefully
installAPK(apkName) Runtime.getRuntime().addShutdownHook(
thread(start = false) {
app.stop()
}
) )
}
app.get("/api/v1/extension/uninstall/:apkName") { ctx -> if (hasWebUiBundled && serverConfig.initialOpenInBrowserEnabled) {
val apkName = ctx.pathParam("apkName") openInBrowser()
}
removeExtension(apkName) app.exception(NullPointerException::class.java) { e, ctx ->
ctx.status(200) logger.error("NullPointerException while handling the request", e)
} ctx.status(404)
}
// icon for extension named `apkName` app.exception(IOException::class.java) { e, ctx ->
app.get("/api/v1/extension/icon/:apkName") { ctx -> logger.error("IOException while handling the request", e)
val apkName = ctx.pathParam("apkName") ctx.status(500)
val result = getExtensionIcon(apkName) ctx.result(e.message ?: "Internal Server Error")
}
ctx.result(result.first) app.get("/api/v1/extension/list") { ctx ->
ctx.header("content-type", result.second) ctx.json(
} future {
getExtensionList()
}
)
}
// list of sources app.get("/api/v1/extension/install/:pkgName") { ctx ->
app.get("/api/v1/source/list") { ctx -> val pkgName = ctx.pathParam("pkgName")
ctx.json(getSourceList())
}
// fetch source with id `sourceId` ctx.json(
app.get("/api/v1/source/:sourceId") { ctx -> future {
val sourceId = ctx.pathParam("sourceId").toLong() installExtension(pkgName)
ctx.json(getSource(sourceId)) }
} )
}
// popular mangas from source with id `sourceId` app.get("/api/v1/extension/update/:pkgName") { ctx ->
app.get("/api/v1/source/:sourceId/popular/:pageNum") { ctx -> val pkgName = ctx.pathParam("pkgName")
val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(getMangaList(sourceId, pageNum, popular = true))
}
// latest mangas from source with id `sourceId` ctx.json(
app.get("/api/v1/source/:sourceId/latest/:pageNum") { ctx -> future {
val sourceId = ctx.pathParam("sourceId").toLong() updateExtension(pkgName)
val pageNum = ctx.pathParam("pageNum").toInt() }
ctx.json(getMangaList(sourceId, pageNum, popular = false)) )
} }
// get manga info app.get("/api/v1/extension/uninstall/:pkgName") { ctx ->
app.get("/api/v1/manga/:mangaId/") { ctx -> val pkgName = ctx.pathParam("pkgName")
val mangaId = ctx.pathParam("mangaId").toInt()
ctx.json(getManga(mangaId))
}
// manga thumbnail uninstallExtension(pkgName)
app.get("api/v1/manga/:mangaId/thumbnail") { ctx -> ctx.status(200)
val mangaId = ctx.pathParam("mangaId").toInt() }
val result = getThumbnail(mangaId)
ctx.result(result.first) // icon for extension named `apkName`
ctx.header("content-type", result.second) app.get("/api/v1/extension/icon/:apkName") { ctx ->
} val apkName = ctx.pathParam("apkName")
// adds the manga to library ctx.result(
app.get("api/v1/manga/:mangaId/library") { ctx -> future { getExtensionIcon(apkName) }
val mangaId = ctx.pathParam("mangaId").toInt() .thenApply {
addMangaToLibrary(mangaId) ctx.header("content-type", it.second)
ctx.status(200) it.first
} }
)
}
// removes the manga from the library // list of sources
app.delete("api/v1/manga/:mangaId/library") { ctx -> app.get("/api/v1/source/list") { ctx ->
val mangaId = ctx.pathParam("mangaId").toInt() ctx.json(getSourceList())
removeMangaFromLibrary(mangaId) }
ctx.status(200)
}
// list manga's categories // fetch source with id `sourceId`
app.get("api/v1/manga/:mangaId/category/") { ctx -> app.get("/api/v1/source/:sourceId") { ctx ->
val mangaId = ctx.pathParam("mangaId").toInt() val sourceId = ctx.pathParam("sourceId").toLong()
ctx.json(getMangaCategories(mangaId)) ctx.json(getSource(sourceId))
} }
// adds the manga to category // popular mangas from source with id `sourceId`
app.get("api/v1/manga/:mangaId/category/:categoryId") { ctx -> app.get("/api/v1/source/:sourceId/popular/:pageNum") { ctx ->
val mangaId = ctx.pathParam("mangaId").toInt() val sourceId = ctx.pathParam("sourceId").toLong()
val categoryId = ctx.pathParam("categoryId").toInt() val pageNum = ctx.pathParam("pageNum").toInt()
addMangaToCategory(mangaId, categoryId) ctx.json(
ctx.status(200) future {
} getMangaList(sourceId, pageNum, popular = true)
}
)
}
// removes the manga from the category // latest mangas from source with id `sourceId`
app.delete("api/v1/manga/:mangaId/category/:categoryId") { ctx -> app.get("/api/v1/source/:sourceId/latest/:pageNum") { ctx ->
val mangaId = ctx.pathParam("mangaId").toInt() val sourceId = ctx.pathParam("sourceId").toLong()
val categoryId = ctx.pathParam("categoryId").toInt() val pageNum = ctx.pathParam("pageNum").toInt()
removeMangaFromCategory(mangaId, categoryId) ctx.json(
ctx.status(200) future {
} getMangaList(sourceId, pageNum, popular = false)
}
)
}
app.get("/api/v1/manga/:mangaId/chapters") { ctx -> // get manga info
val mangaId = ctx.pathParam("mangaId").toInt() app.get("/api/v1/manga/:mangaId/") { ctx ->
ctx.json(getChapterList(mangaId)) val mangaId = ctx.pathParam("mangaId").toInt()
} ctx.json(
future {
getManga(mangaId)
}
)
}
app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex") { ctx -> // manga thumbnail
val chapterIndex = ctx.pathParam("chapterIndex").toInt() app.get("api/v1/manga/:mangaId/thumbnail") { ctx ->
val mangaId = ctx.pathParam("mangaId").toInt() val mangaId = ctx.pathParam("mangaId").toInt()
ctx.json(getChapter(chapterIndex, mangaId))
}
app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex/page/:index") { ctx -> ctx.result(
val mangaId = ctx.pathParam("mangaId").toInt() future { getMangaThumbnail(mangaId) }
val chapterIndex = ctx.pathParam("chapterIndex").toInt() .thenApply {
val index = ctx.pathParam("index").toInt() ctx.header("content-type", it.second)
val result = getPageImage(mangaId, chapterIndex, index) it.first
}
)
}
ctx.result(result.first) // adds the manga to library
ctx.header("content-type", result.second) app.get("api/v1/manga/:mangaId/library") { ctx ->
} val mangaId = ctx.pathParam("mangaId").toInt()
// global search ctx.result(
app.get("/api/v1/search/:searchTerm") { ctx -> future { addMangaToLibrary(mangaId) }
val searchTerm = ctx.pathParam("searchTerm") )
ctx.json(sourceGlobalSearch(searchTerm)) }
}
// single source search // removes the manga from the library
app.get("/api/v1/source/:sourceId/search/:searchTerm/:pageNum") { ctx -> app.delete("api/v1/manga/:mangaId/library") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong() val mangaId = ctx.pathParam("mangaId").toInt()
val searchTerm = ctx.pathParam("searchTerm")
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(sourceSearch(sourceId, searchTerm, pageNum))
}
// source filter list ctx.result(
app.get("/api/v1/source/:sourceId/filters/") { ctx -> future { removeMangaFromLibrary(mangaId) }
val sourceId = ctx.pathParam("sourceId").toLong() )
ctx.json(sourceFilters(sourceId)) }
}
// lists mangas that have no category assigned // list manga's categories
app.get("/api/v1/library/") { ctx -> app.get("api/v1/manga/:mangaId/category/") { ctx ->
ctx.json(getLibraryMangas()) val mangaId = ctx.pathParam("mangaId").toInt()
} ctx.json(getMangaCategories(mangaId))
}
// category list // adds the manga to category
app.get("/api/v1/category/") { ctx -> app.get("api/v1/manga/:mangaId/category/:categoryId") { ctx ->
ctx.json(getCategoryList()) val mangaId = ctx.pathParam("mangaId").toInt()
} val categoryId = ctx.pathParam("categoryId").toInt()
addMangaToCategory(mangaId, categoryId)
ctx.status(200)
}
// category create // removes the manga from the category
app.post("/api/v1/category/") { ctx -> app.delete("api/v1/manga/:mangaId/category/:categoryId") { ctx ->
val name = ctx.formParam("name")!! val mangaId = ctx.pathParam("mangaId").toInt()
createCategory(name) val categoryId = ctx.pathParam("categoryId").toInt()
ctx.status(200) removeMangaFromCategory(mangaId, categoryId)
} ctx.status(200)
}
// category modification // get chapter list when showing a manga
app.patch("/api/v1/category/:categoryId") { ctx -> app.get("/api/v1/manga/:mangaId/chapters") { ctx ->
val categoryId = ctx.pathParam("categoryId").toInt() val mangaId = ctx.pathParam("mangaId").toInt()
val name = ctx.formParam("name") ctx.json(future { getChapterList(mangaId) })
val isLanding = if (ctx.formParam("isLanding") != null) ctx.formParam("isLanding")?.toBoolean() else null }
updateCategory(categoryId, name, isLanding)
ctx.status(200)
}
// category re-ordering // used to display a chapter, get a chapter in order to show it's pages
app.patch("/api/v1/category/:categoryId/reorder") { ctx -> app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex") { ctx ->
val categoryId = ctx.pathParam("categoryId").toInt() val chapterIndex = ctx.pathParam("chapterIndex").toInt()
val from = ctx.formParam("from")!!.toInt() val mangaId = ctx.pathParam("mangaId").toInt()
val to = ctx.formParam("to")!!.toInt() ctx.json(future { getChapter(chapterIndex, mangaId) })
reorderCategory(categoryId, from, to) }
ctx.status(200)
}
// category delete app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex/page/:index") { ctx ->
app.delete("/api/v1/category/:categoryId") { ctx -> val mangaId = ctx.pathParam("mangaId").toInt()
val categoryId = ctx.pathParam("categoryId").toInt() val chapterIndex = ctx.pathParam("chapterIndex").toInt()
removeCategory(categoryId) val index = ctx.pathParam("index").toInt()
ctx.status(200)
}
// returns the manga list associated with a category ctx.result(
app.get("/api/v1/category/:categoryId") { ctx -> future { getPageImage(mangaId, chapterIndex, index) }
val categoryId = ctx.pathParam("categoryId").toInt() .thenApply {
ctx.json(getCategoryMangaList(categoryId)) ctx.header("content-type", it.second)
it.first
}
)
}
// global search
app.get("/api/v1/search/:searchTerm") { ctx ->
val searchTerm = ctx.pathParam("searchTerm")
ctx.json(sourceGlobalSearch(searchTerm))
}
// single source search
app.get("/api/v1/source/:sourceId/search/:searchTerm/:pageNum") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
val searchTerm = ctx.pathParam("searchTerm")
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(future { sourceSearch(sourceId, searchTerm, pageNum) })
}
// source filter list
app.get("/api/v1/source/:sourceId/filters/") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
ctx.json(sourceFilters(sourceId))
}
// lists mangas that have no category assigned
app.get("/api/v1/library/") { ctx ->
ctx.json(getLibraryMangas())
}
// category list
app.get("/api/v1/category/") { ctx ->
ctx.json(getCategoryList())
}
// category create
app.post("/api/v1/category/") { ctx ->
val name = ctx.formParam("name")!!
createCategory(name)
ctx.status(200)
}
// returns some static info of the current app build
app.get("/api/v1/about/") { ctx ->
ctx.json(getAbout())
}
// category modification
app.patch("/api/v1/category/:categoryId") { ctx ->
val categoryId = ctx.pathParam("categoryId").toInt()
val name = ctx.formParam("name")
val isLanding = if (ctx.formParam("isLanding") != null) ctx.formParam("isLanding")?.toBoolean() else null
updateCategory(categoryId, name, isLanding)
ctx.status(200)
}
// category re-ordering
app.patch("/api/v1/category/:categoryId/reorder") { ctx ->
val categoryId = ctx.pathParam("categoryId").toInt()
val from = ctx.formParam("from")!!.toInt()
val to = ctx.formParam("to")!!.toInt()
reorderCategory(categoryId, from, to)
ctx.status(200)
}
// category delete
app.delete("/api/v1/category/:categoryId") { ctx ->
val categoryId = ctx.pathParam("categoryId").toInt()
removeCategory(categoryId)
ctx.status(200)
}
// returns the manga list associated with a category
app.get("/api/v1/category/:categoryId") { ctx ->
val categoryId = ctx.pathParam("categoryId").toInt()
ctx.json(getCategoryMangaList(categoryId))
}
// expects a Tachiyomi legacy backup json in the body
app.post("/api/v1/backup/legacy/import") { ctx ->
ctx.result(
future {
restoreLegacyBackup(ctx.bodyAsInputStream())
}
)
}
// expects a Tachiyomi legacy backup json as a file upload, the file must be named "backup.json"
app.post("/api/v1/backup/legacy/import/file") { ctx ->
ctx.result(
future {
restoreLegacyBackup(ctx.uploadedFile("backup.json")!!.content)
}
)
}
// returns a Tachiyomi legacy backup json created from the current database as a json body
app.get("/api/v1/backup/legacy/export") { ctx ->
ctx.contentType("application/json")
ctx.result(
future {
createLegacyBackup(
BackupFlags(
includeManga = true,
includeCategories = true,
includeChapters = true,
includeTracking = true,
includeHistory = true,
)
)
}
)
}
// returns a Tachiyomi legacy backup json created from the current database as a file
app.get("/api/v1/backup/legacy/export/file") { ctx ->
ctx.contentType("application/json")
val sdf = SimpleDateFormat("yyyy-MM-dd_HH-mm")
val currentDate = sdf.format(Date())
ctx.header("Content-Disposition", "attachment; filename=\"tachidesk_$currentDate.json\"")
ctx.result(
future {
createLegacyBackup(
BackupFlags(
includeManga = true,
includeCategories = true,
includeChapters = true,
includeTracking = true,
includeHistory = true,
)
)
}
)
}
} }
} }
@@ -16,7 +16,7 @@ class ServerConfig(config: Config) : ConfigModule(config) {
val port: Int by config val port: Int by config
// proxy // proxy
val socksProxy: Boolean by config val socksProxyEnabled: Boolean by config
val socksProxyHost: String by config val socksProxyHost: String by config
val socksProxyPort: String by config val socksProxyPort: String by config
@@ -10,22 +10,26 @@ package ir.armor.tachidesk.server
import ch.qos.logback.classic.Level import ch.qos.logback.classic.Level
import eu.kanade.tachiyomi.App import eu.kanade.tachiyomi.App
import ir.armor.tachidesk.Main import ir.armor.tachidesk.Main
import ir.armor.tachidesk.database.makeDataBaseTables import ir.armor.tachidesk.model.database.databaseUp
import ir.armor.tachidesk.server.util.systemTray import ir.armor.tachidesk.server.util.systemTray
import mu.KotlinLogging import mu.KotlinLogging
import net.harawata.appdirs.AppDirsFactory
import org.kodein.di.DI import org.kodein.di.DI
import org.kodein.di.bind
import org.kodein.di.conf.global import org.kodein.di.conf.global
import org.kodein.di.singleton
import org.slf4j.Logger
import xyz.nulldev.androidcompat.AndroidCompat import xyz.nulldev.androidcompat.AndroidCompat
import xyz.nulldev.androidcompat.AndroidCompatInitializer import xyz.nulldev.androidcompat.AndroidCompatInitializer
import xyz.nulldev.ts.config.ApplicationRootDir
import xyz.nulldev.ts.config.ConfigKodeinModule import xyz.nulldev.ts.config.ConfigKodeinModule
import xyz.nulldev.ts.config.GlobalConfigManager import xyz.nulldev.ts.config.GlobalConfigManager
import java.io.File import java.io.File
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
object applicationDirs { class ApplicationDirs(
val dataRoot = AppDirsFactory.getInstance().getUserDataDir("Tachidesk", null, null)!! val dataRoot: String = ApplicationRootDir
) {
val extensionsRoot = "$dataRoot/extensions" val extensionsRoot = "$dataRoot/extensions"
val thumbnailsRoot = "$dataRoot/thumbnails" val thumbnailsRoot = "$dataRoot/thumbnails"
val mangaRoot = "$dataRoot/manga" val mangaRoot = "$dataRoot/manga"
@@ -38,26 +42,41 @@ val systemTray by lazy { systemTray() }
val androidCompat by lazy { AndroidCompat() } val androidCompat by lazy { AndroidCompat() }
fun applicationSetup() { fun applicationSetup() {
// register server config // Application dirs
GlobalConfigManager.registerModule( val applicationDirs = ApplicationDirs()
ServerConfig.register(GlobalConfigManager.config) DI.global.addImport(
DI.Module("Server") {
bind<ApplicationDirs>() with singleton { applicationDirs }
}
) )
// set application wide logging level
if (serverConfig.debugLogsEnabled) {
(mu.KotlinLogging.logger(org.slf4j.Logger.ROOT_LOGGER_NAME).underlyingLogger as ch.qos.logback.classic.Logger).level = Level.DEBUG
}
// make dirs we need // make dirs we need
listOf( listOf(
applicationDirs.dataRoot, applicationDirs.dataRoot,
applicationDirs.extensionsRoot, applicationDirs.extensionsRoot,
"${applicationDirs.extensionsRoot}/icon", applicationDirs.extensionsRoot + "/icon",
applicationDirs.thumbnailsRoot applicationDirs.thumbnailsRoot
).forEach { ).forEach {
File(it).mkdirs() File(it).mkdirs()
} }
// register Tachidesk's config which is dubbed "ServerConfig"
GlobalConfigManager.registerModule(
ServerConfig.register(GlobalConfigManager.config)
)
// Load config API
DI.global.addImport(ConfigKodeinModule().create())
// Load Android compatibility dependencies
AndroidCompatInitializer().init()
// start app
androidCompat.startApp(App())
// set application wide logging level
if (serverConfig.debugLogsEnabled) {
(KotlinLogging.logger(Logger.ROOT_LOGGER_NAME).underlyingLogger as ch.qos.logback.classic.Logger).level = Level.DEBUG
}
// create conf file if doesn't exist // create conf file if doesn't exist
try { try {
val dataConfFile = File("${applicationDirs.dataRoot}/server.conf") val dataConfFile = File("${applicationDirs.dataRoot}/server.conf")
@@ -72,22 +91,16 @@ fun applicationSetup() {
logger.error("Exception while creating initial server.conf:\n", e) logger.error("Exception while creating initial server.conf:\n", e)
} }
makeDataBaseTables() databaseUp()
// create system tray // create system tray
if (serverConfig.systemTrayEnabled) if (serverConfig.systemTrayEnabled) {
try { try {
systemTray systemTray
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
}
// Load config API
DI.global.addImport(ConfigKodeinModule().create())
// Load Android compatibility dependencies
AndroidCompatInitializer().init()
// start app
androidCompat.startApp(App())
// Disable jetty's logging // Disable jetty's logging
System.setProperty("org.eclipse.jetty.util.log.announce", "false") System.setProperty("org.eclipse.jetty.util.log.announce", "false")
@@ -95,7 +108,10 @@ fun applicationSetup() {
System.setProperty("org.eclipse.jetty.LEVEL", "OFF") System.setProperty("org.eclipse.jetty.LEVEL", "OFF")
// socks proxy settings // socks proxy settings
System.getProperties()["proxySet"] = serverConfig.socksProxy.toString() if (serverConfig.socksProxyEnabled) {
System.getProperties()["socksProxyHost"] = serverConfig.socksProxyHost // System.getProperties()["proxySet"] = "true"
System.getProperties()["socksProxyPort"] = serverConfig.socksProxyPort System.getProperties()["socksProxyHost"] = serverConfig.socksProxyHost
System.getProperties()["socksProxyPort"] = serverConfig.socksProxyPort
logger.info("Socks Proxy is enabled to ${serverConfig.socksProxyHost}:${serverConfig.socksProxyPort}")
}
} }
@@ -0,0 +1,24 @@
package ir.armor.tachidesk.server.internal
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import ir.armor.tachidesk.server.BuildConfig
data class AboutDataClass(
val version: String,
val revision: String,
)
object About {
fun getAbout(): AboutDataClass {
return AboutDataClass(
BuildConfig.version,
BuildConfig.revision,
)
}
}
@@ -13,15 +13,15 @@ import dorkbox.systemTray.SystemTray.TrayType
import dorkbox.util.CacheUtil import dorkbox.util.CacheUtil
import dorkbox.util.Desktop import dorkbox.util.Desktop
import ir.armor.tachidesk.Main import ir.armor.tachidesk.Main
import ir.armor.tachidesk.server.BuildConfig
import ir.armor.tachidesk.server.serverConfig import ir.armor.tachidesk.server.serverConfig
import java.awt.event.ActionListener import kotlin.system.exitProcess
import java.io.IOException
fun openInBrowser() { fun openInBrowser() {
try { try {
Desktop.browseURL("http://127.0.0.1:4567") Desktop.browseURL("http://127.0.0.1:4567")
} catch (e1: IOException) { } catch (e: Exception) {
e1.printStackTrace() e.printStackTrace()
} }
} }
@@ -32,22 +32,17 @@ fun systemTray(): SystemTray? {
if (System.getProperty("os.name").startsWith("Windows")) if (System.getProperty("os.name").startsWith("Windows"))
SystemTray.FORCE_TRAY_TYPE = TrayType.Swing SystemTray.FORCE_TRAY_TYPE = TrayType.Swing
CacheUtil.clear() CacheUtil.clear(BuildConfig.name)
val systemTray = SystemTray.get() ?: return null val systemTray = SystemTray.get(BuildConfig.name) ?: return null
val mainMenu = systemTray.menu val mainMenu = systemTray.menu
mainMenu.add( mainMenu.add(
MenuItem( MenuItem(
"Open Tachidesk", "Open Tachidesk"
ActionListener { ) {
try { openInBrowser()
Desktop.browseURL("http://127.0.0.1:4567") }
} catch (e: IOException) {
e.printStackTrace()
}
}
)
) )
val icon = Main::class.java.getResource("/icon/faviconlogo.png") val icon = Main::class.java.getResource("/icon/faviconlogo.png")
@@ -56,13 +51,15 @@ fun systemTray(): SystemTray? {
systemTray.setImage(icon) systemTray.setImage(icon)
// systemTray.status = "No Mail" // systemTray.status = "No Mail"
systemTray.getMenu().add( mainMenu.add(
MenuItem("Quit") { MenuItem("Quit") {
systemTray.shutdown() systemTray.shutdown()
System.exit(0) exitProcess(0)
} }
) )
systemTray.installShutdownHook()
return systemTray return systemTray
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
@@ -3,7 +3,7 @@ server.ip = "0.0.0.0"
server.port = 4567 server.port = 4567
# Socks5 proxy # Socks5 proxy
server.socksProxy = false server.socksProxyEnabled = false
server.socksProxyHost = "" server.socksProxyHost = ""
server.socksProxyPort = "" server.socksProxyPort = ""

Some files were not shown because too many files have changed in this diff Show More