mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-07 12:54:35 -05:00
Compare commits
108 Commits
21c69dcc96
...
renovate/l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da3cf12c5c | ||
|
|
c8f5d83e9c | ||
|
|
be5e3f022e | ||
|
|
40a21fabca | ||
|
|
b33069f107 | ||
|
|
14ab3aa9f4 | ||
|
|
bfa0038f53 | ||
|
|
934459f15f | ||
|
|
bab58daecc | ||
|
|
61eb0630cb | ||
|
|
0d3afadfa3 | ||
|
|
f9e81c75b6 | ||
|
|
e123857399 | ||
|
|
31817639bd | ||
|
|
2b91ab755d | ||
|
|
98576c6e62 | ||
|
|
8fbc8fd3d4 | ||
|
|
c81020dbb1 | ||
|
|
348d525b00 | ||
|
|
a9e3d4dc81 | ||
|
|
15af84e626 | ||
|
|
745b11d91a | ||
|
|
b9efbc1aa4 | ||
|
|
ba347e24a2 | ||
|
|
811e15162b | ||
|
|
a403b1c564 | ||
|
|
98833fa738 | ||
|
|
f571b52740 | ||
|
|
744c5189c4 | ||
|
|
96859f90f0 | ||
|
|
76ee471933 | ||
|
|
6fdc247ace | ||
|
|
d0d28a692a | ||
|
|
7051a5d525 | ||
|
|
0e4de02b52 | ||
|
|
1b9db6cb01 | ||
|
|
af45cce641 | ||
|
|
903a3d53b3 | ||
|
|
13a5b3a831 | ||
|
|
da3a1dce9e | ||
|
|
c68a108298 | ||
|
|
911c0ce2e3 | ||
|
|
2a1f1faae5 | ||
|
|
07dceeb07c | ||
|
|
ba2257a164 | ||
|
|
669cb44864 | ||
|
|
6493eaaa02 | ||
|
|
701e4674ea | ||
|
|
75fa4b4b23 | ||
|
|
00861d7750 | ||
|
|
fff291cdb5 | ||
|
|
70f3036f58 | ||
|
|
cc75ad328d | ||
|
|
c0618fcc5c | ||
|
|
9686f75a2d | ||
|
|
4d5307f15b | ||
|
|
779229a48a | ||
|
|
762d5bdbe6 | ||
|
|
41bb6d3dc1 | ||
|
|
fbb383b1f1 | ||
|
|
558407d92c | ||
|
|
6870922784 | ||
|
|
a4b647972e | ||
|
|
16a14e6ac2 | ||
|
|
a2f29ec9dc | ||
|
|
82df985201 | ||
|
|
740db4f1ab | ||
|
|
c4711dec00 | ||
|
|
75d8d172aa | ||
|
|
81fb8c395d | ||
|
|
e93efa9627 | ||
|
|
03a95e6652 | ||
|
|
c117d380a3 | ||
|
|
5bdb945406 | ||
|
|
3064f51d25 | ||
|
|
edf376e3dd | ||
|
|
dff66547b4 | ||
|
|
6fef27bb56 | ||
|
|
505e966653 | ||
|
|
6ee3348f50 | ||
|
|
c98899d501 | ||
|
|
7654653a25 | ||
|
|
0c1a0ef408 | ||
|
|
e7f2192579 | ||
|
|
5c3b1e0b07 | ||
|
|
6ad59f2e2b | ||
|
|
03dd778fac | ||
|
|
6b833a38d1 | ||
|
|
a83885353c | ||
|
|
10d7c7c06d | ||
|
|
3b575271cb | ||
|
|
5ad92413f3 | ||
|
|
7fbdb39319 | ||
|
|
92abdf7fb7 | ||
|
|
ea0c666cfe | ||
|
|
b5e395a039 | ||
|
|
e530072a07 | ||
|
|
f85cbe1ca5 | ||
|
|
5f9126eb2f | ||
|
|
d77c57ede0 | ||
|
|
02f9a0d1d7 | ||
|
|
53192f56ca | ||
|
|
01d89cbb48 | ||
|
|
be55cb974b | ||
|
|
34c394ed19 | ||
|
|
1433a21abd | ||
|
|
ec28794655 | ||
|
|
72122b7cbf |
@@ -11,4 +11,8 @@ ktlint_standard_if-else-wrapping=disabled
|
||||
ktlint_standard_no-consecutive-comments=disabled
|
||||
|
||||
[**/generated/**]
|
||||
ktlint=disabled
|
||||
ktlint=disabled
|
||||
|
||||
[*.json]
|
||||
indent_size=2
|
||||
indent_style = space
|
||||
8
.github/workflows/build_pull_request.yml
vendored
8
.github/workflows/build_pull_request.yml
vendored
@@ -67,7 +67,7 @@ jobs:
|
||||
export LD_PRELOAD="$(pwd)/scripts/resources/catch_abort.so"
|
||||
JAR=$(ls ./server/build/*.jar| head -1)
|
||||
set +e
|
||||
timeout 30s java -DcrashOnFailedMigration=true \
|
||||
timeout 30s java \
|
||||
-Dsuwayomi.tachidesk.config.server.systemTrayEnabled=false \
|
||||
-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false \
|
||||
-Dsuwayomi.tachidesk.config.server.databaseType=POSTGRESQL \
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
exit "$ecode"
|
||||
fi
|
||||
|
||||
timeout 30s java -DcrashOnFailedMigration=true \
|
||||
timeout 30s java \
|
||||
-Dsuwayomi.tachidesk.config.server.systemTrayEnabled=false \
|
||||
-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false \
|
||||
-jar "$JAR"
|
||||
@@ -96,6 +96,10 @@ jobs:
|
||||
fi
|
||||
exit 0
|
||||
|
||||
- name: "Run tests"
|
||||
working-directory: master
|
||||
run: ./gradlew test --stacktrace
|
||||
|
||||
check_docs:
|
||||
name: Validate that all options are documented
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
36
.github/workflows/build_push.yml
vendored
36
.github/workflows/build_push.yml
vendored
@@ -54,14 +54,14 @@ jobs:
|
||||
run: ./gradlew :server:shadowJar --stacktrace
|
||||
|
||||
- name: Upload Jar
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: jar
|
||||
path: master/server/build/*.jar
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload icons
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: icon
|
||||
path: master/server/src/main/resources/icon
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
run: tar -cvzf scripts.tar.gz -C master/ scripts/
|
||||
|
||||
- name: Upload scripts.tar.gz
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: scripts
|
||||
path: scripts.tar.gz
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
run: jlink --add-modules java.base,java.compiler,java.datatransfer,java.desktop,java.instrument,java.logging,java.management,java.naming,java.prefs,java.scripting,java.se,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,jdk.attach,jdk.crypto.ec,jdk.jdi,jdk.management,jdk.net,jdk.unsupported,jdk.unsupported.desktop,jdk.zipfs,jdk.accessibility --output suwa --strip-debug --no-man-pages --no-header-files --compress=2
|
||||
|
||||
- name: Upload JRE package
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.name }}-jre
|
||||
path: suwa
|
||||
@@ -134,26 +134,26 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download Jar
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: jar
|
||||
path: server/build
|
||||
|
||||
- name: Download JRE
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
if: matrix.name != 'linux-assets' && matrix.name != 'debian-all'
|
||||
with:
|
||||
name: ${{ matrix.jre }}-jre
|
||||
path: jre
|
||||
|
||||
- name: Download icons
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: icon
|
||||
path: server/src/main/resources/icon
|
||||
|
||||
- name: Download scripts.tar.gz
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: scripts
|
||||
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
scripts/bundler.sh -o upload/ ${{ matrix.name }}
|
||||
|
||||
- name: Upload ${{ matrix.name }} release
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.name }}
|
||||
path: upload/*
|
||||
@@ -174,35 +174,35 @@ jobs:
|
||||
needs: bundle
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: jar
|
||||
path: release
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: debian-all
|
||||
path: release
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: appimage
|
||||
path: release
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: linux-assets
|
||||
path: release
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: linux-x64
|
||||
path: release
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: macOS-x64
|
||||
path: release
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: macOS-arm64
|
||||
path: release
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: windows-x64
|
||||
path: release
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
git push origin $TAG
|
||||
|
||||
- name: Upload Preview Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
token: ${{ secrets.DEPLOY_PREVIEW_TOKEN }}
|
||||
repository: "Suwayomi/Suwayomi-Server-preview"
|
||||
|
||||
36
.github/workflows/publish.yml
vendored
36
.github/workflows/publish.yml
vendored
@@ -56,14 +56,14 @@ jobs:
|
||||
run: ./gradlew :server:downloadWebUI :server:shadowJar --stacktrace
|
||||
|
||||
- name: Upload Jar
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: jar
|
||||
path: master/server/build/*.jar
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload icons
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: icon
|
||||
path: master/server/src/main/resources/icon
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
run: tar -cvzf scripts.tar.gz -C master/ scripts/
|
||||
|
||||
- name: Upload scripts.tar.gz
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: scripts
|
||||
path: scripts.tar.gz
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
run: jlink --add-modules java.base,java.compiler,java.datatransfer,java.desktop,java.instrument,java.logging,java.management,java.naming,java.prefs,java.scripting,java.se,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,jdk.attach,jdk.crypto.ec,jdk.jdi,jdk.management,jdk.net,jdk.unsupported,jdk.unsupported.desktop,jdk.zipfs,jdk.accessibility --output suwa --strip-debug --no-man-pages --no-header-files --compress=2
|
||||
|
||||
- name: Upload JDK package
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.name }}-jre
|
||||
path: suwa
|
||||
@@ -136,26 +136,26 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download Jar
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: jar
|
||||
path: server/build
|
||||
|
||||
- name: Download JRE
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
if: matrix.name != 'linux-assets' && matrix.name != 'debian-all'
|
||||
with:
|
||||
name: ${{ matrix.jre }}-jre
|
||||
path: jre
|
||||
|
||||
- name: Download icons
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: icon
|
||||
path: server/src/main/resources/icon
|
||||
|
||||
- name: Download scripts.tar.gz
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: scripts
|
||||
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
scripts/bundler.sh -o upload/ ${{ matrix.name }}
|
||||
|
||||
- name: Upload ${{ matrix.name }} files
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.name }}
|
||||
path: upload/*
|
||||
@@ -177,35 +177,35 @@ jobs:
|
||||
needs: bundle
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: jar
|
||||
path: release
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: debian-all
|
||||
path: release
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: appimage
|
||||
path: release
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: linux-assets
|
||||
path: release
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: linux-x64
|
||||
path: release
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: macOS-x64
|
||||
path: release
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: macOS-arm64
|
||||
path: release
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: windows-x64
|
||||
path: release
|
||||
@@ -214,7 +214,7 @@ jobs:
|
||||
run: cd release && sha256sum * > Checksums.sha256
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
token: ${{ secrets.DEPLOY_RELEASE_TOKEN }}
|
||||
draft: true
|
||||
|
||||
10
.github/workflows/wiki.yml
vendored
10
.github/workflows/wiki.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: GitHub Wiki upload
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -23,6 +24,8 @@ jobs:
|
||||
with:
|
||||
repository: ${{github.repository}}
|
||||
path: ${{github.repository}}
|
||||
fetch-depth: 0 # fetch history & tags to determine stable version
|
||||
fetch-tags: true
|
||||
|
||||
- name: Checkout Wiki
|
||||
uses: actions/checkout@v6
|
||||
@@ -35,6 +38,13 @@ jobs:
|
||||
set -e
|
||||
cd $GITHUB_WORKSPACE/${{github.repository}}.wiki
|
||||
cp -r $GITHUB_WORKSPACE/${{github.repository}}/docs/* .
|
||||
|
||||
stable="$(git -C $GITHUB_WORKSPACE/${{github.repository}} describe --abbrev=0 --tags)"
|
||||
if ! git -C $GITHUB_WORKSPACE/${{github.repository}} log --exit-code --pretty= "$stable.." -- docs/Configuring-Suwayomi‐Server.md; then
|
||||
echo "Changes to config detected, embedding link to stable"
|
||||
sed -i '1s/^/> [!WARNING]\n> This document describes the settings available in the preview version. Please head to ['$stable'](https:\/\/github.com\/Suwayomi\/Suwayomi-Server\/blob\/'$stable'\/docs\/Configuring-Suwayomi%E2%80%90Server.md) for the current stable version\n\n/' Configuring-Suwayomi‐Server.md
|
||||
fi
|
||||
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add .
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,9 @@ package xyz.nulldev.androidcompat.util
|
||||
|
||||
// adopted from: https://github.com/tachiyomiorg/tachiyomi/blob/4cefbce7c34e724b409b6ba127f3c6c5c346ad8d/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt
|
||||
object SafePath {
|
||||
private const val MAX_FILENAME_CHARS = 240
|
||||
private const val MAX_FILENAME_UTF8_BYTES = 240
|
||||
|
||||
/**
|
||||
* Mutate the given filename to make it valid for a FAT filesystem,
|
||||
* replacing any invalid characters with "_". This method doesn't allow hidden files (starting
|
||||
@@ -27,11 +30,41 @@ object SafePath {
|
||||
sb.append('_')
|
||||
}
|
||||
}
|
||||
// Even though vfat allows 255 UCS-2 chars, we might eventually write to
|
||||
// ext4 through a FUSE layer, so use that limit minus 15 reserved characters.
|
||||
return sb.toString().take(240)
|
||||
|
||||
return truncateFilename(sb.toString())
|
||||
}
|
||||
|
||||
private fun truncateFilename(filename: String): String {
|
||||
// Keep a safety margin under common filesystem limits and satisfy both
|
||||
// character count and UTF-8 byte-length constraints.
|
||||
val output = StringBuilder(minOf(filename.length, MAX_FILENAME_CHARS))
|
||||
var usedBytes = 0
|
||||
var index = 0
|
||||
|
||||
while (index < filename.length && output.length < MAX_FILENAME_CHARS) {
|
||||
val codePoint = Character.codePointAt(filename, index)
|
||||
val codePointBytes = utf8ByteCount(codePoint)
|
||||
|
||||
if (usedBytes + codePointBytes > MAX_FILENAME_UTF8_BYTES) {
|
||||
break
|
||||
}
|
||||
|
||||
output.appendCodePoint(codePoint)
|
||||
usedBytes += codePointBytes
|
||||
index += Character.charCount(codePoint)
|
||||
}
|
||||
|
||||
return output.toString()
|
||||
}
|
||||
|
||||
private fun utf8ByteCount(codePoint: Int): Int =
|
||||
when {
|
||||
codePoint <= 0x7f -> 1
|
||||
codePoint <= 0x7ff -> 2
|
||||
codePoint <= 0xffff -> 3
|
||||
else -> 4
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given character is a valid filename character, false otherwise.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package xyz.nulldev.androidcompat.webkit
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.cef.CefApp
|
||||
import org.cef.CefClient
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
object CefHelper {
|
||||
val cefApp = MutableStateFlow<Result<CefApp?>>(Result.success(null))
|
||||
|
||||
suspend fun createClient(): CefClient {
|
||||
val app = waitForInit().first()
|
||||
val client = app.createClient()
|
||||
JsHandler(client) // This adds itself to a global map
|
||||
return client
|
||||
}
|
||||
|
||||
fun waitForInit() =
|
||||
callbackFlow {
|
||||
val app = cefApp.first { it.isFailure || it.getOrThrow() != null }.getOrThrow()!!
|
||||
app.onInitialization {
|
||||
logger.debug { "CEF: Initialization state $it" }
|
||||
when (it) {
|
||||
CefApp.CefAppState.INITIALIZED -> {
|
||||
trySend(app)
|
||||
close()
|
||||
}
|
||||
|
||||
CefApp.CefAppState.SHUTTING_DOWN, CefApp.CefAppState.TERMINATED -> {
|
||||
close(CefException("Shutting down"))
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
awaitClose {}
|
||||
}
|
||||
|
||||
class CefException(
|
||||
msg: String,
|
||||
) : Exception(msg)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package xyz.nulldev.androidcompat.webkit
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.cef.CefClient
|
||||
import org.cef.browser.CefBrowser
|
||||
import org.cef.browser.CefFrame
|
||||
import org.cef.browser.CefMessageRouter
|
||||
import org.cef.callback.CefQueryCallback
|
||||
import org.cef.handler.CefMessageRouterHandlerAdapter
|
||||
import kotlin.random.Random
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private val jsHandler: MutableMap<CefClient, JsHandler> = mutableMapOf()
|
||||
|
||||
fun CefBrowser.evaluateJavaScript(
|
||||
expression: String,
|
||||
cb: (String?) -> Unit,
|
||||
) = jsHandler[this.client]!!.eval(this, expression, cb)
|
||||
|
||||
fun CefBrowser.dispose() {
|
||||
stopLoad()
|
||||
setCloseAllowed()
|
||||
close(true)
|
||||
}
|
||||
|
||||
class JsHandler : CefMessageRouterHandlerAdapter {
|
||||
private val handler: MutableMap<String, (String?) -> Unit> = mutableMapOf()
|
||||
|
||||
constructor(client: CefClient) {
|
||||
val config = CefMessageRouter.CefMessageRouterConfig()
|
||||
config.jsQueryFunction = QUERY_FN
|
||||
config.jsCancelFunction = QUERY_CANCEL_FN
|
||||
client.addMessageRouter(CefMessageRouter.create(config, this))
|
||||
jsHandler[client] = this
|
||||
}
|
||||
|
||||
fun eval(
|
||||
frame: CefFrame,
|
||||
expression: String,
|
||||
cb: (String?) -> Unit,
|
||||
) {
|
||||
val id = Random.nextBytes(48).toHexString()
|
||||
handler[id] = cb
|
||||
frame.executeJavaScript(expression.toCode(id), "about:cef", 0)
|
||||
}
|
||||
|
||||
fun eval(
|
||||
browser: CefBrowser,
|
||||
expression: String,
|
||||
cb: (String?) -> Unit,
|
||||
) {
|
||||
val id = Random.nextBytes(48).toHexString()
|
||||
handler[id] = cb
|
||||
browser.executeJavaScript(expression.toCode(id), "about:cef", 0)
|
||||
}
|
||||
|
||||
override fun onQuery(
|
||||
browser: CefBrowser?,
|
||||
frame: CefFrame?,
|
||||
queryId: Long,
|
||||
request: String?,
|
||||
persistent: Boolean,
|
||||
callback: CefQueryCallback?,
|
||||
): Boolean {
|
||||
super.onQuery(browser, frame, queryId, request, persistent, callback)
|
||||
|
||||
if (request != null) {
|
||||
val invoke =
|
||||
try {
|
||||
Json.decodeFromString<FunctionCall>(request)
|
||||
} catch (e: Exception) {
|
||||
logger.warn(e) { "Invalid request received" }
|
||||
return false
|
||||
}
|
||||
val handler = handler.remove(invoke.id) ?: return false
|
||||
handler(invoke.result)
|
||||
callback?.success("")
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class FunctionCall(
|
||||
val id: String,
|
||||
val result: String? = null,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val QUERY_FN = "__\$_evalQuery"
|
||||
const val QUERY_CANCEL_FN = "__\$_evalQueryCancel"
|
||||
|
||||
private fun Char.isLineBreak(): Boolean = this == '\n' || this == '\r'
|
||||
|
||||
private fun String.containsLineBreak(): Boolean =
|
||||
this.any {
|
||||
it.isLineBreak()
|
||||
}
|
||||
|
||||
private fun String.asFunctionBody(): String =
|
||||
let { expression ->
|
||||
when {
|
||||
expression.containsLineBreak() -> expression
|
||||
expression.trim().startsWith("return", false) -> expression
|
||||
else -> "return $expression"
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toCode(id: String): String =
|
||||
"""
|
||||
function payload() {
|
||||
${this.asFunctionBody()}
|
||||
}
|
||||
|
||||
try {
|
||||
var result = payload();
|
||||
|
||||
window.${QUERY_FN}({
|
||||
request: JSON.stringify({ id: "$id", result }),
|
||||
onSuccess: function (response) {},
|
||||
onFailure: function (error_code, error_message) {}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to eval $id", e)
|
||||
window.${QUERY_CANCEL_FN}({
|
||||
request: JSON.stringify({ id: "$id", error: ""+e }),
|
||||
onSuccess: function (response) {},
|
||||
onFailure: function (error_code, error_message) {}
|
||||
});
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
@@ -51,12 +51,10 @@ import android.webkit.WebViewProvider.ScrollDelegate
|
||||
import android.webkit.WebViewProvider.ViewDelegate
|
||||
import android.webkit.WebViewRenderProcess
|
||||
import android.webkit.WebViewRenderProcessClient
|
||||
import dev.datlag.kcef.KCEF
|
||||
import dev.datlag.kcef.KCEFBrowser
|
||||
import dev.datlag.kcef.KCEFClient
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.cef.CefClient
|
||||
import org.cef.CefSettings
|
||||
import org.cef.browser.CefBrowser
|
||||
import org.cef.browser.CefFrame
|
||||
@@ -70,6 +68,7 @@ import org.cef.handler.CefLoadHandler
|
||||
import org.cef.handler.CefLoadHandlerAdapter
|
||||
import org.cef.handler.CefMessageRouterHandlerAdapter
|
||||
import org.cef.handler.CefPermissionHandler
|
||||
import org.cef.handler.CefRenderHandlerAdapter
|
||||
import org.cef.handler.CefRequestHandler
|
||||
import org.cef.handler.CefRequestHandlerAdapter
|
||||
import org.cef.handler.CefResourceHandler
|
||||
@@ -84,12 +83,14 @@ import org.cef.network.CefPostDataElement
|
||||
import org.cef.network.CefRequest
|
||||
import org.cef.network.CefResponse
|
||||
import org.koin.mp.KoinPlatformTools
|
||||
import java.awt.Rectangle
|
||||
import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.collections.Map
|
||||
import kotlin.reflect.KClass
|
||||
import javax.swing.JPanel
|
||||
import kotlin.math.min
|
||||
import kotlin.reflect.KFunction
|
||||
import kotlin.reflect.full.declaredMemberFunctions
|
||||
import kotlin.reflect.jvm.javaMethod
|
||||
@@ -100,12 +101,13 @@ class KcefWebViewProvider(
|
||||
private val settings = KcefWebSettings()
|
||||
private var viewClient = WebViewClient()
|
||||
private var chromeClient = WebChromeClient()
|
||||
private val renderHandler = RenderHandler()
|
||||
private val mappings: MutableList<FunctionMapping> = mutableListOf()
|
||||
private val urlHttpMapping: MutableMap<String, String> = mutableMapOf()
|
||||
private var initialRequestData: InitialRequestData? = null
|
||||
|
||||
private var kcefClient: KCEFClient? = null
|
||||
private var browser: KCEFBrowser? = null
|
||||
private var kcefClient: CefClient? = null
|
||||
private var browser: CefBrowser? = null
|
||||
|
||||
private val handler = Handler(view.webViewLooper)
|
||||
|
||||
@@ -117,8 +119,8 @@ class KcefWebViewProvider(
|
||||
private val initHandler: InitBrowserHandler by KoinPlatformTools.defaultContext().get().inject()
|
||||
}
|
||||
|
||||
public interface InitBrowserHandler {
|
||||
public fun init(provider: KcefWebViewProvider): Unit
|
||||
interface InitBrowserHandler {
|
||||
fun init(provider: KcefWebViewProvider): Unit
|
||||
}
|
||||
|
||||
private data class InitialRequestData(
|
||||
@@ -194,7 +196,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
}
|
||||
|
||||
private inner class DisplayHandler : CefDisplayHandlerAdapter() {
|
||||
private class DisplayHandler : CefDisplayHandlerAdapter() {
|
||||
override fun onConsoleMessage(
|
||||
browser: CefBrowser,
|
||||
level: CefSettings.LogSeverity,
|
||||
@@ -222,6 +224,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private inner class LoadHandler : CefLoadHandlerAdapter() {
|
||||
override fun onLoadEnd(
|
||||
browser: CefBrowser,
|
||||
@@ -368,7 +371,7 @@ class KcefWebViewProvider(
|
||||
callback: CefCallback,
|
||||
): Boolean {
|
||||
val data = resolvedData ?: return false
|
||||
val bytesToTransfer = Math.min(bytesToRead, data.size - readOffset)
|
||||
val bytesToTransfer = min(bytesToRead, data.size - readOffset)
|
||||
Log.v(
|
||||
TAG,
|
||||
"readResponse: $readOffset/${data.size}, reading $bytesToRead->$bytesToTransfer",
|
||||
@@ -380,7 +383,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
}
|
||||
|
||||
private inner class WebResponseResourceHandler(
|
||||
private class WebResponseResourceHandler(
|
||||
val webResponse: WebResourceResponse,
|
||||
) : ArrayResponseResourceHandler() {
|
||||
override fun processRequest(
|
||||
@@ -410,7 +413,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
}
|
||||
|
||||
private inner class HtmlResponseResourceHandler(
|
||||
private class HtmlResponseResourceHandler(
|
||||
val html: String,
|
||||
) : ArrayResponseResourceHandler() {
|
||||
override fun processRequest(
|
||||
@@ -441,7 +444,7 @@ class KcefWebViewProvider(
|
||||
view,
|
||||
CefWebResourceRequest(request, frame, false),
|
||||
)
|
||||
Log.v(TAG, "Resource ${request?.url}, result is cancel? $cancel")
|
||||
Log.v(TAG, "Resource ${request.url}, result is cancel? $cancel")
|
||||
|
||||
handler.post { viewClient.onLoadResource(view, frame?.url) }
|
||||
|
||||
@@ -468,7 +471,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
if (response == null) {
|
||||
// prefer user's response override
|
||||
urlHttpMapping.get(request.url.trimEnd('/'))?.let {
|
||||
urlHttpMapping[request.url.trimEnd('/')]?.let {
|
||||
return HtmlResponseResourceHandler(it)
|
||||
}
|
||||
}
|
||||
@@ -477,6 +480,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private inner class RequestHandler : CefRequestHandlerAdapter() {
|
||||
override fun getResourceRequestHandler(
|
||||
browser: CefBrowser,
|
||||
@@ -486,11 +490,13 @@ class KcefWebViewProvider(
|
||||
isDownload: Boolean,
|
||||
requestInitiator: String,
|
||||
disableDefaultHandling: BoolRef,
|
||||
): CefResourceRequestHandler? = ResourceRequestHandler()
|
||||
): CefResourceRequestHandler = ResourceRequestHandler()
|
||||
|
||||
override fun onRenderProcessTerminated(
|
||||
browser: CefBrowser,
|
||||
status: CefRequestHandler.TerminationStatus,
|
||||
errorCode: Int,
|
||||
errorString: String,
|
||||
) {
|
||||
handler.post {
|
||||
viewClient.onRenderProcessGone(
|
||||
@@ -509,18 +515,33 @@ class KcefWebViewProvider(
|
||||
override fun onRequestMediaAccessPermission(
|
||||
browser: CefBrowser,
|
||||
frame: CefFrame,
|
||||
requesting_url: String,
|
||||
requested_permissions: Int,
|
||||
requestingUrl: String,
|
||||
requestedPermissions: Int,
|
||||
callback: CefMediaAccessCallback,
|
||||
): Boolean {
|
||||
handler.post {
|
||||
Log.v(TAG, "Checking permission for $requesting_url: $requested_permissions")
|
||||
chromeClient.onPermissionRequest(CefPermissionRequest(requesting_url, requested_permissions, callback))
|
||||
Log.v(TAG, "Checking permission for $requestingUrl: $requestedPermissions")
|
||||
chromeClient.onPermissionRequest(CefPermissionRequest(requestingUrl, requestedPermissions, callback))
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private class RenderHandler : CefRenderHandlerAdapter() {
|
||||
override fun getViewRect(browser: CefBrowser): Rectangle = Rectangle(0, 0, 1280, 2856)
|
||||
|
||||
override fun onPaint(
|
||||
browser: CefBrowser,
|
||||
popup: Boolean,
|
||||
dirtyRects: Array<Rectangle>,
|
||||
buffer: ByteBuffer,
|
||||
width: Int,
|
||||
height: Int,
|
||||
) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
override fun init(
|
||||
javaScriptInterfaces: Map<String, Any>?,
|
||||
privateBrowsing: Boolean,
|
||||
@@ -528,16 +549,18 @@ class KcefWebViewProvider(
|
||||
Log.v(TAG, "KcefWebViewProvider: initialize")
|
||||
destroy()
|
||||
kcefClient =
|
||||
KCEF.newClientBlocking().apply {
|
||||
addDisplayHandler(DisplayHandler())
|
||||
addLoadHandler(LoadHandler())
|
||||
addRequestHandler(RequestHandler())
|
||||
addPermissionHandler(PermissionHandler())
|
||||
runBlocking {
|
||||
CefHelper.createClient().apply {
|
||||
addDisplayHandler(DisplayHandler())
|
||||
addLoadHandler(LoadHandler())
|
||||
addRequestHandler(RequestHandler())
|
||||
addPermissionHandler(PermissionHandler())
|
||||
|
||||
val config = CefMessageRouter.CefMessageRouterConfig()
|
||||
config.jsQueryFunction = QUERY_FN
|
||||
config.jsCancelFunction = QUERY_CANCEL_FN
|
||||
addMessageRouter(CefMessageRouter.create(config, MessageRouterHandler()))
|
||||
val config = CefMessageRouter.CefMessageRouterConfig()
|
||||
config.jsQueryFunction = QUERY_FN
|
||||
config.jsCancelFunction = QUERY_CANCEL_FN
|
||||
addMessageRouter(CefMessageRouter.create(config, MessageRouterHandler()))
|
||||
}
|
||||
}
|
||||
initHandler.init(this)
|
||||
}
|
||||
@@ -614,7 +637,8 @@ class KcefWebViewProvider(
|
||||
kcefClient!!
|
||||
.createBrowser(
|
||||
loadUrl,
|
||||
CefRendering.OFFSCREEN,
|
||||
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
|
||||
false,
|
||||
).apply {
|
||||
// NOTE: Without this, we don't seem to be receiving any events
|
||||
createImmediately()
|
||||
@@ -638,7 +662,8 @@ class KcefWebViewProvider(
|
||||
kcefClient!!
|
||||
.createBrowser(
|
||||
url,
|
||||
CefRendering.OFFSCREEN,
|
||||
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
|
||||
false,
|
||||
).apply {
|
||||
// NOTE: Without this, we don't seem to be receiving any events
|
||||
createImmediately()
|
||||
@@ -664,27 +689,19 @@ class KcefWebViewProvider(
|
||||
browser?.close(true)
|
||||
browser?.dispose()
|
||||
chromeClient.onProgressChanged(view, 0)
|
||||
val url = baseUrl ?: "about:blank"
|
||||
urlHttpMapping[url.trimEnd('/')] = data
|
||||
|
||||
browser =
|
||||
(
|
||||
baseUrl?.let { url ->
|
||||
urlHttpMapping.put(url.trimEnd('/'), data)
|
||||
kcefClient!!.createBrowser(
|
||||
url,
|
||||
CefRendering.OFFSCREEN,
|
||||
)
|
||||
kcefClient!!
|
||||
.createBrowser(
|
||||
url,
|
||||
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
|
||||
false,
|
||||
).apply {
|
||||
// NOTE: Without this, we don't seem to be receiving any events
|
||||
createImmediately()
|
||||
}
|
||||
?: run {
|
||||
kcefClient!!.createBrowserWithHtml(
|
||||
data,
|
||||
KCEFBrowser.BLANK_URI,
|
||||
CefRendering.OFFSCREEN,
|
||||
)
|
||||
}
|
||||
).apply {
|
||||
// NOTE: Without this, we don't seem to be receiving any events
|
||||
createImmediately()
|
||||
}
|
||||
Log.d(TAG, "Page loaded from data at base URL $baseUrl")
|
||||
}
|
||||
|
||||
@@ -694,11 +711,11 @@ class KcefWebViewProvider(
|
||||
) {
|
||||
browser!!.evaluateJavaScript(
|
||||
script.removePrefix("javascript:"),
|
||||
)
|
||||
{
|
||||
Log.v(TAG, "JS returned: $it")
|
||||
it?.let { handler.post { resultCallback?.onReceiveValue(it) } }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun saveWebArchive(filename: String): Unit = throw RuntimeException("Stub!")
|
||||
@@ -778,15 +795,23 @@ class KcefWebViewProvider(
|
||||
|
||||
override fun getContentWidth(): Int = throw RuntimeException("Stub!")
|
||||
|
||||
override fun pauseTimers(): Unit = throw RuntimeException("Stub!")
|
||||
override fun pauseTimers() {
|
||||
Log.v(TAG, "pauseTimers: doing nothing")
|
||||
}
|
||||
|
||||
override fun resumeTimers(): Unit = throw RuntimeException("Stub!")
|
||||
override fun resumeTimers() {
|
||||
Log.v(TAG, "resumeTimers: doing nothing")
|
||||
}
|
||||
|
||||
override fun onPause(): Unit = throw RuntimeException("Stub!")
|
||||
override fun onPause() {
|
||||
Log.v(TAG, "onPause: doing nothing")
|
||||
}
|
||||
|
||||
override fun onResume(): Unit = throw RuntimeException("Stub!")
|
||||
override fun onResume() {
|
||||
Log.v(TAG, "onResume: doing nothing")
|
||||
}
|
||||
|
||||
override fun isPaused(): Boolean = throw RuntimeException("Stub!")
|
||||
override fun isPaused(): Boolean = false
|
||||
|
||||
override fun freeMemory(): Unit = throw RuntimeException("Stub!")
|
||||
|
||||
@@ -840,6 +865,7 @@ class KcefWebViewProvider(
|
||||
|
||||
override fun getWebChromeClient(): WebChromeClient = chromeClient
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun setPictureListener(listener: PictureListener): Unit = throw RuntimeException("Stub!")
|
||||
|
||||
@Serializable
|
||||
@@ -862,7 +888,7 @@ class KcefWebViewProvider(
|
||||
obj: Any,
|
||||
interfaceName: String,
|
||||
) {
|
||||
val cls = obj::class as KClass<Any>
|
||||
val cls = obj::class
|
||||
mappings.addAll(
|
||||
cls.declaredMemberFunctions.map {
|
||||
// This is ridiculous, but necessary, otherwise "public final" throws
|
||||
@@ -924,7 +950,8 @@ class KcefWebViewProvider(
|
||||
override fun getRendererPriorityWaivedWhenNotVisible(): Boolean = throw RuntimeException("Stub!")
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
override fun setTextClassifier(textClassifier: TextClassifier?) {}
|
||||
override fun setTextClassifier(textClassifier: TextClassifier?) {
|
||||
}
|
||||
|
||||
override fun getTextClassifier(): TextClassifier = TextClassifier.NO_OP
|
||||
|
||||
@@ -950,11 +977,13 @@ class KcefWebViewProvider(
|
||||
override fun onProvideAutofillVirtualStructure(
|
||||
@SuppressWarnings("unused") structure: android.view.ViewStructure,
|
||||
@SuppressWarnings("unused") flags: Int,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
override fun autofill(
|
||||
@SuppressWarnings("unused") values: SparseArray<AutofillValue>,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
override fun isVisibleToUserForAutofill(
|
||||
@SuppressWarnings("unused") virtualId: Int,
|
||||
@@ -965,7 +994,8 @@ class KcefWebViewProvider(
|
||||
override fun onProvideContentCaptureStructure(
|
||||
@SuppressWarnings("unused") structure: android.view.ViewStructure,
|
||||
@SuppressWarnings("unused") flags: Int,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
override fun getAccessibilityNodeProvider(): AccessibilityNodeProvider = throw RuntimeException("Stub!")
|
||||
|
||||
@@ -1035,7 +1065,8 @@ class KcefWebViewProvider(
|
||||
override fun onMovedToDisplay(
|
||||
displayId: Int,
|
||||
config: Configuration,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
override fun onVisibilityChanged(
|
||||
changedView: View,
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -7,13 +7,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
## [Unreleased] (Preview)
|
||||
|
||||
### Added
|
||||
- .
|
||||
- (**Sync**) Added [SyncYomi](https://github.com/syncyomi/syncyomi) support
|
||||
- (**OPDS**) Add option to skip chapter metadata feed providing direct stream/download links
|
||||
|
||||
### Changed
|
||||
- .
|
||||
- (**Database/H2**) Use the latest H2 database engine
|
||||
- (**Startup**) Crash on startup if an unrecoverable error happens
|
||||
- (**WebView**) Use JCEF directly and update to newest Chromium
|
||||
- (**Extension/Android**) Switch MessageQueue to LegacyMessageQueue from ConcurrentMessageQueue
|
||||
|
||||
### Fixed
|
||||
- .
|
||||
- (**CloudFlareInterceptor**) Don't send the `cf_clearance` cookie back to Flaresolverr
|
||||
- (**WebUI**) Handle serving non-default webui with "bundled"
|
||||
- (**WebUI**) Wait until WebUI is ready to open in browser
|
||||
- (**Downloads**) Truncate filenames by byte length to prevent "File name too long" IO errors
|
||||
- (**Downloads**) Fix being unable to find downloads after manga was renamed during an update
|
||||
- (**Downloads**) Fix preserving chapter download states during an update
|
||||
- (**Extension**) Do not indicate an update is available when the extension is not installed
|
||||
- (**Chapter**) Fix losing chapter data on failed chapter list update
|
||||
- (**Chapter**) Fix database error when fetching chapter updates
|
||||
- (**Manga/API**) Fix "mangas" graphql query with active sorting and using a PostgreSQL database (QUERY "mangas")
|
||||
- (**API**) Fix GraphQL `Filter` `notAll` and `notAny` being inversed
|
||||
- (**API**) Fix GraphQL `Filter` causing an UnsupportedOperationException when passing an empty list as a `Any` filter value
|
||||
- (**Build**) Fix CURL failing silently in builds
|
||||
- (**Backup/Database**) Fix backup creation slowdown when mapping chapters
|
||||
|
||||
## [v2.2.2100] + [WebUI: v20260508.01] - 2026-05-08
|
||||
|
||||
|
||||
24
README.md
24
README.md
@@ -8,7 +8,7 @@
|
||||
- [Features](#features)
|
||||
- [Suwayomi client projects](#suwayomi-client-projects)
|
||||
- [Integrated clients](#integrated-clients)
|
||||
- [Other clients](#other-clients-potentially-inactive-or-abondend)
|
||||
- [Other clients](#other-clients-potentially-inactive-or-abandoned)
|
||||
- [Downloading and Running the app](#downloading-and-running-the-app)
|
||||
- [Using Operating System Specific Bundles](#using-operating-system-specific-bundles)
|
||||
- [Windows](#windows)
|
||||
@@ -76,12 +76,13 @@ These clients are built-in options, and the server can keep them automatically u
|
||||
- [Suwayomi-WebUI](https://github.com/Suwayomi/Suwayomi-WebUI): Web app, PWA
|
||||
- [Suwayomi-VUI](https://github.com/Suwayomi/Suwayomi-VUI): Web app, PWA
|
||||
|
||||
##### Other clients (potentially inactive or abondend)
|
||||
##### Other clients (potentially inactive or abandoned)
|
||||
- [Tachidesk-VaadinUI](https://github.com/Suwayomi/Tachidesk-VaadinUI): Desktop app (windows, linux, mac); UI in the browser, manages its own suwayomi server instance
|
||||
- [Moku](https://github.com/Youwes09/Moku): Desktop app (windows, linux, mac), requires access to a running server
|
||||
- [Moku](https://github.com/Youwes09/Moku): Desktop app (windows, linux, mac), can manage its own suwayomi server instance
|
||||
- [Tachidesk-JUI](https://github.com/Suwayomi/Tachidesk-JUI): Desktop app (windows, linux, mac); can manage its own suwayomi server instance
|
||||
- [Tachidesk-Sorayomi](https://github.com/Suwayomi/Tachidesk-Sorayomi): Web app; Desktop app (windows, linux, mac); Android app; requires access to a running server
|
||||
- [Tachidesk-qtui](https://github.com/Suwayomi/Tachidesk-qtui): Android app; iOS app Desktop app (linux); requires access to a running server
|
||||
- [Suwayomi Client for KOReader](https://github.com/LK4D4/suwayomi.koplugin): KOReader plugin; works anywhere KOReader can run (Android, Kindle, Kobo, etc.); requires access to a running server
|
||||
|
||||
# Downloading and Running the app
|
||||
## Using Operating System Specific Bundles
|
||||
@@ -106,23 +107,22 @@ Download the latest `linux-x64`(x86_64) release from [the releases section](http
|
||||
|
||||
#### WebView support (GNU/Linux)
|
||||
|
||||
WebView support is implemented via [KCEF](https://github.com/DATL4G/KCEF).
|
||||
WebView support is implemented via [JCEF](https://github.com/JetBrains/jcef).
|
||||
This is optional, and is only necessary to support some extensions.
|
||||
|
||||
To have a functional WebView, several dependencies are required; aside from X11 libraries necessary for rendering Chromium, some JNI bindings are necessary: gluegen and jogl (found in Ubuntu as `libgluegen2-jni` and `libjogl2-jni`).
|
||||
Note that on some systems (e.g. Ubuntu), the JNI libraries are not automatically found, see below.
|
||||
To have a functional WebView, some X11 dependencies are required for rendering Chromium.
|
||||
These include `libxrender`, `libxcomposite` `libxdamage`, `libxkbcommon` and `libxtst`.
|
||||
|
||||
A KCEF server is launched on startup, which loads the X11 libraries.
|
||||
A CEF server is launched on startup, which loads the X11 libraries.
|
||||
If those are missing, you should see "Could not load 'jcef' library".
|
||||
If so, use `ldd ~/.local/share/Tachidesk/bin/kcef/libjcef.so | grep not` to figure out which libraries are not found on your system.
|
||||
|
||||
The JNI bindings are only loaded when a browser is actually launched.
|
||||
This is done by extensions that rely on WebView, not by Suwayomi itself.
|
||||
If there is a problem loading the JNI libraries, you should see a message indicating the library and the search path.
|
||||
This search path includes the current working directory, if you do not want to modify system directories.
|
||||
|
||||
Refer to the [Dockerfile](https://github.com/Suwayomi/Suwayomi-Server-docker/blob/main/Dockerfile) for more details.
|
||||
|
||||
Note that it is required to have an X session active and available to Suwayomi (i.e. `DISPLAY` is set).
|
||||
It is not enough to have `WAYLAND_DISPLAY`, if your environment does not provide xwayland (or if you run Suwayomi as a service), you need to use a tool like [`Xvfb`](https://en.wikipedia.org/wiki/Xvfb).
|
||||
The Dockerfile linked above also does this.
|
||||
|
||||
## Other methods of getting Suwayomi
|
||||
### Docker
|
||||
Check our Official Docker release [Suwayomi Container](https://github.com/orgs/Suwayomi/packages/container/package/tachidesk) for running Suwayomi Server in a docker container. Source code for our container is available at [docker-tachidesk](https://github.com/Suwayomi/docker-tachidesk), an example compose file can also be found there. By default, the server will be running on http://localhost:4567 open this url in your browser.
|
||||
|
||||
@@ -25,6 +25,7 @@ allprojects {
|
||||
maven("https://github.com/Suwayomi/Suwayomi-Server/raw/android-jar/")
|
||||
maven("https://jitpack.io")
|
||||
maven("https://jogamp.org/deployment/maven")
|
||||
maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +54,7 @@ subprojects {
|
||||
}
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
|
||||
freeCompilerArgs.add("-Xcontext-receivers")
|
||||
freeCompilerArgs.add("-Xcontext-parameters")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ val getTachideskVersion = { "v2.2.${getCommitCount()}" }
|
||||
|
||||
val webUIRevisionTag = "r3136"
|
||||
|
||||
val webviewJbrRelease = "jbr-release-25.0.3b508.4"
|
||||
|
||||
private val getCommitCount = {
|
||||
runCatching {
|
||||
ProcessBuilder()
|
||||
|
||||
@@ -63,6 +63,14 @@ server.webUISubpath = ""
|
||||
- `server.webUIUpdateCheckInterval` the interval time in hours at which to check for updates. Use `0` to disable update checking.
|
||||
- `server.webUISubpath` controls on which sub-path the UI is served; by default, it will be accessible on `/` (i.e. directly), with this setting it can also be set to appear at e.g. `/suwayomi`
|
||||
|
||||
|
||||
### webView
|
||||
```
|
||||
server.kcefEnabled = true
|
||||
```
|
||||
- `server.kcefEnabled` controls if KCEF WebView provider is enabled.
|
||||
|
||||
|
||||
### Downloader
|
||||
```
|
||||
server.downloadAsCbz = true
|
||||
@@ -224,6 +232,7 @@ server.opdsShowOnlyUnreadChapters = false
|
||||
server.opdsShowOnlyDownloadedChapters = false
|
||||
server.opdsChapterSortOrder = "DESC"
|
||||
server.opdsCbzMimetype = "MODERN"
|
||||
server.opdsSkipChapterMetadataFeed = false
|
||||
```
|
||||
- `server.opdsUseBinaryFileSizes = false` controls if Suwayomi should display file sizes in binary units (KiB, MiB, GiB) or decimal (KB, MB, GB) in OPDS listings.
|
||||
- `server.opdsItemsPerPage = 50` sets the number of items per page in OPDS listings. Range: 10 <= n <= 5000.
|
||||
@@ -233,6 +242,7 @@ server.opdsCbzMimetype = "MODERN"
|
||||
- `server.opdsShowOnlyDownloadedChapters = false` controls if OPDS listings should only include downloaded chapters.
|
||||
- `server.opdsChapterSortOrder = "DESC"` sets the default chapter sort order in OPDS listings, either `"ASC"` or `"DESC"`
|
||||
- `server.opdsCbzMimetype = "MODERN"` controls which mimetype to use for CBZ downloads. This affects the offered link in OPDS, as well as the content type of the CBZ download. Allowed is MODERN (current IANA standard), LEGACY (deprecated mimetype for .cbz) and COMPATIBLE (deprecated mimetype for all comic archives). Use LEGACY or COMPATIBLE if older clients don't offer the chapter download (note that the chapter needs to first be downloaded in Suwayomi, before it is available in OPDS).
|
||||
- `server.opdsSkipChapterMetadataFeed = false` controls if the metadata feed should be skipped. When enabled, download and streaming links are provided directly in the chapter list. This improves compatibility with automated downloaders (like KOReader). KoSync strategies are applied, but `PROMPT` conflicts are ignored (treating local progress as priority).
|
||||
|
||||
### KOReader Sync
|
||||
```
|
||||
@@ -268,6 +278,28 @@ server.useHikariConnectionPool = true
|
||||
- `server.databasePassword` the username with which to authenticate at the PostgreSQL instance.
|
||||
- `server.useHikariConnectionPool` use Hikari Connection Pool to connect to the database.
|
||||
|
||||
### SyncYomi
|
||||
```
|
||||
server.syncYomiEnabled = false
|
||||
server.syncYomiHost = ""
|
||||
server.syncYomiApiKey = ""
|
||||
server.syncDataManga = true
|
||||
server.syncDataChapters = true
|
||||
server.syncDataTracking = true
|
||||
server.syncDataHistory = true
|
||||
server.syncDataCategories = true
|
||||
server.syncInterval = "0s"
|
||||
```
|
||||
- `server.syncYomiEnabled` controls whether SyncYomi is enabled.
|
||||
- `server.syncYomiHost` base URL of the SyncYomi server instance. e.g. `http://localhost:8282`
|
||||
- `server.syncYomiApiKey` API key to authenticate with SyncYomi. You must use the same API key in both Suwayomi and SyncYomi.
|
||||
- `server.syncDataManga` enables syncing manga.
|
||||
- `server.syncDataChapters` enables syncing chapters.
|
||||
- `server.syncDataTracking` enables syncing tracking data.
|
||||
- `server.syncDataHistory` enables syncing reading history.
|
||||
- `server.syncDataCategories` enables syncing categories.
|
||||
- `server.syncInterval` interval between automatic sync operations. Use `0s` to disable.
|
||||
|
||||
**Note:** The example [docker-compose.yml file](https://github.com/Suwayomi/Suwayomi-Server-docker/blob/main/docker-compose.yml) contains everything you need to get started with Suwayomi+PostgreSQL. Please be aware that PostgreSQL support is currently still in beta.
|
||||
|
||||
**Note:** These settings are excluded from backups, so a backup can be used to easily switch database installations by setting up the connection first, then restoring the backup.
|
||||
|
||||
@@ -1,19 +1,92 @@
|
||||
# Troubleshooting
|
||||
|
||||
This page is laid out in several sections, where each section describes a specific problem, followed by one or more possible solutions.
|
||||
|
||||
At the end, you will find a General section, which is the nuclear option if nothing else works.
|
||||
|
||||
For further support, visit the [official Suwayomi Discord server](https://discord.gg/DDZdqZWaHA).
|
||||
In such cases, it will be helpful to have logs ready. You can find them in [The Data Directory](./The-Data-Directory) in the logs directory.
|
||||
|
||||
**All steps below assume that you have stopped Suwayomi**.
|
||||
|
||||
|
||||
## Broken database
|
||||
|
||||
- `failed due to
|
||||
org.jetbrains.exposed.exceptions.ExposedSQLException: org.h2.jdbc.JdbcSQLSyntaxErrorException: Column "CATEGORY.SORT_ORDER" not found`
|
||||
- `org.h2.jdbc.JdbcSQLSyntaxErrorException: Column "CHAPTER.KOREADER_HASH" not found`
|
||||
- `java.lang.IllegalStateException: Unable to read the page at position 96170708817765466`
|
||||
- Any other error text that includes "SQL Statement"
|
||||
|
||||
Your database is either corrupted or incompatible.
|
||||
|
||||
One of these is the cause:
|
||||
- You were running a preview version and decided to downgrade to stable.
|
||||
- You did not shut down Suwayomi properly.
|
||||
- Suwayomi crashed in an unexpected way.
|
||||
|
||||
Solutions:
|
||||
- If you downgraded, upgrade to preview again.
|
||||
- Otherwise, you will need to reset and restore from a backup. See [General Troubleshooting](#general-troubleshooting) below.
|
||||
|
||||
|
||||
## `HTTP error 429`
|
||||
|
||||
The source (or, if trackers are enabled, possibly the tracker) has blocked you for sending too many requests.
|
||||
Note that Mass-Migration can result in an unexpectedly high number of requests to both the source and any configured trackers.
|
||||
|
||||
Solution: Use other/more sources, download less, and wait between request-heavy actions.
|
||||
|
||||
|
||||
## Extension times out
|
||||
|
||||
- `Timed out waiting for 20000 ms…`
|
||||
- `Timed out waiting for page list`
|
||||
|
||||
First, check if this is an extension issue or a Suwayomi issue.
|
||||
On the manga page of the problematic entry, click "Open in WebView".
|
||||
|
||||
Solutions:
|
||||
- If the WebView loads: The issue is with the extension. Search [the issues](https://github.com/Suwayomi/Suwayomi-Server/issues) and discord if there are known problems with that extension.
|
||||
- If the WebView errors: Go to [The Data Directory](./The-Data-Directory) and remove the `bin` and `cache` folders.
|
||||
- If the WebView still does not work after a restart, your installation is incomplete. On Linux, refer to [the README](https://github.com/Suwayomi/Suwayomi-Server#webview-support-gnulinux).
|
||||
|
||||
|
||||
## Flaresolverr required
|
||||
|
||||
- `java.io.IOException: Cloudflare bypass currently disabled`
|
||||
|
||||
The source you are using has enabled CloudFlare's bot protection.
|
||||
If you open the source's website in your browser, you should see the "Confirm I'm human" page.
|
||||
|
||||
Solution:
|
||||
- Download and set up [Flaresolverr](https://github.com/FlareSolverr/FlareSolverr) or [Byparr](https://github.com/ThePhaseless/Byparr). Make sure to run Flaresolverr/Byparr every time you use this source.
|
||||
|
||||
|
||||
## Flaresolverr not running
|
||||
|
||||
- `java.io.IOException: Failed to connect to localhost/[0:0:0:0:0:0:0:1]:8191`
|
||||
|
||||
You have configured Flaresolverr by enabling the `server.flareSolverrEnabled` setting, but Flaresolverr is not installed and/or running.
|
||||
|
||||
Solutions:
|
||||
- Install Flaresolverr if you haven't already (see previous section). Then ensure it is running (Windows: do not close the console window!).
|
||||
- If it is running, ensure the configured url in `server.flareSolverrUrl` is correct. There is usually no need to change this.
|
||||
- If it is running and the url is correct, check your firewall settings, your system may be blocking access to Flaresolverr.
|
||||
|
||||
|
||||
## General Troubleshooting
|
||||
This guide will try to fix Suwayomi by reseting it to a clean installation state.
|
||||
|
||||
> [!WARNING]
|
||||
> This will remove all your data, including the library.
|
||||
> Make sure you have copied your backups as described above!
|
||||
|
||||
- Make sure you have a recent backup of your library or create one in the app (if possible) because we **are going to wipe all Suwayomi data**.
|
||||
- Make sure Suwayomi is not running (right click on tray icon and quit or kill it through the way your Operating System provides)
|
||||
- Clear all browsing data on your browser if you use Suwayomi from a browser.
|
||||
- Delete the Suwayomi data directory located below and re-run the app.
|
||||
|
||||
Note: Replace `<Account>` with the currently logged in account/username on your pc.
|
||||
|
||||
On Mac OS X : `/Users/<Account>/Library/Application Support/Tachidesk`
|
||||
|
||||
On Windows XP : `C:\Documents and Settings\<Account>\Application Data\Local Settings\Tachidesk`
|
||||
|
||||
On Windows 7 and later : `C:\Users\<Account>\AppData\Local\Tachidesk`
|
||||
|
||||
On Unix/Linux : `/home/<account>/.local/share/Tachidesk`
|
||||
|
||||
- Delete the Suwayomi data directory located below and re-run the app. See the article [The Data Directory](./The-Data-Directory) for information on how to find it.
|
||||
- If you wish to keep your downloads, you may also attempt to surgically remove only parts. You will need to remove `database.mv.db`, `database.trace.db`, `bin`, `cache`, `extensions`, `settings`, `webUI`. Removing only a subset of these files and folders may fail to resolve the problem.
|
||||
- Open Suwayomi and go to Settings > Backup > Restore Backup, and select the latest backup you have.
|
||||
- Restoring from backup does not restore your downloads. If you chose to keep them in the above step, you will now need to re-download all manga. Suwayomi will pick up on the existing files and not actually download anything that isn't new.
|
||||
- In the case that you have to periodically perform this fix or the problem persists or the method failed to fix it, open an issue or Join the [Suwayomi discord server](https://discord.gg/DDZdqZWaHA) to hang out with the community and to receive support and help.
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
[versions]
|
||||
kotlin = "2.3.10"
|
||||
coroutines = "1.10.2"
|
||||
serialization = "1.10.0"
|
||||
kotlin = "2.4.0"
|
||||
coroutines = "1.11.0"
|
||||
serialization = "1.11.0"
|
||||
jvmTarget = "21"
|
||||
okhttp = "5.3.2" # Major version is locked by Tachiyomi extensions
|
||||
javalin = "6.7.0"
|
||||
jte = "3.2.3"
|
||||
jackson = "2.18.3" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
|
||||
exposed = "0.61.0"
|
||||
dex2jar = "2.4.34"
|
||||
polyglot = "24.2.2"
|
||||
okhttp = "5.4.0" # Major version is locked by Tachiyomi extensions
|
||||
javalin = "7.2.2"
|
||||
jte = "3.2.4"
|
||||
jackson = "3.2.0" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
|
||||
exposed = "1.2.0"
|
||||
dex2jar = "2.4.37"
|
||||
polyglot = "25.0.3"
|
||||
settings = "1.3.0"
|
||||
twelvemonkeys = "3.13.1"
|
||||
graphqlkotlin = "8.9.0"
|
||||
graphqlkotlin = "10.0.0"
|
||||
xmlserialization = "0.91.3"
|
||||
ktlint = "1.8.0"
|
||||
koin = "4.1.1"
|
||||
moko = "0.26.0"
|
||||
koin = "4.2.2"
|
||||
moko = "0.26.4"
|
||||
jcef = "144.0.15-g72717cf-chromium-144.0.7559.172-api-1.21-262-b37"
|
||||
|
||||
[libraries]
|
||||
# Kotlin
|
||||
@@ -37,24 +38,24 @@ serialization-xml-core = { module = "io.github.pdvrieze.xmlutil:core", version.r
|
||||
serialization-xml = { module = "io.github.pdvrieze.xmlutil:serialization-jvm", version.ref = "xmlserialization" }
|
||||
|
||||
# Logging
|
||||
slf4japi = "org.slf4j:slf4j-api:2.0.17"
|
||||
logback = "ch.qos.logback:logback-classic:1.5.32"
|
||||
kotlinlogging = "io.github.oshai:kotlin-logging-jvm:8.0.01"
|
||||
slf4japi = "org.slf4j:slf4j-api:2.0.18"
|
||||
logback = "ch.qos.logback:logback-classic:1.5.37"
|
||||
kotlinlogging = "io.github.oshai:kotlin-logging-jvm:8.0.4"
|
||||
|
||||
# OkHttp
|
||||
okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||
okhttp-dnsoverhttps = { module = "com.squareup.okhttp3:okhttp-dnsoverhttps", version.ref = "okhttp" }
|
||||
okhttp-brotli = { module = "com.squareup.okhttp3:okhttp-brotli", version.ref = "okhttp" }
|
||||
okio = "com.squareup.okio:okio:3.16.4"
|
||||
okio = "com.squareup.okio:okio:3.17.0"
|
||||
|
||||
# Javalin api
|
||||
javalin-core = { module = "io.javalin:javalin", version.ref = "javalin" }
|
||||
javalin-openapi = { module = "io.javalin:javalin-openapi", version.ref = "javalin" }
|
||||
javalin-rendering = { module = "io.javalin:javalin-rendering", version.ref = "javalin" }
|
||||
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
|
||||
jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
|
||||
jackson-annotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jackson" }
|
||||
javalin-rendering = { module = "io.javalin:javalin-rendering-jte", version.ref = "javalin" }
|
||||
jackson-databind = { module = "tools.jackson.core:jackson-databind", version.ref = "jackson" }
|
||||
jackson-kotlin = { module = "tools.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
|
||||
jackson-annotations = "com.fasterxml.jackson.core:jackson-annotations:2.22"
|
||||
jte = { module = "gg.jte:jte", version.ref = "jte" }
|
||||
kte = { module = "gg.jte:jte-kotlin", version.ref = "jte" }
|
||||
|
||||
@@ -68,12 +69,13 @@ exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "e
|
||||
exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" }
|
||||
exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
|
||||
exposed-javatime = { module = "org.jetbrains.exposed:exposed-java-time", version.ref = "exposed" }
|
||||
postgres = "org.postgresql:postgresql:42.7.10"
|
||||
h2 = "com.h2database:h2:1.4.200" # current database driver, can't update to h2 v2 without sql migration
|
||||
hikaricp = "com.zaxxer:HikariCP:7.0.2"
|
||||
exposed-kotlintime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" }
|
||||
postgres = "org.postgresql:postgresql:42.7.11"
|
||||
h2 = "com.h2database:h2:2.4.240"
|
||||
hikaricp = "com.zaxxer:HikariCP:7.1.0"
|
||||
|
||||
# Exposed Migrations
|
||||
exposed-migrations = "com.github.Suwayomi:exposed-migrations:3.8.0"
|
||||
exposed-migrations = "com.github.Suwayomi:exposed-migrations:3.10.1"
|
||||
|
||||
# Dependency Injection
|
||||
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
|
||||
@@ -86,10 +88,10 @@ systemtray-desktop = "com.dorkbox:Desktop:1.1" # version locked by SystemTray
|
||||
# dependencies of Tachiyomi extensions
|
||||
injekt = "com.github.null2264:injekt-koin:ee267b2e27"
|
||||
rxjava = "io.reactivex:rxjava:1.3.8"
|
||||
jsoup = "org.jsoup:jsoup:1.22.1"
|
||||
jsoup = "org.jsoup:jsoup:1.22.2"
|
||||
|
||||
# Config
|
||||
config = "com.typesafe:config:1.4.5"
|
||||
config = "com.typesafe:config:1.4.9"
|
||||
config4k = "io.github.config4k:config4k:0.7.0"
|
||||
|
||||
# Sort
|
||||
@@ -105,7 +107,7 @@ dex2jar-tools = { module = "de.femtopedia.dex2jar:dex-tools", version.ref = "dex
|
||||
|
||||
# APK
|
||||
apk-parser = "net.dongliu:apk-parser:2.6.10"
|
||||
apksig = "com.android.tools.build:apksig:9.0.1"
|
||||
apksig = "com.android.tools.build:apksig:9.2.1"
|
||||
|
||||
# Xml
|
||||
xmlpull = "xmlpull:xmlpull:1.1.3.4a"
|
||||
@@ -115,13 +117,13 @@ appdirs = "ca.gosyer:kotlin-multiplatform-appdirs:2.0.0"
|
||||
cache4k = "io.github.reactivecircus.cache4k:cache4k:0.14.0"
|
||||
zip4j = "net.lingala.zip4j:zip4j:2.11.6"
|
||||
commonscompress = "org.apache.commons:commons-compress:1.28.0"
|
||||
junrar = "com.github.junrar:junrar:7.5.10"
|
||||
junrar = "com.github.junrar:junrar:7.6.0"
|
||||
|
||||
# AES/CBC/PKCS7Padding Cypher provider
|
||||
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.83"
|
||||
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.84"
|
||||
|
||||
# AndroidX annotations
|
||||
android-annotations = "androidx.annotation:annotation:1.9.1"
|
||||
android-annotations = "androidx.annotation:annotation:1.10.0"
|
||||
|
||||
# Substitute for duktape-android
|
||||
polyglot-core = { module = "org.graalvm.polyglot:polyglot", version.ref = "polyglot" }
|
||||
@@ -132,7 +134,7 @@ settings-core = { module = "com.russhwolf:multiplatform-settings-jvm", version.r
|
||||
settings-serialization = { module = "com.russhwolf:multiplatform-settings-serialization-jvm", version.ref = "settings" }
|
||||
|
||||
# ICU4J
|
||||
icu4j = "com.ibm.icu:icu4j:78.2"
|
||||
icu4j = "com.ibm.icu:icu4j:78.3"
|
||||
|
||||
# Image Decoding implementation provider
|
||||
twelvemonkeys-common-lang = { module = "com.twelvemonkeys.common:common-lang", version.ref = "twelvemonkeys" }
|
||||
@@ -143,10 +145,10 @@ twelvemonkeys-imageio-metadata = { module = "com.twelvemonkeys.imageio:imageio-m
|
||||
twelvemonkeys-imageio-jpeg = { module = "com.twelvemonkeys.imageio:imageio-jpeg", version.ref = "twelvemonkeys" }
|
||||
twelvemonkeys-imageio-webp = { module = "com.twelvemonkeys.imageio:imageio-webp", version.ref = "twelvemonkeys" }
|
||||
|
||||
imageio-webp = "com.github.usefulness:webp-imageio:0.10.2"
|
||||
imageio-webp = "com.github.usefulness:webp-imageio:0.11.0"
|
||||
|
||||
# Testing
|
||||
mockk = "io.mockk:mockk:1.14.9"
|
||||
mockk = "io.mockk:mockk:1.14.11"
|
||||
|
||||
# cron scheduler
|
||||
cron4j = "it.sauronsoftware.cron4j:cron4j:2.2.5"
|
||||
@@ -155,10 +157,10 @@ cron4j = "it.sauronsoftware.cron4j:cron4j:2.2.5"
|
||||
cronUtils = "com.cronutils:cron-utils:9.2.1"
|
||||
|
||||
# Webview
|
||||
kcef = "dev.datlag:kcef:2024.04.20.4"
|
||||
jcef = { module = "org.jetbrains.intellij.deps.jcef:jcef", version.ref = "jcef" }
|
||||
|
||||
# User
|
||||
jwt = "com.auth0:java-jwt:4.5.1"
|
||||
jwt = "com.auth0:java-jwt:4.5.2"
|
||||
|
||||
# lint - used for renovate to update ktlint version
|
||||
ktlint = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlint" }
|
||||
@@ -173,16 +175,16 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi
|
||||
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin"}
|
||||
|
||||
# Linter
|
||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "14.0.1"}
|
||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "14.2.0"}
|
||||
|
||||
# Build config
|
||||
buildconfig = { id = "com.github.gmazzo.buildconfig", version = "6.0.7"}
|
||||
buildconfig = { id = "com.github.gmazzo.buildconfig", version = "6.0.10"}
|
||||
|
||||
# Download
|
||||
download = { id = "de.undercouch.download", version = "5.7.0"}
|
||||
|
||||
# ShadowJar
|
||||
shadowjar = { id = "com.gradleup.shadow", version = "8.3.9"}
|
||||
shadowjar = { id = "com.gradleup.shadow", version = "8.3.11"}
|
||||
|
||||
# Moko
|
||||
moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" }
|
||||
@@ -212,7 +214,7 @@ shared = [
|
||||
"dex2jar-tools",
|
||||
"apk-parser",
|
||||
"jackson-annotations",
|
||||
"kcef"
|
||||
"jcef",
|
||||
]
|
||||
|
||||
sharedTest = [
|
||||
@@ -242,6 +244,7 @@ exposed = [
|
||||
"exposed-dao",
|
||||
"exposed-jdbc",
|
||||
"exposed-javatime",
|
||||
"exposed-kotlintime",
|
||||
]
|
||||
systemtray = [
|
||||
"systemtray-core",
|
||||
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
4
gradle/wrapper/gradle-wrapper.properties
vendored
4
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,7 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
2
gradlew
vendored
2
gradlew
vendored
@@ -57,7 +57,7 @@
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
|
||||
31
gradlew.bat
vendored
31
gradlew.bat
vendored
@@ -23,8 +23,8 @@
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@@ -51,7 +51,7 @@ echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
@@ -65,7 +65,7 @@ echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
@@ -73,21 +73,10 @@ goto fail
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
|
||||
@@ -16,14 +16,43 @@
|
||||
"depNameTemplate": "zulu",
|
||||
"datasourceTemplate": "custom.zulu",
|
||||
"versioningTemplate": "regex:^(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+).*$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/buildSrc/src/main/kotlin/Constants.kt/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"val\\s+webviewJbrRelease\\s*=\\s*\"(?<currentValue>[^\"]+)\""
|
||||
],
|
||||
"depNameTemplate": "JetBrainsRuntime",
|
||||
"packageNameTemplate": "JetBrains/JetBrainsRuntime",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"versioningTemplate": "regex:^jbr-release-(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)b(?<build>\\d+)\\.(?<revision>\\d+)$"
|
||||
}
|
||||
],
|
||||
"customDatasources": {
|
||||
"zulu": {
|
||||
"defaultRegistryUrlTemplate": "https://api.azul.com/metadata/v1/zulu/packages?availability_types=ca&release_status=both&java_package_type=jre&crac_supported=false&javafx_bundled=false&support_term=lts&arch=x86&os=linux&archive_type=zip&page_size=1000&include_fields=java_package_features,release_status,support_term,os,arch,hw_bitness,abi,java_package_type,javafx_bundled,sha256_hash,cpu_gen,size,archive_type,certifications,lib_c_type,crac_supported&page=1&azul_com=true",
|
||||
"transformTemplates": [
|
||||
"{\"releases\": $$.$join([$join($map(distro_version[[0..2]], $string), \".\"), \"_\", $join($map(java_version, $string), \".\")])}"
|
||||
"{ \"releases\": $map($, function($v) { { \"version\": $join([$string($v.distro_version[0]), \".\", $string($v.distro_version[1]), \".\", $string($v.distro_version[2]), \"_\", $string($v.java_version[0]), \".\", $string($v.java_version[1]), \".\", $string($v.java_version[2])]) } }) }"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": [
|
||||
"org.jetbrains.intellij.deps.jcef:jcef",
|
||||
"JetBrains/JetBrainsRuntime"
|
||||
],
|
||||
"groupName": "JCEF + JetBrains Runtime",
|
||||
"groupSlug": "jcef-jbr"
|
||||
},
|
||||
{
|
||||
"matchPackageNames": [
|
||||
"com.github.Suwayomi:android-jar"
|
||||
],
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ main() {
|
||||
gcc -fPIC -shared scripts/resources/catch_abort.c -lpthread -o scripts/resources/catch_abort.so
|
||||
fi
|
||||
|
||||
JRE_ZULU="25.30.17_25.0.1"
|
||||
JRE_ZULU="25.34.17_25.0.3"
|
||||
JRE_RELEASE="jre${JRE_ZULU#*_}" # e.g. jre25.0.1
|
||||
ZULU_RELEASE="zulu${JRE_ZULU%_*}" # e.g. zulu25.30.17
|
||||
|
||||
@@ -149,15 +149,15 @@ move_release_to_output_dir() {
|
||||
}
|
||||
|
||||
download_launcher() {
|
||||
LAUNCHER_URL=$(curl -s "https://api.github.com/repos/Suwayomi/Suwayomi-Launcher/releases/latest" | grep "browser_download_url" | grep ".jar" | head -n 1 | cut -d '"' -f 4)
|
||||
curl -L "$LAUNCHER_URL" -o "Suwayomi-Launcher.jar"
|
||||
LAUNCHER_URL=$(curl -sf "https://api.github.com/repos/Suwayomi/Suwayomi-Launcher/releases/latest" | grep "browser_download_url" | grep ".jar" | head -n 1 | cut -d '"' -f 4)
|
||||
curl -fL "$LAUNCHER_URL" -o "Suwayomi-Launcher.jar"
|
||||
mv "Suwayomi-Launcher.jar" "$RELEASE_NAME/Suwayomi-Launcher.jar"
|
||||
}
|
||||
|
||||
download_jogamp() {
|
||||
local platform="$1"
|
||||
if [ ! -f jogamp-all-platforms.7z ]; then
|
||||
curl "https://jogamp.org/deployment/jogamp-current/archive/jogamp-all-platforms.7z" -o jogamp-all-platforms.7z
|
||||
curl -f "https://jogamp.org/deployment/jogamp-current/archive/jogamp-all-platforms.7z" -o jogamp-all-platforms.7z
|
||||
fi
|
||||
|
||||
7z x jogamp-all-platforms.7z "jogamp-all-platforms/lib/$platform/"
|
||||
@@ -168,7 +168,7 @@ download_jogamp() {
|
||||
|
||||
download_electron() {
|
||||
if [ ! -f "$ELECTRON" ]; then
|
||||
curl -L "$ELECTRON_URL" -o "$ELECTRON"
|
||||
curl -fL "$ELECTRON_URL" -o "$ELECTRON"
|
||||
fi
|
||||
|
||||
unzip "$ELECTRON" -d "$RELEASE_NAME/electron/"
|
||||
@@ -181,7 +181,7 @@ setup_jre() {
|
||||
mv "jre" "$RELEASE_NAME/jre"
|
||||
else
|
||||
if [ ! -f "$JRE" ]; then
|
||||
curl -L "$JRE_URL" -o "$JRE"
|
||||
curl -fL "$JRE_URL" -o "$JRE"
|
||||
fi
|
||||
|
||||
local ext="${JRE##*.}"
|
||||
@@ -273,7 +273,7 @@ make_appimage() {
|
||||
sudo apt update
|
||||
sudo apt install libfuse2
|
||||
fi
|
||||
curl -L $APPIMAGE_URL -o $APPIMAGE_TOOLNAME
|
||||
curl -fL $APPIMAGE_URL -o $APPIMAGE_TOOLNAME
|
||||
chmod +x $APPIMAGE_TOOLNAME
|
||||
ARCH=x86_64 ./$APPIMAGE_TOOLNAME "$RELEASE_NAME" "$RELEASE"
|
||||
}
|
||||
@@ -300,7 +300,7 @@ make_windows_bundle() {
|
||||
#local rcedit_url="https://github.com/electron/rcedit/releases/download/v0.1.1/$rcedit"
|
||||
## change electron's icon
|
||||
#if [ ! -f "$rcedit" ]; then
|
||||
#curl -L "$rcedit_url" -o "$rcedit"
|
||||
#curl -fL "$rcedit_url" -o "$rcedit"
|
||||
#fi
|
||||
|
||||
#local icon="server/src/main/resources/icon/faviconlogo.ico"
|
||||
|
||||
@@ -159,6 +159,8 @@ buildConfig {
|
||||
|
||||
buildConfigField("String", "GITHUB", quoteWrap("https://github.com/Suwayomi/Suwayomi-Server"))
|
||||
buildConfigField("String", "DISCORD", quoteWrap("https://discord.gg/DDZdqZWaHA"))
|
||||
buildConfigField("String", "JCEF_VERSION", quoteWrap(libs.versions.jcef.get()))
|
||||
buildConfigField("String", "JCEF_JBR_RELEASE", quoteWrap(webviewJbrRelease))
|
||||
}
|
||||
|
||||
tasks {
|
||||
@@ -171,6 +173,8 @@ tasks {
|
||||
"Implementation-Vendor" to "The Suwayomi Project",
|
||||
"Specification-Version" to getTachideskVersion(),
|
||||
"Implementation-Version" to getTachideskRevision(),
|
||||
"Multi-Release" to true, // needed for polyglot
|
||||
"X-JBR-Release" to webviewJbrRelease,
|
||||
)
|
||||
}
|
||||
archiveBaseName.set(rootProject.name)
|
||||
@@ -181,7 +185,11 @@ tasks {
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
useJUnitPlatform {
|
||||
if (!project.hasProperty("masstest")) {
|
||||
exclude("**/masstest/*")
|
||||
}
|
||||
}
|
||||
testLogging {
|
||||
showStandardStreams = true
|
||||
events("passed", "skipped", "failed")
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
<!-- OPDS errors -->
|
||||
<string name="opds_error_manga_not_found">Series with ID %1$d not found.</string>
|
||||
<string name="opds_error_chapter_not_found">Chapter with index %1$d not found.</string>
|
||||
<string name="opds_error_chapters_not_found">No chapters found or the source is unreachable on page %1$d.</string>
|
||||
|
||||
<!-- OPDS facets (Filters and Sorting) -->
|
||||
<string name="opds_facetgroup_sort_order">Sort Order</string>
|
||||
@@ -153,4 +154,7 @@
|
||||
<string name="login_label_login">Log In</string>
|
||||
<string name="login_placeholder_username">Type username...</string>
|
||||
<string name="login_placeholder_password">Secret...</string>
|
||||
|
||||
<string name="opds_chapter_title_oneshot">Oneshot</string>
|
||||
<string name="opds_chapter_title_fallback">Chapter %1$s</string>
|
||||
</resources>
|
||||
|
||||
125
server/i18n/src/commonMain/moko-resources/values/el/strings.xml
Normal file
125
server/i18n/src/commonMain/moko-resources/values/el/strings.xml
Normal file
@@ -0,0 +1,125 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="opds_feeds_root">Suwayomi OPDS Κατάλογος</string>
|
||||
<string name="opds_feeds_manga_chapters">Κεφάλαια %1$s</string>
|
||||
<string name="opds_feeds_chapter_details">%1$s | %2$s | Λεπτομέρειες</string>
|
||||
<string name="opds_feeds_explore_title">Εξερεύνηση</string>
|
||||
<string name="opds_feeds_explore_entry_content">Εξερεύνησε νέες σειρές από τις πηγές σου</string>
|
||||
<string name="opds_feeds_history_title">Ιστορικό</string>
|
||||
<string name="opds_feeds_history_entry_content">Πρόσφατα διαβασμένα κεφάλαια</string>
|
||||
<string name="opds_feeds_all_series_in_library_title">Όλες οι σειρές</string>
|
||||
<string name="opds_feeds_all_series_in_library_entry_content">Περιήγηση σε όλες τις σειρές της βιβλιοθήκης σου</string>
|
||||
<string name="opds_feeds_library_sources_title">Πηγές</string>
|
||||
<string name="opds_feeds_library_sources_entry_content">Περιήγηση σε σειρές της βιβλιοθήκης σου φιλτραρισμένες κατά πηγή</string>
|
||||
<string name="opds_feeds_categories_title">Κατηγορίες</string>
|
||||
<string name="opds_feeds_categories_entry_content">Περιήγηση σε σειρές οργανωμένες κατά κατηγορία</string>
|
||||
<string name="opds_feeds_genres_title">Είδη</string>
|
||||
<string name="opds_feeds_genres_entry_content">Περιήγηση σε σειρές κατά ετικέτες είδους</string>
|
||||
<string name="opds_feeds_status_title">Κατάσταση</string>
|
||||
<string name="opds_feeds_status_entry_content">Περιήγηση σε σειρές κατά κατάσταση δημοσίευσης</string>
|
||||
<string name="opds_feeds_languages_title">Γλώσσες</string>
|
||||
<string name="opds_feeds_languages_entry_content">Περιήγηση σε σειρές κατά γλώσσα περιεχομένου</string>
|
||||
<string name="opds_feeds_library_updates_title">Ιστορικό ενημερώσεων βιβλιοθήκης</string>
|
||||
<string name="opds_feeds_library_updates_entry_content">Πρόσφατα ενημερωμένα κεφάλαια από τη βιβλιοθήκη σου</string>
|
||||
<string name="opds_feeds_search_results_title">Αποτελέσματα αναζήτησης</string>
|
||||
<string name="opds_feeds_sources_title">Όλες οι πηγές</string>
|
||||
<string name="opds_feeds_category_specific_title">Κατηγορία: %1$s</string>
|
||||
<string name="opds_feeds_genre_specific_title">Είδος: %1$s</string>
|
||||
<string name="opds_feeds_status_specific_title">Κατάσταση: %1$s</string>
|
||||
<string name="opds_feeds_language_specific_title">Γλώσσα: %1$s</string>
|
||||
<string name="opds_feeds_source_specific_title">Πηγή: %1$s</string>
|
||||
<string name="opds_feeds_library_source_specific_title">Βιβλιοθήκη - Πηγή: %1$s</string>
|
||||
<string name="opds_feeds_source_specific_popular_title">Πηγή: %1$s - Δημοφιλές</string>
|
||||
<string name="opds_feeds_source_specific_latest_title">Πηγή: %1$s - Τελευταίο</string>
|
||||
<string name="opds_search_shortname">Suwayomi OPDS Αναζήτηση</string>
|
||||
<string name="opds_search_description">Αναζήτηση σειρών στον κατάλογο.</string>
|
||||
<string name="opds_error_manga_not_found">Η σειρά με ID %1$d δεν βρέθηκε.</string>
|
||||
<string name="opds_error_chapter_not_found">Το κεφάλαιο με δείκτη %1$d δεν βρέθηκε.</string>
|
||||
<string name="opds_facetgroup_sort_order">Σειρά ταξινόμησης</string>
|
||||
<string name="opds_facetgroup_filter_read_status">Φίλτρο κατά κατάσταση ανάγνωσης</string>
|
||||
<string name="opds_facetgroup_filter_content">Φίλτρο περιεχομένου</string>
|
||||
<string name="opds_facetgroup_filter_source">Φίλτρο κατά πηγή</string>
|
||||
<string name="opds_facetgroup_filter_category">Φίλτρο κατά κατηγορία</string>
|
||||
<string name="opds_facetgroup_filter_status">Φίλτρο κατά κατάσταση</string>
|
||||
<string name="opds_facetgroup_filter_language">Φίλτρο κατά γλώσσα</string>
|
||||
<string name="opds_facetgroup_filter_genre">Φίλτρο κατά είδος</string>
|
||||
<string name="opds_facet_sort_oldest_first">Παλαιότερα πρώτα</string>
|
||||
<string name="opds_facet_sort_newest_first">Νεότερα πρώτα</string>
|
||||
<string name="opds_facet_sort_date_asc">Ημερομηνία αύξουσα</string>
|
||||
<string name="opds_facet_sort_date_desc">Ημερομηνία φθίνουσα</string>
|
||||
<string name="opds_facet_sort_popular">Δημοφιλές</string>
|
||||
<string name="opds_facet_sort_latest">Τελευταίο</string>
|
||||
<string name="opds_facet_sort_alpha_asc">Αλφαβητικά Α-Ω</string>
|
||||
<string name="opds_facet_sort_alpha_desc">Αλφαβητικά Ω-Α</string>
|
||||
<string name="opds_facet_sort_last_read_desc">Τελευταία ανάγνωση</string>
|
||||
<string name="opds_facet_sort_latest_chapter_desc">Τελευταίο κεφάλαιο</string>
|
||||
<string name="opds_facet_sort_date_added_desc">Ημερομηνία προσθήκης</string>
|
||||
<string name="opds_facet_sort_unread_desc">Αδιάβαστα κεφάλαια</string>
|
||||
<string name="opds_facet_filter_all">Όλα</string>
|
||||
<string name="opds_facet_filter_all_chapters">Όλα τα κεφάλαια</string>
|
||||
<string name="opds_facet_filter_unread_only">Αδιάβαστα</string>
|
||||
<string name="opds_facet_filter_read_only">Διαβασμένα</string>
|
||||
<string name="opds_facet_filter_downloaded">Ληφθέντα</string>
|
||||
<string name="opds_facet_filter_ongoing">Σε εξέλιξη</string>
|
||||
<string name="opds_facet_filter_completed">Ολοκληρωμένα</string>
|
||||
<string name="opds_facet_all_sources">Όλες οι πηγές</string>
|
||||
<string name="opds_facet_all_categories">Όλες οι κατηγορίες</string>
|
||||
<string name="opds_facet_all_statuses">Όλες οι καταστάσεις</string>
|
||||
<string name="opds_facet_all_languages">Όλες οι γλώσσες</string>
|
||||
<string name="opds_facet_all_genres">Όλα τα είδη</string>
|
||||
<string name="opds_linktitle_catalog_root">Ρίζα καταλόγου</string>
|
||||
<string name="opds_linktitle_search_catalog">Αναζήτηση καταλόγου</string>
|
||||
<string name="opds_linktitle_first_page">Πρώτη σελίδα</string>
|
||||
<string name="opds_linktitle_previous_page">Προηγούμενη σελίδα</string>
|
||||
<string name="opds_linktitle_next_page">Επόμενη σελίδα</string>
|
||||
<string name="opds_linktitle_last_page">Τελευταία σελίδα</string>
|
||||
<string name="opds_linktitle_self_feed">Τρέχουσα ροή</string>
|
||||
<string name="opds_linktitle_view_on_web">Προβολή στο Web</string>
|
||||
<string name="opds_linktitle_stream_pages_start">Διάβασε Online</string>
|
||||
<string name="opds_linktitle_stream_pages_continue">Συνέχισε να διαβάζεις Online</string>
|
||||
<string name="opds_linktitle_stream_pages_start_local">Διάβασε Online (Τοπική πρόοδος)</string>
|
||||
<string name="opds_linktitle_stream_pages_continue_local">Συνέχισε να διαβάζεις Online (Τοπική πρόοδος)</string>
|
||||
<string name="opds_linktitle_stream_pages_start_remote">Διάβασε Online (Συγχρονισμένο από %1$s)</string>
|
||||
<string name="opds_linktitle_stream_pages_continue_remote">Συνέχισε να διαβάζεις Online (Συγχρονισμένο από %1$s)</string>
|
||||
<string name="opds_linktitle_download_cbz">Λήψη CBZ</string>
|
||||
<string name="opds_linktitle_chapter_cover">Εξώφυλλο κεφαλαίου</string>
|
||||
<string name="opds_linktitle_view_chapter_details">Λεπτομέρειες κεφαλαίου & Σελίδες</string>
|
||||
<string name="opds_chapter_status_read">✅</string>
|
||||
<string name="opds_chapter_status_in_progress">⌛</string>
|
||||
<string name="opds_chapter_status_unread">⭕</string>
|
||||
<string name="opds_chapter_status_downloaded">⬇️</string>
|
||||
<string name="opds_chapter_status_synced">🌐</string>
|
||||
<string name="opds_chapter_details_base">Σειρά: %1$s | %2$s</string>
|
||||
<string name="opds_chapter_details_scanlator">| Από %1$s</string>
|
||||
<string name="opds_chapter_details_progress">| Πρόοδος: %1$d από %2$d</string>
|
||||
<string name="opds_manga_summary_status">Κατάσταση: %1$s</string>
|
||||
<string name="opds_manga_summary_source">Πηγή: %1$s</string>
|
||||
<string name="opds_manga_summary_language">Γλώσσα: %1$s</string>
|
||||
<string name="manga_status_ongoing">Σε εξέλιξη</string>
|
||||
<string name="manga_status_completed">Ολοκληρωμένο</string>
|
||||
<string name="manga_status_licensed">Υπό άδεια</string>
|
||||
<string name="manga_status_publishing_finished">Δημοσίευση ολοκληρωμένη</string>
|
||||
<string name="manga_status_cancelled">Ακυρωμένο</string>
|
||||
<string name="manga_status_on_hiatus">Σε παύση</string>
|
||||
<string name="manga_status_unknown">Άγνωστο</string>
|
||||
<string name="label_error">Σφάλμα</string>
|
||||
<string name="label_version">Έκδοση <xliff:g id="version" example="v2.0.1833">%1$s</xliff:g></string>
|
||||
<string name="label_close">Κλείσε</string>
|
||||
<string name="webview_label_title">Suwayomi WebView</string>
|
||||
<string name="webview_label_disconnected">Αποσυνδεδεμένο, κάνε ανανέωση</string>
|
||||
<string name="webview_label_reversescroll">Αντίστροφη κύλιση</string>
|
||||
<string name="webview_label_bindingshint">Σημείωση: Όταν η εστίαση είναι στο τμήμα WebView, καμία συντόμευση πληκτρολογίου, συμπεριλαμβανομένης της ανανέωσης, δεν θα αντιμετωπίζεται από το πρόγραμμα περιήγησης.</string>
|
||||
<string name="webview_label_init">Αρχικοποίηση... Παρακαλώ περίμενε</string>
|
||||
<string name="webview_label_getstarted">Εισήγαγε μια διεύθυνση URL για να ξεκινήσεις</string>
|
||||
<string name="webview_label_loading">Φόρτωση σελίδας...</string>
|
||||
<string name="webview_label_copy">Αντιγραφή στο πρόχειρο</string>
|
||||
<string name="webview_label_copy_description">Η αυτόματη αντιγραφή στο πρόχειρο απέτυχε, χρησιμοποίησε το παρακάτω πεδίο για να αντιγράψεις χειροκίνητα την τιμή.</string>
|
||||
<string name="webview_label_login_required">Η διαμόρφωσή σου απαιτεί σύνδεση. Εισήγαγε όνομα χρήστη και κωδικό.</string>
|
||||
<string name="webview_placeholder_url">Εισήγαγε URL...</string>
|
||||
<string name="login_label_title">Suwayomi Σύνδεση</string>
|
||||
<string name="login_label_username">Όνομα χρήστη</string>
|
||||
<string name="login_label_password">Κωδικός</string>
|
||||
<string name="login_label_login">Σύνδεση</string>
|
||||
<string name="login_placeholder_username">Πληκτρολόγησε όνομα χρήστη...</string>
|
||||
<string name="login_placeholder_password">Μυστικό...</string>
|
||||
</resources>
|
||||
@@ -2,7 +2,6 @@ package suwayomi.tachidesk.server.settings.generation
|
||||
|
||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||
import java.io.File
|
||||
import kotlin.text.appendLine
|
||||
|
||||
object SettingsBackupServerSettingsGenerator {
|
||||
fun generate(
|
||||
|
||||
@@ -2,7 +2,6 @@ package suwayomi.tachidesk.server.settings.generation
|
||||
|
||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||
import java.io.File
|
||||
import kotlin.text.appendLine
|
||||
|
||||
object SettingsBackupSettingsHandlerGenerator {
|
||||
fun generate(
|
||||
|
||||
@@ -2,7 +2,6 @@ package suwayomi.tachidesk.server.settings.generation
|
||||
|
||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||
import java.io.File
|
||||
import kotlin.text.appendLine
|
||||
|
||||
object SettingsGraphqlTypeGenerator {
|
||||
fun generate(
|
||||
|
||||
@@ -15,17 +15,15 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import suwayomi.tachidesk.graphql.types.AuthMode
|
||||
import suwayomi.tachidesk.graphql.types.CbzMediaType
|
||||
import suwayomi.tachidesk.graphql.types.DatabaseType
|
||||
@@ -56,16 +54,14 @@ import suwayomi.tachidesk.server.settings.PathSetting
|
||||
import suwayomi.tachidesk.server.settings.SettingGroup
|
||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||
import suwayomi.tachidesk.server.settings.StringSetting
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import xyz.nulldev.ts.config.GlobalConfigManager
|
||||
import xyz.nulldev.ts.config.SystemPropertyOverridableConfigModule
|
||||
import kotlin.collections.associate
|
||||
import kotlin.getValue
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
val mutableConfigValueScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@@ -75,6 +71,21 @@ val serverConfig: ServerConfig by lazy { GlobalConfigManager.module() }
|
||||
|
||||
private val application: Application by injectLazy()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun <T> subscribeTo(
|
||||
flow: Flow<T>,
|
||||
ignoreInitialValue: Boolean = true,
|
||||
onChange: suspend (value: T) -> Unit,
|
||||
) {
|
||||
val actualFlow =
|
||||
if (ignoreInitialValue) {
|
||||
flow.drop(1)
|
||||
} else {
|
||||
flow
|
||||
}
|
||||
actualFlow.distinctUntilChanged().conflate().onEach { onChange(it) }.launchIn(mutableConfigValueScope)
|
||||
}
|
||||
|
||||
// Settings are ordered by "protoNumber".
|
||||
class ServerConfig(
|
||||
getConfig: () -> Config,
|
||||
@@ -582,7 +593,7 @@ class ServerConfig(
|
||||
privacySafe = true,
|
||||
defaultValue = SortOrder.DESC,
|
||||
enumClass = SortOrder::class,
|
||||
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("org.jetbrains.exposed.sql.SortOrder")),
|
||||
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("org.jetbrains.exposed.v1.core.SortOrder")),
|
||||
)
|
||||
|
||||
val authMode: MutableStateFlow<AuthMode> by EnumSetting(
|
||||
@@ -1016,7 +1027,85 @@ class ServerConfig(
|
||||
description = "Use Hikari Connection Pool to connect to the database.",
|
||||
)
|
||||
|
||||
val kcefEnabled: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 86,
|
||||
group = SettingGroup.WEB_VIEW,
|
||||
privacySafe = true,
|
||||
defaultValue = true,
|
||||
description = "Enable the WebView via CEF (Chromium)"
|
||||
)
|
||||
|
||||
val syncYomiEnabled: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 87,
|
||||
defaultValue = false,
|
||||
group = SettingGroup.SYNCYOMI,
|
||||
privacySafe = true
|
||||
)
|
||||
|
||||
val syncYomiHost: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 88,
|
||||
defaultValue = "",
|
||||
group = SettingGroup.SYNCYOMI,
|
||||
privacySafe = true,
|
||||
)
|
||||
|
||||
val syncYomiApiKey: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 89,
|
||||
defaultValue = "",
|
||||
group = SettingGroup.SYNCYOMI,
|
||||
privacySafe = false,
|
||||
)
|
||||
|
||||
val syncDataManga: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 90,
|
||||
defaultValue = true,
|
||||
group = SettingGroup.SYNCYOMI,
|
||||
privacySafe = true,
|
||||
)
|
||||
|
||||
val syncDataChapters: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 91,
|
||||
defaultValue = true,
|
||||
group = SettingGroup.SYNCYOMI,
|
||||
privacySafe = true,
|
||||
)
|
||||
|
||||
val syncDataTracking: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 92,
|
||||
defaultValue = true,
|
||||
group = SettingGroup.SYNCYOMI,
|
||||
privacySafe = true,
|
||||
)
|
||||
|
||||
val syncDataHistory: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 93,
|
||||
defaultValue = true,
|
||||
group = SettingGroup.SYNCYOMI,
|
||||
privacySafe = true,
|
||||
)
|
||||
|
||||
val syncDataCategories: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 94,
|
||||
defaultValue = true,
|
||||
group = SettingGroup.SYNCYOMI,
|
||||
privacySafe = true,
|
||||
)
|
||||
|
||||
val syncInterval: MutableStateFlow<Duration> by DurationSetting(
|
||||
protoNumber = 95,
|
||||
defaultValue = 0.seconds,
|
||||
group = SettingGroup.SYNCYOMI,
|
||||
privacySafe = true,
|
||||
)
|
||||
|
||||
val opdsSkipChapterMetadataFeed: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 96,
|
||||
group = SettingGroup.OPDS,
|
||||
privacySafe = true,
|
||||
defaultValue = false,
|
||||
description = "Skips the metadata feed and provides download/stream links directly in the chapter list. Improves compatibility with KOReader auto-downloader. KoSync strategies are applied, but PROMPT conflicts are ignored (treating local progress as priority)."
|
||||
|
||||
)
|
||||
|
||||
/** ****************************************************************** **/
|
||||
/** **/
|
||||
@@ -1061,18 +1150,7 @@ class ServerConfig(
|
||||
flow: Flow<T>,
|
||||
onChange: suspend (value: T) -> Unit,
|
||||
ignoreInitialValue: Boolean = true,
|
||||
) {
|
||||
val actualFlow =
|
||||
if (ignoreInitialValue) {
|
||||
flow.drop(1)
|
||||
} else {
|
||||
flow
|
||||
}
|
||||
|
||||
val sharedFlow = MutableSharedFlow<T>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
actualFlow.distinctUntilChanged().mapLatest { sharedFlow.emit(it) }.launchIn(mutableConfigValueScope)
|
||||
sharedFlow.onEach { onChange(it) }.launchIn(mutableConfigValueScope)
|
||||
}
|
||||
) = subscribeTo(flow, ignoreInitialValue, onChange)
|
||||
|
||||
fun <T> subscribeTo(
|
||||
flow: Flow<T>,
|
||||
|
||||
@@ -17,6 +17,8 @@ enum class SettingGroup(
|
||||
CLOUDFLARE("Cloudflare"),
|
||||
OPDS("OPDS"),
|
||||
KOREADER_SYNC("KOReader sync"),
|
||||
WEB_VIEW("WebView"),
|
||||
SYNCYOMI("SyncYomi")
|
||||
;
|
||||
|
||||
override fun toString(): String = value
|
||||
|
||||
@@ -2,7 +2,6 @@ package suwayomi.tachidesk.server.settings
|
||||
|
||||
import com.typesafe.config.ConfigValue
|
||||
import com.typesafe.config.parser.ConfigDocument
|
||||
import kotlin.collections.find
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
@@ -89,4 +88,6 @@ object SettingsRegistry {
|
||||
fun get(name: String): SettingMetadata? = settings[name]
|
||||
|
||||
fun getAll(): Map<String, SettingMetadata> = settings.toMap()
|
||||
|
||||
fun clear() = settings.clear()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.network
|
||||
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.serialization.DeserializationStrategy
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||
import kotlinx.serialization.serializer
|
||||
@@ -140,17 +139,16 @@ fun OkHttpClient.newCachelessCallWithProgress(
|
||||
return progressClient.newCall(request)
|
||||
}
|
||||
|
||||
context(Json)
|
||||
context(_: Json)
|
||||
inline fun <reified T> Response.parseAs(): T = decodeFromJsonResponse(serializer(), this)
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
context(Json)
|
||||
context(json: Json)
|
||||
fun <T> decodeFromJsonResponse(
|
||||
deserializer: DeserializationStrategy<T>,
|
||||
response: Response,
|
||||
): T =
|
||||
response.body.source().use {
|
||||
decodeFromBufferedSource(deserializer, it)
|
||||
json.decodeFromBufferedSource(deserializer, it)
|
||||
}
|
||||
|
||||
class HttpException(
|
||||
|
||||
@@ -103,7 +103,7 @@ class CloudflareInterceptor(
|
||||
companion object {
|
||||
private val ERROR_CODES = listOf(403, 503)
|
||||
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
|
||||
private val COOKIE_NAMES = listOf("cf_clearance")
|
||||
val COOKIE_NAMES = listOf("cf_clearance")
|
||||
private val CHROME_IMAGE_TEMPLATE_REGEX = Regex("""<title>(.*?) \(\d+×\d+\)</title>""")
|
||||
}
|
||||
}
|
||||
@@ -205,9 +205,12 @@ object CFClearance {
|
||||
session = serverConfig.flareSolverrSessionName.value,
|
||||
sessionTtlMinutes = serverConfig.flareSolverrSessionTtl.value,
|
||||
cookies =
|
||||
network.cookieStore.get(originalRequest.url).map {
|
||||
FlareSolverCookie(it.name, it.value)
|
||||
},
|
||||
network.cookieStore
|
||||
.get(originalRequest.url)
|
||||
.filter { it.name !in CloudflareInterceptor.COOKIE_NAMES }
|
||||
.map { cookie ->
|
||||
FlareSolverCookie(cookie.name, cookie.value)
|
||||
},
|
||||
returnOnlyCookies = onlyCookies,
|
||||
maxTimeout = timeout.inWholeMilliseconds.toInt(),
|
||||
postData =
|
||||
|
||||
@@ -36,10 +36,11 @@ import nl.adaptivity.xmlutil.ExperimentalXmlUtilApi
|
||||
import nl.adaptivity.xmlutil.core.KtXmlReader
|
||||
import nl.adaptivity.xmlutil.serialization.XML
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.insertAndGetId
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.insertAndGetId
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.registerCatalogueSource
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
|
||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package suwayomi.tachidesk.global.impl
|
||||
|
||||
import org.jetbrains.exposed.dao.id.EntityID
|
||||
import org.jetbrains.exposed.sql.batchInsert
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.statements.BatchUpdateStatement
|
||||
import org.jetbrains.exposed.v1.jdbc.batchInsert
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.global.model.table.GlobalMetaTable
|
||||
|
||||
/*
|
||||
@@ -32,13 +34,14 @@ object GlobalMeta {
|
||||
val (existingMeta, newMeta) = meta.toList().partition { (key) -> key in dbMetaMap.keys }
|
||||
|
||||
if (existingMeta.isNotEmpty()) {
|
||||
BatchUpdateStatement(GlobalMetaTable).apply {
|
||||
existingMeta.forEach { (key, value) ->
|
||||
addBatch(EntityID(dbMetaMap[key]!![GlobalMetaTable.id].value, GlobalMetaTable))
|
||||
this[GlobalMetaTable.value] = value
|
||||
}
|
||||
execute(this@transaction)
|
||||
}
|
||||
BatchUpdateStatement(GlobalMetaTable)
|
||||
.apply {
|
||||
existingMeta.forEach { (key, value) ->
|
||||
addBatch(EntityID(dbMetaMap[key]!![GlobalMetaTable.id].value, GlobalMetaTable))
|
||||
this[GlobalMetaTable.value] = value
|
||||
}
|
||||
}.toExecutable()
|
||||
.execute(this@transaction)
|
||||
}
|
||||
|
||||
if (newMeta.isNotEmpty()) {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
package suwayomi.tachidesk.global.impl
|
||||
|
||||
import dev.datlag.kcef.KCEF
|
||||
import dev.datlag.kcef.KCEFBrowser
|
||||
import dev.datlag.kcef.KCEFClient
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import org.cef.CefClient
|
||||
import org.cef.CefSettings
|
||||
import org.cef.browser.CefBrowser
|
||||
import org.cef.browser.CefFrame
|
||||
@@ -26,6 +25,9 @@ import org.cef.network.CefCookie
|
||||
import org.cef.network.CefCookieManager
|
||||
import org.cef.network.CefRequest
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import xyz.nulldev.androidcompat.webkit.CefHelper
|
||||
import xyz.nulldev.androidcompat.webkit.dispose
|
||||
import xyz.nulldev.androidcompat.webkit.evaluateJavaScript
|
||||
import java.awt.Component
|
||||
import java.awt.HeadlessException
|
||||
import java.awt.Rectangle
|
||||
@@ -47,8 +49,8 @@ import javax.swing.JPanel
|
||||
class KcefWebView {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private val renderHandler = RenderHandler()
|
||||
private var kcefClient: KCEFClient? = null
|
||||
private var browser: KCEFBrowser? = null
|
||||
private var kcefClient: CefClient? = null
|
||||
private var browser: CefBrowser? = null
|
||||
private var width = 1000
|
||||
private var height = 1000
|
||||
|
||||
@@ -76,7 +78,8 @@ class KcefWebView {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable sealed class Event
|
||||
@Serializable
|
||||
sealed class Event
|
||||
|
||||
@Serializable
|
||||
@SerialName("consoleMessage")
|
||||
@@ -247,10 +250,12 @@ class KcefWebView {
|
||||
init {
|
||||
destroy()
|
||||
kcefClient =
|
||||
KCEF.newClientBlocking().apply {
|
||||
addDisplayHandler(DisplayHandler())
|
||||
addLoadHandler(LoadHandler())
|
||||
addRequestHandler(RequestHandler())
|
||||
runBlocking {
|
||||
CefHelper.createClient().apply {
|
||||
addDisplayHandler(DisplayHandler())
|
||||
addLoadHandler(LoadHandler())
|
||||
addRequestHandler(RequestHandler())
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug { "Start loading cookies" }
|
||||
@@ -289,6 +294,7 @@ class KcefWebView {
|
||||
.createBrowser(
|
||||
url,
|
||||
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
|
||||
false,
|
||||
// NOTE: with a context, we don't seem to be getting any cookies
|
||||
).apply {
|
||||
// NOTE: Without this, we don't seem to be receiving any events
|
||||
|
||||
@@ -6,7 +6,7 @@ import io.javalin.websocket.WsMessageContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.eclipse.jetty.websocket.api.CloseStatus
|
||||
import org.eclipse.jetty.websocket.core.CloseStatus
|
||||
import suwayomi.tachidesk.manga.impl.update.Websocket
|
||||
|
||||
object WebView : Websocket<String>() {
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
package suwayomi.tachidesk.global.impl.sync
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import suwayomi.tachidesk.graphql.types.StartSyncResult
|
||||
import suwayomi.tachidesk.manga.impl.Category
|
||||
import suwayomi.tachidesk.manga.impl.Library.handleMangaThumbnail
|
||||
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
|
||||
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupImport
|
||||
import suwayomi.tachidesk.manga.impl.backup.proto.handlers.BackupCategoryHandler
|
||||
import suwayomi.tachidesk.manga.impl.backup.proto.handlers.BackupMangaHandler
|
||||
import suwayomi.tachidesk.manga.impl.backup.proto.handlers.BackupSourceHandler
|
||||
import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup
|
||||
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupChapter
|
||||
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga
|
||||
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||
import suwayomi.tachidesk.server.serverConfig
|
||||
import suwayomi.tachidesk.util.HAScheduler
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.measureTime
|
||||
|
||||
@Serializable
|
||||
data class SyncData(
|
||||
val backup: Backup? = null,
|
||||
)
|
||||
|
||||
object SyncManager {
|
||||
private val syncPreferences = Injekt.get<Application>().getSharedPreferences("sync", Context.MODE_PRIVATE)
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
private var currentTaskId: String? = null
|
||||
private val syncMutex = Mutex()
|
||||
|
||||
private val _lastSyncState: MutableStateFlow<SyncState?> = MutableStateFlow(null)
|
||||
val lastSyncState: StateFlow<SyncState?> = _lastSyncState.asStateFlow()
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun scheduleSyncTask() {
|
||||
serverConfig.subscribeTo(
|
||||
combine(
|
||||
serverConfig.syncYomiEnabled,
|
||||
serverConfig.syncInterval,
|
||||
) { enabled, interval -> Pair(enabled, interval) },
|
||||
{ (enabled, interval) ->
|
||||
currentTaskId?.let { HAScheduler.deschedule(it) }
|
||||
|
||||
currentTaskId =
|
||||
if (enabled && interval > 0.seconds) {
|
||||
val lastSyncDate =
|
||||
syncPreferences
|
||||
.getLong("last_scheduled_sync", 0L)
|
||||
.takeIf { it != 0L }
|
||||
?.let { Instant.fromEpochMilliseconds(it) }
|
||||
|
||||
if (lastSyncDate == null) {
|
||||
syncPreferences
|
||||
.edit()
|
||||
.putLong("last_scheduled_sync", Clock.System.now().toEpochMilliseconds())
|
||||
.apply()
|
||||
}
|
||||
|
||||
val delay =
|
||||
if (lastSyncDate != null) {
|
||||
((interval) - (Clock.System.now() - lastSyncDate)).coerceAtLeast(0.seconds)
|
||||
} else {
|
||||
interval
|
||||
}
|
||||
|
||||
HAScheduler.schedule(
|
||||
{
|
||||
startSync(periodic = true)
|
||||
|
||||
syncPreferences
|
||||
.edit()
|
||||
.putLong("last_scheduled_sync", Clock.System.now().toEpochMilliseconds())
|
||||
.apply()
|
||||
},
|
||||
interval = interval.inWholeMilliseconds,
|
||||
delay = delay.inWholeMilliseconds,
|
||||
name = "sync",
|
||||
)
|
||||
} else {
|
||||
syncPreferences
|
||||
.edit()
|
||||
.remove("last_scheduled_sync")
|
||||
.apply()
|
||||
null
|
||||
}
|
||||
},
|
||||
ignoreInitialValue = false,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun startSync(periodic: Boolean = false): StartSyncResult {
|
||||
if (!serverConfig.syncYomiEnabled.value) {
|
||||
return StartSyncResult.SYNC_DISABLED
|
||||
}
|
||||
|
||||
if (!syncMutex.tryLock()) {
|
||||
return StartSyncResult.SYNC_IN_PROGRESS
|
||||
}
|
||||
|
||||
GlobalScope.launch {
|
||||
try {
|
||||
syncData(periodic)
|
||||
} finally {
|
||||
syncMutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
return StartSyncResult.SUCCESS
|
||||
}
|
||||
|
||||
suspend fun ensureSync() {
|
||||
if (!serverConfig.syncYomiEnabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (syncMutex.tryLock()) {
|
||||
// there is no ongoing sync, so start one
|
||||
try {
|
||||
syncData()
|
||||
} finally {
|
||||
syncMutex.unlock()
|
||||
}
|
||||
} else {
|
||||
// wait for the ongoing sync to finish
|
||||
syncMutex.withLock {}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncData(periodic: Boolean = false) {
|
||||
val startInstant = Clock.System.now()
|
||||
_lastSyncState.value = SyncState.Started(startInstant)
|
||||
|
||||
try {
|
||||
logger.info {
|
||||
if (periodic) {
|
||||
"Starting periodic sync"
|
||||
} else {
|
||||
"Starting manual sync"
|
||||
}
|
||||
}
|
||||
|
||||
transaction {
|
||||
MangaTable.update({ MangaTable.isSyncing eq true }) {
|
||||
it[isSyncing] = false
|
||||
}
|
||||
ChapterTable.update({ ChapterTable.isSyncing eq true }) {
|
||||
it[isSyncing] = false
|
||||
}
|
||||
CategoryTable.update({ CategoryTable.isSyncing eq true }) {
|
||||
it[isSyncing] = false
|
||||
}
|
||||
}
|
||||
|
||||
val backupFlags =
|
||||
BackupFlags(
|
||||
includeManga = serverConfig.syncDataManga.value,
|
||||
includeCategories = serverConfig.syncDataCategories.value,
|
||||
includeChapters = serverConfig.syncDataChapters.value,
|
||||
includeTracking = serverConfig.syncDataTracking.value,
|
||||
includeHistory = serverConfig.syncDataHistory.value,
|
||||
includeClientData = false,
|
||||
includeServerSettings = false,
|
||||
)
|
||||
|
||||
_lastSyncState.value = SyncState.CreatingBackup(startInstant)
|
||||
val backupMangas = BackupMangaHandler.backup(backupFlags)
|
||||
val backup =
|
||||
Backup(
|
||||
backupMangas,
|
||||
BackupCategoryHandler.backup(backupFlags).filter { it.name != Category.DEFAULT_CATEGORY_NAME },
|
||||
BackupSourceHandler.backup(backupMangas, backupFlags),
|
||||
emptyMap(),
|
||||
null,
|
||||
)
|
||||
|
||||
val syncData =
|
||||
SyncData(
|
||||
backup = backup,
|
||||
)
|
||||
|
||||
val remoteBackup =
|
||||
SyncYomiSyncService.doSync(syncData, startInstant) {
|
||||
_lastSyncState.value = it
|
||||
}
|
||||
|
||||
if (remoteBackup == null) {
|
||||
logger.debug { "Skip restore due to network issues" }
|
||||
finishWithError(startInstant, "Network error", periodic)
|
||||
return
|
||||
}
|
||||
|
||||
if (remoteBackup === syncData.backup) {
|
||||
// nothing changed
|
||||
logger.debug { "Skip restore due to remote was overwrite from local" }
|
||||
finishWithSuccess(startInstant, periodic)
|
||||
return
|
||||
}
|
||||
|
||||
// Stop the sync early if the remote backup is null or empty
|
||||
if (remoteBackup.backupManga.isEmpty() && remoteBackup.backupCategories.isEmpty() && remoteBackup.backupSources.isEmpty()) {
|
||||
logger.error { "No data found on remote server." }
|
||||
finishWithError(startInstant, "No data found on remote server.", periodic)
|
||||
return
|
||||
}
|
||||
|
||||
val isLibraryEmpty =
|
||||
transaction {
|
||||
MangaTable
|
||||
.selectAll()
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
.empty()
|
||||
}
|
||||
|
||||
// Check if it's first sync based on lastSyncTimestamp
|
||||
if (syncPreferences.getLong("last_sync_timestamp", 0) == 0L && !isLibraryEmpty) {
|
||||
// It's first sync no need to restore data. (just update remote data)
|
||||
finishWithSuccess(startInstant, periodic)
|
||||
return
|
||||
}
|
||||
|
||||
val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup)
|
||||
updateNonFavorites(nonFavorites)
|
||||
|
||||
val newSyncData =
|
||||
backup.copy(
|
||||
backupManga = filteredFavorites,
|
||||
backupCategories = remoteBackup.backupCategories,
|
||||
backupSources = remoteBackup.backupSources,
|
||||
)
|
||||
|
||||
val hasMangaChanges = filteredFavorites.isNotEmpty()
|
||||
val hasCategoryChanges = remoteBackup.backupCategories != backup.backupCategories
|
||||
val hasSourceChanges = remoteBackup.backupSources != backup.backupSources
|
||||
|
||||
if (!hasMangaChanges && !hasCategoryChanges && !hasSourceChanges) {
|
||||
// update the sync timestamp
|
||||
finishWithSuccess(startInstant, periodic)
|
||||
return
|
||||
}
|
||||
|
||||
if (serverConfig.syncDataCategories.value) {
|
||||
val mergedUids = newSyncData.backupCategories.map { it.uid }.toSet()
|
||||
val mergedNames = newSyncData.backupCategories.map { it.name }.toSet()
|
||||
val localCategories = Category.getCategoryList().filterNot { it.default } // Exclude system category
|
||||
val categoriesToDelete =
|
||||
localCategories.filter {
|
||||
it.uid !in mergedUids && it.name !in mergedNames
|
||||
}
|
||||
if (categoriesToDelete.isNotEmpty()) {
|
||||
transaction {
|
||||
categoriesToDelete.forEach {
|
||||
Category.removeCategory(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val backupStream = ProtoBuf.encodeToByteArray(Backup.serializer(), newSyncData).inputStream()
|
||||
val restoreId =
|
||||
ProtoBackupImport.restore(
|
||||
sourceStream = backupStream,
|
||||
flags = backupFlags,
|
||||
isSync = true,
|
||||
)
|
||||
_lastSyncState.value = SyncState.Restoring(startInstant, restoreId)
|
||||
|
||||
ProtoBackupImport.notifyFlow.first {
|
||||
val restoreState = ProtoBackupImport.getRestoreState(restoreId)
|
||||
|
||||
restoreState == ProtoBackupImport.BackupRestoreState.Success ||
|
||||
restoreState == ProtoBackupImport.BackupRestoreState.Failure
|
||||
}
|
||||
|
||||
// update the sync timestamp
|
||||
finishWithSuccess(startInstant, periodic)
|
||||
} catch (e: Throwable) {
|
||||
logger.error { "Error syncing: ${e.message}" }
|
||||
finishWithError(startInstant, "${e::class.qualifiedName}: ${e.message}", periodic)
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishWithSuccess(
|
||||
startInstant: Instant,
|
||||
periodic: Boolean,
|
||||
) {
|
||||
syncPreferences
|
||||
.edit()
|
||||
.putLong("last_sync_timestamp", Clock.System.now().toEpochMilliseconds())
|
||||
.apply()
|
||||
_lastSyncState.value = SyncState.Success(startInstant)
|
||||
|
||||
logger.info {
|
||||
if (periodic) {
|
||||
"Periodic sync completed successfully"
|
||||
} else {
|
||||
"Manual sync completed successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishWithError(
|
||||
startInstant: Instant,
|
||||
message: String,
|
||||
periodic: Boolean,
|
||||
) {
|
||||
_lastSyncState.value = SyncState.Error(startInstant, message)
|
||||
|
||||
logger.info {
|
||||
if (periodic) {
|
||||
"Periodic sync failed: $message"
|
||||
} else {
|
||||
"Manual sync failed: $message"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMangaDifferent(
|
||||
localManga: MangaDataClass,
|
||||
remoteManga: BackupManga,
|
||||
): Boolean {
|
||||
if (localManga.version != remoteManga.version) {
|
||||
return true
|
||||
}
|
||||
|
||||
val localChapters =
|
||||
transaction {
|
||||
ChapterTable
|
||||
.selectAll()
|
||||
.where { ChapterTable.manga eq localManga.id }
|
||||
.map { ChapterTable.toDataClass(it) }
|
||||
}
|
||||
|
||||
if (areChaptersDifferent(localChapters, remoteManga.chapters)) {
|
||||
return true
|
||||
}
|
||||
|
||||
val localCategories =
|
||||
transaction {
|
||||
CategoryMangaTable
|
||||
.innerJoin(CategoryTable)
|
||||
.selectAll()
|
||||
.where { CategoryMangaTable.manga eq localManga.id }
|
||||
.map { it[CategoryTable.order] }
|
||||
}
|
||||
|
||||
return localCategories.toSet() != remoteManga.categories.toSet()
|
||||
}
|
||||
|
||||
private fun areChaptersDifferent(
|
||||
localChapters: List<ChapterDataClass>,
|
||||
remoteChapters: List<BackupChapter>,
|
||||
): Boolean {
|
||||
val localChapterMap = localChapters.associateBy { it.url }
|
||||
val remoteChapterMap = remoteChapters.associateBy { it.url }
|
||||
|
||||
if (localChapterMap.size != remoteChapterMap.size) {
|
||||
return true
|
||||
}
|
||||
|
||||
for ((url, localChapter) in localChapterMap) {
|
||||
val remoteChapter = remoteChapterMap[url]
|
||||
|
||||
// If a matching remote chapter doesn't exist, or the version numbers are different, consider them different
|
||||
if (remoteChapter == null || localChapter.version != remoteChapter.version) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun filterFavoritesAndNonFavorites(backup: Backup): Pair<List<BackupManga>, List<BackupManga>> {
|
||||
val favorites = mutableListOf<BackupManga>()
|
||||
val nonFavorites = mutableListOf<BackupManga>()
|
||||
|
||||
val elapsedTime =
|
||||
measureTime {
|
||||
logger.debug { "Starting to filter favorites and non-favorites from backup data." }
|
||||
|
||||
backup.backupManga.forEach { remoteManga ->
|
||||
val localManga =
|
||||
transaction {
|
||||
MangaTable
|
||||
.selectAll()
|
||||
.where {
|
||||
(MangaTable.sourceReference eq remoteManga.source) and
|
||||
(MangaTable.url eq remoteManga.url)
|
||||
}.limit(1)
|
||||
.map { MangaTable.toDataClass(it) }
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
when {
|
||||
// Checks if the manga is in favorites and needs updating or adding
|
||||
remoteManga.favorite -> {
|
||||
if (localManga == null || isMangaDifferent(localManga, remoteManga)) {
|
||||
logger.debug { "Adding to favorites: ${remoteManga.title}" }
|
||||
favorites.add(remoteManga)
|
||||
} else {
|
||||
logger.debug { "Already up-to-date favorite: ${remoteManga.title}" }
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-favorites
|
||||
!remoteManga.favorite -> {
|
||||
logger.debug { "Adding to non-favorites: ${remoteManga.title}" }
|
||||
nonFavorites.add(remoteManga)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug {
|
||||
"Filtering completed in $elapsedTime. Favorites found: ${favorites.size}, Non-favorites found: ${nonFavorites.size}"
|
||||
}
|
||||
|
||||
return Pair(favorites, nonFavorites)
|
||||
}
|
||||
|
||||
private fun updateNonFavorites(nonFavorites: List<BackupManga>) {
|
||||
nonFavorites.forEach { nonFavorite ->
|
||||
val localManga =
|
||||
transaction {
|
||||
MangaTable
|
||||
.selectAll()
|
||||
.where {
|
||||
(MangaTable.sourceReference eq nonFavorite.source) and
|
||||
(MangaTable.url eq nonFavorite.url)
|
||||
}.limit(1)
|
||||
.map { MangaTable.toDataClass(it) }
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
if (localManga != null) {
|
||||
if (localManga.inLibrary != nonFavorite.favorite) {
|
||||
transaction {
|
||||
MangaTable.update({ MangaTable.id eq localManga.id }) {
|
||||
it[inLibrary] = nonFavorite.favorite
|
||||
}
|
||||
}.apply {
|
||||
handleMangaThumbnail(localManga.id, nonFavorite.favorite)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class SyncState(
|
||||
open val startDate: Instant,
|
||||
) {
|
||||
data class Started(
|
||||
override val startDate: Instant,
|
||||
) : SyncState(startDate)
|
||||
|
||||
data class CreatingBackup(
|
||||
override val startDate: Instant,
|
||||
) : SyncState(startDate)
|
||||
|
||||
data class Downloading(
|
||||
override val startDate: Instant,
|
||||
) : SyncState(startDate)
|
||||
|
||||
data class Merging(
|
||||
override val startDate: Instant,
|
||||
) : SyncState(startDate)
|
||||
|
||||
data class Uploading(
|
||||
override val startDate: Instant,
|
||||
) : SyncState(startDate)
|
||||
|
||||
data class Restoring(
|
||||
override val startDate: Instant,
|
||||
val restoreId: String,
|
||||
) : SyncState(startDate)
|
||||
|
||||
data class Success(
|
||||
override val startDate: Instant,
|
||||
val endDate: Instant = Clock.System.now(),
|
||||
) : SyncState(startDate)
|
||||
|
||||
data class Error(
|
||||
override val startDate: Instant,
|
||||
val message: String,
|
||||
val endDate: Instant = Clock.System.now(),
|
||||
) : SyncState(startDate)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
package suwayomi.tachidesk.global.impl.sync
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.PUT
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.javalin.http.HttpStatus
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import okhttp3.Headers
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup
|
||||
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupCategory
|
||||
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupChapter
|
||||
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga
|
||||
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSource
|
||||
import suwayomi.tachidesk.server.serverConfig
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
object SyncYomiSyncService {
|
||||
private val syncPreferences = Injekt.get<Application>().getSharedPreferences("sync", Context.MODE_PRIVATE)
|
||||
|
||||
private val network: NetworkHelper by injectLazy()
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
private class SyncYomiException(
|
||||
message: String?,
|
||||
) : Exception(message)
|
||||
|
||||
@Serializable
|
||||
private data class SyncEvent(
|
||||
val event: SyncEventStatus,
|
||||
val device_Name: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private enum class SyncEventStatus {
|
||||
SYNC_STARTED,
|
||||
SYNC_SUCCESS,
|
||||
SYNC_FAILED,
|
||||
SYNC_ERROR,
|
||||
SYNC_CANCELLED,
|
||||
}
|
||||
|
||||
suspend fun doSync(
|
||||
syncData: SyncData,
|
||||
startDate: Instant,
|
||||
setSyncState: (SyncManager.SyncState) -> Unit,
|
||||
): Backup? {
|
||||
reportSyncEvent(SyncEventStatus.SYNC_STARTED)
|
||||
setSyncState(SyncManager.SyncState.Downloading(startDate))
|
||||
|
||||
return try {
|
||||
val (remoteData, etag) = pullSyncData()
|
||||
|
||||
val finalSyncData =
|
||||
if (remoteData != null) {
|
||||
require(etag.isNotEmpty()) { "ETag should never be empty if remote data is not null" }
|
||||
logger.debug { "Try update remote data with ETag($etag)" }
|
||||
setSyncState(SyncManager.SyncState.Merging(startDate))
|
||||
mergeSyncData(syncData, remoteData)
|
||||
} else {
|
||||
// init or overwrite remote data
|
||||
logger.debug { "Try overwrite remote data with ETag($etag)" }
|
||||
syncData
|
||||
}
|
||||
|
||||
if (finalSyncData.backup != null) {
|
||||
setSyncState(SyncManager.SyncState.Uploading(startDate))
|
||||
}
|
||||
|
||||
val success = pushSyncData(finalSyncData, etag)
|
||||
if (success) {
|
||||
reportSyncEvent(SyncEventStatus.SYNC_SUCCESS)
|
||||
} else {
|
||||
reportSyncEvent(SyncEventStatus.SYNC_FAILED, "Failed to push sync data")
|
||||
}
|
||||
|
||||
finalSyncData.backup
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) {
|
||||
reportSyncEvent(SyncEventStatus.SYNC_CANCELLED, e.message)
|
||||
throw e
|
||||
}
|
||||
logger.error { "Error syncing: ${e.message}" }
|
||||
reportSyncEvent(SyncEventStatus.SYNC_ERROR, e.message)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pullSyncData(): Pair<SyncData?, String> {
|
||||
val host = serverConfig.syncYomiHost.value
|
||||
val apiKey = serverConfig.syncYomiApiKey.value
|
||||
val downloadUrl = "$host/api/sync/content"
|
||||
|
||||
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
|
||||
val lastETag = syncPreferences.getString("last_sync_etag", "") ?: ""
|
||||
if (lastETag != "") {
|
||||
headersBuilder.add("If-None-Match", lastETag)
|
||||
}
|
||||
val headers = headersBuilder.build()
|
||||
|
||||
val downloadRequest =
|
||||
GET(
|
||||
url = downloadUrl,
|
||||
headers = headers,
|
||||
)
|
||||
|
||||
val response = network.client.newCall(downloadRequest).await()
|
||||
|
||||
if (response.code == HttpStatus.NOT_MODIFIED.code) {
|
||||
// not modified
|
||||
require(lastETag.isNotEmpty())
|
||||
logger.info { "Remote server not modified" }
|
||||
return Pair(null, lastETag)
|
||||
} else if (response.code == HttpStatus.NOT_FOUND.code) {
|
||||
// maybe got deleted from remote
|
||||
return Pair(null, "")
|
||||
}
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val newETag =
|
||||
response.headers["ETag"]
|
||||
?.takeIf { it.isNotEmpty() } ?: throw SyncYomiException("Missing ETag")
|
||||
|
||||
val byteArray =
|
||||
response.body.byteStream().use {
|
||||
return@use it.readBytes()
|
||||
}
|
||||
|
||||
return try {
|
||||
val backup = ProtoBuf.decodeFromByteArray(Backup.serializer(), byteArray)
|
||||
return Pair(SyncData(backup = backup), newETag)
|
||||
} catch (_: SerializationException) {
|
||||
logger.info { "Bad content responsed from server" }
|
||||
// the body is invalid
|
||||
// return default value so we can overwrite it
|
||||
Pair(null, "")
|
||||
}
|
||||
} else {
|
||||
val responseBody = response.body.string()
|
||||
logger.error { "SyncError: $responseBody" }
|
||||
throw SyncYomiException("Failed to download sync data: $responseBody")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pushSyncData(
|
||||
syncData: SyncData,
|
||||
eTag: String,
|
||||
): Boolean {
|
||||
val backup = syncData.backup ?: return true
|
||||
|
||||
val host = serverConfig.syncYomiHost.value
|
||||
val apiKey = serverConfig.syncYomiApiKey.value
|
||||
val uploadUrl = "$host/api/sync/content"
|
||||
|
||||
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
|
||||
if (eTag.isNotEmpty()) {
|
||||
headersBuilder.add("If-Match", eTag)
|
||||
}
|
||||
val headers = headersBuilder.build()
|
||||
|
||||
// Set timeout to 30 seconds
|
||||
val timeout = 30.seconds
|
||||
val client =
|
||||
network.client
|
||||
.newBuilder()
|
||||
.connectTimeout(timeout)
|
||||
.readTimeout(timeout)
|
||||
.writeTimeout(timeout)
|
||||
.build()
|
||||
|
||||
val byteArray = ProtoBuf.encodeToByteArray(Backup.serializer(), backup)
|
||||
if (byteArray.isEmpty()) {
|
||||
throw IllegalStateException("Empty backup error")
|
||||
}
|
||||
val body = byteArray.toRequestBody("application/octet-stream".toMediaType())
|
||||
|
||||
val uploadRequest =
|
||||
PUT(
|
||||
url = uploadUrl,
|
||||
headers = headers,
|
||||
body = body,
|
||||
)
|
||||
|
||||
val response = client.newCall(uploadRequest).await()
|
||||
|
||||
return if (response.isSuccessful) {
|
||||
val newETag =
|
||||
response.headers["ETag"]
|
||||
?.takeIf { it.isNotEmpty() } ?: throw SyncYomiException("Missing ETag")
|
||||
syncPreferences
|
||||
.edit()
|
||||
.putString("last_sync_etag", newETag)
|
||||
.apply()
|
||||
logger.debug { "SyncYomi sync completed" }
|
||||
true
|
||||
} else if (response.code == HttpStatus.PRECONDITION_FAILED.code) {
|
||||
// other clients updated remote data, will try next time
|
||||
logger.debug { "SyncYomi sync failed with 412" }
|
||||
false
|
||||
} else {
|
||||
val responseBody = response.body.string()
|
||||
logger.error { "SyncError: $responseBody" }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reportSyncEvent(
|
||||
event: SyncEventStatus,
|
||||
message: String? = null,
|
||||
) {
|
||||
try {
|
||||
val host = serverConfig.syncYomiHost.value
|
||||
val apiKey = serverConfig.syncYomiApiKey.value
|
||||
val url = "$host/api/sync/event"
|
||||
|
||||
val headers = Headers.Builder().add("X-API-Token", apiKey).build()
|
||||
|
||||
// Use a fixed server name.
|
||||
val bodyObj =
|
||||
SyncEvent(
|
||||
event = event,
|
||||
device_Name = "Suwayomi Server",
|
||||
message = message,
|
||||
)
|
||||
|
||||
val jsonBody = Json.encodeToString(SyncEvent.serializer(), bodyObj)
|
||||
val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||
|
||||
val request =
|
||||
POST(
|
||||
url = url,
|
||||
headers = headers,
|
||||
body = requestBody,
|
||||
)
|
||||
|
||||
network.client
|
||||
.newCall(request)
|
||||
.await()
|
||||
.close()
|
||||
} catch (e: Exception) {
|
||||
logger.error { "Failed to report sync event: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
fun mergeSyncData(
|
||||
localSyncData: SyncData,
|
||||
remoteSyncData: SyncData,
|
||||
): SyncData {
|
||||
val mergedCategoriesList =
|
||||
mergeCategoriesLists(localSyncData.backup?.backupCategories, remoteSyncData.backup?.backupCategories)
|
||||
|
||||
val mergedMangaList =
|
||||
mergeMangaLists(
|
||||
localSyncData.backup?.backupManga,
|
||||
remoteSyncData.backup?.backupManga,
|
||||
localSyncData.backup?.backupCategories ?: emptyList(),
|
||||
remoteSyncData.backup?.backupCategories ?: emptyList(),
|
||||
mergedCategoriesList,
|
||||
)
|
||||
|
||||
val mergedSourcesList =
|
||||
mergeSourcesLists(localSyncData.backup?.backupSources, remoteSyncData.backup?.backupSources)
|
||||
|
||||
// Create the merged Backup object
|
||||
val mergedBackup =
|
||||
Backup(
|
||||
backupManga = mergedMangaList,
|
||||
backupCategories = mergedCategoriesList,
|
||||
backupSources = mergedSourcesList,
|
||||
meta = emptyMap(),
|
||||
serverSettings = null,
|
||||
)
|
||||
|
||||
// Create the merged SData object
|
||||
return SyncData(
|
||||
backup = mergedBackup,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mergeMangaLists(
|
||||
localMangaList: List<BackupManga>?,
|
||||
remoteMangaList: List<BackupManga>?,
|
||||
localCategories: List<BackupCategory>,
|
||||
remoteCategories: List<BackupCategory>,
|
||||
mergedCategories: List<BackupCategory>,
|
||||
): List<BackupManga> {
|
||||
val localMangaListSafe = localMangaList.orEmpty()
|
||||
val remoteMangaListSafe = remoteMangaList.orEmpty()
|
||||
|
||||
logger.debug { "Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}" }
|
||||
|
||||
fun mangaCompositeKey(manga: BackupManga): String =
|
||||
"${manga.source}|${manga.url}|${manga.title.lowercase().trim()}|${manga.author?.lowercase()?.trim()}"
|
||||
|
||||
// Create maps using composite keys
|
||||
val localMangaMap = localMangaListSafe.associateBy { mangaCompositeKey(it) }
|
||||
val remoteMangaMap = remoteMangaListSafe.associateBy { mangaCompositeKey(it) }
|
||||
|
||||
val localCategoriesMapByOrder = localCategories.associateBy { it.order }
|
||||
val remoteCategoriesMapByOrder = remoteCategories.associateBy { it.order }
|
||||
val mergedCategoriesMapByName = mergedCategories.associateBy { it.name }
|
||||
|
||||
fun updateCategories(
|
||||
theManga: BackupManga,
|
||||
theMap: Map<Int, BackupCategory>,
|
||||
): BackupManga =
|
||||
theManga.copy(
|
||||
categories =
|
||||
theManga.categories.mapNotNull {
|
||||
theMap[it]?.let { category ->
|
||||
mergedCategoriesMapByName[category.name]?.order
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val lastSyncTime = syncPreferences.getLong("last_sync_timestamp", 0).milliseconds.inWholeSeconds
|
||||
|
||||
val mergedList =
|
||||
(localMangaMap.keys + remoteMangaMap.keys).distinct().mapNotNull { compositeKey ->
|
||||
val local = localMangaMap[compositeKey]
|
||||
val remote = remoteMangaMap[compositeKey]
|
||||
|
||||
// New version comparison logic
|
||||
when {
|
||||
local != null && remote == null -> {
|
||||
if (lastSyncTime == 0L || local.lastModifiedAt > lastSyncTime) {
|
||||
updateCategories(local, localCategoriesMapByOrder)
|
||||
} else {
|
||||
logger.debug { "Dropping local manga deleted on remote: ${local.title}." }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
local == null && remote != null -> {
|
||||
if (lastSyncTime == 0L || remote.lastModifiedAt > lastSyncTime) {
|
||||
updateCategories(remote, remoteCategoriesMapByOrder)
|
||||
} else {
|
||||
logger.debug { "Dropping deleted remote manga: ${remote.title}." }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
local != null && remote != null -> {
|
||||
// Compare versions to decide which manga to keep
|
||||
if (local.version >= remote.version) {
|
||||
logger.debug { "Keeping local version of ${local.title} with merged chapters." }
|
||||
updateCategories(
|
||||
local.copy(
|
||||
chapters =
|
||||
mergeChapters(
|
||||
local.chapters,
|
||||
remote.chapters,
|
||||
lastSyncTime,
|
||||
serverConfig.syncDataChapters.value,
|
||||
),
|
||||
),
|
||||
localCategoriesMapByOrder,
|
||||
)
|
||||
} else {
|
||||
logger.debug { "Keeping remote version of ${remote.title} with merged chapters." }
|
||||
updateCategories(
|
||||
remote.copy(
|
||||
chapters =
|
||||
mergeChapters(
|
||||
local.chapters,
|
||||
remote.chapters,
|
||||
lastSyncTime,
|
||||
serverConfig.syncDataChapters.value,
|
||||
),
|
||||
),
|
||||
remoteCategoriesMapByOrder,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
} // No manga found for key
|
||||
}
|
||||
}
|
||||
|
||||
// Counting favorites and non-favorites
|
||||
val (favorites, nonFavorites) = mergedList.partition { it.favorite }
|
||||
|
||||
logger.debug {
|
||||
"Merge completed. Total merged manga: ${mergedList.size}, Favorites: ${favorites.size}, Non-Favorites: ${nonFavorites.size}"
|
||||
}
|
||||
|
||||
return mergedList
|
||||
}
|
||||
|
||||
private fun mergeChapters(
|
||||
localChapters: List<BackupChapter>,
|
||||
remoteChapters: List<BackupChapter>,
|
||||
lastSyncTime: Long,
|
||||
syncingChapters: Boolean,
|
||||
): List<BackupChapter> {
|
||||
if (!syncingChapters) {
|
||||
return remoteChapters // If not syncing chapters, keep remote untouched
|
||||
}
|
||||
|
||||
fun chapterCompositeKey(chapter: BackupChapter): String = "${chapter.url}|${chapter.name}|${chapter.chapterNumber}"
|
||||
|
||||
val localChapterMap = localChapters.associateBy { chapterCompositeKey(it) }
|
||||
val remoteChapterMap = remoteChapters.associateBy { chapterCompositeKey(it) }
|
||||
|
||||
logger.debug { "Starting chapter merge. Local chapters: ${localChapters.size}, Remote chapters: ${remoteChapters.size}" }
|
||||
|
||||
// Merge both chapter maps based on version numbers
|
||||
val mergedChapters =
|
||||
(localChapterMap.keys + remoteChapterMap.keys).distinct().mapNotNull { compositeKey ->
|
||||
val localChapter = localChapterMap[compositeKey]
|
||||
val remoteChapter = remoteChapterMap[compositeKey]
|
||||
|
||||
logger.debug {
|
||||
"Processing chapter key: $compositeKey. Local chapter: ${localChapter != null}, Remote chapter: ${remoteChapter != null}"
|
||||
}
|
||||
|
||||
when {
|
||||
localChapter != null && remoteChapter == null -> {
|
||||
if (lastSyncTime == 0L || localChapter.lastModifiedAt > lastSyncTime) {
|
||||
logger.debug { "Keeping local chapter: ${localChapter.name}." }
|
||||
localChapter
|
||||
} else {
|
||||
logger.debug { "Dropping local chapter deleted on remote: ${localChapter.name}." }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
localChapter == null && remoteChapter != null -> {
|
||||
if (lastSyncTime == 0L || remoteChapter.lastModifiedAt > lastSyncTime) {
|
||||
logger.debug { "Taking remote chapter: ${remoteChapter.name}." }
|
||||
remoteChapter
|
||||
} else {
|
||||
logger.debug { "Dropping deleted remote chapter: ${remoteChapter.name}." }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
localChapter != null && remoteChapter != null -> {
|
||||
// Use version number to decide which chapter to keep
|
||||
val chosenChapter =
|
||||
if (localChapter.version >= remoteChapter.version) {
|
||||
// If there are more chapter on remote, local sourceOrder will need to be updated to maintain correct source order.
|
||||
if (localChapters.size < remoteChapters.size) {
|
||||
localChapter.copy(sourceOrder = remoteChapter.sourceOrder)
|
||||
} else {
|
||||
localChapter
|
||||
}
|
||||
} else {
|
||||
remoteChapter
|
||||
}
|
||||
logger.debug {
|
||||
"Merging chapter: ${chosenChapter.name}. Chosen version from: ${if (localChapter.version >= remoteChapter.version) "Local" else "Remote"}, Local version: ${localChapter.version}, Remote version: ${remoteChapter.version}."
|
||||
}
|
||||
chosenChapter
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.debug { "No chapter found for composite key: $compositeKey. Skipping." }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug { "Chapter merge completed. Total merged chapters: ${mergedChapters.size}" }
|
||||
|
||||
return mergedChapters
|
||||
}
|
||||
|
||||
private fun mergeCategoriesLists(
|
||||
localCategoriesList: List<BackupCategory>?,
|
||||
remoteCategoriesList: List<BackupCategory>?,
|
||||
): List<BackupCategory> {
|
||||
if (localCategoriesList == null) return remoteCategoriesList ?: emptyList()
|
||||
if (remoteCategoriesList == null) return localCategoriesList
|
||||
val result = mutableListOf<BackupCategory>()
|
||||
val processedLocals = mutableSetOf<BackupCategory>()
|
||||
|
||||
val localMapByUid = localCategoriesList.filter { it.uid != 0L }.associateBy { it.uid }
|
||||
val localMapByName = localCategoriesList.associateBy { it.name }
|
||||
|
||||
val lastSyncTime = syncPreferences.getLong("last_sync_timestamp", 0)
|
||||
|
||||
remoteCategoriesList.forEach { remote ->
|
||||
var localMatch: BackupCategory? = null
|
||||
|
||||
// 1. Try match by UID
|
||||
if (remote.uid != 0L) {
|
||||
localMatch = localMapByUid[remote.uid]
|
||||
}
|
||||
|
||||
// 2. Try match by Name (fallback)
|
||||
if (localMatch == null) {
|
||||
localMatch = localMapByName[remote.name]
|
||||
}
|
||||
|
||||
if (localMatch != null) {
|
||||
processedLocals.add(localMatch)
|
||||
// Conflict resolution
|
||||
if (localMatch.version >= remote.version) {
|
||||
logger.debug { "Keeping local category: ${localMatch.name} (UID: ${localMatch.uid})" }
|
||||
result.add(localMatch)
|
||||
} else {
|
||||
logger.debug { "Keeping remote category: ${remote.name} (UID: ${remote.uid})" }
|
||||
// Preserve Local UID if Remote was 0
|
||||
if (remote.uid == 0L) {
|
||||
remote.uid = localMatch.uid
|
||||
}
|
||||
result.add(remote)
|
||||
}
|
||||
} else {
|
||||
val remoteModifiedTimeMillis = remote.lastModifiedAt.seconds.inWholeMilliseconds
|
||||
if (lastSyncTime == 0L || remoteModifiedTimeMillis > lastSyncTime) {
|
||||
logger.debug { "Adding new remote category: ${remote.name} (UID: ${remote.uid})" }
|
||||
result.add(remote)
|
||||
} else {
|
||||
logger.debug { "Dropping deleted remote category: ${remote.name} (UID: ${remote.uid})" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining Local Categories
|
||||
localCategoriesList.forEach { local ->
|
||||
if (local !in processedLocals) {
|
||||
val localModifiedTimeMillis = local.lastModifiedAt.seconds.inWholeMilliseconds
|
||||
if (lastSyncTime == 0L || localModifiedTimeMillis > lastSyncTime) {
|
||||
logger.debug { "Keeping local only category: ${local.name} (UID: ${local.uid})" }
|
||||
result.add(local)
|
||||
} else {
|
||||
logger.debug { "Dropping local category deleted on remote: ${local.name} (UID: ${local.uid})" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.sortedBy { it.order }
|
||||
}
|
||||
|
||||
private fun mergeSourcesLists(
|
||||
localSources: List<BackupSource>?,
|
||||
remoteSources: List<BackupSource>?,
|
||||
): List<BackupSource> {
|
||||
// Create maps using sourceId as key
|
||||
val localSourceMap = localSources?.associateBy { it.sourceId } ?: emptyMap()
|
||||
val remoteSourceMap = remoteSources?.associateBy { it.sourceId } ?: emptyMap()
|
||||
|
||||
logger.debug { "Starting source merge. Local sources: ${localSources?.size}, Remote sources: ${remoteSources?.size}" }
|
||||
|
||||
// Merge both source maps
|
||||
val mergedSources =
|
||||
(localSourceMap.keys + remoteSourceMap.keys).distinct().mapNotNull { sourceId ->
|
||||
val localSource = localSourceMap[sourceId]
|
||||
val remoteSource = remoteSourceMap[sourceId]
|
||||
|
||||
logger.debug {
|
||||
"Processing source ID: $sourceId. Local source: ${localSource != null}, Remote source: ${remoteSource != null}"
|
||||
}
|
||||
|
||||
when {
|
||||
localSource != null && remoteSource == null -> {
|
||||
logger.debug { "Using local source: ${localSource.name}." }
|
||||
localSource
|
||||
}
|
||||
|
||||
remoteSource != null && localSource == null -> {
|
||||
logger.debug { "Using remote source: ${remoteSource.name}." }
|
||||
remoteSource
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.debug { "Remote and local have the same source ID: $sourceId. Keeping local." }
|
||||
localSource
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug { "Source merge completed. Total merged sources: ${mergedSources.size}" }
|
||||
|
||||
return mergedSources
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,12 @@ package suwayomi.tachidesk.global.model.table
|
||||
* 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.jetbrains.exposed.dao.id.IntIdTable
|
||||
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
|
||||
|
||||
/**
|
||||
* Metadata storage for clients, server/global level.
|
||||
*/
|
||||
object GlobalMetaTable : IntIdTable() {
|
||||
val key = varchar("key", 256)
|
||||
val key = varchar("meta_key", 256)
|
||||
val value = varchar("value", 4096)
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package suwayomi.tachidesk.graphql
|
||||
|
||||
import com.expediagroup.graphql.server.extensions.toGraphQLError
|
||||
import graphql.execution.DataFetcherResult
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
|
||||
val logger = KotlinLogging.logger { }
|
||||
|
||||
inline fun <T> asDataFetcherResult(block: () -> T): DataFetcherResult<T?> {
|
||||
val result =
|
||||
runCatching {
|
||||
block()
|
||||
}
|
||||
|
||||
if (result.isFailure) {
|
||||
logger.error(result.exceptionOrNull()) { "asDataFetcherResult: failed due to" }
|
||||
return DataFetcherResult
|
||||
.newResult<T?>()
|
||||
.error(result.exceptionOrNull()?.toGraphQLError())
|
||||
.build()
|
||||
}
|
||||
|
||||
return DataFetcherResult
|
||||
.newResult<T?>()
|
||||
.data(result.getOrNull())
|
||||
.build()
|
||||
}
|
||||
@@ -3,12 +3,8 @@ package suwayomi.tachidesk.graphql.cache
|
||||
import org.dataloader.CacheMap
|
||||
import java.util.concurrent.CompletableFuture
|
||||
|
||||
class CustomCacheMap<K, V> : CacheMap<K, V> {
|
||||
private val cache: MutableMap<K, CompletableFuture<V>>
|
||||
|
||||
init {
|
||||
cache = HashMap()
|
||||
}
|
||||
class CustomCacheMap<K : Any, V : Any> : CacheMap<K, V> {
|
||||
private val cache: MutableMap<K, CompletableFuture<V>> = HashMap()
|
||||
|
||||
override fun containsKey(key: K): Boolean = cache.containsKey(key)
|
||||
|
||||
@@ -18,12 +14,12 @@ class CustomCacheMap<K, V> : CacheMap<K, V> {
|
||||
|
||||
override fun getAll(): Collection<CompletableFuture<V>> = cache.values
|
||||
|
||||
override fun set(
|
||||
override fun putIfAbsentAtomically(
|
||||
key: K,
|
||||
value: CompletableFuture<V>,
|
||||
): CacheMap<K, V> {
|
||||
): CompletableFuture<V> {
|
||||
cache[key] = value
|
||||
return this
|
||||
return value
|
||||
}
|
||||
|
||||
override fun delete(key: K): CacheMap<K, V> {
|
||||
@@ -35,4 +31,6 @@ class CustomCacheMap<K, V> : CacheMap<K, V> {
|
||||
cache.clear()
|
||||
return this
|
||||
}
|
||||
|
||||
override fun size(): Int = cache.size
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import com.expediagroup.graphql.dataloader.KotlinDataLoader
|
||||
import graphql.GraphQLContext
|
||||
import org.dataloader.DataLoader
|
||||
import org.dataloader.DataLoaderFactory
|
||||
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
|
||||
import org.jetbrains.exposed.sql.addLogger
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.Slf4jSqlDebugLogger
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.types.CategoryNodeList
|
||||
import suwayomi.tachidesk.graphql.types.CategoryNodeList.Companion.toNodeList
|
||||
import suwayomi.tachidesk.graphql.types.CategoryType
|
||||
|
||||
@@ -11,24 +11,28 @@ import com.expediagroup.graphql.dataloader.KotlinDataLoader
|
||||
import graphql.GraphQLContext
|
||||
import org.dataloader.DataLoader
|
||||
import org.dataloader.DataLoaderFactory
|
||||
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.addLogger
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.count
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.Slf4jSqlDebugLogger
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.count
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.greaterEq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.jdbc.select
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.types.ChapterNodeList
|
||||
import suwayomi.tachidesk.graphql.types.ChapterNodeList.Companion.toNodeList
|
||||
import suwayomi.tachidesk.graphql.types.ChapterType
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||
|
||||
class ChapterDataLoader : KotlinDataLoader<Int, ChapterType?> {
|
||||
class ChapterDataLoader : KotlinDataLoader<Int, ChapterType> {
|
||||
override val dataLoaderName = "ChapterDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, ChapterType?> =
|
||||
DataLoaderFactory.newDataLoader<Int, ChapterType> { ids ->
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, ChapterType> =
|
||||
DataLoaderFactory.newDataLoader { ids ->
|
||||
future {
|
||||
transaction {
|
||||
addLogger(Slf4jSqlDebugLogger)
|
||||
@@ -48,7 +52,7 @@ class ChaptersForMangaDataLoader : KotlinDataLoader<Int, ChapterNodeList> {
|
||||
override val dataLoaderName = "ChaptersForMangaDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, ChapterNodeList> =
|
||||
DataLoaderFactory.newDataLoader<Int, ChapterNodeList> { ids ->
|
||||
DataLoaderFactory.newDataLoader { ids ->
|
||||
future {
|
||||
transaction {
|
||||
addLogger(Slf4jSqlDebugLogger)
|
||||
@@ -68,7 +72,7 @@ class DownloadedChapterCountForMangaDataLoader : KotlinDataLoader<Int, Int> {
|
||||
override val dataLoaderName = "DownloadedChapterCountForMangaDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, Int> =
|
||||
DataLoaderFactory.newDataLoader<Int, Int> { ids ->
|
||||
DataLoaderFactory.newDataLoader { ids ->
|
||||
future {
|
||||
transaction {
|
||||
addLogger(Slf4jSqlDebugLogger)
|
||||
@@ -90,7 +94,7 @@ class UnreadChapterCountForMangaDataLoader : KotlinDataLoader<Int, Int> {
|
||||
override val dataLoaderName = "UnreadChapterCountForMangaDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, Int> =
|
||||
DataLoaderFactory.newDataLoader<Int, Int> { ids ->
|
||||
DataLoaderFactory.newDataLoader { ids ->
|
||||
future {
|
||||
transaction {
|
||||
addLogger(Slf4jSqlDebugLogger)
|
||||
@@ -112,7 +116,7 @@ class BookmarkedChapterCountForMangaDataLoader : KotlinDataLoader<Int, Int> {
|
||||
override val dataLoaderName = "BookmarkedChapterCountForMangaDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, Int> =
|
||||
DataLoaderFactory.newDataLoader<Int, Int> { ids ->
|
||||
DataLoaderFactory.newDataLoader { ids ->
|
||||
future {
|
||||
transaction {
|
||||
addLogger(Slf4jSqlDebugLogger)
|
||||
@@ -157,11 +161,11 @@ class HasDuplicateChaptersForMangaDataLoader : KotlinDataLoader<Int, Boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
class LastReadChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType?> {
|
||||
class LastReadChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType> {
|
||||
override val dataLoaderName = "LastReadChapterForMangaDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, ChapterType?> =
|
||||
DataLoaderFactory.newDataLoader<Int, ChapterType?> { ids ->
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, ChapterType> =
|
||||
DataLoaderFactory.newDataLoader { ids ->
|
||||
future {
|
||||
transaction {
|
||||
addLogger(Slf4jSqlDebugLogger)
|
||||
@@ -177,11 +181,11 @@ class LastReadChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType?> {
|
||||
}
|
||||
}
|
||||
|
||||
class LatestReadChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType?> {
|
||||
class LatestReadChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType> {
|
||||
override val dataLoaderName = "LatestReadChapterForMangaDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, ChapterType?> =
|
||||
DataLoaderFactory.newDataLoader<Int, ChapterType?> { ids ->
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, ChapterType> =
|
||||
DataLoaderFactory.newDataLoader { ids ->
|
||||
future {
|
||||
transaction {
|
||||
addLogger(Slf4jSqlDebugLogger)
|
||||
@@ -197,11 +201,11 @@ class LatestReadChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType?>
|
||||
}
|
||||
}
|
||||
|
||||
class LatestFetchedChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType?> {
|
||||
class LatestFetchedChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType> {
|
||||
override val dataLoaderName = "LatestFetchedChapterForMangaDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, ChapterType?> =
|
||||
DataLoaderFactory.newDataLoader<Int, ChapterType?> { ids ->
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, ChapterType> =
|
||||
DataLoaderFactory.newDataLoader { ids ->
|
||||
future {
|
||||
transaction {
|
||||
addLogger(Slf4jSqlDebugLogger)
|
||||
@@ -217,11 +221,11 @@ class LatestFetchedChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType
|
||||
}
|
||||
}
|
||||
|
||||
class LatestUploadedChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType?> {
|
||||
class LatestUploadedChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType> {
|
||||
override val dataLoaderName = "LatestUploadedChapterForMangaDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, ChapterType?> =
|
||||
DataLoaderFactory.newDataLoader<Int, ChapterType?> { ids ->
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, ChapterType> =
|
||||
DataLoaderFactory.newDataLoader { ids ->
|
||||
future {
|
||||
transaction {
|
||||
addLogger(Slf4jSqlDebugLogger)
|
||||
@@ -237,11 +241,11 @@ class LatestUploadedChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterTyp
|
||||
}
|
||||
}
|
||||
|
||||
class FirstUnreadChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType?> {
|
||||
class FirstUnreadChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType> {
|
||||
override val dataLoaderName = "FirstUnreadChapterForMangaDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, ChapterType?> =
|
||||
DataLoaderFactory.newDataLoader<Int, ChapterType?> { ids ->
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, ChapterType> =
|
||||
DataLoaderFactory.newDataLoader { ids ->
|
||||
future {
|
||||
transaction {
|
||||
addLogger(Slf4jSqlDebugLogger)
|
||||
@@ -257,11 +261,11 @@ class FirstUnreadChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType?>
|
||||
}
|
||||
}
|
||||
|
||||
class HighestNumberedChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType?> {
|
||||
class HighestNumberedChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType> {
|
||||
override val dataLoaderName = "HighestNumberedChapterForMangaDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, ChapterType?> =
|
||||
DataLoaderFactory.newDataLoader<Int, ChapterType?> { ids ->
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, ChapterType> =
|
||||
DataLoaderFactory.newDataLoader { ids ->
|
||||
future {
|
||||
transaction {
|
||||
addLogger(Slf4jSqlDebugLogger)
|
||||
|
||||
@@ -11,19 +11,19 @@ import com.expediagroup.graphql.dataloader.KotlinDataLoader
|
||||
import graphql.GraphQLContext
|
||||
import org.dataloader.DataLoader
|
||||
import org.dataloader.DataLoaderFactory
|
||||
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
|
||||
import org.jetbrains.exposed.sql.addLogger
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.Slf4jSqlDebugLogger
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.types.ExtensionType
|
||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||
|
||||
class ExtensionDataLoader : KotlinDataLoader<String, ExtensionType?> {
|
||||
class ExtensionDataLoader : KotlinDataLoader<String, ExtensionType> {
|
||||
override val dataLoaderName = "ExtensionDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<String, ExtensionType?> =
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<String, ExtensionType> =
|
||||
DataLoaderFactory.newDataLoader { ids ->
|
||||
future {
|
||||
transaction {
|
||||
@@ -40,10 +40,10 @@ class ExtensionDataLoader : KotlinDataLoader<String, ExtensionType?> {
|
||||
}
|
||||
}
|
||||
|
||||
class ExtensionForSourceDataLoader : KotlinDataLoader<Long, ExtensionType?> {
|
||||
class ExtensionForSourceDataLoader : KotlinDataLoader<Long, ExtensionType> {
|
||||
override val dataLoaderName = "ExtensionForSourceDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Long, ExtensionType?> =
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Long, ExtensionType> =
|
||||
DataLoaderFactory.newDataLoader { ids ->
|
||||
future {
|
||||
transaction {
|
||||
|
||||
@@ -12,11 +12,13 @@ import graphql.GraphQLContext
|
||||
import org.dataloader.DataLoader
|
||||
import org.dataloader.DataLoaderFactory
|
||||
import org.dataloader.DataLoaderOptions
|
||||
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
|
||||
import org.jetbrains.exposed.sql.addLogger
|
||||
import org.jetbrains.exposed.sql.andWhere
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.Slf4jSqlDebugLogger
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.isNull
|
||||
import org.jetbrains.exposed.v1.jdbc.andWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.cache.CustomCacheMap
|
||||
import suwayomi.tachidesk.graphql.types.MangaNodeList
|
||||
import suwayomi.tachidesk.graphql.types.MangaNodeList.Companion.toNodeList
|
||||
@@ -25,10 +27,10 @@ import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||
|
||||
class MangaDataLoader : KotlinDataLoader<Int, MangaType?> {
|
||||
class MangaDataLoader : KotlinDataLoader<Int, MangaType> {
|
||||
override val dataLoaderName = "MangaDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, MangaType?> =
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, MangaType> =
|
||||
DataLoaderFactory.newDataLoader { ids ->
|
||||
future {
|
||||
transaction {
|
||||
@@ -122,6 +124,6 @@ class MangaForIdsDataLoader : KotlinDataLoader<List<Int>, MangaNodeList> {
|
||||
}
|
||||
}
|
||||
},
|
||||
DataLoaderOptions.newOptions().setCacheMap(CustomCacheMap<List<Int>, MangaNodeList>()),
|
||||
DataLoaderOptions.newOptions().setCacheMap(CustomCacheMap<List<Int>, MangaNodeList>()).build(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import com.expediagroup.graphql.dataloader.KotlinDataLoader
|
||||
import graphql.GraphQLContext
|
||||
import org.dataloader.DataLoader
|
||||
import org.dataloader.DataLoaderFactory
|
||||
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
|
||||
import org.jetbrains.exposed.sql.addLogger
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.Slf4jSqlDebugLogger
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.global.model.table.GlobalMetaTable
|
||||
import suwayomi.tachidesk.graphql.types.CategoryMetaType
|
||||
import suwayomi.tachidesk.graphql.types.ChapterMetaType
|
||||
@@ -20,11 +20,11 @@ import suwayomi.tachidesk.manga.model.table.MangaMetaTable
|
||||
import suwayomi.tachidesk.manga.model.table.SourceMetaTable
|
||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||
|
||||
class GlobalMetaDataLoader : KotlinDataLoader<String, GlobalMetaType?> {
|
||||
class GlobalMetaDataLoader : KotlinDataLoader<String, GlobalMetaType> {
|
||||
override val dataLoaderName = "GlobalMetaDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<String, GlobalMetaType?> =
|
||||
DataLoaderFactory.newDataLoader<String, GlobalMetaType?> { ids ->
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<String, GlobalMetaType> =
|
||||
DataLoaderFactory.newDataLoader<String, GlobalMetaType> { ids ->
|
||||
future {
|
||||
transaction {
|
||||
addLogger(Slf4jSqlDebugLogger)
|
||||
|
||||
@@ -11,10 +11,10 @@ import com.expediagroup.graphql.dataloader.KotlinDataLoader
|
||||
import graphql.GraphQLContext
|
||||
import org.dataloader.DataLoader
|
||||
import org.dataloader.DataLoaderFactory
|
||||
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
|
||||
import org.jetbrains.exposed.sql.addLogger
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.Slf4jSqlDebugLogger
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.types.SourceNodeList
|
||||
import suwayomi.tachidesk.graphql.types.SourceNodeList.Companion.toNodeList
|
||||
import suwayomi.tachidesk.graphql.types.SourceType
|
||||
@@ -22,10 +22,10 @@ import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||
|
||||
class SourceDataLoader : KotlinDataLoader<Long, SourceType?> {
|
||||
class SourceDataLoader : KotlinDataLoader<Long, SourceType> {
|
||||
override val dataLoaderName = "SourceDataLoader"
|
||||
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Long, SourceType?> =
|
||||
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Long, SourceType> =
|
||||
DataLoaderFactory.newDataLoader { ids ->
|
||||
future {
|
||||
transaction {
|
||||
|
||||
@@ -11,10 +11,10 @@ import com.expediagroup.graphql.dataloader.KotlinDataLoader
|
||||
import graphql.GraphQLContext
|
||||
import org.dataloader.DataLoader
|
||||
import org.dataloader.DataLoaderFactory
|
||||
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
|
||||
import org.jetbrains.exposed.sql.addLogger
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.Slf4jSqlDebugLogger
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.types.TrackRecordNodeList
|
||||
import suwayomi.tachidesk.graphql.types.TrackRecordNodeList.Companion.toNodeList
|
||||
import suwayomi.tachidesk.graphql.types.TrackRecordType
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("RedundantNullableReturnType", "unused")
|
||||
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
@file:Suppress("RedundantNullableReturnType", "unused")
|
||||
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import graphql.execution.DataFetcherResult
|
||||
import org.jetbrains.exposed.sql.LikePattern
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.like
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.minus
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.plus
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.insertAndGetId
|
||||
import org.jetbrains.exposed.sql.or
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import suwayomi.tachidesk.graphql.asDataFetcherResult
|
||||
import org.jetbrains.exposed.v1.core.LikePattern
|
||||
import org.jetbrains.exposed.v1.core.Op
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.greaterEq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.lessEq
|
||||
import org.jetbrains.exposed.v1.core.like
|
||||
import org.jetbrains.exposed.v1.core.minus
|
||||
import org.jetbrains.exposed.v1.core.or
|
||||
import org.jetbrains.exposed.v1.core.plus
|
||||
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.insertAndGetId
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.types.CategoryMetaType
|
||||
import suwayomi.tachidesk.graphql.types.CategoryType
|
||||
@@ -42,14 +44,13 @@ class CategoryMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun setCategoryMeta(input: SetCategoryMetaInput): DataFetcherResult<SetCategoryMetaPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, meta) = input
|
||||
fun setCategoryMeta(input: SetCategoryMetaInput): SetCategoryMetaPayload? {
|
||||
val (clientMutationId, meta) = input
|
||||
|
||||
Category.modifyMeta(meta.categoryId, meta.key, meta.value)
|
||||
Category.modifyMeta(meta.categoryId, meta.key, meta.value)
|
||||
|
||||
SetCategoryMetaPayload(clientMutationId, meta)
|
||||
}
|
||||
return SetCategoryMetaPayload(clientMutationId, meta)
|
||||
}
|
||||
|
||||
data class DeleteCategoryMetaInput(
|
||||
val clientMutationId: String? = null,
|
||||
@@ -64,34 +65,33 @@ class CategoryMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun deleteCategoryMeta(input: DeleteCategoryMetaInput): DataFetcherResult<DeleteCategoryMetaPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, categoryId, key) = input
|
||||
fun deleteCategoryMeta(input: DeleteCategoryMetaInput): DeleteCategoryMetaPayload? {
|
||||
val (clientMutationId, categoryId, key) = input
|
||||
|
||||
val (meta, category) =
|
||||
transaction {
|
||||
val meta =
|
||||
CategoryMetaTable
|
||||
.selectAll()
|
||||
.where { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }
|
||||
.firstOrNull()
|
||||
val (meta, category) =
|
||||
transaction {
|
||||
val meta =
|
||||
CategoryMetaTable
|
||||
.selectAll()
|
||||
.where { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }
|
||||
.firstOrNull()
|
||||
|
||||
CategoryMetaTable.deleteWhere { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }
|
||||
CategoryMetaTable.deleteWhere { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }
|
||||
|
||||
val category =
|
||||
transaction {
|
||||
CategoryType(CategoryTable.selectAll().where { CategoryTable.id eq categoryId }.first())
|
||||
}
|
||||
val category =
|
||||
transaction {
|
||||
CategoryType(CategoryTable.selectAll().where { CategoryTable.id eq categoryId }.first())
|
||||
}
|
||||
|
||||
if (meta != null) {
|
||||
CategoryMetaType(meta)
|
||||
} else {
|
||||
null
|
||||
} to category
|
||||
}
|
||||
if (meta != null) {
|
||||
CategoryMetaType(meta)
|
||||
} else {
|
||||
null
|
||||
} to category
|
||||
}
|
||||
|
||||
DeleteCategoryMetaPayload(clientMutationId, meta, category)
|
||||
}
|
||||
return DeleteCategoryMetaPayload(clientMutationId, meta, category)
|
||||
}
|
||||
|
||||
data class SetCategoryMetasItem(
|
||||
val categoryIds: List<Int>,
|
||||
@@ -110,43 +110,42 @@ class CategoryMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun setCategoryMetas(input: SetCategoryMetasInput): DataFetcherResult<SetCategoryMetasPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, items) = input
|
||||
fun setCategoryMetas(input: SetCategoryMetasInput): SetCategoryMetasPayload? {
|
||||
val (clientMutationId, items) = input
|
||||
|
||||
val metaByCategoryId =
|
||||
items
|
||||
.flatMap { item ->
|
||||
val metaMap = item.metas.associate { it.key to it.value }
|
||||
item.categoryIds.map { categoryId -> categoryId to metaMap }
|
||||
}.groupBy({ it.first }, { it.second })
|
||||
.mapValues { (_, maps) -> maps.reduce { acc, map -> acc + map } }
|
||||
val metaByCategoryId =
|
||||
items
|
||||
.flatMap { item ->
|
||||
val metaMap = item.metas.associate { it.key to it.value }
|
||||
item.categoryIds.map { categoryId -> categoryId to metaMap }
|
||||
}.groupBy({ it.first }, { it.second })
|
||||
.mapValues { (_, maps) -> maps.reduce { acc, map -> acc + map } }
|
||||
|
||||
Category.modifyCategoriesMetas(metaByCategoryId)
|
||||
Category.modifyCategoriesMetas(metaByCategoryId)
|
||||
|
||||
val allCategoryIds = metaByCategoryId.keys
|
||||
val allMetaKeys = metaByCategoryId.values.flatMap { item -> item.keys }.distinct()
|
||||
val allCategoryIds = metaByCategoryId.keys
|
||||
val allMetaKeys = metaByCategoryId.values.flatMap { item -> item.keys }.distinct()
|
||||
|
||||
val (updatedMetas, categories) =
|
||||
transaction {
|
||||
val updatedMetas =
|
||||
CategoryMetaTable
|
||||
.selectAll()
|
||||
.where { (CategoryMetaTable.ref inList allCategoryIds) and (CategoryMetaTable.key inList allMetaKeys) }
|
||||
.map { CategoryMetaType(it) }
|
||||
val (updatedMetas, categories) =
|
||||
transaction {
|
||||
val updatedMetas =
|
||||
CategoryMetaTable
|
||||
.selectAll()
|
||||
.where { (CategoryMetaTable.ref inList allCategoryIds) and (CategoryMetaTable.key inList allMetaKeys) }
|
||||
.map { CategoryMetaType(it) }
|
||||
|
||||
val categories =
|
||||
CategoryTable
|
||||
.selectAll()
|
||||
.where { CategoryTable.id inList allCategoryIds }
|
||||
.map { CategoryType(it) }
|
||||
.distinctBy { it.id }
|
||||
val categories =
|
||||
CategoryTable
|
||||
.selectAll()
|
||||
.where { CategoryTable.id inList allCategoryIds }
|
||||
.map { CategoryType(it) }
|
||||
.distinctBy { it.id }
|
||||
|
||||
updatedMetas to categories
|
||||
}
|
||||
updatedMetas to categories
|
||||
}
|
||||
|
||||
SetCategoryMetasPayload(clientMutationId, updatedMetas, categories)
|
||||
}
|
||||
return SetCategoryMetasPayload(clientMutationId, updatedMetas, categories)
|
||||
}
|
||||
|
||||
data class DeleteCategoryMetasItem(
|
||||
val categoryIds: List<Int>,
|
||||
@@ -166,64 +165,63 @@ class CategoryMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun deleteCategoryMetas(input: DeleteCategoryMetasInput): DataFetcherResult<DeleteCategoryMetasPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, items) = input
|
||||
fun deleteCategoryMetas(input: DeleteCategoryMetasInput): DeleteCategoryMetasPayload? {
|
||||
val (clientMutationId, items) = input
|
||||
|
||||
items.forEach { item ->
|
||||
require(!item.keys.isNullOrEmpty() || !item.prefixes.isNullOrEmpty()) {
|
||||
"Either 'keys' or 'prefixes' must be provided for each item"
|
||||
items.forEach { item ->
|
||||
require(!item.keys.isNullOrEmpty() || !item.prefixes.isNullOrEmpty()) {
|
||||
"Either 'keys' or 'prefixes' must be provided for each item"
|
||||
}
|
||||
}
|
||||
|
||||
val (allDeletedMetas, allCategoryIds) =
|
||||
transaction {
|
||||
val deletedMetas = mutableListOf<CategoryMetaType>()
|
||||
val categoryIds = mutableSetOf<Int>()
|
||||
|
||||
items.forEach { item ->
|
||||
val keyCondition: Op<Boolean>? =
|
||||
item.keys?.takeIf { it.isNotEmpty() }?.let { CategoryMetaTable.key inList it }
|
||||
|
||||
val prefixCondition: Op<Boolean>? =
|
||||
item.prefixes
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.map { (CategoryMetaTable.key like LikePattern("$it%")) as Op<Boolean> }
|
||||
?.reduceOrNull { acc, op -> acc or op }
|
||||
|
||||
val metaKeyCondition =
|
||||
if (keyCondition != null && prefixCondition != null) {
|
||||
keyCondition or prefixCondition
|
||||
} else {
|
||||
keyCondition ?: prefixCondition!!
|
||||
}
|
||||
|
||||
val condition = (CategoryMetaTable.ref inList item.categoryIds) and metaKeyCondition
|
||||
|
||||
deletedMetas +=
|
||||
CategoryMetaTable
|
||||
.selectAll()
|
||||
.where { condition }
|
||||
.map { CategoryMetaType(it) }
|
||||
|
||||
CategoryMetaTable.deleteWhere { condition }
|
||||
categoryIds += item.categoryIds
|
||||
}
|
||||
|
||||
deletedMetas to categoryIds
|
||||
}
|
||||
|
||||
val (allDeletedMetas, allCategoryIds) =
|
||||
transaction {
|
||||
val deletedMetas = mutableListOf<CategoryMetaType>()
|
||||
val categoryIds = mutableSetOf<Int>()
|
||||
val categories =
|
||||
transaction {
|
||||
CategoryTable
|
||||
.selectAll()
|
||||
.where { CategoryTable.id inList allCategoryIds }
|
||||
.map { CategoryType(it) }
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
|
||||
items.forEach { item ->
|
||||
val keyCondition: Op<Boolean>? =
|
||||
item.keys?.takeIf { it.isNotEmpty() }?.let { CategoryMetaTable.key inList it }
|
||||
|
||||
val prefixCondition: Op<Boolean>? =
|
||||
item.prefixes
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.map { (CategoryMetaTable.key like LikePattern("$it%")) as Op<Boolean> }
|
||||
?.reduceOrNull { acc, op -> acc or op }
|
||||
|
||||
val metaKeyCondition =
|
||||
if (keyCondition != null && prefixCondition != null) {
|
||||
keyCondition or prefixCondition
|
||||
} else {
|
||||
keyCondition ?: prefixCondition!!
|
||||
}
|
||||
|
||||
val condition = (CategoryMetaTable.ref inList item.categoryIds) and metaKeyCondition
|
||||
|
||||
deletedMetas +=
|
||||
CategoryMetaTable
|
||||
.selectAll()
|
||||
.where { condition }
|
||||
.map { CategoryMetaType(it) }
|
||||
|
||||
CategoryMetaTable.deleteWhere { condition }
|
||||
categoryIds += item.categoryIds
|
||||
}
|
||||
|
||||
deletedMetas to categoryIds
|
||||
}
|
||||
|
||||
val categories =
|
||||
transaction {
|
||||
CategoryTable
|
||||
.selectAll()
|
||||
.where { CategoryTable.id inList allCategoryIds }
|
||||
.map { CategoryType(it) }
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
|
||||
DeleteCategoryMetasPayload(clientMutationId, allDeletedMetas, categories)
|
||||
}
|
||||
return DeleteCategoryMetasPayload(clientMutationId, allDeletedMetas, categories)
|
||||
}
|
||||
|
||||
data class UpdateCategoryPatch(
|
||||
val name: String? = null,
|
||||
@@ -291,40 +289,38 @@ class CategoryMutation {
|
||||
}
|
||||
|
||||
@RequireAuth
|
||||
fun updateCategory(input: UpdateCategoryInput): DataFetcherResult<UpdateCategoryPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, id, patch) = input
|
||||
fun updateCategory(input: UpdateCategoryInput): UpdateCategoryPayload? {
|
||||
val (clientMutationId, id, patch) = input
|
||||
|
||||
updateCategories(listOf(id), patch)
|
||||
updateCategories(listOf(id), patch)
|
||||
|
||||
val category =
|
||||
transaction {
|
||||
CategoryType(CategoryTable.selectAll().where { CategoryTable.id eq id }.first())
|
||||
}
|
||||
val category =
|
||||
transaction {
|
||||
CategoryType(CategoryTable.selectAll().where { CategoryTable.id eq id }.first())
|
||||
}
|
||||
|
||||
UpdateCategoryPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
category = category,
|
||||
)
|
||||
}
|
||||
return UpdateCategoryPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
category = category,
|
||||
)
|
||||
}
|
||||
|
||||
@RequireAuth
|
||||
fun updateCategories(input: UpdateCategoriesInput): DataFetcherResult<UpdateCategoriesPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, ids, patch) = input
|
||||
fun updateCategories(input: UpdateCategoriesInput): UpdateCategoriesPayload? {
|
||||
val (clientMutationId, ids, patch) = input
|
||||
|
||||
updateCategories(ids, patch)
|
||||
updateCategories(ids, patch)
|
||||
|
||||
val categories =
|
||||
transaction {
|
||||
CategoryTable.selectAll().where { CategoryTable.id inList ids }.map { CategoryType(it) }
|
||||
}
|
||||
val categories =
|
||||
transaction {
|
||||
CategoryTable.selectAll().where { CategoryTable.id inList ids }.map { CategoryType(it) }
|
||||
}
|
||||
|
||||
UpdateCategoriesPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
categories = categories,
|
||||
)
|
||||
}
|
||||
return UpdateCategoriesPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
categories = categories,
|
||||
)
|
||||
}
|
||||
|
||||
data class UpdateCategoryOrderPayload(
|
||||
val clientMutationId: String?,
|
||||
@@ -338,50 +334,49 @@ class CategoryMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun updateCategoryOrder(input: UpdateCategoryOrderInput): DataFetcherResult<UpdateCategoryOrderPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, categoryId, position) = input
|
||||
require(position > 0) {
|
||||
"'order' must not be <= 0"
|
||||
}
|
||||
|
||||
transaction {
|
||||
val currentOrder =
|
||||
CategoryTable
|
||||
.selectAll()
|
||||
.where { CategoryTable.id eq categoryId }
|
||||
.first()[CategoryTable.order]
|
||||
|
||||
if (currentOrder != position) {
|
||||
if (position < currentOrder) {
|
||||
CategoryTable.update({ CategoryTable.order greaterEq position }) {
|
||||
it[CategoryTable.order] = CategoryTable.order + 1
|
||||
}
|
||||
} else {
|
||||
CategoryTable.update({ CategoryTable.order lessEq position }) {
|
||||
it[CategoryTable.order] = CategoryTable.order - 1
|
||||
}
|
||||
}
|
||||
|
||||
CategoryTable.update({ CategoryTable.id eq categoryId }) {
|
||||
it[CategoryTable.order] = position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Category.normalizeCategories()
|
||||
|
||||
val categories =
|
||||
transaction {
|
||||
CategoryTable.selectAll().orderBy(CategoryTable.order).map { CategoryType(it) }
|
||||
}
|
||||
|
||||
UpdateCategoryOrderPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
categories = categories,
|
||||
)
|
||||
fun updateCategoryOrder(input: UpdateCategoryOrderInput): UpdateCategoryOrderPayload? {
|
||||
val (clientMutationId, categoryId, position) = input
|
||||
require(position > 0) {
|
||||
"'order' must not be <= 0"
|
||||
}
|
||||
|
||||
transaction {
|
||||
val currentOrder =
|
||||
CategoryTable
|
||||
.selectAll()
|
||||
.where { CategoryTable.id eq categoryId }
|
||||
.first()[CategoryTable.order]
|
||||
|
||||
if (currentOrder != position) {
|
||||
if (position < currentOrder) {
|
||||
CategoryTable.update({ CategoryTable.order greaterEq position }) {
|
||||
it[CategoryTable.order] = CategoryTable.order + 1
|
||||
}
|
||||
} else {
|
||||
CategoryTable.update({ CategoryTable.order lessEq position }) {
|
||||
it[CategoryTable.order] = CategoryTable.order - 1
|
||||
}
|
||||
}
|
||||
|
||||
CategoryTable.update({ CategoryTable.id eq categoryId }) {
|
||||
it[CategoryTable.order] = position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Category.normalizeCategories()
|
||||
|
||||
val categories =
|
||||
transaction {
|
||||
CategoryTable.selectAll().orderBy(CategoryTable.order).map { CategoryType(it) }
|
||||
}
|
||||
|
||||
return UpdateCategoryOrderPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
categories = categories,
|
||||
)
|
||||
}
|
||||
|
||||
data class CreateCategoryInput(
|
||||
val clientMutationId: String? = null,
|
||||
val name: String,
|
||||
@@ -397,53 +392,52 @@ class CategoryMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun createCategory(input: CreateCategoryInput): DataFetcherResult<CreateCategoryPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, name, order, default, includeInUpdate, includeInDownload) = input
|
||||
transaction {
|
||||
require(CategoryTable.selectAll().where { CategoryTable.name eq input.name }.isEmpty()) {
|
||||
"'name' must be unique"
|
||||
}
|
||||
fun createCategory(input: CreateCategoryInput): CreateCategoryPayload? {
|
||||
val (clientMutationId, name, order, default, includeInUpdate, includeInDownload) = input
|
||||
transaction {
|
||||
require(CategoryTable.selectAll().where { CategoryTable.name eq input.name }.isEmpty()) {
|
||||
"'name' must be unique"
|
||||
}
|
||||
require(!name.equals(Category.DEFAULT_CATEGORY_NAME, ignoreCase = true)) {
|
||||
"'name' must not be ${Category.DEFAULT_CATEGORY_NAME}"
|
||||
}
|
||||
if (order != null) {
|
||||
require(order > 0) {
|
||||
"'order' must not be <= 0"
|
||||
}
|
||||
}
|
||||
require(!name.equals(Category.DEFAULT_CATEGORY_NAME, ignoreCase = true)) {
|
||||
"'name' must not be ${Category.DEFAULT_CATEGORY_NAME}"
|
||||
}
|
||||
if (order != null) {
|
||||
require(order > 0) {
|
||||
"'order' must not be <= 0"
|
||||
}
|
||||
}
|
||||
|
||||
val category =
|
||||
transaction {
|
||||
if (order != null) {
|
||||
CategoryTable.update({ CategoryTable.order greaterEq order }) {
|
||||
it[CategoryTable.order] = CategoryTable.order + 1
|
||||
val category =
|
||||
transaction {
|
||||
if (order != null) {
|
||||
CategoryTable.update({ CategoryTable.order greaterEq order }) {
|
||||
it[CategoryTable.order] = CategoryTable.order + 1
|
||||
}
|
||||
}
|
||||
|
||||
val id =
|
||||
CategoryTable.insertAndGetId {
|
||||
it[CategoryTable.name] = input.name
|
||||
it[CategoryTable.order] = order ?: Int.MAX_VALUE
|
||||
if (default != null) {
|
||||
it[CategoryTable.isDefault] = default
|
||||
}
|
||||
if (includeInUpdate != null) {
|
||||
it[CategoryTable.includeInUpdate] = includeInUpdate.value
|
||||
}
|
||||
if (includeInDownload != null) {
|
||||
it[CategoryTable.includeInDownload] = includeInDownload.value
|
||||
}
|
||||
}
|
||||
|
||||
val id =
|
||||
CategoryTable.insertAndGetId {
|
||||
it[CategoryTable.name] = input.name
|
||||
it[CategoryTable.order] = order ?: Int.MAX_VALUE
|
||||
if (default != null) {
|
||||
it[CategoryTable.isDefault] = default
|
||||
}
|
||||
if (includeInUpdate != null) {
|
||||
it[CategoryTable.includeInUpdate] = includeInUpdate.value
|
||||
}
|
||||
if (includeInDownload != null) {
|
||||
it[CategoryTable.includeInDownload] = includeInDownload.value
|
||||
}
|
||||
}
|
||||
Category.normalizeCategories()
|
||||
|
||||
Category.normalizeCategories()
|
||||
CategoryType(CategoryTable.selectAll().where { CategoryTable.id eq id }.first())
|
||||
}
|
||||
|
||||
CategoryType(CategoryTable.selectAll().where { CategoryTable.id eq id }.first())
|
||||
}
|
||||
|
||||
CreateCategoryPayload(clientMutationId, category)
|
||||
}
|
||||
return CreateCategoryPayload(clientMutationId, category)
|
||||
}
|
||||
|
||||
data class DeleteCategoryInput(
|
||||
val clientMutationId: String? = null,
|
||||
@@ -457,47 +451,45 @@ class CategoryMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun deleteCategory(input: DeleteCategoryInput): DataFetcherResult<DeleteCategoryPayload?> {
|
||||
return asDataFetcherResult {
|
||||
val (clientMutationId, categoryId) = input
|
||||
if (categoryId == 0) { // Don't delete default category
|
||||
return@asDataFetcherResult DeleteCategoryPayload(
|
||||
clientMutationId,
|
||||
null,
|
||||
emptyList(),
|
||||
)
|
||||
fun deleteCategory(input: DeleteCategoryInput): DeleteCategoryPayload? {
|
||||
val (clientMutationId, categoryId) = input
|
||||
if (categoryId == 0) { // Don't delete default category
|
||||
return DeleteCategoryPayload(
|
||||
clientMutationId,
|
||||
null,
|
||||
emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
val (category, mangas) =
|
||||
transaction {
|
||||
val category =
|
||||
CategoryTable
|
||||
.selectAll()
|
||||
.where { CategoryTable.id eq categoryId }
|
||||
.firstOrNull()
|
||||
|
||||
val mangas =
|
||||
transaction {
|
||||
MangaTable
|
||||
.innerJoin(CategoryMangaTable)
|
||||
.selectAll()
|
||||
.where { CategoryMangaTable.category eq categoryId }
|
||||
.map { MangaType(it) }
|
||||
}
|
||||
|
||||
CategoryTable.deleteWhere { CategoryTable.id eq categoryId }
|
||||
|
||||
Category.normalizeCategories()
|
||||
|
||||
if (category != null) {
|
||||
CategoryType(category)
|
||||
} else {
|
||||
null
|
||||
} to mangas
|
||||
}
|
||||
|
||||
val (category, mangas) =
|
||||
transaction {
|
||||
val category =
|
||||
CategoryTable
|
||||
.selectAll()
|
||||
.where { CategoryTable.id eq categoryId }
|
||||
.firstOrNull()
|
||||
|
||||
val mangas =
|
||||
transaction {
|
||||
MangaTable
|
||||
.innerJoin(CategoryMangaTable)
|
||||
.selectAll()
|
||||
.where { CategoryMangaTable.category eq categoryId }
|
||||
.map { MangaType(it) }
|
||||
}
|
||||
|
||||
CategoryTable.deleteWhere { CategoryTable.id eq categoryId }
|
||||
|
||||
Category.normalizeCategories()
|
||||
|
||||
if (category != null) {
|
||||
CategoryType(category)
|
||||
} else {
|
||||
null
|
||||
} to mangas
|
||||
}
|
||||
|
||||
DeleteCategoryPayload(clientMutationId, category, mangas)
|
||||
}
|
||||
return DeleteCategoryPayload(clientMutationId, category, mangas)
|
||||
}
|
||||
|
||||
data class UpdateMangaCategoriesPatch(
|
||||
@@ -547,38 +539,36 @@ class CategoryMutation {
|
||||
}
|
||||
|
||||
@RequireAuth
|
||||
fun updateMangaCategories(input: UpdateMangaCategoriesInput): DataFetcherResult<UpdateMangaCategoriesPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, id, patch) = input
|
||||
fun updateMangaCategories(input: UpdateMangaCategoriesInput): UpdateMangaCategoriesPayload? {
|
||||
val (clientMutationId, id, patch) = input
|
||||
|
||||
updateMangas(listOf(id), patch)
|
||||
updateMangas(listOf(id), patch)
|
||||
|
||||
val manga =
|
||||
transaction {
|
||||
MangaType(MangaTable.selectAll().where { MangaTable.id eq id }.first())
|
||||
}
|
||||
val manga =
|
||||
transaction {
|
||||
MangaType(MangaTable.selectAll().where { MangaTable.id eq id }.first())
|
||||
}
|
||||
|
||||
UpdateMangaCategoriesPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
manga = manga,
|
||||
)
|
||||
}
|
||||
return UpdateMangaCategoriesPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
manga = manga,
|
||||
)
|
||||
}
|
||||
|
||||
@RequireAuth
|
||||
fun updateMangasCategories(input: UpdateMangasCategoriesInput): DataFetcherResult<UpdateMangasCategoriesPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, ids, patch) = input
|
||||
fun updateMangasCategories(input: UpdateMangasCategoriesInput): UpdateMangasCategoriesPayload? {
|
||||
val (clientMutationId, ids, patch) = input
|
||||
|
||||
updateMangas(ids, patch)
|
||||
updateMangas(ids, patch)
|
||||
|
||||
val mangas =
|
||||
transaction {
|
||||
MangaTable.selectAll().where { MangaTable.id inList ids }.map { MangaType(it) }
|
||||
}
|
||||
val mangas =
|
||||
transaction {
|
||||
MangaTable.selectAll().where { MangaTable.id inList ids }.map { MangaType(it) }
|
||||
}
|
||||
|
||||
UpdateMangasCategoriesPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
mangas = mangas,
|
||||
)
|
||||
}
|
||||
return UpdateMangasCategoriesPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
mangas = mangas,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
@file:Suppress("RedundantNullableReturnType", "unused")
|
||||
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import graphql.execution.DataFetcherResult
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.exposed.dao.id.EntityID
|
||||
import org.jetbrains.exposed.sql.LikePattern
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.like
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.or
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import suwayomi.tachidesk.graphql.asDataFetcherResult
|
||||
import org.jetbrains.exposed.v1.core.LikePattern
|
||||
import org.jetbrains.exposed.v1.core.Op
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.like
|
||||
import org.jetbrains.exposed.v1.core.or
|
||||
import org.jetbrains.exposed.v1.core.statements.BatchUpdateStatement
|
||||
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.select
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.types.ChapterMetaType
|
||||
import suwayomi.tachidesk.graphql.types.ChapterType
|
||||
@@ -90,22 +92,23 @@ class ChapterMutation {
|
||||
if (patch.isRead != null || patch.isBookmarked != null || patch.lastPageRead != null) {
|
||||
val now = Instant.now().epochSecond
|
||||
|
||||
BatchUpdateStatement(ChapterTable).apply {
|
||||
ids.forEach { chapterId ->
|
||||
addBatch(EntityID(chapterId, ChapterTable))
|
||||
patch.isRead?.also {
|
||||
this[ChapterTable.isRead] = it
|
||||
BatchUpdateStatement(ChapterTable)
|
||||
.apply {
|
||||
ids.forEach { chapterId ->
|
||||
addBatch(EntityID(chapterId, ChapterTable))
|
||||
patch.isRead?.also {
|
||||
this[ChapterTable.isRead] = it
|
||||
}
|
||||
patch.isBookmarked?.also {
|
||||
this[ChapterTable.isBookmarked] = it
|
||||
}
|
||||
patch.lastPageRead?.also {
|
||||
this[ChapterTable.lastPageRead] = it.coerceAtMost(chapterIdToPageCount[chapterId] ?: 0).coerceAtLeast(0)
|
||||
this[ChapterTable.lastReadAt] = now
|
||||
}
|
||||
}
|
||||
patch.isBookmarked?.also {
|
||||
this[ChapterTable.isBookmarked] = it
|
||||
}
|
||||
patch.lastPageRead?.also {
|
||||
this[ChapterTable.lastPageRead] = it.coerceAtMost(chapterIdToPageCount[chapterId] ?: 0).coerceAtLeast(0)
|
||||
this[ChapterTable.lastReadAt] = now
|
||||
}
|
||||
}
|
||||
execute(this@transaction)
|
||||
}
|
||||
}.toExecutable()
|
||||
.execute(this@transaction)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,40 +123,38 @@ class ChapterMutation {
|
||||
}
|
||||
|
||||
@RequireAuth
|
||||
fun updateChapter(input: UpdateChapterInput): DataFetcherResult<UpdateChapterPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, id, patch) = input
|
||||
fun updateChapter(input: UpdateChapterInput): UpdateChapterPayload? {
|
||||
val (clientMutationId, id, patch) = input
|
||||
|
||||
updateChapters(listOf(id), patch)
|
||||
updateChapters(listOf(id), patch)
|
||||
|
||||
val chapter =
|
||||
transaction {
|
||||
ChapterType(ChapterTable.selectAll().where { ChapterTable.id eq id }.first())
|
||||
}
|
||||
val chapter =
|
||||
transaction {
|
||||
ChapterType(ChapterTable.selectAll().where { ChapterTable.id eq id }.first())
|
||||
}
|
||||
|
||||
UpdateChapterPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
chapter = chapter,
|
||||
)
|
||||
}
|
||||
return UpdateChapterPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
chapter = chapter,
|
||||
)
|
||||
}
|
||||
|
||||
@RequireAuth
|
||||
fun updateChapters(input: UpdateChaptersInput): DataFetcherResult<UpdateChaptersPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, ids, patch) = input
|
||||
fun updateChapters(input: UpdateChaptersInput): UpdateChaptersPayload? {
|
||||
val (clientMutationId, ids, patch) = input
|
||||
|
||||
updateChapters(ids, patch)
|
||||
updateChapters(ids, patch)
|
||||
|
||||
val chapters =
|
||||
transaction {
|
||||
ChapterTable.selectAll().where { ChapterTable.id inList ids }.map { ChapterType(it) }
|
||||
}
|
||||
val chapters =
|
||||
transaction {
|
||||
ChapterTable.selectAll().where { ChapterTable.id inList ids }.map { ChapterType(it) }
|
||||
}
|
||||
|
||||
UpdateChaptersPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
chapters = chapters,
|
||||
)
|
||||
}
|
||||
return UpdateChaptersPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
chapters = chapters,
|
||||
)
|
||||
}
|
||||
|
||||
data class FetchChaptersInput(
|
||||
val clientMutationId: String? = null,
|
||||
@@ -166,27 +167,25 @@ class ChapterMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun fetchChapters(input: FetchChaptersInput): CompletableFuture<DataFetcherResult<FetchChaptersPayload?>> {
|
||||
fun fetchChapters(input: FetchChaptersInput): CompletableFuture<FetchChaptersPayload?> {
|
||||
val (clientMutationId, mangaId) = input
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
Chapter.fetchChapterList(mangaId)
|
||||
Chapter.fetchChapterList(mangaId)
|
||||
|
||||
val chapters =
|
||||
transaction {
|
||||
ChapterTable
|
||||
.selectAll()
|
||||
.where { ChapterTable.manga eq mangaId }
|
||||
.orderBy(ChapterTable.sourceOrder)
|
||||
.map { ChapterType(it) }
|
||||
}
|
||||
val chapters =
|
||||
transaction {
|
||||
ChapterTable
|
||||
.selectAll()
|
||||
.where { ChapterTable.manga eq mangaId }
|
||||
.orderBy(ChapterTable.sourceOrder)
|
||||
.map { ChapterType(it) }
|
||||
}
|
||||
|
||||
FetchChaptersPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
chapters = chapters,
|
||||
)
|
||||
}
|
||||
FetchChaptersPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
chapters = chapters,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,14 +200,13 @@ class ChapterMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun setChapterMeta(input: SetChapterMetaInput): DataFetcherResult<SetChapterMetaPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, meta) = input
|
||||
fun setChapterMeta(input: SetChapterMetaInput): SetChapterMetaPayload? {
|
||||
val (clientMutationId, meta) = input
|
||||
|
||||
Chapter.modifyChapterMeta(meta.chapterId, meta.key, meta.value)
|
||||
Chapter.modifyChapterMeta(meta.chapterId, meta.key, meta.value)
|
||||
|
||||
SetChapterMetaPayload(clientMutationId, meta)
|
||||
}
|
||||
return SetChapterMetaPayload(clientMutationId, meta)
|
||||
}
|
||||
|
||||
data class DeleteChapterMetaInput(
|
||||
val clientMutationId: String? = null,
|
||||
@@ -223,34 +221,33 @@ class ChapterMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun deleteChapterMeta(input: DeleteChapterMetaInput): DataFetcherResult<DeleteChapterMetaPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, chapterId, key) = input
|
||||
fun deleteChapterMeta(input: DeleteChapterMetaInput): DeleteChapterMetaPayload? {
|
||||
val (clientMutationId, chapterId, key) = input
|
||||
|
||||
val (meta, chapter) =
|
||||
transaction {
|
||||
val meta =
|
||||
ChapterMetaTable
|
||||
.selectAll()
|
||||
.where { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
|
||||
.firstOrNull()
|
||||
val (meta, chapter) =
|
||||
transaction {
|
||||
val meta =
|
||||
ChapterMetaTable
|
||||
.selectAll()
|
||||
.where { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
|
||||
.firstOrNull()
|
||||
|
||||
ChapterMetaTable.deleteWhere { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
|
||||
ChapterMetaTable.deleteWhere { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
|
||||
|
||||
val chapter =
|
||||
transaction {
|
||||
ChapterType(ChapterTable.selectAll().where { ChapterTable.id eq chapterId }.first())
|
||||
}
|
||||
val chapter =
|
||||
transaction {
|
||||
ChapterType(ChapterTable.selectAll().where { ChapterTable.id eq chapterId }.first())
|
||||
}
|
||||
|
||||
if (meta != null) {
|
||||
ChapterMetaType(meta)
|
||||
} else {
|
||||
null
|
||||
} to chapter
|
||||
}
|
||||
if (meta != null) {
|
||||
ChapterMetaType(meta)
|
||||
} else {
|
||||
null
|
||||
} to chapter
|
||||
}
|
||||
|
||||
DeleteChapterMetaPayload(clientMutationId, meta, chapter)
|
||||
}
|
||||
return DeleteChapterMetaPayload(clientMutationId, meta, chapter)
|
||||
}
|
||||
|
||||
data class SetChapterMetasItem(
|
||||
val chapterIds: List<Int>,
|
||||
@@ -269,43 +266,42 @@ class ChapterMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun setChapterMetas(input: SetChapterMetasInput): DataFetcherResult<SetChapterMetasPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, items) = input
|
||||
fun setChapterMetas(input: SetChapterMetasInput): SetChapterMetasPayload? {
|
||||
val (clientMutationId, items) = input
|
||||
|
||||
val metaByChapterId =
|
||||
items
|
||||
.flatMap { item ->
|
||||
val metaMap = item.metas.associate { it.key to it.value }
|
||||
item.chapterIds.map { chapterId -> chapterId to metaMap }
|
||||
}.groupBy({ it.first }, { it.second })
|
||||
.mapValues { (_, maps) -> maps.reduce { acc, map -> acc + map } }
|
||||
val metaByChapterId =
|
||||
items
|
||||
.flatMap { item ->
|
||||
val metaMap = item.metas.associate { it.key to it.value }
|
||||
item.chapterIds.map { chapterId -> chapterId to metaMap }
|
||||
}.groupBy({ it.first }, { it.second })
|
||||
.mapValues { (_, maps) -> maps.reduce { acc, map -> acc + map } }
|
||||
|
||||
Chapter.modifyChaptersMetas(metaByChapterId)
|
||||
Chapter.modifyChaptersMetas(metaByChapterId)
|
||||
|
||||
val allChapterIds = metaByChapterId.keys
|
||||
val allMetaKeys = metaByChapterId.values.flatMap { it.keys }.distinct()
|
||||
val allChapterIds = metaByChapterId.keys
|
||||
val allMetaKeys = metaByChapterId.values.flatMap { it.keys }.distinct()
|
||||
|
||||
val (updatedMetas, chapters) =
|
||||
transaction {
|
||||
val updatedMetas =
|
||||
ChapterMetaTable
|
||||
.selectAll()
|
||||
.where { (ChapterMetaTable.ref inList allChapterIds) and (ChapterMetaTable.key inList allMetaKeys) }
|
||||
.map { ChapterMetaType(it) }
|
||||
val (updatedMetas, chapters) =
|
||||
transaction {
|
||||
val updatedMetas =
|
||||
ChapterMetaTable
|
||||
.selectAll()
|
||||
.where { (ChapterMetaTable.ref inList allChapterIds) and (ChapterMetaTable.key inList allMetaKeys) }
|
||||
.map { ChapterMetaType(it) }
|
||||
|
||||
val chapters =
|
||||
ChapterTable
|
||||
.selectAll()
|
||||
.where { ChapterTable.id inList allChapterIds }
|
||||
.map { ChapterType(it) }
|
||||
.distinctBy { it.id }
|
||||
val chapters =
|
||||
ChapterTable
|
||||
.selectAll()
|
||||
.where { ChapterTable.id inList allChapterIds }
|
||||
.map { ChapterType(it) }
|
||||
.distinctBy { it.id }
|
||||
|
||||
updatedMetas to chapters
|
||||
}
|
||||
updatedMetas to chapters
|
||||
}
|
||||
|
||||
SetChapterMetasPayload(clientMutationId, updatedMetas, chapters)
|
||||
}
|
||||
return SetChapterMetasPayload(clientMutationId, updatedMetas, chapters)
|
||||
}
|
||||
|
||||
data class DeleteChapterMetasItem(
|
||||
val chapterIds: List<Int>,
|
||||
@@ -325,64 +321,63 @@ class ChapterMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun deleteChapterMetas(input: DeleteChapterMetasInput): DataFetcherResult<DeleteChapterMetasPayload?> =
|
||||
asDataFetcherResult {
|
||||
val (clientMutationId, items) = input
|
||||
fun deleteChapterMetas(input: DeleteChapterMetasInput): DeleteChapterMetasPayload? {
|
||||
val (clientMutationId, items) = input
|
||||
|
||||
items.forEach { item ->
|
||||
require(!item.keys.isNullOrEmpty() || !item.prefixes.isNullOrEmpty()) {
|
||||
"Either 'keys' or 'prefixes' must be provided for each item"
|
||||
items.forEach { item ->
|
||||
require(!item.keys.isNullOrEmpty() || !item.prefixes.isNullOrEmpty()) {
|
||||
"Either 'keys' or 'prefixes' must be provided for each item"
|
||||
}
|
||||
}
|
||||
|
||||
val (allDeletedMetas, allChapterIds) =
|
||||
transaction {
|
||||
val deletedMetas = mutableListOf<ChapterMetaType>()
|
||||
val chapterIds = mutableSetOf<Int>()
|
||||
|
||||
items.forEach { item ->
|
||||
val keyCondition: Op<Boolean>? =
|
||||
item.keys?.takeIf { it.isNotEmpty() }?.let { ChapterMetaTable.key inList it }
|
||||
|
||||
val prefixCondition: Op<Boolean>? =
|
||||
item.prefixes
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.map { (ChapterMetaTable.key like LikePattern("$it%")) as Op<Boolean> }
|
||||
?.reduceOrNull { acc, op -> acc or op }
|
||||
|
||||
val metaKeyCondition =
|
||||
if (keyCondition != null && prefixCondition != null) {
|
||||
keyCondition or prefixCondition
|
||||
} else {
|
||||
keyCondition ?: prefixCondition!!
|
||||
}
|
||||
|
||||
val condition = (ChapterMetaTable.ref inList item.chapterIds) and metaKeyCondition
|
||||
|
||||
deletedMetas +=
|
||||
ChapterMetaTable
|
||||
.selectAll()
|
||||
.where { condition }
|
||||
.map { ChapterMetaType(it) }
|
||||
|
||||
ChapterMetaTable.deleteWhere { condition }
|
||||
chapterIds += item.chapterIds
|
||||
}
|
||||
|
||||
deletedMetas to chapterIds
|
||||
}
|
||||
|
||||
val (allDeletedMetas, allChapterIds) =
|
||||
transaction {
|
||||
val deletedMetas = mutableListOf<ChapterMetaType>()
|
||||
val chapterIds = mutableSetOf<Int>()
|
||||
val chapters =
|
||||
transaction {
|
||||
ChapterTable
|
||||
.selectAll()
|
||||
.where { ChapterTable.id inList allChapterIds }
|
||||
.map { ChapterType(it) }
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
|
||||
items.forEach { item ->
|
||||
val keyCondition: Op<Boolean>? =
|
||||
item.keys?.takeIf { it.isNotEmpty() }?.let { ChapterMetaTable.key inList it }
|
||||
|
||||
val prefixCondition: Op<Boolean>? =
|
||||
item.prefixes
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.map { (ChapterMetaTable.key like LikePattern("$it%")) as Op<Boolean> }
|
||||
?.reduceOrNull { acc, op -> acc or op }
|
||||
|
||||
val metaKeyCondition =
|
||||
if (keyCondition != null && prefixCondition != null) {
|
||||
keyCondition or prefixCondition
|
||||
} else {
|
||||
keyCondition ?: prefixCondition!!
|
||||
}
|
||||
|
||||
val condition = (ChapterMetaTable.ref inList item.chapterIds) and metaKeyCondition
|
||||
|
||||
deletedMetas +=
|
||||
ChapterMetaTable
|
||||
.selectAll()
|
||||
.where { condition }
|
||||
.map { ChapterMetaType(it) }
|
||||
|
||||
ChapterMetaTable.deleteWhere { condition }
|
||||
chapterIds += item.chapterIds
|
||||
}
|
||||
|
||||
deletedMetas to chapterIds
|
||||
}
|
||||
|
||||
val chapters =
|
||||
transaction {
|
||||
ChapterTable
|
||||
.selectAll()
|
||||
.where { ChapterTable.id inList allChapterIds }
|
||||
.map { ChapterType(it) }
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
|
||||
DeleteChapterMetasPayload(clientMutationId, allDeletedMetas, chapters)
|
||||
}
|
||||
return DeleteChapterMetasPayload(clientMutationId, allDeletedMetas, chapters)
|
||||
}
|
||||
|
||||
data class FetchChapterPagesInput(
|
||||
val clientMutationId: String? = null,
|
||||
@@ -405,67 +400,65 @@ class ChapterMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun fetchChapterPages(input: FetchChapterPagesInput): CompletableFuture<DataFetcherResult<FetchChapterPagesPayload?>> {
|
||||
fun fetchChapterPages(input: FetchChapterPagesInput): CompletableFuture<FetchChapterPagesPayload?> {
|
||||
val (clientMutationId, chapterId) = input
|
||||
val paramsMap = input.toParams()
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
var chapter = getChapterDownloadReadyById(chapterId)
|
||||
val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
|
||||
var syncConflictInfo: SyncConflictInfoType? = null
|
||||
var chapter = getChapterDownloadReadyById(chapterId)
|
||||
val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
|
||||
var syncConflictInfo: SyncConflictInfoType? = null
|
||||
|
||||
if (syncResult != null) {
|
||||
if (syncResult.isConflict) {
|
||||
syncConflictInfo =
|
||||
SyncConflictInfoType(
|
||||
deviceName = syncResult.device,
|
||||
remotePage = syncResult.pageRead,
|
||||
)
|
||||
}
|
||||
|
||||
if (syncResult.shouldUpdate) {
|
||||
// Update DB for SILENT and RECEIVE
|
||||
transaction {
|
||||
ChapterTable.update({ ChapterTable.id eq chapter.id }) {
|
||||
it[lastPageRead] = syncResult.pageRead
|
||||
it[lastReadAt] = syncResult.timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
// For PROMPT, SILENT, and RECEIVE, return the remote progress
|
||||
chapter =
|
||||
chapter.copy(
|
||||
lastPageRead = if (syncResult.shouldUpdate) syncResult.pageRead else chapter.lastPageRead,
|
||||
lastReadAt = if (syncResult.shouldUpdate) syncResult.timestamp else chapter.lastReadAt,
|
||||
if (syncResult != null) {
|
||||
if (syncResult.isConflict) {
|
||||
syncConflictInfo =
|
||||
SyncConflictInfoType(
|
||||
deviceName = syncResult.device,
|
||||
remotePage = syncResult.pageRead,
|
||||
)
|
||||
}
|
||||
|
||||
val params =
|
||||
buildString {
|
||||
if (paramsMap.isNotEmpty()) {
|
||||
append("?")
|
||||
paramsMap.entries.forEach { entry ->
|
||||
if (length > 1) {
|
||||
append("&")
|
||||
}
|
||||
append(entry.key)
|
||||
append("=")
|
||||
append(URLEncoder.encode(entry.value, Charsets.UTF_8))
|
||||
}
|
||||
if (syncResult.shouldUpdate) {
|
||||
// Update DB for SILENT and RECEIVE
|
||||
transaction {
|
||||
ChapterTable.update({ ChapterTable.id eq chapter.id }) {
|
||||
it[lastPageRead] = syncResult.pageRead
|
||||
it[lastReadAt] = syncResult.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
FetchChapterPagesPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
pages =
|
||||
List(chapter.pageCount) { index ->
|
||||
"/api/v1/manga/${chapter.mangaId}/chapter/${chapter.index}/page/${index}$params"
|
||||
},
|
||||
chapter = ChapterType(chapter),
|
||||
syncConflict = syncConflictInfo,
|
||||
)
|
||||
}
|
||||
// For PROMPT, SILENT, and RECEIVE, return the remote progress
|
||||
chapter =
|
||||
chapter.copy(
|
||||
lastPageRead = if (syncResult.shouldUpdate) syncResult.pageRead else chapter.lastPageRead,
|
||||
lastReadAt = if (syncResult.shouldUpdate) syncResult.timestamp else chapter.lastReadAt,
|
||||
)
|
||||
}
|
||||
|
||||
val params =
|
||||
buildString {
|
||||
if (paramsMap.isNotEmpty()) {
|
||||
append("?")
|
||||
paramsMap.entries.forEach { entry ->
|
||||
if (length > 1) {
|
||||
append("&")
|
||||
}
|
||||
append(entry.key)
|
||||
append("=")
|
||||
append(URLEncoder.encode(entry.value, Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FetchChapterPagesPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
pages =
|
||||
List(chapter.pageCount) { index ->
|
||||
"/api/v1/manga/${chapter.mangaId}/chapter/${chapter.index}/page/${index}$params"
|
||||
},
|
||||
chapter = ChapterType(chapter),
|
||||
syncConflict = syncConflictInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
@file:Suppress("RedundantNullableReturnType", "unused")
|
||||
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import graphql.execution.DataFetcherResult
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.asDataFetcherResult
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.types.ChapterType
|
||||
import suwayomi.tachidesk.graphql.types.DownloadStatus
|
||||
@@ -30,23 +32,21 @@ class DownloadMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun deleteDownloadedChapters(input: DeleteDownloadedChaptersInput): DataFetcherResult<DeleteDownloadedChaptersPayload?> {
|
||||
fun deleteDownloadedChapters(input: DeleteDownloadedChaptersInput): DeleteDownloadedChaptersPayload? {
|
||||
val (clientMutationId, chapters) = input
|
||||
|
||||
return asDataFetcherResult {
|
||||
Chapter.deleteChapters(chapters)
|
||||
Chapter.deleteChapters(chapters)
|
||||
|
||||
DeleteDownloadedChaptersPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
chapters =
|
||||
transaction {
|
||||
ChapterTable
|
||||
.selectAll()
|
||||
.where { ChapterTable.id inList chapters }
|
||||
.map { ChapterType(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
return DeleteDownloadedChaptersPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
chapters =
|
||||
transaction {
|
||||
ChapterTable
|
||||
.selectAll()
|
||||
.where { ChapterTable.id inList chapters }
|
||||
.map { ChapterType(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
data class DeleteDownloadedChapterInput(
|
||||
@@ -60,20 +60,18 @@ class DownloadMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun deleteDownloadedChapter(input: DeleteDownloadedChapterInput): DataFetcherResult<DeleteDownloadedChapterPayload?> {
|
||||
fun deleteDownloadedChapter(input: DeleteDownloadedChapterInput): DeleteDownloadedChapterPayload? {
|
||||
val (clientMutationId, chapter) = input
|
||||
|
||||
return asDataFetcherResult {
|
||||
Chapter.deleteChapters(listOf(chapter))
|
||||
Chapter.deleteChapters(listOf(chapter))
|
||||
|
||||
DeleteDownloadedChapterPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
chapters =
|
||||
transaction {
|
||||
ChapterType(ChapterTable.selectAll().where { ChapterTable.id eq chapter }.first())
|
||||
},
|
||||
)
|
||||
}
|
||||
return DeleteDownloadedChapterPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
chapters =
|
||||
transaction {
|
||||
ChapterType(ChapterTable.selectAll().where { ChapterTable.id eq chapter }.first())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
data class EnqueueChapterDownloadsInput(
|
||||
@@ -87,28 +85,24 @@ class DownloadMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun enqueueChapterDownloads(
|
||||
input: EnqueueChapterDownloadsInput,
|
||||
): CompletableFuture<DataFetcherResult<EnqueueChapterDownloadsPayload?>> {
|
||||
fun enqueueChapterDownloads(input: EnqueueChapterDownloadsInput): CompletableFuture<EnqueueChapterDownloadsPayload?> {
|
||||
val (clientMutationId, chapters) = input
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
DownloadManager.enqueue(DownloadManager.EnqueueInput(chapters))
|
||||
DownloadManager.enqueue(DownloadManager.EnqueueInput(chapters))
|
||||
|
||||
EnqueueChapterDownloadsPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first {
|
||||
DownloadManager.getStatus().queue.any { it.chapterId in chapters }
|
||||
}.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
EnqueueChapterDownloadsPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first {
|
||||
DownloadManager.getStatus().queue.any { it.chapterId in chapters }
|
||||
}.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,25 +117,23 @@ class DownloadMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun enqueueChapterDownload(input: EnqueueChapterDownloadInput): CompletableFuture<DataFetcherResult<EnqueueChapterDownloadPayload?>> {
|
||||
fun enqueueChapterDownload(input: EnqueueChapterDownloadInput): CompletableFuture<EnqueueChapterDownloadPayload?> {
|
||||
val (clientMutationId, chapter) = input
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
DownloadManager.enqueue(DownloadManager.EnqueueInput(listOf(chapter)))
|
||||
DownloadManager.enqueue(DownloadManager.EnqueueInput(listOf(chapter)))
|
||||
|
||||
EnqueueChapterDownloadPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first { it.updates.any { it.downloadQueueItem.chapterId == chapter } }
|
||||
.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
EnqueueChapterDownloadPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first { it.updates.any { it.downloadQueueItem.chapterId == chapter } }
|
||||
.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,30 +148,26 @@ class DownloadMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun dequeueChapterDownloads(
|
||||
input: DequeueChapterDownloadsInput,
|
||||
): CompletableFuture<DataFetcherResult<DequeueChapterDownloadsPayload?>> {
|
||||
fun dequeueChapterDownloads(input: DequeueChapterDownloadsInput): CompletableFuture<DequeueChapterDownloadsPayload?> {
|
||||
val (clientMutationId, chapters) = input
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
DownloadManager.dequeue(DownloadManager.EnqueueInput(chapters))
|
||||
DownloadManager.dequeue(DownloadManager.EnqueueInput(chapters))
|
||||
|
||||
DequeueChapterDownloadsPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first {
|
||||
it.updates.any {
|
||||
it.downloadQueueItem.chapterId in chapters && it.type == DEQUEUED
|
||||
}
|
||||
}.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
DequeueChapterDownloadsPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first {
|
||||
it.updates.any {
|
||||
it.downloadQueueItem.chapterId in chapters && it.type == DEQUEUED
|
||||
}
|
||||
}.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,28 +182,26 @@ class DownloadMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun dequeueChapterDownload(input: DequeueChapterDownloadInput): CompletableFuture<DataFetcherResult<DequeueChapterDownloadPayload?>> {
|
||||
fun dequeueChapterDownload(input: DequeueChapterDownloadInput): CompletableFuture<DequeueChapterDownloadPayload?> {
|
||||
val (clientMutationId, chapter) = input
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
DownloadManager.dequeue(DownloadManager.EnqueueInput(listOf(chapter)))
|
||||
DownloadManager.dequeue(DownloadManager.EnqueueInput(listOf(chapter)))
|
||||
|
||||
DequeueChapterDownloadPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first {
|
||||
it.updates.any {
|
||||
it.downloadQueueItem.chapterId == chapter && it.type == DEQUEUED
|
||||
}
|
||||
}.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
DequeueChapterDownloadPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first {
|
||||
it.updates.any {
|
||||
it.downloadQueueItem.chapterId == chapter && it.type == DEQUEUED
|
||||
}
|
||||
}.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,23 +215,21 @@ class DownloadMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun startDownloader(input: StartDownloaderInput): CompletableFuture<DataFetcherResult<StartDownloaderPayload?>> =
|
||||
fun startDownloader(input: StartDownloaderInput): CompletableFuture<StartDownloaderPayload?> =
|
||||
future {
|
||||
asDataFetcherResult {
|
||||
DownloadManager.start()
|
||||
DownloadManager.start()
|
||||
|
||||
StartDownloaderPayload(
|
||||
input.clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first { it.status == Status.Started }
|
||||
.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
StartDownloaderPayload(
|
||||
input.clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first { it.status == Status.Started }
|
||||
.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
data class StopDownloaderInput(
|
||||
@@ -258,23 +242,21 @@ class DownloadMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun stopDownloader(input: StopDownloaderInput): CompletableFuture<DataFetcherResult<StopDownloaderPayload?>> =
|
||||
fun stopDownloader(input: StopDownloaderInput): CompletableFuture<StopDownloaderPayload?> =
|
||||
future {
|
||||
asDataFetcherResult {
|
||||
DownloadManager.stop()
|
||||
DownloadManager.stop()
|
||||
|
||||
StopDownloaderPayload(
|
||||
input.clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first { it.status == Status.Stopped }
|
||||
.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
StopDownloaderPayload(
|
||||
input.clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first { it.status == Status.Stopped }
|
||||
.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
data class ClearDownloaderInput(
|
||||
@@ -287,23 +269,21 @@ class DownloadMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun clearDownloader(input: ClearDownloaderInput): CompletableFuture<DataFetcherResult<ClearDownloaderPayload?>> =
|
||||
fun clearDownloader(input: ClearDownloaderInput): CompletableFuture<ClearDownloaderPayload?> =
|
||||
future {
|
||||
asDataFetcherResult {
|
||||
DownloadManager.clear()
|
||||
DownloadManager.clear()
|
||||
|
||||
ClearDownloaderPayload(
|
||||
input.clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first { it.status == Status.Stopped }
|
||||
.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
ClearDownloaderPayload(
|
||||
input.clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first { it.status == Status.Stopped }
|
||||
.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
data class ReorderChapterDownloadInput(
|
||||
@@ -318,25 +298,23 @@ class DownloadMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun reorderChapterDownload(input: ReorderChapterDownloadInput): CompletableFuture<DataFetcherResult<ReorderChapterDownloadPayload?>> {
|
||||
fun reorderChapterDownload(input: ReorderChapterDownloadInput): CompletableFuture<ReorderChapterDownloadPayload?> {
|
||||
val (clientMutationId, chapter, to) = input
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
DownloadManager.reorder(chapter, to)
|
||||
DownloadManager.reorder(chapter, to)
|
||||
|
||||
ReorderChapterDownloadPayload(
|
||||
clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first { it.updates.indexOfFirst { it.downloadQueueItem.chapterId == chapter } <= to }
|
||||
.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
ReorderChapterDownloadPayload(
|
||||
clientMutationId,
|
||||
downloadStatus =
|
||||
withTimeout(30.seconds) {
|
||||
DownloadStatus(
|
||||
DownloadManager.updates
|
||||
.first { it.updates.indexOfFirst { it.downloadQueueItem.chapterId == chapter } <= to }
|
||||
.let { DownloadManager.getStatus() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
@file:Suppress("RedundantNullableReturnType", "unused")
|
||||
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import eu.kanade.tachiyomi.source.local.LocalSource
|
||||
import graphql.execution.DataFetcherResult
|
||||
import io.javalin.http.UploadedFile
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.asDataFetcherResult
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.neq
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.types.ExtensionType
|
||||
import suwayomi.tachidesk.manga.impl.extension.Extension
|
||||
@@ -75,51 +78,47 @@ class ExtensionMutation {
|
||||
}
|
||||
|
||||
@RequireAuth
|
||||
fun updateExtension(input: UpdateExtensionInput): CompletableFuture<DataFetcherResult<UpdateExtensionPayload?>> {
|
||||
fun updateExtension(input: UpdateExtensionInput): CompletableFuture<UpdateExtensionPayload?> {
|
||||
val (clientMutationId, id, patch) = input
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
updateExtensions(listOf(id), patch)
|
||||
updateExtensions(listOf(id), patch)
|
||||
|
||||
val extension =
|
||||
transaction {
|
||||
ExtensionTable
|
||||
.selectAll()
|
||||
.where { ExtensionTable.pkgName eq id }
|
||||
.firstOrNull()
|
||||
?.let { ExtensionType(it) }
|
||||
}
|
||||
val extension =
|
||||
transaction {
|
||||
ExtensionTable
|
||||
.selectAll()
|
||||
.where { ExtensionTable.pkgName eq id }
|
||||
.firstOrNull()
|
||||
?.let { ExtensionType(it) }
|
||||
}
|
||||
|
||||
UpdateExtensionPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
extension = extension,
|
||||
)
|
||||
}
|
||||
UpdateExtensionPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
extension = extension,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequireAuth
|
||||
fun updateExtensions(input: UpdateExtensionsInput): CompletableFuture<DataFetcherResult<UpdateExtensionsPayload?>> {
|
||||
fun updateExtensions(input: UpdateExtensionsInput): CompletableFuture<UpdateExtensionsPayload?> {
|
||||
val (clientMutationId, ids, patch) = input
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
updateExtensions(ids, patch)
|
||||
updateExtensions(ids, patch)
|
||||
|
||||
val extensions =
|
||||
transaction {
|
||||
ExtensionTable
|
||||
.selectAll()
|
||||
.where { ExtensionTable.pkgName inList ids }
|
||||
.map { ExtensionType(it) }
|
||||
}
|
||||
val extensions =
|
||||
transaction {
|
||||
ExtensionTable
|
||||
.selectAll()
|
||||
.where { ExtensionTable.pkgName inList ids }
|
||||
.map { ExtensionType(it) }
|
||||
}
|
||||
|
||||
UpdateExtensionsPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
extensions = extensions,
|
||||
)
|
||||
}
|
||||
UpdateExtensionsPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
extensions = extensions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,26 +132,24 @@ class ExtensionMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun fetchExtensions(input: FetchExtensionsInput): CompletableFuture<DataFetcherResult<FetchExtensionsPayload?>> {
|
||||
fun fetchExtensions(input: FetchExtensionsInput): CompletableFuture<FetchExtensionsPayload?> {
|
||||
val (clientMutationId) = input
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
ExtensionsList.fetchExtensions()
|
||||
ExtensionsList.fetchExtensions()
|
||||
|
||||
val extensions =
|
||||
transaction {
|
||||
ExtensionTable
|
||||
.selectAll()
|
||||
.where { ExtensionTable.name neq LocalSource.EXTENSION_NAME }
|
||||
.map { ExtensionType(it) }
|
||||
}
|
||||
val extensions =
|
||||
transaction {
|
||||
ExtensionTable
|
||||
.selectAll()
|
||||
.where { ExtensionTable.name neq LocalSource.EXTENSION_NAME }
|
||||
.map { ExtensionType(it) }
|
||||
}
|
||||
|
||||
FetchExtensionsPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
extensions = extensions,
|
||||
)
|
||||
}
|
||||
FetchExtensionsPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
extensions = extensions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,23 +164,19 @@ class ExtensionMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun installExternalExtension(
|
||||
input: InstallExternalExtensionInput,
|
||||
): CompletableFuture<DataFetcherResult<InstallExternalExtensionPayload?>> {
|
||||
fun installExternalExtension(input: InstallExternalExtensionInput): CompletableFuture<InstallExternalExtensionPayload?> {
|
||||
val (clientMutationId, extensionFile) = input
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
Extension.installExternalExtension(extensionFile.content(), extensionFile.filename())
|
||||
Extension.installExternalExtension(extensionFile.content(), extensionFile.filename())
|
||||
|
||||
val dbExtension =
|
||||
transaction { ExtensionTable.selectAll().where { ExtensionTable.apkName eq extensionFile.filename() }.first() }
|
||||
val dbExtension =
|
||||
transaction { ExtensionTable.selectAll().where { ExtensionTable.apkName eq extensionFile.filename() }.first() }
|
||||
|
||||
InstallExternalExtensionPayload(
|
||||
clientMutationId,
|
||||
extension = ExtensionType(dbExtension),
|
||||
)
|
||||
}
|
||||
InstallExternalExtensionPayload(
|
||||
clientMutationId,
|
||||
extension = ExtensionType(dbExtension),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("RedundantNullableReturnType", "unused")
|
||||
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
@file:Suppress("RedundantNullableReturnType", "unused")
|
||||
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import graphql.execution.DataFetcherResult
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import suwayomi.tachidesk.graphql.asDataFetcherResult
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.types.UpdateState.DOWNLOADING
|
||||
import suwayomi.tachidesk.graphql.types.UpdateState.ERROR
|
||||
@@ -26,55 +26,51 @@ class InfoMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun updateWebUI(input: WebUIUpdateInput): CompletableFuture<DataFetcherResult<WebUIUpdatePayload?>> {
|
||||
fun updateWebUI(input: WebUIUpdateInput): CompletableFuture<WebUIUpdatePayload?> {
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
withTimeout(30.seconds) {
|
||||
if (WebInterfaceManager.status.value.state === DOWNLOADING) {
|
||||
return@withTimeout WebUIUpdatePayload(input.clientMutationId, WebInterfaceManager.status.value)
|
||||
}
|
||||
withTimeout(30.seconds) {
|
||||
if (WebInterfaceManager.status.value.state === DOWNLOADING) {
|
||||
return@withTimeout WebUIUpdatePayload(input.clientMutationId, WebInterfaceManager.status.value)
|
||||
}
|
||||
|
||||
val flavor = WebUIFlavor.current
|
||||
val flavor = WebUIFlavor.current
|
||||
|
||||
val (version, updateAvailable) = WebInterfaceManager.isUpdateAvailable(flavor)
|
||||
val (version, updateAvailable) = WebInterfaceManager.isUpdateAvailable(flavor)
|
||||
|
||||
if (!updateAvailable) {
|
||||
val didUpdateCheckFail = version.isEmpty()
|
||||
if (!updateAvailable) {
|
||||
val didUpdateCheckFail = version.isEmpty()
|
||||
|
||||
return@withTimeout WebUIUpdatePayload(
|
||||
input.clientMutationId,
|
||||
WebInterfaceManager.getStatus(version, if (didUpdateCheckFail) ERROR else IDLE),
|
||||
)
|
||||
}
|
||||
try {
|
||||
WebInterfaceManager.startDownloadInScope(flavor, version)
|
||||
} catch (e: Exception) {
|
||||
// ignore since we use the status anyway
|
||||
}
|
||||
|
||||
WebUIUpdatePayload(
|
||||
return@withTimeout WebUIUpdatePayload(
|
||||
input.clientMutationId,
|
||||
updateStatus = WebInterfaceManager.status.first { it.state == DOWNLOADING },
|
||||
WebInterfaceManager.getStatus(version, if (didUpdateCheckFail) ERROR else IDLE),
|
||||
)
|
||||
}
|
||||
try {
|
||||
WebInterfaceManager.startDownloadInScope(flavor, version)
|
||||
} catch (e: Exception) {
|
||||
// ignore since we use the status anyway
|
||||
}
|
||||
|
||||
WebUIUpdatePayload(
|
||||
input.clientMutationId,
|
||||
updateStatus = WebInterfaceManager.status.first { it.state == DOWNLOADING },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequireAuth
|
||||
fun resetWebUIUpdateStatus(): CompletableFuture<DataFetcherResult<WebUIUpdateStatus?>> =
|
||||
fun resetWebUIUpdateStatus(): CompletableFuture<WebUIUpdateStatus?> =
|
||||
future {
|
||||
asDataFetcherResult {
|
||||
withTimeout(30.seconds) {
|
||||
val isUpdateFinished = WebInterfaceManager.status.value.state != DOWNLOADING
|
||||
if (!isUpdateFinished) {
|
||||
throw Exception("Status reset is not allowed during status \"$DOWNLOADING\"")
|
||||
}
|
||||
|
||||
WebInterfaceManager.resetStatus()
|
||||
|
||||
WebInterfaceManager.status.first { it.state == IDLE }
|
||||
withTimeout(30.seconds) {
|
||||
val isUpdateFinished = WebInterfaceManager.status.value.state != DOWNLOADING
|
||||
if (!isUpdateFinished) {
|
||||
throw Exception("Status reset is not allowed during status \"$DOWNLOADING\"")
|
||||
}
|
||||
|
||||
WebInterfaceManager.resetStatus()
|
||||
|
||||
WebInterfaceManager.status.first { it.state == IDLE }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
@file:Suppress("RedundantNullableReturnType", "unused")
|
||||
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import graphql.execution.DataFetcherResult
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import suwayomi.tachidesk.graphql.asDataFetcherResult
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.types.ChapterType
|
||||
import suwayomi.tachidesk.graphql.types.KoSyncConnectPayload
|
||||
@@ -62,26 +63,24 @@ class KoreaderSyncMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun pushKoSyncProgress(input: PushKoSyncProgressInput): CompletableFuture<DataFetcherResult<PushKoSyncProgressPayload?>> =
|
||||
fun pushKoSyncProgress(input: PushKoSyncProgressInput): CompletableFuture<PushKoSyncProgressPayload?> =
|
||||
future {
|
||||
asDataFetcherResult {
|
||||
KoreaderSyncService.pushProgress(input.chapterId)
|
||||
KoreaderSyncService.pushProgress(input.chapterId)
|
||||
|
||||
val chapter =
|
||||
transaction {
|
||||
ChapterTable
|
||||
.selectAll()
|
||||
.where { ChapterTable.id eq input.chapterId }
|
||||
.firstOrNull()
|
||||
?.let { ChapterType(it) }
|
||||
}
|
||||
val chapter =
|
||||
transaction {
|
||||
ChapterTable
|
||||
.selectAll()
|
||||
.where { ChapterTable.id eq input.chapterId }
|
||||
.firstOrNull()
|
||||
?.let { ChapterType(it) }
|
||||
}
|
||||
|
||||
PushKoSyncProgressPayload(
|
||||
clientMutationId = input.clientMutationId,
|
||||
success = true,
|
||||
chapter = chapter,
|
||||
)
|
||||
}
|
||||
PushKoSyncProgressPayload(
|
||||
clientMutationId = input.clientMutationId,
|
||||
success = true,
|
||||
chapter = chapter,
|
||||
)
|
||||
}
|
||||
|
||||
data class PullKoSyncProgressInput(
|
||||
@@ -96,45 +95,43 @@ class KoreaderSyncMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun pullKoSyncProgress(input: PullKoSyncProgressInput): CompletableFuture<DataFetcherResult<PullKoSyncProgressPayload?>> =
|
||||
fun pullKoSyncProgress(input: PullKoSyncProgressInput): CompletableFuture<PullKoSyncProgressPayload?> =
|
||||
future {
|
||||
asDataFetcherResult {
|
||||
val syncResult = KoreaderSyncService.checkAndPullProgress(input.chapterId)
|
||||
var syncConflictInfo: SyncConflictInfoType? = null
|
||||
val syncResult = KoreaderSyncService.checkAndPullProgress(input.chapterId)
|
||||
var syncConflictInfo: SyncConflictInfoType? = null
|
||||
|
||||
if (syncResult != null) {
|
||||
if (syncResult.isConflict) {
|
||||
syncConflictInfo =
|
||||
SyncConflictInfoType(
|
||||
deviceName = syncResult.device,
|
||||
remotePage = syncResult.pageRead,
|
||||
)
|
||||
}
|
||||
if (syncResult != null) {
|
||||
if (syncResult.isConflict) {
|
||||
syncConflictInfo =
|
||||
SyncConflictInfoType(
|
||||
deviceName = syncResult.device,
|
||||
remotePage = syncResult.pageRead,
|
||||
)
|
||||
}
|
||||
|
||||
if (syncResult.shouldUpdate) {
|
||||
transaction {
|
||||
ChapterTable.update({ ChapterTable.id eq input.chapterId }) {
|
||||
it[lastPageRead] = syncResult.pageRead
|
||||
it[lastReadAt] = syncResult.timestamp
|
||||
}
|
||||
if (syncResult.shouldUpdate) {
|
||||
transaction {
|
||||
ChapterTable.update({ ChapterTable.id eq input.chapterId }) {
|
||||
it[lastPageRead] = syncResult.pageRead
|
||||
it[lastReadAt] = syncResult.timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val chapter =
|
||||
transaction {
|
||||
ChapterTable
|
||||
.selectAll()
|
||||
.where { ChapterTable.id eq input.chapterId }
|
||||
.firstOrNull()
|
||||
?.let { ChapterType(it) }
|
||||
}
|
||||
|
||||
PullKoSyncProgressPayload(
|
||||
clientMutationId = input.clientMutationId,
|
||||
chapter = chapter,
|
||||
syncConflict = syncConflictInfo,
|
||||
)
|
||||
}
|
||||
|
||||
val chapter =
|
||||
transaction {
|
||||
ChapterTable
|
||||
.selectAll()
|
||||
.where { ChapterTable.id eq input.chapterId }
|
||||
.firstOrNull()
|
||||
?.let { ChapterType(it) }
|
||||
}
|
||||
|
||||
PullKoSyncProgressPayload(
|
||||
clientMutationId = input.clientMutationId,
|
||||
chapter = chapter,
|
||||
syncConflict = syncConflictInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
@file:Suppress("RedundantNullableReturnType", "unused")
|
||||
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import graphql.execution.DataFetcherResult
|
||||
import org.jetbrains.exposed.sql.LikePattern
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.like
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.or
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import suwayomi.tachidesk.graphql.asDataFetcherResult
|
||||
import org.jetbrains.exposed.v1.core.LikePattern
|
||||
import org.jetbrains.exposed.v1.core.Op
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.like
|
||||
import org.jetbrains.exposed.v1.core.or
|
||||
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.types.MangaMetaType
|
||||
import suwayomi.tachidesk.graphql.types.MangaType
|
||||
@@ -98,44 +98,40 @@ class MangaMutation {
|
||||
}
|
||||
|
||||
@RequireAuth
|
||||
fun updateManga(input: UpdateMangaInput): CompletableFuture<DataFetcherResult<UpdateMangaPayload?>> {
|
||||
fun updateManga(input: UpdateMangaInput): CompletableFuture<UpdateMangaPayload?> {
|
||||
val (clientMutationId, id, patch) = input
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
updateMangas(listOf(id), patch)
|
||||
updateMangas(listOf(id), patch)
|
||||
|
||||
val manga =
|
||||
transaction {
|
||||
MangaType(MangaTable.selectAll().where { MangaTable.id eq id }.first())
|
||||
}
|
||||
val manga =
|
||||
transaction {
|
||||
MangaType(MangaTable.selectAll().where { MangaTable.id eq id }.first())
|
||||
}
|
||||
|
||||
UpdateMangaPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
manga = manga,
|
||||
)
|
||||
}
|
||||
UpdateMangaPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
manga = manga,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequireAuth
|
||||
fun updateMangas(input: UpdateMangasInput): CompletableFuture<DataFetcherResult<UpdateMangasPayload?>> {
|
||||
fun updateMangas(input: UpdateMangasInput): CompletableFuture<UpdateMangasPayload?> {
|
||||
val (clientMutationId, ids, patch) = input
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
updateMangas(ids, patch)
|
||||
updateMangas(ids, patch)
|
||||
|
||||
val mangas =
|
||||
transaction {
|
||||
MangaTable.selectAll().where { MangaTable.id inList ids }.map { MangaType(it) }
|
||||
}
|
||||
val mangas =
|
||||
transaction {
|
||||
MangaTable.selectAll().where { MangaTable.id inList ids }.map { MangaType(it) }
|
||||
}
|
||||
|
||||
UpdateMangasPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
mangas = mangas,
|
||||
)
|
||||
}
|
||||
UpdateMangasPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
mangas = mangas,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,22 +146,20 @@ class MangaMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun fetchManga(input: FetchMangaInput): CompletableFuture<DataFetcherResult<FetchMangaPayload?>> {
|
||||
fun fetchManga(input: FetchMangaInput): CompletableFuture<FetchMangaPayload?> {
|
||||
val (clientMutationId, id) = input
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
Manga.fetchManga(id)
|
||||
Manga.fetchManga(id)
|
||||
|
||||
val manga =
|
||||
transaction {
|
||||
MangaTable.selectAll().where { MangaTable.id eq id }.first()
|
||||
}
|
||||
FetchMangaPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
manga = MangaType(manga),
|
||||
)
|
||||
}
|
||||
val manga =
|
||||
transaction {
|
||||
MangaTable.selectAll().where { MangaTable.id eq id }.first()
|
||||
}
|
||||
FetchMangaPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
manga = MangaType(manga),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,14 +174,12 @@ class MangaMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun setMangaMeta(input: SetMangaMetaInput): DataFetcherResult<SetMangaMetaPayload?> {
|
||||
fun setMangaMeta(input: SetMangaMetaInput): SetMangaMetaPayload? {
|
||||
val (clientMutationId, meta) = input
|
||||
|
||||
return asDataFetcherResult {
|
||||
Manga.modifyMangaMeta(meta.mangaId, meta.key, meta.value)
|
||||
Manga.modifyMangaMeta(meta.mangaId, meta.key, meta.value)
|
||||
|
||||
SetMangaMetaPayload(clientMutationId, meta)
|
||||
}
|
||||
return SetMangaMetaPayload(clientMutationId, meta)
|
||||
}
|
||||
|
||||
data class DeleteMangaMetaInput(
|
||||
@@ -203,34 +195,32 @@ class MangaMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun deleteMangaMeta(input: DeleteMangaMetaInput): DataFetcherResult<DeleteMangaMetaPayload?> {
|
||||
fun deleteMangaMeta(input: DeleteMangaMetaInput): DeleteMangaMetaPayload? {
|
||||
val (clientMutationId, mangaId, key) = input
|
||||
|
||||
return asDataFetcherResult {
|
||||
val (meta, manga) =
|
||||
transaction {
|
||||
val meta =
|
||||
MangaMetaTable
|
||||
.selectAll()
|
||||
.where { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
|
||||
.firstOrNull()
|
||||
val (meta, manga) =
|
||||
transaction {
|
||||
val meta =
|
||||
MangaMetaTable
|
||||
.selectAll()
|
||||
.where { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
|
||||
.firstOrNull()
|
||||
|
||||
MangaMetaTable.deleteWhere { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
|
||||
MangaMetaTable.deleteWhere { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
|
||||
|
||||
val manga =
|
||||
transaction {
|
||||
MangaType(MangaTable.selectAll().where { MangaTable.id eq mangaId }.first())
|
||||
}
|
||||
val manga =
|
||||
transaction {
|
||||
MangaType(MangaTable.selectAll().where { MangaTable.id eq mangaId }.first())
|
||||
}
|
||||
|
||||
if (meta != null) {
|
||||
MangaMetaType(meta)
|
||||
} else {
|
||||
null
|
||||
} to manga
|
||||
}
|
||||
if (meta != null) {
|
||||
MangaMetaType(meta)
|
||||
} else {
|
||||
null
|
||||
} to manga
|
||||
}
|
||||
|
||||
DeleteMangaMetaPayload(clientMutationId, meta, manga)
|
||||
}
|
||||
return DeleteMangaMetaPayload(clientMutationId, meta, manga)
|
||||
}
|
||||
|
||||
data class SetMangaMetasItem(
|
||||
@@ -250,43 +240,41 @@ class MangaMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun setMangaMetas(input: SetMangaMetasInput): DataFetcherResult<SetMangaMetasPayload?> {
|
||||
fun setMangaMetas(input: SetMangaMetasInput): SetMangaMetasPayload? {
|
||||
val (clientMutationId, items) = input
|
||||
|
||||
return asDataFetcherResult {
|
||||
val metaByMangaId =
|
||||
items
|
||||
.flatMap { item ->
|
||||
val metaMap = item.metas.associate { it.key to it.value }
|
||||
item.mangaIds.map { mangaId -> mangaId to metaMap }
|
||||
}.groupBy({ it.first }, { it.second })
|
||||
.mapValues { (_, maps) -> maps.reduce { acc, map -> acc + map } }
|
||||
val metaByMangaId =
|
||||
items
|
||||
.flatMap { item ->
|
||||
val metaMap = item.metas.associate { it.key to it.value }
|
||||
item.mangaIds.map { mangaId -> mangaId to metaMap }
|
||||
}.groupBy({ it.first }, { it.second })
|
||||
.mapValues { (_, maps) -> maps.reduce { acc, map -> acc + map } }
|
||||
|
||||
Manga.modifyMangasMetas(metaByMangaId)
|
||||
Manga.modifyMangasMetas(metaByMangaId)
|
||||
|
||||
val allMangaIds = metaByMangaId.keys
|
||||
val allMetaKeys = metaByMangaId.values.flatMap { it.keys }.distinct()
|
||||
val allMangaIds = metaByMangaId.keys
|
||||
val allMetaKeys = metaByMangaId.values.flatMap { it.keys }.distinct()
|
||||
|
||||
val (updatedMetas, mangas) =
|
||||
transaction {
|
||||
val updatedMetas =
|
||||
MangaMetaTable
|
||||
.selectAll()
|
||||
.where { (MangaMetaTable.ref inList allMangaIds) and (MangaMetaTable.key inList allMetaKeys) }
|
||||
.map { MangaMetaType(it) }
|
||||
val (updatedMetas, mangas) =
|
||||
transaction {
|
||||
val updatedMetas =
|
||||
MangaMetaTable
|
||||
.selectAll()
|
||||
.where { (MangaMetaTable.ref inList allMangaIds) and (MangaMetaTable.key inList allMetaKeys) }
|
||||
.map { MangaMetaType(it) }
|
||||
|
||||
val mangas =
|
||||
MangaTable
|
||||
.selectAll()
|
||||
.where { MangaTable.id inList allMangaIds }
|
||||
.map { MangaType(it) }
|
||||
.distinctBy { it.id }
|
||||
val mangas =
|
||||
MangaTable
|
||||
.selectAll()
|
||||
.where { MangaTable.id inList allMangaIds }
|
||||
.map { MangaType(it) }
|
||||
.distinctBy { it.id }
|
||||
|
||||
updatedMetas to mangas
|
||||
}
|
||||
updatedMetas to mangas
|
||||
}
|
||||
|
||||
SetMangaMetasPayload(clientMutationId, updatedMetas, mangas)
|
||||
}
|
||||
return SetMangaMetasPayload(clientMutationId, updatedMetas, mangas)
|
||||
}
|
||||
|
||||
data class DeleteMangaMetasItem(
|
||||
@@ -307,63 +295,61 @@ class MangaMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun deleteMangaMetas(input: DeleteMangaMetasInput): DataFetcherResult<DeleteMangaMetasPayload?> {
|
||||
fun deleteMangaMetas(input: DeleteMangaMetasInput): DeleteMangaMetasPayload? {
|
||||
val (clientMutationId, items) = input
|
||||
|
||||
return asDataFetcherResult {
|
||||
items.forEach { item ->
|
||||
require(!item.keys.isNullOrEmpty() || !item.prefixes.isNullOrEmpty()) {
|
||||
"Either 'keys' or 'prefixes' must be provided for each item"
|
||||
items.forEach { item ->
|
||||
require(!item.keys.isNullOrEmpty() || !item.prefixes.isNullOrEmpty()) {
|
||||
"Either 'keys' or 'prefixes' must be provided for each item"
|
||||
}
|
||||
}
|
||||
|
||||
val (allDeletedMetas, allMangaIds) =
|
||||
transaction {
|
||||
val deletedMetas = mutableListOf<MangaMetaType>()
|
||||
val mangaIds = mutableSetOf<Int>()
|
||||
|
||||
items.forEach { item ->
|
||||
val keyCondition: Op<Boolean>? =
|
||||
item.keys?.takeIf { it.isNotEmpty() }?.let { MangaMetaTable.key inList it }
|
||||
|
||||
val prefixCondition: Op<Boolean>? =
|
||||
item.prefixes
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.map { (MangaMetaTable.key like LikePattern("$it%")) as Op<Boolean> }
|
||||
?.reduceOrNull { acc, op -> acc or op }
|
||||
|
||||
val metaKeyCondition =
|
||||
if (keyCondition != null && prefixCondition != null) {
|
||||
keyCondition or prefixCondition
|
||||
} else {
|
||||
keyCondition ?: prefixCondition!!
|
||||
}
|
||||
|
||||
val condition = (MangaMetaTable.ref inList item.mangaIds) and metaKeyCondition
|
||||
|
||||
deletedMetas +=
|
||||
MangaMetaTable
|
||||
.selectAll()
|
||||
.where { condition }
|
||||
.map { MangaMetaType(it) }
|
||||
|
||||
MangaMetaTable.deleteWhere { condition }
|
||||
mangaIds += item.mangaIds
|
||||
}
|
||||
|
||||
deletedMetas to mangaIds
|
||||
}
|
||||
|
||||
val (allDeletedMetas, allMangaIds) =
|
||||
transaction {
|
||||
val deletedMetas = mutableListOf<MangaMetaType>()
|
||||
val mangaIds = mutableSetOf<Int>()
|
||||
val mangas =
|
||||
transaction {
|
||||
MangaTable
|
||||
.selectAll()
|
||||
.where { MangaTable.id inList allMangaIds }
|
||||
.map { MangaType(it) }
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
|
||||
items.forEach { item ->
|
||||
val keyCondition: Op<Boolean>? =
|
||||
item.keys?.takeIf { it.isNotEmpty() }?.let { MangaMetaTable.key inList it }
|
||||
|
||||
val prefixCondition: Op<Boolean>? =
|
||||
item.prefixes
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.map { (MangaMetaTable.key like LikePattern("$it%")) as Op<Boolean> }
|
||||
?.reduceOrNull { acc, op -> acc or op }
|
||||
|
||||
val metaKeyCondition =
|
||||
if (keyCondition != null && prefixCondition != null) {
|
||||
keyCondition or prefixCondition
|
||||
} else {
|
||||
keyCondition ?: prefixCondition!!
|
||||
}
|
||||
|
||||
val condition = (MangaMetaTable.ref inList item.mangaIds) and metaKeyCondition
|
||||
|
||||
deletedMetas +=
|
||||
MangaMetaTable
|
||||
.selectAll()
|
||||
.where { condition }
|
||||
.map { MangaMetaType(it) }
|
||||
|
||||
MangaMetaTable.deleteWhere { condition }
|
||||
mangaIds += item.mangaIds
|
||||
}
|
||||
|
||||
deletedMetas to mangaIds
|
||||
}
|
||||
|
||||
val mangas =
|
||||
transaction {
|
||||
MangaTable
|
||||
.selectAll()
|
||||
.where { MangaTable.id inList allMangaIds }
|
||||
.map { MangaType(it) }
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
|
||||
DeleteMangaMetasPayload(clientMutationId, allDeletedMetas, mangas)
|
||||
}
|
||||
return DeleteMangaMetasPayload(clientMutationId, allDeletedMetas, mangas)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
@file:Suppress("RedundantNullableReturnType", "unused")
|
||||
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import graphql.execution.DataFetcherResult
|
||||
import org.jetbrains.exposed.sql.LikePattern
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.like
|
||||
import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.or
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.LikePattern
|
||||
import org.jetbrains.exposed.v1.core.Op
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.like
|
||||
import org.jetbrains.exposed.v1.core.or
|
||||
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.global.impl.GlobalMeta
|
||||
import suwayomi.tachidesk.global.model.table.GlobalMetaTable
|
||||
import suwayomi.tachidesk.graphql.asDataFetcherResult
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.types.GlobalMetaType
|
||||
import suwayomi.tachidesk.graphql.types.MetaInput
|
||||
@@ -29,14 +29,12 @@ class MetaMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun setGlobalMeta(input: SetGlobalMetaInput): DataFetcherResult<SetGlobalMetaPayload?> {
|
||||
fun setGlobalMeta(input: SetGlobalMetaInput): SetGlobalMetaPayload? {
|
||||
val (clientMutationId, meta) = input
|
||||
|
||||
return asDataFetcherResult {
|
||||
GlobalMeta.modifyMeta(meta.key, meta.value)
|
||||
GlobalMeta.modifyMeta(meta.key, meta.value)
|
||||
|
||||
SetGlobalMetaPayload(clientMutationId, meta)
|
||||
}
|
||||
return SetGlobalMetaPayload(clientMutationId, meta)
|
||||
}
|
||||
|
||||
data class DeleteGlobalMetaInput(
|
||||
@@ -50,29 +48,27 @@ class MetaMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun deleteGlobalMeta(input: DeleteGlobalMetaInput): DataFetcherResult<DeleteGlobalMetaPayload?> {
|
||||
fun deleteGlobalMeta(input: DeleteGlobalMetaInput): DeleteGlobalMetaPayload? {
|
||||
val (clientMutationId, key) = input
|
||||
|
||||
return asDataFetcherResult {
|
||||
val meta =
|
||||
transaction {
|
||||
val meta =
|
||||
GlobalMetaTable
|
||||
.selectAll()
|
||||
.where { GlobalMetaTable.key eq key }
|
||||
.firstOrNull()
|
||||
val meta =
|
||||
transaction {
|
||||
val meta =
|
||||
GlobalMetaTable
|
||||
.selectAll()
|
||||
.where { GlobalMetaTable.key eq key }
|
||||
.firstOrNull()
|
||||
|
||||
GlobalMetaTable.deleteWhere { GlobalMetaTable.key eq key }
|
||||
GlobalMetaTable.deleteWhere { GlobalMetaTable.key eq key }
|
||||
|
||||
if (meta != null) {
|
||||
GlobalMetaType(meta)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (meta != null) {
|
||||
GlobalMetaType(meta)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
DeleteGlobalMetaPayload(clientMutationId, meta)
|
||||
}
|
||||
return DeleteGlobalMetaPayload(clientMutationId, meta)
|
||||
}
|
||||
|
||||
data class SetGlobalMetasInput(
|
||||
@@ -86,23 +82,21 @@ class MetaMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun setGlobalMetas(input: SetGlobalMetasInput): DataFetcherResult<SetGlobalMetasPayload?> {
|
||||
fun setGlobalMetas(input: SetGlobalMetasInput): SetGlobalMetasPayload? {
|
||||
val (clientMutationId, metas) = input
|
||||
|
||||
return asDataFetcherResult {
|
||||
val metaMap = metas.associate { it.key to it.value }
|
||||
GlobalMeta.modifyMetas(metaMap)
|
||||
val metaMap = metas.associate { it.key to it.value }
|
||||
GlobalMeta.modifyMetas(metaMap)
|
||||
|
||||
val updatedMetas =
|
||||
transaction {
|
||||
GlobalMetaTable
|
||||
.selectAll()
|
||||
.where { GlobalMetaTable.key inList metaMap.keys }
|
||||
.map { GlobalMetaType(it) }
|
||||
}
|
||||
val updatedMetas =
|
||||
transaction {
|
||||
GlobalMetaTable
|
||||
.selectAll()
|
||||
.where { GlobalMetaTable.key inList metaMap.keys }
|
||||
.map { GlobalMetaType(it) }
|
||||
}
|
||||
|
||||
SetGlobalMetasPayload(clientMutationId, updatedMetas)
|
||||
}
|
||||
return SetGlobalMetasPayload(clientMutationId, updatedMetas)
|
||||
}
|
||||
|
||||
data class DeleteGlobalMetasInput(
|
||||
@@ -117,43 +111,41 @@ class MetaMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun deleteGlobalMetas(input: DeleteGlobalMetasInput): DataFetcherResult<DeleteGlobalMetasPayload?> {
|
||||
fun deleteGlobalMetas(input: DeleteGlobalMetasInput): DeleteGlobalMetasPayload? {
|
||||
val (clientMutationId, keys, prefixes) = input
|
||||
|
||||
return asDataFetcherResult {
|
||||
require(!keys.isNullOrEmpty() || !prefixes.isNullOrEmpty()) {
|
||||
"Either 'keys' or 'prefixes' must be provided"
|
||||
require(!keys.isNullOrEmpty() || !prefixes.isNullOrEmpty()) {
|
||||
"Either 'keys' or 'prefixes' must be provided"
|
||||
}
|
||||
|
||||
val metas =
|
||||
transaction {
|
||||
val keyCondition: Op<Boolean>? = keys?.takeIf { it.isNotEmpty() }?.let { GlobalMetaTable.key inList it }
|
||||
|
||||
val prefixCondition: Op<Boolean>? =
|
||||
prefixes
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.map { (GlobalMetaTable.key like LikePattern("$it%")) as Op<Boolean> }
|
||||
?.reduceOrNull { acc, op -> acc or op }
|
||||
|
||||
val finalCondition =
|
||||
if (keyCondition != null && prefixCondition != null) {
|
||||
keyCondition or prefixCondition
|
||||
} else {
|
||||
keyCondition ?: prefixCondition!!
|
||||
}
|
||||
|
||||
val metas =
|
||||
GlobalMetaTable
|
||||
.selectAll()
|
||||
.where { finalCondition }
|
||||
.map { GlobalMetaType(it) }
|
||||
|
||||
GlobalMetaTable.deleteWhere { finalCondition }
|
||||
|
||||
metas
|
||||
}
|
||||
|
||||
val metas =
|
||||
transaction {
|
||||
val keyCondition: Op<Boolean>? = keys?.takeIf { it.isNotEmpty() }?.let { GlobalMetaTable.key inList it }
|
||||
|
||||
val prefixCondition: Op<Boolean>? =
|
||||
prefixes
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.map { (GlobalMetaTable.key like LikePattern("$it%")) as Op<Boolean> }
|
||||
?.reduceOrNull { acc, op -> acc or op }
|
||||
|
||||
val finalCondition =
|
||||
if (keyCondition != null && prefixCondition != null) {
|
||||
keyCondition or prefixCondition
|
||||
} else {
|
||||
keyCondition ?: prefixCondition!!
|
||||
}
|
||||
|
||||
val metas =
|
||||
GlobalMetaTable
|
||||
.selectAll()
|
||||
.where { finalCondition }
|
||||
.map { GlobalMetaType(it) }
|
||||
|
||||
GlobalMetaTable.deleteWhere { finalCondition }
|
||||
|
||||
metas
|
||||
}
|
||||
|
||||
DeleteGlobalMetasPayload(clientMutationId, metas)
|
||||
}
|
||||
return DeleteGlobalMetasPayload(clientMutationId, metas)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("RedundantNullableReturnType", "unused")
|
||||
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import com.expediagroup.graphql.generator.annotations.GraphQLIgnore
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("RedundantNullableReturnType", "unused")
|
||||
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import androidx.preference.CheckBoxPreference
|
||||
@@ -5,18 +7,16 @@ import androidx.preference.EditTextPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import graphql.execution.DataFetcherResult
|
||||
import org.jetbrains.exposed.sql.LikePattern
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.like
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.or
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.asDataFetcherResult
|
||||
import org.jetbrains.exposed.v1.core.LikePattern
|
||||
import org.jetbrains.exposed.v1.core.Op
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.like
|
||||
import org.jetbrains.exposed.v1.core.or
|
||||
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.types.FilterChange
|
||||
import suwayomi.tachidesk.graphql.types.MangaType
|
||||
@@ -47,14 +47,12 @@ class SourceMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun setSourceMeta(input: SetSourceMetaInput): DataFetcherResult<SetSourceMetaPayload?> {
|
||||
fun setSourceMeta(input: SetSourceMetaInput): SetSourceMetaPayload? {
|
||||
val (clientMutationId, meta) = input
|
||||
|
||||
return asDataFetcherResult {
|
||||
Source.modifyMeta(meta.sourceId, meta.key, meta.value)
|
||||
Source.modifyMeta(meta.sourceId, meta.key, meta.value)
|
||||
|
||||
SetSourceMetaPayload(clientMutationId, meta)
|
||||
}
|
||||
return SetSourceMetaPayload(clientMutationId, meta)
|
||||
}
|
||||
|
||||
data class DeleteSourceMetaInput(
|
||||
@@ -70,38 +68,36 @@ class SourceMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun deleteSourceMeta(input: DeleteSourceMetaInput): DataFetcherResult<DeleteSourceMetaPayload?> {
|
||||
fun deleteSourceMeta(input: DeleteSourceMetaInput): DeleteSourceMetaPayload? {
|
||||
val (clientMutationId, sourceId, key) = input
|
||||
|
||||
return asDataFetcherResult {
|
||||
val (meta, source) =
|
||||
transaction {
|
||||
val meta =
|
||||
SourceMetaTable
|
||||
val (meta, source) =
|
||||
transaction {
|
||||
val meta =
|
||||
SourceMetaTable
|
||||
.selectAll()
|
||||
.where { (SourceMetaTable.ref eq sourceId) and (SourceMetaTable.key eq key) }
|
||||
.firstOrNull()
|
||||
|
||||
SourceMetaTable.deleteWhere { (SourceMetaTable.ref eq sourceId) and (SourceMetaTable.key eq key) }
|
||||
|
||||
val source =
|
||||
transaction {
|
||||
SourceTable
|
||||
.selectAll()
|
||||
.where { (SourceMetaTable.ref eq sourceId) and (SourceMetaTable.key eq key) }
|
||||
.where { SourceTable.id eq sourceId }
|
||||
.firstOrNull()
|
||||
?.let { SourceType(it) }
|
||||
}
|
||||
|
||||
SourceMetaTable.deleteWhere { (SourceMetaTable.ref eq sourceId) and (SourceMetaTable.key eq key) }
|
||||
if (meta != null) {
|
||||
SourceMetaType(meta)
|
||||
} else {
|
||||
null
|
||||
} to source
|
||||
}
|
||||
|
||||
val source =
|
||||
transaction {
|
||||
SourceTable
|
||||
.selectAll()
|
||||
.where { SourceTable.id eq sourceId }
|
||||
.firstOrNull()
|
||||
?.let { SourceType(it) }
|
||||
}
|
||||
|
||||
if (meta != null) {
|
||||
SourceMetaType(meta)
|
||||
} else {
|
||||
null
|
||||
} to source
|
||||
}
|
||||
|
||||
DeleteSourceMetaPayload(clientMutationId, meta, source)
|
||||
}
|
||||
return DeleteSourceMetaPayload(clientMutationId, meta, source)
|
||||
}
|
||||
|
||||
data class SetSourceMetasItem(
|
||||
@@ -121,43 +117,41 @@ class SourceMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun setSourceMetas(input: SetSourceMetasInput): DataFetcherResult<SetSourceMetasPayload?> {
|
||||
fun setSourceMetas(input: SetSourceMetasInput): SetSourceMetasPayload? {
|
||||
val (clientMutationId, items) = input
|
||||
|
||||
return asDataFetcherResult {
|
||||
val metaBySourceId =
|
||||
items
|
||||
.flatMap { item ->
|
||||
val metaMap = item.metas.associate { it.key to it.value }
|
||||
item.sourceIds.map { sourceId -> sourceId to metaMap }
|
||||
}.groupBy({ it.first }, { it.second })
|
||||
.mapValues { (_, maps) -> maps.reduce { acc, map -> acc + map } }
|
||||
val metaBySourceId =
|
||||
items
|
||||
.flatMap { item ->
|
||||
val metaMap = item.metas.associate { it.key to it.value }
|
||||
item.sourceIds.map { sourceId -> sourceId to metaMap }
|
||||
}.groupBy({ it.first }, { it.second })
|
||||
.mapValues { (_, maps) -> maps.reduce { acc, map -> acc + map } }
|
||||
|
||||
Source.modifySourceMetas(metaBySourceId)
|
||||
Source.modifySourceMetas(metaBySourceId)
|
||||
|
||||
val allSourceIds = metaBySourceId.keys
|
||||
val allMetaKeys = metaBySourceId.values.flatMap { it.keys }.distinct()
|
||||
val allSourceIds = metaBySourceId.keys
|
||||
val allMetaKeys = metaBySourceId.values.flatMap { it.keys }.distinct()
|
||||
|
||||
val (updatedMetas, sources) =
|
||||
transaction {
|
||||
val updatedMetas =
|
||||
SourceMetaTable
|
||||
.selectAll()
|
||||
.where { (SourceMetaTable.ref inList allSourceIds) and (SourceMetaTable.key inList allMetaKeys) }
|
||||
.map { SourceMetaType(it) }
|
||||
val (updatedMetas, sources) =
|
||||
transaction {
|
||||
val updatedMetas =
|
||||
SourceMetaTable
|
||||
.selectAll()
|
||||
.where { (SourceMetaTable.ref inList allSourceIds) and (SourceMetaTable.key inList allMetaKeys) }
|
||||
.map { SourceMetaType(it) }
|
||||
|
||||
val sources =
|
||||
SourceTable
|
||||
.selectAll()
|
||||
.where { SourceTable.id inList allSourceIds }
|
||||
.mapNotNull { SourceType(it) }
|
||||
.distinctBy { it.id }
|
||||
val sources =
|
||||
SourceTable
|
||||
.selectAll()
|
||||
.where { SourceTable.id inList allSourceIds }
|
||||
.mapNotNull { SourceType(it) }
|
||||
.distinctBy { it.id }
|
||||
|
||||
updatedMetas to sources
|
||||
}
|
||||
updatedMetas to sources
|
||||
}
|
||||
|
||||
SetSourceMetasPayload(clientMutationId, updatedMetas, sources)
|
||||
}
|
||||
return SetSourceMetasPayload(clientMutationId, updatedMetas, sources)
|
||||
}
|
||||
|
||||
data class DeleteSourceMetasItem(
|
||||
@@ -178,64 +172,62 @@ class SourceMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun deleteSourceMetas(input: DeleteSourceMetasInput): DataFetcherResult<DeleteSourceMetasPayload?> {
|
||||
fun deleteSourceMetas(input: DeleteSourceMetasInput): DeleteSourceMetasPayload? {
|
||||
val (clientMutationId, items) = input
|
||||
|
||||
return asDataFetcherResult {
|
||||
items.forEach { item ->
|
||||
require(!item.keys.isNullOrEmpty() || !item.prefixes.isNullOrEmpty()) {
|
||||
"Either 'keys' or 'prefixes' must be provided for each item"
|
||||
items.forEach { item ->
|
||||
require(!item.keys.isNullOrEmpty() || !item.prefixes.isNullOrEmpty()) {
|
||||
"Either 'keys' or 'prefixes' must be provided for each item"
|
||||
}
|
||||
}
|
||||
|
||||
val (allDeletedMetas, allSourceIds) =
|
||||
transaction {
|
||||
val deletedMetas = mutableListOf<SourceMetaType>()
|
||||
val sourceIds = mutableSetOf<Long>()
|
||||
|
||||
items.forEach { item ->
|
||||
val keyCondition: Op<Boolean>? =
|
||||
item.keys?.takeIf { it.isNotEmpty() }?.let { SourceMetaTable.key inList it }
|
||||
|
||||
val prefixCondition: Op<Boolean>? =
|
||||
item.prefixes
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.map { (SourceMetaTable.key like LikePattern("$it%")) as Op<Boolean> }
|
||||
?.reduceOrNull { acc, op -> acc or op }
|
||||
|
||||
val metaKeyCondition =
|
||||
if (keyCondition != null && prefixCondition != null) {
|
||||
keyCondition or prefixCondition
|
||||
} else {
|
||||
keyCondition ?: prefixCondition!!
|
||||
}
|
||||
|
||||
val condition = (SourceMetaTable.ref inList item.sourceIds) and metaKeyCondition
|
||||
|
||||
deletedMetas +=
|
||||
SourceMetaTable
|
||||
.selectAll()
|
||||
.where { condition }
|
||||
.map { SourceMetaType(it) }
|
||||
|
||||
SourceMetaTable.deleteWhere { condition }
|
||||
sourceIds += item.sourceIds
|
||||
}
|
||||
|
||||
deletedMetas to sourceIds
|
||||
}
|
||||
|
||||
val (allDeletedMetas, allSourceIds) =
|
||||
transaction {
|
||||
val deletedMetas = mutableListOf<SourceMetaType>()
|
||||
val sourceIds = mutableSetOf<Long>()
|
||||
val sources =
|
||||
transaction {
|
||||
SourceTable
|
||||
.selectAll()
|
||||
.where { SourceTable.id inList allSourceIds }
|
||||
.mapNotNull { SourceType(it) }
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
|
||||
items.forEach { item ->
|
||||
val keyCondition: Op<Boolean>? =
|
||||
item.keys?.takeIf { it.isNotEmpty() }?.let { SourceMetaTable.key inList it }
|
||||
|
||||
val prefixCondition: Op<Boolean>? =
|
||||
item.prefixes
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.map { (SourceMetaTable.key like LikePattern("$it%")) as Op<Boolean> }
|
||||
?.reduceOrNull { acc, op -> acc or op }
|
||||
|
||||
val metaKeyCondition =
|
||||
if (keyCondition != null && prefixCondition != null) {
|
||||
keyCondition or prefixCondition
|
||||
} else {
|
||||
keyCondition ?: prefixCondition!!
|
||||
}
|
||||
|
||||
val condition = (SourceMetaTable.ref inList item.sourceIds) and metaKeyCondition
|
||||
|
||||
deletedMetas +=
|
||||
SourceMetaTable
|
||||
.selectAll()
|
||||
.where { condition }
|
||||
.map { SourceMetaType(it) }
|
||||
|
||||
SourceMetaTable.deleteWhere { condition }
|
||||
sourceIds += item.sourceIds
|
||||
}
|
||||
|
||||
deletedMetas to sourceIds
|
||||
}
|
||||
|
||||
val sources =
|
||||
transaction {
|
||||
SourceTable
|
||||
.selectAll()
|
||||
.where { SourceTable.id inList allSourceIds }
|
||||
.mapNotNull { SourceType(it) }
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
|
||||
DeleteSourceMetasPayload(clientMutationId, allDeletedMetas, sources)
|
||||
}
|
||||
return DeleteSourceMetasPayload(clientMutationId, allDeletedMetas, sources)
|
||||
}
|
||||
|
||||
enum class FetchSourceMangaType {
|
||||
@@ -260,50 +252,48 @@ class SourceMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun fetchSourceManga(input: FetchSourceMangaInput): CompletableFuture<DataFetcherResult<FetchSourceMangaPayload?>> {
|
||||
fun fetchSourceManga(input: FetchSourceMangaInput): CompletableFuture<FetchSourceMangaPayload?> {
|
||||
val (clientMutationId, sourceId, type, page, query, filters) = input
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
val source = GetCatalogueSource.getCatalogueSourceOrNull(sourceId)!!
|
||||
val mangasPage =
|
||||
when (type) {
|
||||
FetchSourceMangaType.SEARCH -> {
|
||||
source.getSearchManga(
|
||||
page = page,
|
||||
query = query.orEmpty(),
|
||||
filters = updateFilterList(source, filters),
|
||||
)
|
||||
}
|
||||
|
||||
FetchSourceMangaType.POPULAR -> {
|
||||
source.getPopularManga(page)
|
||||
}
|
||||
|
||||
FetchSourceMangaType.LATEST -> {
|
||||
if (!source.supportsLatest) throw Exception("Source does not support latest")
|
||||
source.getLatestUpdates(page)
|
||||
}
|
||||
val source = GetCatalogueSource.getCatalogueSourceOrNull(sourceId)!!
|
||||
val mangasPage =
|
||||
when (type) {
|
||||
FetchSourceMangaType.SEARCH -> {
|
||||
source.getSearchManga(
|
||||
page = page,
|
||||
query = query.orEmpty(),
|
||||
filters = updateFilterList(source, filters),
|
||||
)
|
||||
}
|
||||
|
||||
val mangaIds = mangasPage.insertOrUpdate(sourceId)
|
||||
|
||||
val mangas =
|
||||
transaction {
|
||||
MangaTable
|
||||
.selectAll()
|
||||
.where { MangaTable.id inList mangaIds }
|
||||
.map { MangaType(it) }
|
||||
}.sortedBy {
|
||||
mangaIds.indexOf(it.id)
|
||||
FetchSourceMangaType.POPULAR -> {
|
||||
source.getPopularManga(page)
|
||||
}
|
||||
|
||||
FetchSourceMangaPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
mangas = mangas,
|
||||
hasNextPage = mangasPage.hasNextPage,
|
||||
)
|
||||
}
|
||||
FetchSourceMangaType.LATEST -> {
|
||||
if (!source.supportsLatest) throw Exception("Source does not support latest")
|
||||
source.getLatestUpdates(page)
|
||||
}
|
||||
}
|
||||
|
||||
val mangaIds = mangasPage.insertOrUpdate(sourceId)
|
||||
|
||||
val mangas =
|
||||
transaction {
|
||||
MangaTable
|
||||
.selectAll()
|
||||
.where { MangaTable.id inList mangaIds }
|
||||
.map { MangaType(it) }
|
||||
}.sortedBy {
|
||||
mangaIds.indexOf(it.id)
|
||||
}
|
||||
|
||||
FetchSourceMangaPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
mangas = mangas,
|
||||
hasNextPage = mangasPage.hasNextPage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,29 +319,27 @@ class SourceMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun updateSourcePreference(input: UpdateSourcePreferenceInput): DataFetcherResult<UpdateSourcePreferencePayload?> {
|
||||
fun updateSourcePreference(input: UpdateSourcePreferenceInput): UpdateSourcePreferencePayload? {
|
||||
val (clientMutationId, sourceId, change) = input
|
||||
|
||||
return asDataFetcherResult {
|
||||
Source.setSourcePreference(sourceId, change.position, "") { preference ->
|
||||
when (preference) {
|
||||
is SwitchPreferenceCompat -> change.switchState
|
||||
is CheckBoxPreference -> change.checkBoxState
|
||||
is EditTextPreference -> change.editTextState
|
||||
is ListPreference -> change.listState
|
||||
is MultiSelectListPreference -> change.multiSelectState?.toSet()
|
||||
else -> throw RuntimeException("sealed class cannot have more subtypes!")
|
||||
} ?: throw Exception("Expected change to ${preference::class.simpleName}")
|
||||
}
|
||||
|
||||
UpdateSourcePreferencePayload(
|
||||
clientMutationId = clientMutationId,
|
||||
preferences = Source.getSourcePreferencesRaw(sourceId).map { preferenceOf(it) },
|
||||
source =
|
||||
transaction {
|
||||
SourceType(SourceTable.selectAll().where { SourceTable.id eq sourceId }.first())!!
|
||||
},
|
||||
)
|
||||
Source.setSourcePreference(sourceId, change.position, "") { preference ->
|
||||
when (preference) {
|
||||
is SwitchPreferenceCompat -> change.switchState
|
||||
is CheckBoxPreference -> change.checkBoxState
|
||||
is EditTextPreference -> change.editTextState
|
||||
is ListPreference -> change.listState
|
||||
is MultiSelectListPreference -> change.multiSelectState?.toSet()
|
||||
else -> throw RuntimeException("sealed class cannot have more subtypes!")
|
||||
} ?: throw Exception("Expected change to ${preference::class.simpleName}")
|
||||
}
|
||||
|
||||
return UpdateSourcePreferencePayload(
|
||||
clientMutationId = clientMutationId,
|
||||
preferences = Source.getSourcePreferencesRaw(sourceId).map { preferenceOf(it) },
|
||||
source =
|
||||
transaction {
|
||||
SourceType(SourceTable.selectAll().where { SourceTable.id eq sourceId }.first())!!
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import suwayomi.tachidesk.global.impl.sync.SyncManager
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.types.StartSyncResult
|
||||
|
||||
class SyncMutation {
|
||||
data class StartSyncInput(
|
||||
val clientMutationId: String? = null,
|
||||
)
|
||||
|
||||
data class StartSyncPayload(
|
||||
val clientMutationId: String? = null,
|
||||
val result: StartSyncResult,
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun startSync(input: StartSyncInput): StartSyncPayload {
|
||||
val (clientMutationId) = input
|
||||
|
||||
val result = SyncManager.startSync()
|
||||
|
||||
return StartSyncPayload(
|
||||
clientMutationId = clientMutationId,
|
||||
result = result,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
@file:Suppress("RedundantNullableReturnType", "unused")
|
||||
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
|
||||
import com.expediagroup.graphql.generator.annotations.GraphQLDescription
|
||||
import graphql.execution.DataFetcherResult
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.asDataFetcherResult
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.types.TrackRecordType
|
||||
import suwayomi.tachidesk.graphql.types.TrackerType
|
||||
@@ -222,24 +223,22 @@ class TrackMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun trackProgress(input: TrackProgressInput): CompletableFuture<DataFetcherResult<TrackProgressPayload?>> {
|
||||
fun trackProgress(input: TrackProgressInput): CompletableFuture<TrackProgressPayload?> {
|
||||
val (clientMutationId, mangaId) = input
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
Track.trackChapter(mangaId)
|
||||
val trackRecords =
|
||||
transaction {
|
||||
TrackRecordTable
|
||||
.selectAll()
|
||||
.where { TrackRecordTable.mangaId eq mangaId }
|
||||
.toList()
|
||||
}
|
||||
TrackProgressPayload(
|
||||
clientMutationId,
|
||||
trackRecords.map { TrackRecordType(it) },
|
||||
)
|
||||
}
|
||||
Track.trackChapter(mangaId)
|
||||
val trackRecords =
|
||||
transaction {
|
||||
TrackRecordTable
|
||||
.selectAll()
|
||||
.where { TrackRecordTable.mangaId eq mangaId }
|
||||
.toList()
|
||||
}
|
||||
TrackProgressPayload(
|
||||
clientMutationId,
|
||||
trackRecords.map { TrackRecordType(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
@file:Suppress("RedundantNullableReturnType", "unused")
|
||||
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import graphql.execution.DataFetcherResult
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import suwayomi.tachidesk.graphql.asDataFetcherResult
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.types.LibraryUpdateStatus
|
||||
import suwayomi.tachidesk.graphql.types.UpdateStatus
|
||||
@@ -28,7 +28,7 @@ class UpdateMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun updateLibrary(input: UpdateLibraryInput): CompletableFuture<DataFetcherResult<UpdateLibraryPayload?>> {
|
||||
fun updateLibrary(input: UpdateLibraryInput): CompletableFuture<UpdateLibraryPayload?> {
|
||||
updater.addCategoriesToUpdateQueue(
|
||||
Category.getCategoryList().filter { input.categories?.contains(it.id) ?: true },
|
||||
clear = true,
|
||||
@@ -36,17 +36,15 @@ class UpdateMutation {
|
||||
)
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
UpdateLibraryPayload(
|
||||
input.clientMutationId,
|
||||
updateStatus =
|
||||
withTimeout(30.seconds) {
|
||||
LibraryUpdateStatus(
|
||||
updater.updates.first(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
UpdateLibraryPayload(
|
||||
input.clientMutationId,
|
||||
updateStatus =
|
||||
withTimeout(30.seconds) {
|
||||
LibraryUpdateStatus(
|
||||
updater.updates.first(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +58,7 @@ class UpdateMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun updateLibraryManga(input: UpdateLibraryMangaInput): CompletableFuture<DataFetcherResult<UpdateLibraryMangaPayload?>> {
|
||||
fun updateLibraryManga(input: UpdateLibraryMangaInput): CompletableFuture<UpdateLibraryMangaPayload?> {
|
||||
updateLibrary(
|
||||
UpdateLibraryInput(
|
||||
clientMutationId = input.clientMutationId,
|
||||
@@ -69,15 +67,13 @@ class UpdateMutation {
|
||||
)
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
UpdateLibraryMangaPayload(
|
||||
input.clientMutationId,
|
||||
updateStatus =
|
||||
withTimeout(30.seconds) {
|
||||
UpdateStatus(updater.status.first())
|
||||
},
|
||||
)
|
||||
}
|
||||
UpdateLibraryMangaPayload(
|
||||
input.clientMutationId,
|
||||
updateStatus =
|
||||
withTimeout(30.seconds) {
|
||||
UpdateStatus(updater.status.first())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +88,7 @@ class UpdateMutation {
|
||||
)
|
||||
|
||||
@RequireAuth
|
||||
fun updateCategoryManga(input: UpdateCategoryMangaInput): CompletableFuture<DataFetcherResult<UpdateCategoryMangaPayload?>> {
|
||||
fun updateCategoryManga(input: UpdateCategoryMangaInput): CompletableFuture<UpdateCategoryMangaPayload?> {
|
||||
updateLibrary(
|
||||
UpdateLibraryInput(
|
||||
clientMutationId = input.clientMutationId,
|
||||
@@ -101,15 +97,13 @@ class UpdateMutation {
|
||||
)
|
||||
|
||||
return future {
|
||||
asDataFetcherResult {
|
||||
UpdateCategoryMangaPayload(
|
||||
input.clientMutationId,
|
||||
updateStatus =
|
||||
withTimeout(30.seconds) {
|
||||
UpdateStatus(updater.status.first())
|
||||
},
|
||||
)
|
||||
}
|
||||
UpdateCategoryMangaPayload(
|
||||
input.clientMutationId,
|
||||
updateStatus =
|
||||
withTimeout(30.seconds) {
|
||||
UpdateStatus(updater.status.first())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
@file:Suppress("RedundantNullableReturnType", "unused")
|
||||
|
||||
package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import suwayomi.tachidesk.global.impl.util.Jwt
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.server.getAttribute
|
||||
import suwayomi.tachidesk.server.JavalinSetup.Attribute
|
||||
import suwayomi.tachidesk.server.serverConfig
|
||||
|
||||
@@ -10,13 +10,13 @@ package suwayomi.tachidesk.graphql.queries
|
||||
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
|
||||
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import org.jetbrains.exposed.sql.Column
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.Column
|
||||
import org.jetbrains.exposed.v1.core.Op
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
|
||||
import suwayomi.tachidesk.graphql.queries.filter.Filter
|
||||
|
||||
@@ -10,14 +10,14 @@ package suwayomi.tachidesk.graphql.queries
|
||||
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
|
||||
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import org.jetbrains.exposed.sql.Column
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
|
||||
import org.jetbrains.exposed.sql.andWhere
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.Column
|
||||
import org.jetbrains.exposed.v1.core.Op
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.jdbc.andWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
|
||||
import suwayomi.tachidesk.graphql.queries.filter.DoubleFilter
|
||||
|
||||
@@ -11,14 +11,14 @@ import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
|
||||
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||
import eu.kanade.tachiyomi.source.local.LocalSource
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import org.jetbrains.exposed.sql.Column
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.neq
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.Column
|
||||
import org.jetbrains.exposed.v1.core.Op
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.core.neq
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
|
||||
import suwayomi.tachidesk.graphql.queries.filter.Filter
|
||||
|
||||
@@ -10,12 +10,17 @@ package suwayomi.tachidesk.graphql.queries
|
||||
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
|
||||
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import org.jetbrains.exposed.sql.Column
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.Column
|
||||
import org.jetbrains.exposed.v1.core.Op
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.inSubQuery
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.core.like
|
||||
import org.jetbrains.exposed.v1.jdbc.select
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
|
||||
import suwayomi.tachidesk.graphql.queries.filter.ComparableScalarFilter
|
||||
@@ -240,13 +245,16 @@ class MangaQuery {
|
||||
): MangaNodeList {
|
||||
val queryResults =
|
||||
transaction {
|
||||
val res =
|
||||
val mangaIdsQuery =
|
||||
MangaTable
|
||||
.leftJoin(CategoryMangaTable)
|
||||
.select(MangaTable.columns)
|
||||
.withDistinctOn(MangaTable.id)
|
||||
.select(MangaTable.id)
|
||||
.withDistinct()
|
||||
|
||||
res.applyOps(condition, filter)
|
||||
mangaIdsQuery.applyOps(condition, filter)
|
||||
|
||||
val res =
|
||||
MangaTable.selectAll().where { MangaTable.id inSubQuery mangaIdsQuery }
|
||||
|
||||
if (order != null || orderBy != null || (last != null || before != null)) {
|
||||
val baseSort = listOf(MangaOrder(MangaOrderBy.ID, SortOrder.ASC))
|
||||
|
||||
@@ -10,13 +10,13 @@ package suwayomi.tachidesk.graphql.queries
|
||||
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
|
||||
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import org.jetbrains.exposed.sql.Column
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.Column
|
||||
import org.jetbrains.exposed.v1.core.Op
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.global.model.table.GlobalMetaTable
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.queries.filter.Filter
|
||||
|
||||
@@ -10,13 +10,13 @@ package suwayomi.tachidesk.graphql.queries
|
||||
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
|
||||
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import org.jetbrains.exposed.sql.Column
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.Column
|
||||
import org.jetbrains.exposed.v1.core.Op
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
|
||||
import suwayomi.tachidesk.graphql.queries.filter.Filter
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package suwayomi.tachidesk.graphql.queries
|
||||
|
||||
import suwayomi.tachidesk.global.impl.sync.SyncManager
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.types.SyncStatus
|
||||
import suwayomi.tachidesk.graphql.types.toStatus
|
||||
|
||||
class SyncQuery {
|
||||
@RequireAuth
|
||||
fun lastSyncStatus(): SyncStatus? = SyncManager.lastSyncState.value?.toStatus()
|
||||
}
|
||||
@@ -3,13 +3,13 @@ package suwayomi.tachidesk.graphql.queries
|
||||
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
|
||||
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import org.jetbrains.exposed.sql.Column
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.Column
|
||||
import org.jetbrains.exposed.v1.core.Op
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
|
||||
import suwayomi.tachidesk.graphql.queries.filter.DoubleFilter
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
package suwayomi.tachidesk.graphql.queries.filter
|
||||
|
||||
import org.jetbrains.exposed.dao.id.EntityID
|
||||
import org.jetbrains.exposed.sql.Column
|
||||
import org.jetbrains.exposed.sql.ComparisonOp
|
||||
import org.jetbrains.exposed.sql.Expression
|
||||
import org.jetbrains.exposed.sql.ExpressionWithColumnType
|
||||
import org.jetbrains.exposed.sql.LikePattern
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.Query
|
||||
import org.jetbrains.exposed.sql.QueryBuilder
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.andWhere
|
||||
import org.jetbrains.exposed.sql.not
|
||||
import org.jetbrains.exposed.sql.or
|
||||
import org.jetbrains.exposed.sql.stringParam
|
||||
import org.jetbrains.exposed.sql.upperCase
|
||||
import org.jetbrains.exposed.v1.core.Column
|
||||
import org.jetbrains.exposed.v1.core.ComparisonOp
|
||||
import org.jetbrains.exposed.v1.core.Expression
|
||||
import org.jetbrains.exposed.v1.core.ExpressionWithColumnType
|
||||
import org.jetbrains.exposed.v1.core.LikePattern
|
||||
import org.jetbrains.exposed.v1.core.Op
|
||||
import org.jetbrains.exposed.v1.core.QueryBuilder
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.greaterEq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.isNotNull
|
||||
import org.jetbrains.exposed.v1.core.isNull
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.core.lessEq
|
||||
import org.jetbrains.exposed.v1.core.like
|
||||
import org.jetbrains.exposed.v1.core.neq
|
||||
import org.jetbrains.exposed.v1.core.not
|
||||
import org.jetbrains.exposed.v1.core.notInList
|
||||
import org.jetbrains.exposed.v1.core.notLike
|
||||
import org.jetbrains.exposed.v1.core.or
|
||||
import org.jetbrains.exposed.v1.core.stringParam
|
||||
import org.jetbrains.exposed.v1.core.upperCase
|
||||
import org.jetbrains.exposed.v1.core.wrap
|
||||
import org.jetbrains.exposed.v1.jdbc.Query
|
||||
import org.jetbrains.exposed.v1.jdbc.andWhere
|
||||
|
||||
class ILikeEscapeOp(
|
||||
expr1: Expression<*>,
|
||||
@@ -88,9 +100,7 @@ class DistinctFromOp(
|
||||
): DistinctFromOp =
|
||||
DistinctFromOp(
|
||||
expression,
|
||||
with(SqlExpressionBuilder) {
|
||||
expression.wrap(t)
|
||||
},
|
||||
expression.wrap(t),
|
||||
false,
|
||||
)
|
||||
|
||||
@@ -100,9 +110,7 @@ class DistinctFromOp(
|
||||
): DistinctFromOp =
|
||||
DistinctFromOp(
|
||||
expression,
|
||||
with(SqlExpressionBuilder) {
|
||||
expression.wrap(t)
|
||||
},
|
||||
expression.wrap(t),
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -112,9 +120,7 @@ class DistinctFromOp(
|
||||
): DistinctFromOp =
|
||||
DistinctFromOp(
|
||||
expression,
|
||||
with(SqlExpressionBuilder) {
|
||||
expression.wrap(t)
|
||||
},
|
||||
expression.wrap(t),
|
||||
false,
|
||||
)
|
||||
|
||||
@@ -124,9 +130,7 @@ class DistinctFromOp(
|
||||
): DistinctFromOp =
|
||||
DistinctFromOp(
|
||||
expression,
|
||||
with(SqlExpressionBuilder) {
|
||||
expression.wrap(t)
|
||||
},
|
||||
expression.wrap(t),
|
||||
true,
|
||||
)
|
||||
}
|
||||
@@ -431,7 +435,7 @@ fun <T : String, S : T?> andFilterWithCompareString(
|
||||
|
||||
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
|
||||
opAnd.andWhere(filter.equalTo) { column eq it as S }
|
||||
opAnd.andWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it as S }
|
||||
opAnd.andNotWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it as S }
|
||||
opAnd.andWhere(
|
||||
filter.distinctFrom,
|
||||
filter.distinctFromAll,
|
||||
@@ -451,36 +455,36 @@ fun <T : String, S : T?> andFilterWithCompareString(
|
||||
opAnd.andWhere(filter.greaterThanOrEqualTo) { column greaterEq it }
|
||||
|
||||
opAnd.andWhere(filter.includes, filter.includesAll, filter.includesAny) { column like "%$it%" }
|
||||
opAnd.andWhere(filter.notIncludes, filter.notIncludesAll, filter.notIncludesAny) { column notLike "%$it%" }
|
||||
opAnd.andNotWhere(filter.notIncludes, filter.notIncludesAll, filter.notIncludesAny) { column notLike "%$it%" }
|
||||
opAnd.andWhere(filter.includesInsensitive, filter.includesInsensitiveAll, filter.includesInsensitiveAny) {
|
||||
ILikeEscapeOp.iLike(column, "%$it%")
|
||||
}
|
||||
opAnd.andWhere(filter.notIncludesInsensitive, filter.notIncludesInsensitiveAll, filter.notIncludesInsensitiveAny) {
|
||||
opAnd.andNotWhere(filter.notIncludesInsensitive, filter.notIncludesInsensitiveAll, filter.notIncludesInsensitiveAny) {
|
||||
ILikeEscapeOp.iNotLike(column, "%$it%")
|
||||
}
|
||||
|
||||
opAnd.andWhere(filter.startsWith, filter.startsWithAll, filter.startsWithAny) { column like "$it%" }
|
||||
opAnd.andWhere(filter.notStartsWith, filter.notStartsWithAll, filter.notStartsWithAny) { column notLike "$it%" }
|
||||
opAnd.andNotWhere(filter.notStartsWith, filter.notStartsWithAll, filter.notStartsWithAny) { column notLike "$it%" }
|
||||
opAnd.andWhere(filter.startsWithInsensitive, filter.startsWithInsensitiveAll, filter.startsWithInsensitiveAny) {
|
||||
ILikeEscapeOp.iLike(column, "$it%")
|
||||
}
|
||||
opAnd.andWhere(filter.notStartsWithInsensitive, filter.notStartsWithInsensitiveAll, filter.notStartsWithInsensitiveAny) {
|
||||
opAnd.andNotWhere(filter.notStartsWithInsensitive, filter.notStartsWithInsensitiveAll, filter.notStartsWithInsensitiveAny) {
|
||||
ILikeEscapeOp.iNotLike(column, "$it%")
|
||||
}
|
||||
|
||||
opAnd.andWhere(filter.endsWith, filter.endsWithAll, filter.endsWithAny) { column like "%$it" }
|
||||
opAnd.andWhere(filter.notEndsWith, filter.notEndsWithAll, filter.notEndsWithAny) { column notLike "%$it" }
|
||||
opAnd.andNotWhere(filter.notEndsWith, filter.notEndsWithAll, filter.notEndsWithAny) { column notLike "%$it" }
|
||||
opAnd.andWhere(filter.endsWithInsensitive, filter.endsWithInsensitiveAll, filter.endsWithInsensitiveAny) {
|
||||
ILikeEscapeOp.iLike(column, "%$it")
|
||||
}
|
||||
opAnd.andWhere(filter.notEndsWithInsensitive, filter.notEndsWithInsensitiveAll, filter.notEndsWithInsensitiveAny) {
|
||||
opAnd.andNotWhere(filter.notEndsWithInsensitive, filter.notEndsWithInsensitiveAll, filter.notEndsWithInsensitiveAny) {
|
||||
ILikeEscapeOp.iNotLike(column, "%$it")
|
||||
}
|
||||
|
||||
opAnd.andWhere(filter.like, filter.likeAll, filter.likeAny) { column like it }
|
||||
opAnd.andWhere(filter.notLike, filter.notLikeAll, filter.notLikeAny) { column notLike it }
|
||||
opAnd.andNotWhere(filter.notLike, filter.notLikeAll, filter.notLikeAny) { column notLike it }
|
||||
opAnd.andWhere(filter.likeInsensitive, filter.likeInsensitiveAll, filter.likeInsensitiveAny) { ILikeEscapeOp.iLike(column, it) }
|
||||
opAnd.andWhere(filter.notLikeInsensitive, filter.notLikeInsensitiveAll, filter.notLikeInsensitiveAny) {
|
||||
opAnd.andNotWhere(filter.notLikeInsensitive, filter.notLikeInsensitiveAll, filter.notLikeInsensitiveAny) {
|
||||
ILikeEscapeOp.iNotLike(column, it)
|
||||
}
|
||||
|
||||
@@ -505,48 +509,75 @@ class OpAnd(
|
||||
) {
|
||||
fun <T> andWhere(
|
||||
value: T?,
|
||||
andPart: SqlExpressionBuilder.(T & Any) -> Op<Boolean>,
|
||||
andPart: (T & Any) -> Op<Boolean>,
|
||||
) {
|
||||
value ?: return
|
||||
val expr = Op.build { andPart(value) }
|
||||
val expr = andPart(value)
|
||||
op = if (op == null) expr else (op!! and expr)
|
||||
}
|
||||
|
||||
fun <T : Any> andWhere(
|
||||
values: List<T>?,
|
||||
andPart: SqlExpressionBuilder.(List<T>) -> Op<Boolean>,
|
||||
andPart: (List<T>) -> Op<Boolean>,
|
||||
) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return andWhere(values as T?, andPart as SqlExpressionBuilder.(Any) -> Op<Boolean>)
|
||||
return andWhere(values as T?, andPart as (Any) -> Op<Boolean>)
|
||||
}
|
||||
|
||||
fun <T : Any> andWhere(
|
||||
valueDefault: T?,
|
||||
valueAll: List<T>?,
|
||||
valueAny: List<T>?,
|
||||
expr: SqlExpressionBuilder.(T) -> Op<Boolean>,
|
||||
expr: (T) -> Op<Boolean>,
|
||||
) {
|
||||
andWhere(valueDefault, expr)
|
||||
andWhereAll(valueAll, expr)
|
||||
andWhereAny(valueAny, expr)
|
||||
}
|
||||
|
||||
fun <T : Any> andNotWhere(
|
||||
valueDefault: T?,
|
||||
valueAll: List<T>?,
|
||||
valueAny: List<T>?,
|
||||
expr: (T) -> Op<Boolean>,
|
||||
) {
|
||||
andWhere(valueDefault, expr)
|
||||
andNotWhereAll(valueAll, expr)
|
||||
andNotWhereAny(valueAny, expr)
|
||||
}
|
||||
|
||||
fun <T : Any> andWhereAll(
|
||||
values: List<T>?,
|
||||
andPart: SqlExpressionBuilder.(T) -> Op<Boolean>,
|
||||
andPart: (T) -> Op<Boolean>,
|
||||
) {
|
||||
values?.map { andWhere(it, andPart) }
|
||||
}
|
||||
|
||||
fun <T : Any> andNotWhereAll(
|
||||
values: List<T>?,
|
||||
andPart: (T) -> Op<Boolean>,
|
||||
) {
|
||||
// Inversed all equals any
|
||||
andWhereAny(values, andPart)
|
||||
}
|
||||
|
||||
fun <T : Any> andWhereAny(
|
||||
values: List<T>?,
|
||||
andPart: SqlExpressionBuilder.(T) -> Op<Boolean>,
|
||||
andPart: (T) -> Op<Boolean>,
|
||||
) {
|
||||
values ?: return
|
||||
val expr = values.map { Op.build { andPart(it) } }.reduce { acc, op -> acc or op }
|
||||
val expr = values.map { andPart(it) }.reduceOrNull { acc, op -> acc or op } ?: return
|
||||
op = if (op == null) expr else (op!! and expr)
|
||||
}
|
||||
|
||||
fun <T : Any> andNotWhereAny(
|
||||
values: List<T>?,
|
||||
andPart: (T) -> Op<Boolean>,
|
||||
) {
|
||||
// Inversed any equals all
|
||||
andWhereAll(values, andPart)
|
||||
}
|
||||
|
||||
fun <T> eq(
|
||||
value: T?,
|
||||
column: Column<T>,
|
||||
@@ -574,7 +605,7 @@ fun <T : Comparable<T>, S : T?> andFilterWithCompare(
|
||||
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
|
||||
|
||||
opAnd.andWhere(filter.equalTo) { column eq it as S }
|
||||
opAnd.andWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it as S }
|
||||
opAnd.andNotWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it as S }
|
||||
opAnd.andWhere(filter.distinctFrom, filter.distinctFromAll, filter.distinctFromAny) { DistinctFromOp.distinctFrom(column, it as S) }
|
||||
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it as S) }
|
||||
if (!filter.`in`.isNullOrEmpty()) {
|
||||
@@ -602,7 +633,7 @@ fun <T : Comparable<T>> andFilterWithCompareEntity(
|
||||
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
|
||||
|
||||
opAnd.andWhere(filter.equalTo) { column eq it }
|
||||
opAnd.andWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it }
|
||||
opAnd.andNotWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it }
|
||||
opAnd.andWhere(filter.distinctFrom, filter.distinctFromAll, filter.distinctFromAny) { DistinctFromOp.distinctFrom(column, it) }
|
||||
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it) }
|
||||
if (!filter.`in`.isNullOrEmpty()) {
|
||||
|
||||
@@ -11,15 +11,20 @@ import com.expediagroup.graphql.server.execution.GraphQLRequestParser
|
||||
import com.expediagroup.graphql.server.types.GraphQLBatchRequest
|
||||
import com.expediagroup.graphql.server.types.GraphQLRequest
|
||||
import com.expediagroup.graphql.server.types.GraphQLServerRequest
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.javalin.http.Context
|
||||
import io.javalin.http.UploadedFile
|
||||
import io.javalin.json.fromJsonStream
|
||||
import io.javalin.json.fromJsonString
|
||||
import java.io.IOException
|
||||
|
||||
class JavalinGraphQLRequestParser : GraphQLRequestParser<Context> {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE")
|
||||
override suspend fun parseRequest(context: Context): GraphQLServerRequest? {
|
||||
return try {
|
||||
val jsonMapper = context.jsonMapper()
|
||||
val contentType = context.contentType()
|
||||
val formParam =
|
||||
if (
|
||||
@@ -29,17 +34,17 @@ class JavalinGraphQLRequestParser : GraphQLRequestParser<Context> {
|
||||
context.formParam("operations")
|
||||
?: throw IllegalArgumentException("Cannot find 'operations' body")
|
||||
} else {
|
||||
return context.bodyAsClass(GraphQLServerRequest::class.java)
|
||||
return context.bodyInputStream().use { jsonMapper.fromJsonStream<GraphQLServerRequest>(it) }
|
||||
}
|
||||
|
||||
val request =
|
||||
context.jsonMapper().fromJsonString<GraphQLServerRequest>(formParam)
|
||||
jsonMapper.fromJsonString<GraphQLServerRequest>(formParam)
|
||||
|
||||
val map =
|
||||
context
|
||||
.formParam("map")
|
||||
?.let {
|
||||
context.jsonMapper().fromJsonString<Map<String, List<String>>>(it)
|
||||
jsonMapper.fromJsonString<Map<String, List<String>>>(it)
|
||||
}.orEmpty()
|
||||
|
||||
val mapItems =
|
||||
@@ -73,7 +78,8 @@ class JavalinGraphQLRequestParser : GraphQLRequestParser<Context> {
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
} catch (e: IOException) {
|
||||
logger.error(e) { "Error when parsing request" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import suwayomi.tachidesk.graphql.mutations.MangaMutation
|
||||
import suwayomi.tachidesk.graphql.mutations.MetaMutation
|
||||
import suwayomi.tachidesk.graphql.mutations.SettingsMutation
|
||||
import suwayomi.tachidesk.graphql.mutations.SourceMutation
|
||||
import suwayomi.tachidesk.graphql.mutations.SyncMutation
|
||||
import suwayomi.tachidesk.graphql.mutations.TrackMutation
|
||||
import suwayomi.tachidesk.graphql.mutations.UpdateMutation
|
||||
import suwayomi.tachidesk.graphql.mutations.UserMutation
|
||||
@@ -41,6 +42,7 @@ import suwayomi.tachidesk.graphql.queries.MangaQuery
|
||||
import suwayomi.tachidesk.graphql.queries.MetaQuery
|
||||
import suwayomi.tachidesk.graphql.queries.SettingsQuery
|
||||
import suwayomi.tachidesk.graphql.queries.SourceQuery
|
||||
import suwayomi.tachidesk.graphql.queries.SyncQuery
|
||||
import suwayomi.tachidesk.graphql.queries.TrackQuery
|
||||
import suwayomi.tachidesk.graphql.queries.UpdateQuery
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
||||
@@ -50,6 +52,7 @@ import suwayomi.tachidesk.graphql.server.primitives.GraphQLLongAsString
|
||||
import suwayomi.tachidesk.graphql.server.primitives.GraphQLUpload
|
||||
import suwayomi.tachidesk.graphql.subscriptions.DownloadSubscription
|
||||
import suwayomi.tachidesk.graphql.subscriptions.InfoSubscription
|
||||
import suwayomi.tachidesk.graphql.subscriptions.SyncSubscription
|
||||
import suwayomi.tachidesk.graphql.subscriptions.UpdateSubscription
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KType
|
||||
@@ -98,6 +101,7 @@ val schema =
|
||||
TopLevelObject(MetaQuery()),
|
||||
TopLevelObject(SettingsQuery()),
|
||||
TopLevelObject(SourceQuery()),
|
||||
TopLevelObject(SyncQuery()),
|
||||
TopLevelObject(TrackQuery()),
|
||||
TopLevelObject(UpdateQuery()),
|
||||
),
|
||||
@@ -114,6 +118,7 @@ val schema =
|
||||
TopLevelObject(MangaMutation()),
|
||||
TopLevelObject(MetaMutation()),
|
||||
TopLevelObject(SettingsMutation()),
|
||||
TopLevelObject(SyncMutation()),
|
||||
TopLevelObject(SourceMutation()),
|
||||
TopLevelObject(TrackMutation()),
|
||||
TopLevelObject(UpdateMutation()),
|
||||
@@ -123,6 +128,7 @@ val schema =
|
||||
listOf(
|
||||
TopLevelObject(DownloadSubscription()),
|
||||
TopLevelObject(InfoSubscription()),
|
||||
TopLevelObject(SyncSubscription()),
|
||||
TopLevelObject(UpdateSubscription()),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -10,13 +10,11 @@ package suwayomi.tachidesk.graphql.server
|
||||
import com.expediagroup.graphql.generator.execution.FlowSubscriptionExecutionStrategy
|
||||
import com.expediagroup.graphql.server.execution.GraphQLRequestHandler
|
||||
import com.expediagroup.graphql.server.execution.GraphQLServer
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import graphql.ExceptionWhileDataFetching
|
||||
import graphql.GraphQL
|
||||
import graphql.execution.AsyncExecutionStrategy
|
||||
import graphql.execution.DataFetcherExceptionHandler
|
||||
import graphql.execution.DataFetcherExceptionHandlerResult
|
||||
import graphql.schema.idl.RuntimeWiring
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.javalin.http.Context
|
||||
import io.javalin.websocket.WsCloseContext
|
||||
@@ -27,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import suwayomi.tachidesk.graphql.server.subscriptions.ApolloSubscriptionProtocolHandler
|
||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||
import tools.jackson.module.kotlin.jacksonObjectMapper
|
||||
|
||||
class TachideskGraphQLServer(
|
||||
requestParser: JavalinGraphQLRequestParser,
|
||||
|
||||
@@ -58,7 +58,7 @@ private class GraphqlCursorCoercing : Coercing<Cursor, String> {
|
||||
),
|
||||
)
|
||||
}
|
||||
return Cursor(input.value)
|
||||
return Cursor(input.value!!)
|
||||
}
|
||||
|
||||
private fun valueToLiteralImpl(input: Any): StringValue = StringValue.newStringValue(input.toString()).build()
|
||||
|
||||
@@ -71,7 +71,7 @@ private class GraphqlDurationAsStringCoercing : Coercing<Duration, String> {
|
||||
)
|
||||
}
|
||||
return try {
|
||||
Duration.parse(input.value)
|
||||
Duration.parse(input.value!!)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw CoercingParseLiteralException(
|
||||
"Invalid duration format: ${input.value}. Expected ISO-8601 duration string (e.g., 'PT30M', 'P1D')",
|
||||
|
||||
@@ -53,7 +53,7 @@ private class GraphqlLongAsStringCoercing : Coercing<Long, String> {
|
||||
),
|
||||
)
|
||||
}
|
||||
return input.value.toLong()
|
||||
return input.value!!.toLong()
|
||||
}
|
||||
|
||||
private fun valueToLiteralImpl(input: Any): StringValue = StringValue.newStringValue(input.toString()).build()
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
package suwayomi.tachidesk.graphql.server.primitives
|
||||
|
||||
import org.jetbrains.exposed.dao.id.EntityID
|
||||
import org.jetbrains.exposed.sql.Column
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.Query
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.andWhere
|
||||
import org.jetbrains.exposed.sql.or
|
||||
import org.jetbrains.exposed.v1.core.Column
|
||||
import org.jetbrains.exposed.v1.core.Op
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.core.or
|
||||
import org.jetbrains.exposed.v1.jdbc.Query
|
||||
import org.jetbrains.exposed.v1.jdbc.andWhere
|
||||
|
||||
interface OrderBy<T> {
|
||||
val column: Column<*>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package suwayomi.tachidesk.graphql.server.primitives
|
||||
|
||||
import org.jetbrains.exposed.sql.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
|
||||
data class QueryResults<T>(
|
||||
val total: Long,
|
||||
|
||||
@@ -9,9 +9,6 @@ package suwayomi.tachidesk.graphql.server.subscriptions
|
||||
|
||||
import com.expediagroup.graphql.server.execution.GraphQLRequestHandler
|
||||
import com.expediagroup.graphql.server.types.GraphQLRequest
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.convertValue
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.javalin.http.Header
|
||||
import io.javalin.websocket.WsContext
|
||||
@@ -26,7 +23,7 @@ import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.eclipse.jetty.websocket.api.CloseStatus
|
||||
import org.eclipse.jetty.websocket.core.CloseStatus
|
||||
import suwayomi.tachidesk.graphql.server.TachideskGraphQLContextFactory
|
||||
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ClientMessages.GQL_CONNECTION_INIT
|
||||
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ClientMessages.GQL_SUBSCRIBE
|
||||
@@ -41,6 +38,9 @@ import suwayomi.tachidesk.server.JavalinSetup.Attribute
|
||||
import suwayomi.tachidesk.server.JavalinSetup.getAttributeOrSet
|
||||
import suwayomi.tachidesk.server.user.UserType
|
||||
import suwayomi.tachidesk.server.user.getUserFromToken
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import tools.jackson.module.kotlin.convertValue
|
||||
import tools.jackson.module.kotlin.readValue
|
||||
|
||||
/**
|
||||
* Implementation of the `graphql-transport-ws` protocol defined by Denis Badurina
|
||||
|
||||
@@ -13,7 +13,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import org.eclipse.jetty.websocket.api.CloseStatus
|
||||
import org.eclipse.jetty.websocket.core.CloseStatus
|
||||
import suwayomi.tachidesk.graphql.server.toGraphQLContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package suwayomi.tachidesk.graphql.subscriptions
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import suwayomi.tachidesk.global.impl.sync.SyncManager
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.types.SyncStatus
|
||||
import suwayomi.tachidesk.graphql.types.toStatus
|
||||
|
||||
class SyncSubscription {
|
||||
@RequireAuth
|
||||
fun syncStatusChanged(): Flow<SyncStatus> =
|
||||
SyncManager.lastSyncState
|
||||
.filterNotNull()
|
||||
.map { it.toStatus() }
|
||||
}
|
||||
@@ -9,7 +9,7 @@ package suwayomi.tachidesk.graphql.types
|
||||
|
||||
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import org.jetbrains.exposed.sql.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Edge
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Node
|
||||
|
||||
@@ -9,7 +9,7 @@ package suwayomi.tachidesk.graphql.types
|
||||
|
||||
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import org.jetbrains.exposed.sql.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Edge
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Node
|
||||
|
||||
@@ -9,7 +9,7 @@ package suwayomi.tachidesk.graphql.types
|
||||
|
||||
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import org.jetbrains.exposed.sql.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Edge
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Node
|
||||
|
||||
@@ -10,7 +10,7 @@ package suwayomi.tachidesk.graphql.types
|
||||
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import org.jetbrains.exposed.sql.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
import suwayomi.tachidesk.graphql.cache.CustomCacheMap
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Edge
|
||||
|
||||
@@ -2,7 +2,7 @@ package suwayomi.tachidesk.graphql.types
|
||||
|
||||
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import org.jetbrains.exposed.sql.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
import suwayomi.tachidesk.global.model.table.GlobalMetaTable
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Edge
|
||||
|
||||
@@ -13,8 +13,9 @@ import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import org.jetbrains.exposed.sql.ResultRow
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Edge
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Node
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package suwayomi.tachidesk.graphql.types
|
||||
|
||||
import suwayomi.tachidesk.global.impl.sync.SyncManager
|
||||
|
||||
enum class StartSyncResult {
|
||||
SUCCESS,
|
||||
SYNC_IN_PROGRESS,
|
||||
SYNC_DISABLED,
|
||||
}
|
||||
|
||||
enum class SyncState {
|
||||
STARTED,
|
||||
CREATING_BACKUP,
|
||||
DOWNLOADING,
|
||||
MERGING,
|
||||
UPLOADING,
|
||||
RESTORING,
|
||||
SUCCESS,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
data class SyncStatus(
|
||||
val state: SyncState,
|
||||
val startDate: Long,
|
||||
val endDate: Long? = null,
|
||||
val backupRestoreId: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
fun SyncManager.SyncState.toStatus(): SyncStatus =
|
||||
when (this) {
|
||||
is SyncManager.SyncState.Started -> {
|
||||
SyncStatus(
|
||||
state = SyncState.STARTED,
|
||||
startDate = startDate.toEpochMilliseconds(),
|
||||
)
|
||||
}
|
||||
|
||||
is SyncManager.SyncState.CreatingBackup -> {
|
||||
SyncStatus(
|
||||
state = SyncState.CREATING_BACKUP,
|
||||
startDate = startDate.toEpochMilliseconds(),
|
||||
)
|
||||
}
|
||||
|
||||
is SyncManager.SyncState.Downloading -> {
|
||||
SyncStatus(
|
||||
state = SyncState.DOWNLOADING,
|
||||
startDate = startDate.toEpochMilliseconds(),
|
||||
)
|
||||
}
|
||||
|
||||
is SyncManager.SyncState.Merging -> {
|
||||
SyncStatus(
|
||||
state = SyncState.MERGING,
|
||||
startDate = startDate.toEpochMilliseconds(),
|
||||
)
|
||||
}
|
||||
|
||||
is SyncManager.SyncState.Uploading -> {
|
||||
SyncStatus(
|
||||
state = SyncState.UPLOADING,
|
||||
startDate = startDate.toEpochMilliseconds(),
|
||||
)
|
||||
}
|
||||
|
||||
is SyncManager.SyncState.Restoring -> {
|
||||
SyncStatus(
|
||||
state = SyncState.RESTORING,
|
||||
startDate = startDate.toEpochMilliseconds(),
|
||||
backupRestoreId = restoreId,
|
||||
)
|
||||
}
|
||||
|
||||
is SyncManager.SyncState.Success -> {
|
||||
SyncStatus(
|
||||
state = SyncState.SUCCESS,
|
||||
startDate = startDate.toEpochMilliseconds(),
|
||||
endDate = endDate.toEpochMilliseconds(),
|
||||
)
|
||||
}
|
||||
|
||||
is SyncManager.SyncState.Error -> {
|
||||
SyncStatus(
|
||||
state = SyncState.ERROR,
|
||||
startDate = startDate.toEpochMilliseconds(),
|
||||
endDate = endDate.toEpochMilliseconds(),
|
||||
errorMessage = message,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package suwayomi.tachidesk.graphql.types
|
||||
|
||||
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import org.jetbrains.exposed.sql.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Edge
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Node
|
||||
|
||||
@@ -12,8 +12,9 @@ import io.javalin.http.HttpStatus
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import suwayomi.tachidesk.manga.impl.CategoryManga
|
||||
import suwayomi.tachidesk.manga.impl.Chapter
|
||||
import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper
|
||||
|
||||
@@ -7,25 +7,27 @@ package suwayomi.tachidesk.manga.impl
|
||||
* 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.jetbrains.exposed.dao.id.EntityID
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.andWhere
|
||||
import org.jetbrains.exposed.sql.batchInsert
|
||||
import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.isNull
|
||||
import org.jetbrains.exposed.v1.core.neq
|
||||
import org.jetbrains.exposed.v1.core.statements.BatchUpdateStatement
|
||||
import org.jetbrains.exposed.v1.jdbc.andWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.batchInsert
|
||||
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryMetaTable
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||
import kotlin.collections.component1
|
||||
import kotlin.collections.orEmpty
|
||||
|
||||
object Category {
|
||||
/**
|
||||
@@ -248,13 +250,14 @@ object Category {
|
||||
}
|
||||
|
||||
if (existingMetaByMetaId.isNotEmpty()) {
|
||||
BatchUpdateStatement(CategoryMetaTable).apply {
|
||||
existingMetaByMetaId.forEach { (metaId, entry) ->
|
||||
addBatch(EntityID(metaId, CategoryMetaTable))
|
||||
this[CategoryMetaTable.value] = entry.value
|
||||
}
|
||||
execute(this@transaction)
|
||||
}
|
||||
BatchUpdateStatement(CategoryMetaTable)
|
||||
.apply {
|
||||
existingMetaByMetaId.forEach { (metaId, entry) ->
|
||||
addBatch(EntityID(metaId, CategoryMetaTable))
|
||||
this[CategoryMetaTable.value] = entry.value
|
||||
}
|
||||
}.toExecutable()
|
||||
.execute(this@transaction)
|
||||
}
|
||||
|
||||
if (newMetaByCategoryId.isNotEmpty()) {
|
||||
|
||||
@@ -7,19 +7,22 @@ package suwayomi.tachidesk.manga.impl
|
||||
* 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.jetbrains.exposed.sql.ResultRow
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.alias
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.batchInsert
|
||||
import org.jetbrains.exposed.sql.count
|
||||
import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.leftJoin
|
||||
import org.jetbrains.exposed.sql.max
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.wrapAsExpression
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.alias
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.count
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.isNull
|
||||
import org.jetbrains.exposed.v1.core.leftJoin
|
||||
import org.jetbrains.exposed.v1.core.max
|
||||
import org.jetbrains.exposed.v1.core.wrapAsExpression
|
||||
import org.jetbrains.exposed.v1.jdbc.batchInsert
|
||||
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.select
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.manga.impl.Category.DEFAULT_CATEGORY_ID
|
||||
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||
@@ -82,6 +85,12 @@ object CategoryManga {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMangaFromAllCategories(mangaId: Int) {
|
||||
transaction {
|
||||
CategoryMangaTable.deleteWhere { CategoryMangaTable.manga eq mangaId }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* list of mangas that belong to a category
|
||||
*/
|
||||
@@ -108,12 +117,14 @@ object CategoryManga {
|
||||
|
||||
val transform: (ResultRow) -> MangaDataClass = {
|
||||
// Map the data from the result row to the MangaDataClass
|
||||
val dataClass = MangaTable.toDataClass(it)
|
||||
dataClass.lastReadAt = it[lastReadAt]
|
||||
dataClass.unreadCount = it[unreadCount]
|
||||
dataClass.downloadCount = it[downloadedCount]
|
||||
dataClass.chapterCount = it[chapterCount]
|
||||
dataClass
|
||||
MangaTable
|
||||
.toDataClass(it)
|
||||
.copy(
|
||||
lastReadAt = it[lastReadAt],
|
||||
unreadCount = it[unreadCount],
|
||||
downloadCount = it[downloadedCount],
|
||||
chapterCount = it[chapterCount],
|
||||
)
|
||||
}
|
||||
|
||||
return transaction {
|
||||
|
||||
@@ -17,17 +17,21 @@ import io.github.reactivecircus.cache4k.Cache
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jetbrains.exposed.dao.id.EntityID
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.batchInsert
|
||||
import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.core.statements.BatchUpdateStatement
|
||||
import org.jetbrains.exposed.v1.jdbc.batchInsert
|
||||
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.select
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import suwayomi.tachidesk.manga.impl.Manga.getManga
|
||||
import suwayomi.tachidesk.manga.impl.download.DownloadManager
|
||||
import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput
|
||||
@@ -100,9 +104,6 @@ object Chapter {
|
||||
.associateBy({ it[ChapterTable.url] }, { it })
|
||||
}
|
||||
|
||||
val chapterIds = chapterList.map { dbChapterMap.getValue(it.url)[ChapterTable.id] }
|
||||
val chapterMetas = getChaptersMetaMaps(chapterIds.map { it.value })
|
||||
|
||||
return chapterList.mapIndexed { index, it ->
|
||||
|
||||
val dbChapter = dbChapterMap.getValue(it.url)
|
||||
@@ -124,8 +125,8 @@ object Chapter {
|
||||
realUrl = dbChapter[ChapterTable.realUrl],
|
||||
downloaded = dbChapter[ChapterTable.isDownloaded],
|
||||
pageCount = dbChapter[ChapterTable.pageCount],
|
||||
chapterCount = chapterList.size,
|
||||
meta = chapterMetas.getValue(dbChapter[ChapterTable.id].value),
|
||||
lastModifiedAt = dbChapter[ChapterTable.lastModifiedAt],
|
||||
version = dbChapter[ChapterTable.version],
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -188,7 +189,7 @@ object Chapter {
|
||||
}
|
||||
|
||||
// new chapters after they have been added to the database for auto downloads
|
||||
val insertedChapters = mutableListOf<ChapterDataClass>()
|
||||
val insertedChapterIds = mutableListOf<Int>()
|
||||
|
||||
val chaptersToInsert = mutableListOf<ChapterDataClass>() // do not yet have an ID from the database
|
||||
val chaptersToUpdate = mutableListOf<ChapterDataClass>()
|
||||
@@ -232,7 +233,7 @@ object Chapter {
|
||||
val deletedChapterNumbers = TreeSet<Float>()
|
||||
val deletedReadChapterNumbers = TreeSet<Float>()
|
||||
val deletedBookmarkedChapterNumbers = TreeSet<Float>()
|
||||
val deletedDownloadedChapterNumberInfoMap = mutableMapOf<Float, MutableMap<String?, Int>>()
|
||||
val deletedDownloadedChapterNumberToChapter = mutableMapOf<Float, ChapterDataClass>()
|
||||
val deletedChapterNumberDateFetchMap = mutableMapOf<Float, Long>()
|
||||
|
||||
// clear any orphaned/duplicate chapters that are in the db but not in `chapterList`
|
||||
@@ -243,13 +244,7 @@ object Chapter {
|
||||
if (!chapterUrls.contains(dbChapter.url)) {
|
||||
if (dbChapter.read) deletedReadChapterNumbers.add(dbChapter.chapterNumber)
|
||||
if (dbChapter.bookmarked) deletedBookmarkedChapterNumbers.add(dbChapter.chapterNumber)
|
||||
if (dbChapter.downloaded) {
|
||||
val pageCountByScanlator =
|
||||
deletedDownloadedChapterNumberInfoMap.getOrPut(
|
||||
dbChapter.chapterNumber,
|
||||
) { mutableMapOf() }
|
||||
pageCountByScanlator[dbChapter.scanlator] = dbChapter.pageCount
|
||||
}
|
||||
if (dbChapter.downloaded) deletedDownloadedChapterNumberToChapter[dbChapter.chapterNumber] = dbChapter
|
||||
deletedChapterNumbers.add(dbChapter.chapterNumber)
|
||||
deletedChapterNumberDateFetchMap[dbChapter.chapterNumber] = dbChapter.fetchedAt
|
||||
dbChapter.id
|
||||
@@ -258,16 +253,14 @@ object Chapter {
|
||||
}
|
||||
}
|
||||
|
||||
// we got some clean up due
|
||||
if (chaptersIdsToDelete.isNotEmpty()) {
|
||||
DownloadManager.dequeue(chaptersIdsToDelete)
|
||||
transaction {
|
||||
transaction {
|
||||
// we got some clean up due
|
||||
if (chaptersIdsToDelete.isNotEmpty()) {
|
||||
DownloadManager.dequeue(chaptersIdsToDelete)
|
||||
PageTable.deleteWhere { chapter inList chaptersIdsToDelete }
|
||||
ChapterTable.deleteWhere { id inList chaptersIdsToDelete }
|
||||
}
|
||||
}
|
||||
|
||||
transaction {
|
||||
if (chaptersToInsert.isNotEmpty()) {
|
||||
ChapterTable
|
||||
.batchInsert(chaptersToInsert) { chapter ->
|
||||
@@ -283,41 +276,71 @@ object Chapter {
|
||||
this[ChapterTable.isRead] = false
|
||||
this[ChapterTable.isBookmarked] = false
|
||||
this[ChapterTable.isDownloaded] = false
|
||||
this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt
|
||||
this[ChapterTable.version] = chapter.version
|
||||
this[ChapterTable.pageCount] = -1
|
||||
|
||||
// is recognized chapter number
|
||||
if (chapter.chapterNumber >= 0f && chapter.chapterNumber in deletedChapterNumbers) {
|
||||
this[ChapterTable.isRead] = chapter.chapterNumber in deletedReadChapterNumbers
|
||||
this[ChapterTable.isBookmarked] = chapter.chapterNumber in deletedBookmarkedChapterNumbers
|
||||
|
||||
// only preserve download status for chapters of the same scanlator, otherwise,
|
||||
// the downloaded files won't be found anyway
|
||||
val downloadedChapterInfo = deletedDownloadedChapterNumberInfoMap[chapter.chapterNumber]
|
||||
val pageCount = downloadedChapterInfo?.get(chapter.scanlator)
|
||||
if (pageCount != null) {
|
||||
this[ChapterTable.isDownloaded] = true
|
||||
this[ChapterTable.pageCount] = pageCount
|
||||
}
|
||||
// Try to use the fetch date of the original entry to not pollute 'Updates' tab
|
||||
deletedChapterNumberDateFetchMap[chapter.chapterNumber]?.let {
|
||||
this[ChapterTable.fetchedAt] = it
|
||||
}
|
||||
|
||||
deletedDownloadedChapterNumberToChapter[chapter.chapterNumber]?.let {
|
||||
val hasDownloadedPages = it.pageCount > 0
|
||||
val isSameName = it.name == chapter.name
|
||||
val isSameScanlator = it.scanlator == chapter.scanlator
|
||||
|
||||
// Only preserve download status for chapters with the same name and of the same scanlator; otherwise,
|
||||
// the downloaded files won't be found anyway
|
||||
val isDownloadPreservable = hasDownloadedPages && isSameName && isSameScanlator
|
||||
if (isDownloadPreservable) {
|
||||
this[ChapterTable.isDownloaded] = true
|
||||
this[ChapterTable.pageCount] = it.pageCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}.forEach { insertedChapters.add(ChapterTable.toDataClass(it)) }
|
||||
}.forEach { insertedChapterIds.add(it[ChapterTable.id].value) }
|
||||
}
|
||||
|
||||
if (chaptersToUpdate.isNotEmpty()) {
|
||||
BatchUpdateStatement(ChapterTable).apply {
|
||||
chaptersToUpdate.forEach {
|
||||
addBatch(EntityID(it.id, ChapterTable))
|
||||
this[ChapterTable.name] = it.name
|
||||
this[ChapterTable.date_upload] = it.uploadDate
|
||||
this[ChapterTable.chapter_number] = it.chapterNumber
|
||||
this[ChapterTable.scanlator] = it.scanlator
|
||||
this[ChapterTable.sourceOrder] = it.index
|
||||
this[ChapterTable.realUrl] = it.realUrl
|
||||
}
|
||||
execute(this@transaction)
|
||||
}
|
||||
BatchUpdateStatement(ChapterTable)
|
||||
.apply {
|
||||
chaptersToUpdate.forEach {
|
||||
addBatch(EntityID(it.id, ChapterTable))
|
||||
|
||||
val currentChapter = chaptersInDb.find { dbChapter -> dbChapter.id == it.id }!!
|
||||
|
||||
this[ChapterTable.name] = it.name
|
||||
this[ChapterTable.date_upload] = it.uploadDate
|
||||
this[ChapterTable.chapter_number] = it.chapterNumber
|
||||
this[ChapterTable.scanlator] = it.scanlator
|
||||
this[ChapterTable.sourceOrder] = it.index
|
||||
this[ChapterTable.realUrl] = it.realUrl
|
||||
this[ChapterTable.lastModifiedAt] = it.lastModifiedAt
|
||||
this[ChapterTable.version] = it.version
|
||||
this[ChapterTable.isDownloaded] = currentChapter.downloaded
|
||||
this[ChapterTable.pageCount] = currentChapter.pageCount
|
||||
|
||||
if (!currentChapter.downloaded) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val isSameScanlator = currentChapter.scanlator == it.scanlator
|
||||
val isSameName = currentChapter.name == it.name
|
||||
|
||||
val isDownloadPreservable = isSameName && isSameScanlator
|
||||
if (!isDownloadPreservable) {
|
||||
this[ChapterTable.isDownloaded] = false
|
||||
this[ChapterTable.pageCount] = -1
|
||||
}
|
||||
}
|
||||
}.toExecutable()
|
||||
.execute(this@transaction)
|
||||
}
|
||||
|
||||
MangaTable.update({ MangaTable.id eq mangaId }) {
|
||||
@@ -326,6 +349,13 @@ object Chapter {
|
||||
}
|
||||
|
||||
if (manga.inLibrary) {
|
||||
// We have to query the inserted chapters to get the up-to-date data. I.e. "last_modified_at" is not returned by the insert statement, due to being set by a DB trigger
|
||||
val insertedChapters =
|
||||
transaction {
|
||||
ChapterTable.selectAll().where { ChapterTable.id inList insertedChapterIds }.map(
|
||||
ChapterTable::toDataClass,
|
||||
)
|
||||
}
|
||||
downloadNewChapters(mangaId, currentLatestChapterNumber, numberOfCurrentChapters, insertedChapters)
|
||||
}
|
||||
|
||||
@@ -517,11 +547,11 @@ object Chapter {
|
||||
// mangaId is not null, scope query under manga
|
||||
when {
|
||||
input.chapterIds != null -> {
|
||||
Op.build { (ChapterTable.manga eq mangaId) and (ChapterTable.id inList input.chapterIds) }
|
||||
(ChapterTable.manga eq mangaId) and (ChapterTable.id inList input.chapterIds)
|
||||
}
|
||||
|
||||
input.chapterIndexes != null -> {
|
||||
Op.build { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder inList input.chapterIndexes) }
|
||||
(ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder inList input.chapterIndexes)
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -534,7 +564,7 @@ object Chapter {
|
||||
// mangaId is null, only chapterIndexes is valid for this case
|
||||
when {
|
||||
input.chapterIds != null -> {
|
||||
Op.build { (ChapterTable.id inList input.chapterIds) }
|
||||
(ChapterTable.id inList input.chapterIds)
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -583,7 +613,7 @@ object Chapter {
|
||||
.withDefault { emptyMap() }
|
||||
}
|
||||
|
||||
fun getChapterMetaMap(chapter: EntityID<Int>): Map<String, String> =
|
||||
fun getChapterMetaMap(chapter: Int): Map<String, String> =
|
||||
transaction {
|
||||
ChapterMetaTable
|
||||
.selectAll()
|
||||
@@ -650,13 +680,14 @@ object Chapter {
|
||||
}
|
||||
|
||||
if (existingMetaByMetaId.isNotEmpty()) {
|
||||
BatchUpdateStatement(ChapterMetaTable).apply {
|
||||
existingMetaByMetaId.forEach { (metaId, entry) ->
|
||||
addBatch(EntityID(metaId, ChapterMetaTable))
|
||||
this[ChapterMetaTable.value] = entry.value
|
||||
}
|
||||
execute(this@transaction)
|
||||
}
|
||||
BatchUpdateStatement(ChapterMetaTable)
|
||||
.apply {
|
||||
existingMetaByMetaId.forEach { (metaId, entry) ->
|
||||
addBatch(EntityID(metaId, ChapterMetaTable))
|
||||
this[ChapterMetaTable.value] = entry.value
|
||||
}
|
||||
}.toExecutable()
|
||||
.execute(this@transaction)
|
||||
}
|
||||
|
||||
if (newMetaByChapterId.isNotEmpty()) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package suwayomi.tachidesk.manga.impl
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.jdbc.select
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReady
|
||||
import suwayomi.tachidesk.manga.impl.download.fileProvider.ChaptersFilesProvider
|
||||
import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.ArchiveProvider
|
||||
@@ -14,6 +16,7 @@ import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||
import suwayomi.tachidesk.server.serverConfig
|
||||
import xyz.nulldev.androidcompat.util.SafePath
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
@@ -62,6 +65,11 @@ object ChapterDownloadHelper {
|
||||
chapterId: Int,
|
||||
): Pair<InputStream, Long> = provider(mangaId, chapterId).getAsArchiveStream()
|
||||
|
||||
fun getChapterArchiveSize(
|
||||
mangaId: Int,
|
||||
chapterId: Int,
|
||||
): Long = provider(mangaId, chapterId).getArchiveSize()
|
||||
|
||||
private fun getChapterWithCbzFileName(chapterId: Int): Pair<ChapterDataClass, String> =
|
||||
transaction {
|
||||
val row =
|
||||
@@ -69,13 +77,46 @@ object ChapterDownloadHelper {
|
||||
.select(ChapterTable.columns + MangaTable.columns)
|
||||
.where { ChapterTable.id eq chapterId }
|
||||
.firstOrNull() ?: throw IllegalArgumentException("ChapterId $chapterId not found")
|
||||
|
||||
val chapter = ChapterTable.toDataClass(row)
|
||||
val mangaTitle = row[MangaTable.title]
|
||||
val mangaTitle = row[MangaTable.title].trim()
|
||||
|
||||
val scanlatorPart = chapter.scanlator?.let { "[$it] " } ?: ""
|
||||
val fileName = "$mangaTitle - $scanlatorPart${chapter.name}.cbz"
|
||||
val scanlatorName = chapter.scanlator?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val chapterName = chapter.name.trim().takeIf { it.isNotEmpty() }
|
||||
|
||||
Pair(chapter, fileName)
|
||||
val fileName =
|
||||
buildString {
|
||||
append(mangaTitle)
|
||||
append(" - ")
|
||||
|
||||
if (chapterName != null) {
|
||||
append(chapterName)
|
||||
} else if (chapter.chapterNumber >= 0f) {
|
||||
// chapterNumber is stored as Float, drop .0 for whole numbers.
|
||||
val formatNumber =
|
||||
if (chapter.chapterNumber % 1 == 0f) {
|
||||
chapter.chapterNumber.toInt().toString()
|
||||
} else {
|
||||
chapter.chapterNumber.toString()
|
||||
}
|
||||
append("#$formatNumber")
|
||||
} else {
|
||||
// Fallback when neither name nor valid chapter number exists
|
||||
append("#${chapter.index}")
|
||||
}
|
||||
|
||||
if (scanlatorName != null) {
|
||||
append(" [")
|
||||
append(scanlatorName)
|
||||
append("]")
|
||||
}
|
||||
append(".cbz")
|
||||
}
|
||||
|
||||
// Sanitize filename for OS compatibility
|
||||
val safeFileName = SafePath.buildValidFilename(fileName)
|
||||
|
||||
Pair(chapter, safeFileName)
|
||||
}
|
||||
|
||||
fun getCbzForDownload(
|
||||
|
||||
@@ -12,11 +12,14 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.neq
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.select
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import suwayomi.tachidesk.manga.impl.Manga.getManga
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user