From 1ca11fdd34b2f7da015b002c564be86b2a99985d Mon Sep 17 00:00:00 2001 From: Aria Moradi Date: Sat, 18 Sep 2021 00:47:50 +0430 Subject: [PATCH] add Local Source --- server/build.gradle.kts | 2 + .../eu/kanade/tachiyomi/source/LocalSource.kt | 541 +++++++++++------- .../tachiyomi/util/lang/StringExtensions.kt | 58 ++ .../suwayomi/tachidesk/manga/impl/Manga.kt | 20 +- .../suwayomi/tachidesk/manga/impl/Source.kt | 12 +- .../manga/impl/extension/Extension.kt | 4 +- .../manga/impl/extension/ExtensionsList.kt | 3 +- .../manga/impl/util/GetHttpSource.kt | 5 + .../manga/model/dataclass/SourceDataClass.kt | 2 + .../manga/model/table/ExtensionTable.kt | 2 +- .../manga/model/table/SourceTable.kt | 2 +- .../suwayomi/tachidesk/server/ServerSetup.kt | 20 + ...15_SourceAndExtensionLangAddLengthLimit.kt | 18 + .../src/main/resources/icon/localSource.png | Bin 0 -> 6292 bytes 14 files changed, 455 insertions(+), 234 deletions(-) create mode 100644 server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/StringExtensions.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0015_SourceAndExtensionLangAddLengthLimit.kt create mode 100644 server/src/main/resources/icon/localSource.png diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 35306623..371e8555 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -62,6 +62,8 @@ dependencies { implementation("com.google.code.gson:gson:2.8.7") implementation("com.github.salomonbrys.kotson:kotson:2.5.0") + // Sort + implementation("com.github.gpanther:java-nat-sort:natural-comparator-1.1") // asm for ByteCodeEditor(fixing SimpleDateFormat) (must match Dex2Jar version) implementation("org.ow2.asm:asm:9.2") diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/LocalSource.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/LocalSource.kt index 7b36ee6b..277c4151 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/LocalSource.kt @@ -1,153 +1,185 @@ package eu.kanade.tachiyomi.source -import android.content.Context -import eu.kanade.tachiyomi.source.model.FilterList -import eu.kanade.tachiyomi.source.model.MangasPage -import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.source.model.SChapter -import eu.kanade.tachiyomi.source.model.SManga -import rx.Observable - // import com.github.junrar.Archive -// import com.google.gson.JsonParser // import eu.kanade.tachiyomi.R -// import eu.kanade.tachiyomi.source.model.Filter -// import eu.kanade.tachiyomi.source.model.FilterList // import eu.kanade.tachiyomi.source.model.MangasPage // import eu.kanade.tachiyomi.source.model.Page // import eu.kanade.tachiyomi.source.model.SChapter // import eu.kanade.tachiyomi.source.model.SManga // import eu.kanade.tachiyomi.util.chapter.ChapterRecognition -// import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder // import eu.kanade.tachiyomi.util.storage.DiskUtil // import eu.kanade.tachiyomi.util.storage.EpubFile // import eu.kanade.tachiyomi.util.system.ImageUtil // import rx.Observable // import timber.log.Timber -// import java.io.File // import java.io.FileInputStream // import java.io.InputStream // import java.util.Locale -// import java.util.concurrent.TimeUnit // import java.util.zip.ZipFile +import com.google.gson.JsonParser +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.insertAndGetId +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.transactions.transaction +import org.kodein.di.DI +import org.kodein.di.conf.global +import org.kodein.di.instance +import rx.Observable +import suwayomi.tachidesk.manga.model.table.ExtensionTable +import suwayomi.tachidesk.manga.model.table.SourceTable +import suwayomi.tachidesk.server.ApplicationDirs +import java.io.File +import java.io.FileNotFoundException +import java.net.URL +import java.util.Locale +import java.util.concurrent.TimeUnit -class LocalSource(private val context: Context) : CatalogueSource { +class LocalSource(override val baseUrl: String = "") : HttpSource() { companion object { const val ID = 0L -// const val HELP_URL = "https://tachiyomi.org/help/guides/reading-local-manga/" -// -// private const val COVER_NAME = "cover.jpg" -// private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub") -// -// private val POPULAR_FILTERS = FilterList(OrderBy()) -// private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) }) -// private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) -// -// fun updateCover(context: Context, manga: SManga, input: InputStream): File? { + const val LANG = "localsourcelang" + const val NAME = "Local source" + + const val EXTENSION_NAME = "Local Source fake extension" + + const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/" + + private val SUPPORTED_ARCHIVE_TYPES = setOf( +// "zip", +// "rar", +// "cbr", +// "cbz", +// "epub" + ) + + private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) + + // fun updateCover(context: Context, manga: SManga, input: InputStream): File? { // val dir = getBaseDirectories(context).firstOrNull() // if (dir == null) { // input.close() // return null // } -// val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME) +// val cover = getCoverFile(File("${dir.absolutePath}/${manga.url}")) // -// // It might not exist if using the external SD card -// cover.parentFile?.mkdirs() -// input.use { -// cover.outputStream().use { -// input.copyTo(it) +// if (cover != null && cover.exists()) { +// // It might not exist if using the external SD card +// cover.parentFile?.mkdirs() +// input.use { +// cover.outputStream().use { +// input.copyTo(it) +// } // } // } // return cover // } // -// private fun getBaseDirectories(context: Context): List { -// val c = context.getString(R.string.app_name) + File.separator + "local" -// return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) } +// /** +// * Returns valid cover file inside [parent] directory. +// */ +// private fun getCoverFile(parent: File): File? { +// return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf { +// it.isFile && ImageUtil.isImage(it.name) { it.inputStream() } +// } // } +// + private val applicationDirs by DI.global.instance() + + fun addDbRecords() { + transaction { + val sourceRecord = SourceTable.select { SourceTable.id eq ID }.firstOrNull() + + if (sourceRecord == null) { + // must do this to avoid database integrity errors + val extensionId = ExtensionTable.insertAndGetId { + it[apkName] = "localSource" + it[name] = EXTENSION_NAME + it[pkgName] = "eu.kanade.tachiyomi.source.LocalSource" + it[versionName] = "1.2" + it[versionCode] = 0 + it[lang] = LANG + it[isNsfw] = false + it[isInstalled] = true + } + + SourceTable.insert { + it[id] = ID + it[name] = NAME + it[lang] = LANG + it[extension] = extensionId + it[isNsfw] = false + } + } + } + } } override val id = ID - override val name = "Local source" - override val lang = "" + override val name = NAME + override val lang = LANG override val supportsLatest = true - override fun fetchMangaDetails(manga: SManga): Observable { - TODO("Not yet implemented") - } + override val client: OkHttpClient = super.client.newBuilder() + .addInterceptor(FileSystemInterceptor) + .build() - override fun fetchChapterList(manga: SManga): Observable> { - TODO("Not yet implemented") - } + override fun toString() = name - override fun fetchPageList(chapter: SChapter): Observable> { - TODO("Not yet implemented") - } - override fun fetchPopularManga(page: Int): Observable { - TODO("Not yet implemented") - } + override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS) override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { - TODO("Not yet implemented") - } + val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L - override fun fetchLatestUpdates(page: Int): Observable { - TODO("Not yet implemented") - } + var mangaDirs = File(applicationDirs.localMangaRoot).listFiles().orEmpty().toList() + .filter { it.isDirectory } + .filterNot { it.name.startsWith('.') } + .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } + .distinctBy { it.name } + + val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state + when (state?.index) { + 0 -> { + mangaDirs = if (state.ascending) { + mangaDirs.sortedBy { it.name.lowercase(Locale.ENGLISH) } + } else { + mangaDirs.sortedByDescending { it.name.lowercase(Locale.ENGLISH) } + } + } + 1 -> { + mangaDirs = if (state.ascending) { + mangaDirs.sortedBy(File::lastModified) + } else { + mangaDirs.sortedByDescending(File::lastModified) + } + } + } + + val mangas = mangaDirs.map { mangaDir -> + SManga.create().apply { + title = mangaDir.name + url = mangaDir.name + + // Try to find the cover + val cover = File("${applicationDirs.localMangaRoot}/$title/cover.jpg") + if (cover.exists()) { + thumbnail_url = "http://${cover.absolutePath}" + } - override fun getFilterList(): FilterList { - TODO("Not yet implemented") - } -// -// override fun toString() = context.getString(R.string.local_source) -// -// override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS) -// -// override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { -// val baseDirs = getBaseDirectories(context) -// -// val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L -// var mangaDirs = baseDirs -// .asSequence() -// .mapNotNull { it.listFiles()?.toList() } -// .flatten() -// .filter { it.isDirectory } -// .filterNot { it.name.startsWith('.') } -// .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } -// .distinctBy { it.name } -// -// val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state -// when (state?.index) { -// 0 -> { -// mangaDirs = if (state.ascending) { -// mangaDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) } -// } else { -// mangaDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) } -// } -// } -// 1 -> { -// mangaDirs = if (state.ascending) { -// mangaDirs.sortedBy(File::lastModified) -// } else { -// mangaDirs.sortedByDescending(File::lastModified) -// } -// } -// } -// -// val mangas = mangaDirs.map { mangaDir -> -// SManga.create().apply { -// title = mangaDir.name -// url = mangaDir.name -// -// // Try to find the cover -// for (dir in baseDirs) { -// val cover = File("${dir.absolutePath}/$url", COVER_NAME) -// if (cover.exists()) { -// thumbnail_url = cover.absolutePath -// break -// } -// } -// // val chapters = fetchChapterList(this).toBlocking().first() // if (chapters.isNotEmpty()) { // val chapter = chapters.last() @@ -168,117 +200,123 @@ class LocalSource(private val context: Context) : CatalogueSource { // } // } // } -// } -// } -// -// return Observable.just(MangasPage(mangas.toList(), false)) -// } -// -// override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) -// -// override fun fetchMangaDetails(manga: SManga): Observable { -// getBaseDirectories(context) -// .asSequence() -// .mapNotNull { File(it, manga.url).listFiles()?.toList() } -// .flatten() -// .firstOrNull { it.extension == "json" } -// ?.apply { -// val reader = this.inputStream().bufferedReader() -// val json = JsonParser.parseReader(reader).asJsonObject -// -// manga.title = json["title"]?.asString ?: manga.title -// manga.author = json["author"]?.asString ?: manga.author -// manga.artist = json["artist"]?.asString ?: manga.artist -// manga.description = json["description"]?.asString ?: manga.description -// manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString } -// ?: manga.genre -// manga.status = json["status"]?.asInt ?: manga.status -// } -// -// return Observable.just(manga) -// } -// -// override fun fetchChapterList(manga: SManga): Observable> { -// val chapters = getBaseDirectories(context) -// .asSequence() -// .mapNotNull { File(it, manga.url).listFiles()?.toList() } -// .flatten() -// .filter { it.isDirectory || isSupportedFile(it.extension) } -// .map { chapterFile -> -// SChapter.create().apply { -// url = "${manga.url}/${chapterFile.name}" -// name = if (chapterFile.isDirectory) { -// chapterFile.name -// } else { -// chapterFile.nameWithoutExtension -// } -// date_upload = chapterFile.lastModified() -// + } + } + + return Observable.just(MangasPage(mangas.toList(), false)) + } + + override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) + + override fun fetchMangaDetails(manga: SManga): Observable { + File(applicationDirs.localMangaRoot, manga.url).listFiles().orEmpty().toList() + .firstOrNull { it.extension == "json" } + ?.apply { + val reader = this.inputStream().bufferedReader() + val json = JsonParser.parseReader(reader).asJsonObject + + manga.title = json["title"]?.asString ?: manga.title + manga.author = json["author"]?.asString ?: manga.author + manga.artist = json["artist"]?.asString ?: manga.artist + manga.description = json["description"]?.asString ?: manga.description + manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString } + ?: manga.genre + manga.status = json["status"]?.asInt ?: manga.status + } + + return Observable.just(manga) + } + + override fun fetchChapterList(manga: SManga): Observable> { + val chapters = File(applicationDirs.localMangaRoot, manga.url).listFiles().orEmpty().toList() + .filter { it.isDirectory || isSupportedFile(it.extension) } + .map { chapterFile -> + SChapter.create().apply { + url = "${manga.url}/${chapterFile.name}" + name = if (chapterFile.isDirectory) { + chapterFile.name + } else { + chapterFile.nameWithoutExtension + } + date_upload = chapterFile.lastModified() + // val format = getFormat(this) // if (format is Format.Epub) { // EpubFile(format.file).use { epub -> // epub.fillChapterMetadata(this) // } // } -// -// val chapNameCut = stripMangaTitle(name, manga.title) -// if (chapNameCut.isNotEmpty()) name = chapNameCut + + val chapNameCut = stripMangaTitle(name, manga.title) + if (chapNameCut.isNotEmpty()) name = chapNameCut // ChapterRecognition.parseChapterNumber(this, manga) -// } -// } -// .sortedWith( -// Comparator { c1, c2 -> -// val c = c2.chapter_number.compareTo(c1.chapter_number) -// if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c -// } -// ) -// .toList() -// -// return Observable.just(chapters) -// } -// -// /** -// * Strips the manga title from a chapter name, matching only based on alphanumeric and whitespace -// * characters. -// */ -// private fun stripMangaTitle(chapterName: String, mangaTitle: String): String { -// var chapterNameIndex = 0 -// var mangaTitleIndex = 0 -// while (chapterNameIndex < chapterName.length && mangaTitleIndex < mangaTitle.length) { -// val chapterChar = chapterName[chapterNameIndex] -// val mangaChar = mangaTitle[mangaTitleIndex] -// if (!chapterChar.equals(mangaChar, true)) { -// val invalidChapterChar = !chapterChar.isLetterOrDigit() && !chapterChar.isWhitespace() -// val invalidMangaChar = !mangaChar.isLetterOrDigit() && !mangaChar.isWhitespace() -// -// if (!invalidChapterChar && !invalidMangaChar) { -// return chapterName -// } -// -// if (invalidChapterChar) { -// chapterNameIndex++ -// } -// -// if (invalidMangaChar) { -// mangaTitleIndex++ -// } -// } else { -// chapterNameIndex++ -// mangaTitleIndex++ -// } -// } -// -// return chapterName.substring(chapterNameIndex).trimStart(' ', '-', '_', ',', ':') -// } -// -// override fun fetchPageList(chapter: SChapter): Observable> { -// return Observable.error(Exception("Unused")) -// } -// -// private fun isSupportedFile(extension: String): Boolean { -// return extension.toLowerCase() in SUPPORTED_ARCHIVE_TYPES -// } -// + } + } + .sortedWith { c1, c2 -> + val c = c2.chapter_number.compareTo(c1.chapter_number) + if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c + } + .toList() + + return Observable.just(chapters) + } + + /** + * Strips the manga title from a chapter name, matching only based on alphanumeric and whitespace + * characters. + */ + private fun stripMangaTitle(chapterName: String, mangaTitle: String): String { + var chapterNameIndex = 0 + var mangaTitleIndex = 0 + while (chapterNameIndex < chapterName.length && mangaTitleIndex < mangaTitle.length) { + val chapterChar = chapterName[chapterNameIndex] + val mangaChar = mangaTitle[mangaTitleIndex] + if (!chapterChar.equals(mangaChar, true)) { + val invalidChapterChar = !chapterChar.isLetterOrDigit() && !chapterChar.isWhitespace() + val invalidMangaChar = !mangaChar.isLetterOrDigit() && !mangaChar.isWhitespace() + + if (!invalidChapterChar && !invalidMangaChar) { + return chapterName + } + + if (invalidChapterChar) { + chapterNameIndex++ + } + + if (invalidMangaChar) { + mangaTitleIndex++ + } + } else { + chapterNameIndex++ + mangaTitleIndex++ + } + } + + return chapterName.substring(chapterNameIndex).trimStart(' ', '-', '_', ',', ':') + } + + private fun isSupportedFile(extension: String): Boolean { + return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES + } + + override fun fetchPageList(chapter: SChapter): Observable> { + val chapterFile = File(applicationDirs.localMangaRoot + File.separator + chapter.url) + + return Observable.just( + if (chapterFile.isDirectory) { + chapterFile.listFiles().sortedBy { it.name }.mapIndexed { index, page -> + Page( + index, + imageUrl = "http://" + applicationDirs.localMangaRoot + File.separator + chapter.url + File.separator + page.name + ) + } + } else { + throw Exception("Archive chapters are not supported.") + } + ) + } + + // // fun getFormat(chapter: SChapter): Format { // val baseDirs = getBaseDirectories(context) // @@ -288,7 +326,7 @@ class LocalSource(private val context: Context) : CatalogueSource { // // return getFormat(chapFile) // } -// throw Exception("Chapter not found") +// throw Exception(context.getString(R.string.chapter_not_found)) // } // // private fun getFormat(file: File): Format { @@ -302,7 +340,7 @@ class LocalSource(private val context: Context) : CatalogueSource { // } else if (extension.equals("epub", true)) { // Format.Epub(file) // } else { -// throw Exception("Invalid chapter format") +// throw Exception(context.getString(R.string.local_invalid_format)) // } // } // @@ -345,14 +383,73 @@ class LocalSource(private val context: Context) : CatalogueSource { // } // } // -// private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Selection(0, true)) -// -// override fun getFilterList() = FilterList(OrderBy()) -// -// sealed class Format { -// data class Directory(val file: File) : Format() -// data class Zip(val file: File) : Format() -// data class Rar(val file: File) : Format() -// data class Epub(val file: File) : Format() -// } + override fun getFilterList() = POPULAR_FILTERS + + private val POPULAR_FILTERS = FilterList(OrderBy()) + private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) }) + + private class OrderBy : Filter.Sort( + "Order by", + arrayOf("Title", "Date"), + Selection(0, true) + ) + + sealed class Format { + data class Directory(val file: File) : Format() + data class Zip(val file: File) : Format() + data class Rar(val file: File) : Format() + data class Epub(val file: File) : Format() + } + + // ///////////////////// Not used ///////////////////// // + + override fun mangaDetailsParse(response: Response): SManga = throw Exception("Not used") + + override fun chapterListParse(response: Response): List = throw Exception("Not used") + + override fun pageListParse(response: Response): List = throw Exception("Not used") + + override fun imageUrlParse(response: Response): String = throw Exception("Not used") + + override fun popularMangaRequest(page: Int): Request = throw Exception("Not used") + + override fun popularMangaParse(response: Response): MangasPage = throw Exception("Not used") + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = + throw Exception("Not used") + + override fun searchMangaParse(response: Response): MangasPage = throw Exception("Not used") + + override fun latestUpdatesRequest(page: Int): Request = throw Exception("Not used") + + override fun latestUpdatesParse(response: Response): MangasPage = throw Exception("Not used") +} + +private object FileSystemInterceptor : Interceptor { + private fun restoreFileUrl(markedFakeHttpUrl: String): String { + return markedFakeHttpUrl.replaceFirst("http:", "file:/") + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val url = request.url + val fileUrl = restoreFileUrl(url.toString()) + return try { + Response.Builder() + .body(URL(fileUrl).readBytes().toResponseBody()) + .code(200) + .message("Some file") + .protocol(Protocol.HTTP_1_0) + .request(request) + .build() + } catch (e: FileNotFoundException) { + Response.Builder() + .body("".toResponseBody()) + .code(404) + .message(e.message ?: "File not found ($fileUrl)") + .protocol(Protocol.HTTP_1_0) + .request(request) + .build() + } + } } diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/StringExtensions.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/StringExtensions.kt new file mode 100644 index 00000000..ab8c1c93 --- /dev/null +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/StringExtensions.kt @@ -0,0 +1,58 @@ +package eu.kanade.tachiyomi.util.lang + +import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator +import kotlin.math.floor + +/** + * Replaces the given string to have at most [count] characters using [replacement] at its end. + * If [replacement] is longer than [count] an exception will be thrown when `length > count`. + */ +fun String.chop(count: Int, replacement: String = "…"): String { + return if (length > count) { + take(count - replacement.length) + replacement + } else { + this + } +} + +/** + * Replaces the given string to have at most [count] characters using [replacement] near the center. + * If [replacement] is longer than [count] an exception will be thrown when `length > count`. + */ +fun String.truncateCenter(count: Int, replacement: String = "..."): String { + if (length <= count) { + return this + } + + val pieceLength: Int = floor((count - replacement.length).div(2.0)).toInt() + + return "${take(pieceLength)}$replacement${takeLast(pieceLength)}" +} + +/** + * Case-insensitive natural comparator for strings. + */ +fun String.compareToCaseInsensitiveNaturalOrder(other: String): Int { + val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + return comparator.compare(this, other) +} + +/** + * Returns the size of the string as the number of bytes. + */ +fun String.byteSize(): Int { + return toByteArray(Charsets.UTF_8).size +} + +/** + * Returns a string containing the first [n] bytes from this string, or the entire string if this + * string is shorter. + */ +fun String.takeBytes(n: Int): String { + val bytes = toByteArray(Charsets.UTF_8) + return if (bytes.size <= n) { + this + } else { + bytes.decodeToString(endIndex = n).replace("\uFFFD", "") + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt index 6a33808d..c3010e27 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt @@ -75,7 +75,7 @@ object Manga { transaction { MangaTable.update({ MangaTable.id eq mangaId }) { - + it[MangaTable.title] = fetchedManga.title it[MangaTable.initialized] = true it[MangaTable.artist] = fetchedManga.artist @@ -86,7 +86,11 @@ object Manga { if (fetchedManga.thumbnail_url != null && fetchedManga.thumbnail_url.orEmpty().isNotEmpty()) it[MangaTable.thumbnail_url] = fetchedManga.thumbnail_url - it[MangaTable.realUrl] = try { source.mangaDetailsRequest(sManga).url.toString() } catch (e: Exception) { null } + it[MangaTable.realUrl] = try { + source.mangaDetailsRequest(sManga).url.toString() + } catch (e: Exception) { + null + } } } @@ -151,14 +155,20 @@ object Manga { val fileName = mangaId.toString() return getCachedImageResponse(saveDir, fileName) { - getManga(mangaId) // make sure is initialized - val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() } val sourceId = mangaEntry[MangaTable.sourceReference] val source = getHttpSource(sourceId) - val thumbnailUrl = mangaEntry[MangaTable.thumbnail_url]!! + val thumbnailUrl: String = mangaEntry[MangaTable.thumbnail_url] + ?: if (!mangaEntry[MangaTable.initialized]) { + // initialize then try again + getManga(mangaId) + transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }[MangaTable.thumbnail_url]!! + } else { + // source provides no thumbnail url for this manga + throw NullPointerException() + } source.client.newCall( GET(thumbnailUrl, source.headers) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt index aebeda6a..440f3540 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt @@ -11,6 +11,7 @@ import android.app.Application import android.content.Context import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.getPreferenceKey import mu.KotlinLogging import org.jetbrains.exposed.sql.select @@ -45,7 +46,8 @@ object Source { getExtensionIconUrl(sourceExtension[ExtensionTable.apkName]), httpSource.supportsLatest, httpSource is ConfigurableSource, - it[SourceTable.isNsfw] + it[SourceTable.isNsfw], + httpSource.toString(), ) } } @@ -53,6 +55,11 @@ object Source { fun getSource(sourceId: Long): SourceDataClass { // all the data extracted fresh form the source instance return transaction { + if (sourceId == LocalSource.ID) { + // initialize local source + getHttpSource(sourceId) + } + val source = SourceTable.select { SourceTable.id eq sourceId }.firstOrNull() val httpSource = source?.let { getHttpSource(sourceId) } val extension = source?.let { @@ -70,7 +77,8 @@ object Source { }, httpSource?.supportsLatest, httpSource?.let { it is ConfigurableSource }, - source?.get(SourceTable.isNsfw) + source?.get(SourceTable.isNsfw), + httpSource?.toString() ) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/Extension.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/Extension.kt index 4a65ce15..e7160ffc 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/Extension.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/Extension.kt @@ -268,8 +268,8 @@ object Extension { } suspend fun getExtensionIcon(apkName: String): Pair { - val iconUrl = - transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl] + val iconUrl = if (apkName == "localSource") "" + else transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl] val saveDir = "${applicationDirs.extensionsRoot}/icon" diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt index 5ce9aceb..019c9d2f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt @@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl.extension * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import eu.kanade.tachiyomi.source.LocalSource import mu.KotlinLogging import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.insert @@ -46,7 +47,7 @@ object ExtensionsList { } fun extensionTableAsDataClass() = transaction { - ExtensionTable.selectAll().map { + ExtensionTable.selectAll().filter { it[ExtensionTable.name] != LocalSource.EXTENSION_NAME }.map { ExtensionDataClass( it[ExtensionTable.apkName], getExtensionIconUrl(it[ExtensionTable.apkName]), diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/GetHttpSource.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/GetHttpSource.kt index d3fe4e27..ec1ca167 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/GetHttpSource.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/GetHttpSource.kt @@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl.util * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.source.online.HttpSource @@ -35,6 +36,10 @@ object GetHttpSource { SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()!! } + if (sourceId == LocalSource.ID) { + return LocalSource() + } + val extensionId = sourceRecord[SourceTable.extension] val extensionRecord = transaction { ExtensionTable.select { ExtensionTable.id eq extensionId }.first() diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/SourceDataClass.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/SourceDataClass.kt index ea464815..1d6987c8 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/SourceDataClass.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/SourceDataClass.kt @@ -23,4 +23,6 @@ data class SourceDataClass( /** The Source class has a @Nsfw annotation */ val isNsfw: Boolean?, + + val displayName: String?, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ExtensionTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ExtensionTable.kt index 2a50873f..4f22a94b 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ExtensionTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ExtensionTable.kt @@ -20,7 +20,7 @@ object ExtensionTable : IntIdTable() { val pkgName = varchar("pkg_name", 128) val versionName = varchar("version_name", 16) val versionCode = integer("version_code") - val lang = varchar("lang", 10) + val lang = varchar("lang", 32) val isNsfw = bool("is_nsfw") val isInstalled = bool("is_installed").default(false) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/SourceTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/SourceTable.kt index fc6e1883..85769ded 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/SourceTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/SourceTable.kt @@ -12,7 +12,7 @@ import org.jetbrains.exposed.dao.id.IdTable object SourceTable : IdTable() { override val id = long("id").entityId() val name = varchar("name", 128) - val lang = varchar("lang", 10) + val lang = varchar("lang", 32) val extension = reference("extension", ExtensionTable) val isNsfw = bool("is_nsfw").default(false) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt index d6b0235c..2837ea17 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt @@ -8,6 +8,7 @@ package suwayomi.tachidesk.server * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.source.LocalSource import mu.KotlinLogging import org.kodein.di.DI import org.kodein.di.bind @@ -33,6 +34,7 @@ class ApplicationDirs( val mangaThumbnailsRoot = "$dataRoot/manga-thumbnails" val animeThumbnailsRoot = "$dataRoot/anime-thumbnails" val mangaRoot = "$dataRoot/manga" + val localMangaRoot = "$dataRoot/manga-local" val webUIRoot = "$dataRoot/webUI" } @@ -63,6 +65,8 @@ fun applicationSetup() { applicationDirs.extensionsRoot + "/icon", applicationDirs.mangaThumbnailsRoot, applicationDirs.animeThumbnailsRoot, + applicationDirs.mangaRoot, + applicationDirs.localMangaRoot, ).forEach { File(it).mkdirs() } @@ -96,11 +100,27 @@ fun applicationSetup() { logger.error("Exception while creating initial server.conf:\n", e) } + // copy local source icon + try { + val localSourceIconFile = File("${applicationDirs.extensionsRoot}/icon/localSource.png") + if (!localSourceIconFile.exists()) { + JavalinSetup::class.java.getResourceAsStream("/icon/localSource.png").use { input -> + localSourceIconFile.outputStream().use { output -> + input.copyTo(output) + } + } + } + } catch (e: Exception) { + logger.error("Exception while creating initial server.conf:\n", e) + } + // fixes #119 , ref: https://github.com/Suwayomi/Tachidesk-Server/issues/119#issuecomment-894681292 , source Id calculation depends on String.lowercase() Locale.setDefault(Locale.ENGLISH) databaseUp() + LocalSource.addDbRecords() + // create system tray if (serverConfig.systemTrayEnabled) { try { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0015_SourceAndExtensionLangAddLengthLimit.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0015_SourceAndExtensionLangAddLengthLimit.kt new file mode 100644 index 00000000..46af5fe3 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0015_SourceAndExtensionLangAddLengthLimit.kt @@ -0,0 +1,18 @@ +package suwayomi.tachidesk.server.database.migration + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import de.neonew.exposed.migrations.helpers.SQLMigration + +@Suppress("ClassName", "unused") +class M0015_SourceAndExtensionLangAddLengthLimit : SQLMigration() { + override val sql = """ + ALTER TABLE SOURCE ALTER COLUMN LANG VARCHAR(32); + ALTER TABLE EXTENSION ALTER COLUMN LANG VARCHAR(32); + """.trimIndent() +} diff --git a/server/src/main/resources/icon/localSource.png b/server/src/main/resources/icon/localSource.png new file mode 100644 index 0000000000000000000000000000000000000000..27f70b9145e7bbfaf79bb1885b6427fed0cc8522 GIT binary patch literal 6292 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 XIoS!b*6u{!HUN7&r=5@g^gI7waf|00 literal 0 HcmV?d00001