diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce694d1..4987d21e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Server: v0.5.0-r918 + WebUI: r791 +# Server: v0.5.0-r918 + WebUI: r800 ## TL;DR @@ -24,6 +24,14 @@ #### Visible changes - (r790) nice looking progress percentage - (r791) show a Delete button for downloaded chapters +- (r792) Update hover effect using more of Material-UI color pallete ([#29](https://github.com/Suwayomi/Tachidesk-WebUI/pull/21) by @voltrare) +- (r793) Optimize images ([#32](https://github.com/Suwayomi/Tachidesk-WebUI/pull/21) by @phanirithvij) +- (r794) try fix #30 ([#31](https://github.com/Suwayomi/Tachidesk-WebUI/pull/21) by @phanirithvij) +- (r795) fix viewing page number when the string is long +- (r796) show proper display name for source +- (r797) fail gracefully when a thumbnail has errors +- (r798) fix when a source fails to load mangas +- (r800) add Local source ([#31](https://github.com/Suwayomi/Tachidesk-WebUI/pull/21)) #### Bug fixes - N/A diff --git a/buildSrc/src/main/kotlin/Constants.kt b/buildSrc/src/main/kotlin/Constants.kt index 37b30a43..11051be4 100644 --- a/buildSrc/src/main/kotlin/Constants.kt +++ b/buildSrc/src/main/kotlin/Constants.kt @@ -14,7 +14,7 @@ const val MainClass = "suwayomi.tachidesk.MainKt" // should be bumped with each stable release val tachideskVersion = System.getenv("ProductVersion") ?: "v0.5.0" -val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r791" +val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r800" // counts commits on the master branch val tachideskRevision = runCatching { 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..1fad2a0e 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,179 @@ package eu.kanade.tachiyomi.source -import android.content.Context +// import com.github.junrar.Archive +// import java.util.zip.ZipFile +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 kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +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.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 uy.kohesive.injekt.injectLazy +import java.io.File +import java.io.FileNotFoundException +import java.net.URL +import java.util.Locale +import java.util.concurrent.TimeUnit -// import com.github.junrar.Archive -// import com.google.gson.JsonParser -// import eu.kanade.tachiyomi.R -// import eu.kanade.tachiyomi.source.model.Filter -// import eu.kanade.tachiyomi.source.model.FilterList -// import eu.kanade.tachiyomi.source.model.MangasPage -// import eu.kanade.tachiyomi.source.model.Page -// import eu.kanade.tachiyomi.source.model.SChapter -// import eu.kanade.tachiyomi.source.model.SManga -// import eu.kanade.tachiyomi.util.chapter.ChapterRecognition -// import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder -// import eu.kanade.tachiyomi.util.storage.DiskUtil -// import eu.kanade.tachiyomi.util.storage.EpubFile -// import eu.kanade.tachiyomi.util.system.ImageUtil -// import rx.Observable -// import timber.log.Timber -// import java.io.File -// import java.io.FileInputStream -// import java.io.InputStream -// import java.util.Locale -// import java.util.concurrent.TimeUnit -// import java.util.zip.ZipFile - -class LocalSource(private val context: Context) : CatalogueSource { +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) -// + 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") - } + private val json: Json by injectLazy() - override fun fetchPageList(chapter: SChapter): Observable> { - TODO("Not yet implemented") - } - override fun fetchPopularManga(page: Int): Observable { - TODO("Not yet implemented") - } + override fun toString() = name + + 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}/$url/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 +194,122 @@ 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 obj = json.decodeFromStream(inputStream()) + + manga.title = obj["title"]?.jsonPrimitive?.contentOrNull ?: manga.title + manga.author = obj["author"]?.jsonPrimitive?.contentOrNull ?: manga.author + manga.artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: manga.artist + manga.description = obj["description"]?.jsonPrimitive?.contentOrNull ?: manga.description + manga.genre = obj["genre"]?.jsonArray?.joinToString(", ") { it.jsonPrimitive.content } + ?: manga.genre + manga.status = obj["status"]?.jsonPrimitive?.intOrNull ?: 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 +319,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 +333,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 +376,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/Chapter.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt index c39bf58e..454d64f5 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt @@ -18,9 +18,9 @@ import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.update import suwayomi.tachidesk.manga.impl.Manga.getManga -import suwayomi.tachidesk.manga.impl.Page.getChapterDir import suwayomi.tachidesk.manga.impl.Page.getPageName import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource +import suwayomi.tachidesk.manga.impl.util.getChapterDir import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass 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..b0c44539 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt @@ -24,6 +24,7 @@ import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle import suwayomi.tachidesk.manga.impl.util.network.await import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.clearCachedImage import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse +import suwayomi.tachidesk.manga.impl.util.updateMangaDownloadDir import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.manga.model.dataclass.toGenreList import suwayomi.tachidesk.manga.model.table.MangaMetaTable @@ -76,6 +77,12 @@ object Manga { transaction { MangaTable.update({ MangaTable.id eq mangaId }) { + if (fetchedManga.title != mangaEntry[MangaTable.title]) { + val canUpdateTitle = updateMangaDownloadDir(mangaId, fetchedManga.title) + + if (canUpdateTitle) + it[MangaTable.title] = fetchedManga.title + } it[MangaTable.initialized] = true it[MangaTable.artist] = fetchedManga.artist @@ -86,7 +93,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 +162,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 = 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/Page.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Page.kt index aa3d7f80..fe9335d8 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Page.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Page.kt @@ -17,10 +17,10 @@ import org.kodein.di.DI import org.kodein.di.conf.global import org.kodein.di.instance import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource +import suwayomi.tachidesk.manga.impl.util.getChapterDir import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse -import suwayomi.tachidesk.manga.impl.util.storage.SafePath import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.PageTable @@ -92,21 +92,4 @@ object Page { private fun formatPageName(index: Int) = String.format("%03d", index) private val applicationDirs by DI.global.instance() - - fun getChapterDir(mangaId: Int, chapterId: Int): String { - val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() } - val source = getHttpSource(mangaEntry[MangaTable.sourceReference]) - val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.first() } - - val sourceDir = source.toString() - val mangaDir = SafePath.buildValidFilename(mangaEntry[MangaTable.title]) - val chapterDir = SafePath.buildValidFilename( - when { - chapterEntry[ChapterTable.scanlator] != null -> "${chapterEntry[ChapterTable.scanlator]}_${chapterEntry[ChapterTable.name]}" - else -> chapterEntry[ChapterTable.name] - } - ) - - return "${applicationDirs.mangaRoot}/$sourceDir/$mangaDir/$chapterDir" - } } 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/DirName.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/DirName.kt new file mode 100644 index 00000000..cc6c2d2e --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/DirName.kt @@ -0,0 +1,66 @@ +package suwayomi.tachidesk.manga.impl.util + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import org.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 suwayomi.tachidesk.manga.impl.util.storage.SafePath +import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.server.ApplicationDirs +import java.io.File + + +private val applicationDirs by DI.global.instance() + +fun getMangaDir(mangaId: Int): String { + val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() } + val source = GetHttpSource.getHttpSource(mangaEntry[MangaTable.sourceReference]) + + val sourceDir = source.toString() + val mangaDir = SafePath.buildValidFilename(mangaEntry[MangaTable.title]) + + return "${applicationDirs.mangaRoot}/$sourceDir/$mangaDir" +} + +fun getChapterDir(mangaId: Int, chapterId: Int): String { + val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.first() } + + val chapterDir = SafePath.buildValidFilename( + when { + chapterEntry[ChapterTable.scanlator] != null -> "${chapterEntry[ChapterTable.scanlator]}_${chapterEntry[ChapterTable.name]}" + else -> chapterEntry[ChapterTable.name] + } + ) + + return getMangaDir(mangaId) + "/$chapterDir" +} + +/** return value says if rename/move was successful */ +fun updateMangaDownloadDir(mangaId: Int, newTitle: String): Boolean { + val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() } + val source = GetHttpSource.getHttpSource(mangaEntry[MangaTable.sourceReference]) + + val sourceDir = source.toString() + val mangaDir = SafePath.buildValidFilename(mangaEntry[MangaTable.title]) + + val newMangaDir = SafePath.buildValidFilename(newTitle) + + val oldDir = "${applicationDirs.mangaRoot}/$sourceDir/$mangaDir" + val newDir = "${applicationDirs.mangaRoot}/$sourceDir/$newMangaDir" + + val oldDirFile = File(oldDir) + val newDirFile = File(newDir) + + return if (oldDirFile.exists()) + oldDirFile.renameTo(newDirFile) + else true +} 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..0dd49289 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() } @@ -93,7 +97,21 @@ fun applicationSetup() { } } } catch (e: Exception) { - logger.error("Exception while creating initial server.conf:\n", e) + logger.error("Exception while creating initial server.conf", 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 copying Local source's icon", e) } // fixes #119 , ref: https://github.com/Suwayomi/Tachidesk-Server/issues/119#issuecomment-894681292 , source Id calculation depends on String.lowercase() @@ -101,6 +119,8 @@ fun applicationSetup() { 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 00000000..27f70b91 Binary files /dev/null and b/server/src/main/resources/icon/localSource.png differ