From 197039d25a2aea083e750faef4674dd6ccbd263d Mon Sep 17 00:00:00 2001 From: Duanhl <13361063935@163.com> Date: Sun, 24 May 2026 03:57:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E5=B0=8F=E8=AF=B4=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E5=8A=9F=E8=83=BD=20(#1094)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature: add translation full content --------- Co-authored-by: duanhl Co-authored-by: HapeLee <63206378+HapeLee@users.noreply.github.com> --- app/proguard-rules.pro | 3 + .../java/io/legado/app/constant/PreferKey.kt | 13 +- .../java/io/legado/app/data/entities/Book.kt | 12 +- .../app/data/entities/TranslationCache.kt | 26 ++ .../repository/DictionaryRepositoryImpl.kt | 71 ++++ .../repository/LlmTranslateRepositoryImpl.kt | 378 ++++++++++++++++++ .../TranslationCacheRepositoryImpl.kt | 193 +++++++++ .../io/legado/app/di/appDatabaseModule.kt | 24 +- .../main/java/io/legado/app/di/appModule.kt | 20 +- .../app/domain/gateway/DictionaryGateway.kt | 11 + .../legado/app/domain/gateway/LlmGateway.kt | 19 + .../domain/gateway/TranslationCacheGateway.kt | 70 ++++ .../legado/app/domain/model/ContentChunker.kt | 199 +++++++++ .../model/PartialTranslationAssembler.kt | 46 +++ .../app/domain/model/TranslationConstants.kt | 50 +++ .../app/domain/model/TranslationModels.kt | 61 +++ .../domain/usecase/TranslateChapterUseCase.kt | 275 +++++++++++++ .../main/java/io/legado/app/model/ReadBook.kt | 67 +++- .../translation/TranslationChapterState.kt | 34 ++ .../model/translation/TranslationManager.kt | 179 +++++++++ .../legado/app/service/ExportBookService.kt | 152 +++++-- .../app/ui/book/read/ReadBookActivity.kt | 35 +- .../app/ui/book/read/ReadBookViewModel.kt | 6 +- .../io/legado/app/ui/book/read/ReadMenu.kt | 28 +- .../read/config/ToolButtonConfigDialog.kt | 3 +- .../legado/app/ui/config/ConfigNavScreen.kt | 9 +- .../config/translation/TranslationConfig.kt | 67 ++++ .../translation/TranslationConfigScreen.kt | 125 ++++++ .../io/legado/app/ui/main/MainNavGraph.kt | 8 +- .../java/io/legado/app/ui/main/MainNavKey.kt | 4 + .../io/legado/app/ui/main/MainNavigator.kt | 4 +- .../components/image/cover/CoilBookCover.kt | 14 +- app/src/main/res/menu/book_read.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 32 ++ app/src/main/res/values/strings.xml | 32 ++ 35 files changed, 2194 insertions(+), 77 deletions(-) create mode 100644 app/src/main/java/io/legado/app/data/entities/TranslationCache.kt create mode 100644 app/src/main/java/io/legado/app/data/repository/DictionaryRepositoryImpl.kt create mode 100644 app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt create mode 100644 app/src/main/java/io/legado/app/data/repository/TranslationCacheRepositoryImpl.kt create mode 100644 app/src/main/java/io/legado/app/domain/gateway/DictionaryGateway.kt create mode 100644 app/src/main/java/io/legado/app/domain/gateway/LlmGateway.kt create mode 100644 app/src/main/java/io/legado/app/domain/gateway/TranslationCacheGateway.kt create mode 100644 app/src/main/java/io/legado/app/domain/model/ContentChunker.kt create mode 100644 app/src/main/java/io/legado/app/domain/model/PartialTranslationAssembler.kt create mode 100644 app/src/main/java/io/legado/app/domain/model/TranslationConstants.kt create mode 100644 app/src/main/java/io/legado/app/domain/model/TranslationModels.kt create mode 100644 app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt create mode 100644 app/src/main/java/io/legado/app/model/translation/TranslationChapterState.kt create mode 100644 app/src/main/java/io/legado/app/model/translation/TranslationManager.kt create mode 100644 app/src/main/java/io/legado/app/ui/config/translation/TranslationConfig.kt create mode 100644 app/src/main/java/io/legado/app/ui/config/translation/TranslationConfigScreen.kt diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index fe883fa8f..5ba5133f7 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -61,6 +61,9 @@ -keep class * extends io.legado.app.help.JsExtensions{*;} # 数据类 -keep class **.data.entities.**{*;} +# Gson反序列化用的数据传输类 +-keep class io.legado.app.model.translation.**{*;} +-keep class io.legado.app.data.entities.TranslationCache{*;} # hutool-core hutool-crypto -keep class !cn.hutool.core.util.RuntimeUtil, diff --git a/app/src/main/java/io/legado/app/constant/PreferKey.kt b/app/src/main/java/io/legado/app/constant/PreferKey.kt index 8612ca5ad..b6c86ee0e 100644 --- a/app/src/main/java/io/legado/app/constant/PreferKey.kt +++ b/app/src/main/java/io/legado/app/constant/PreferKey.kt @@ -62,7 +62,6 @@ object PreferKey { const val episodeExportFileName = "episodeExportFileName" const val recordLog = "recordLog" const val processText = "process_text" - const val cleanCache = "cleanCache" const val saveTabPosition = "saveTabPosition" const val fontFolder = "fontFolder" const val backupPath = "backupUri" @@ -322,6 +321,18 @@ object PreferKey { const val navIconRss = "navIconRss" const val navIconMy = "navIconMy" + // Translation + const val llmTranslateEnabled = "llmTranslateEnabled" + const val llmProvider = "llmProvider" + const val llmBaseUrl = "llmBaseUrl" + const val llmApiKey = "llmApiKey" + const val llmModel = "llmModel" + const val llmTargetLanguage = "llmTargetLanguage" + const val llmMaxCharsPerChunk = "llmMaxCharsPerChunk" + const val llmConcurrentChunks = "llmConcurrentChunks" + const val llmRetryCount = "llmRetryCount" + const val llmPrompt = "llmPrompt" + const val homepageModuleOrder = "homepageModuleOrder" const val homepageModuleHidden = "homepageModuleHidden" const val homepageLayoutMode = "homepageLayoutMode" diff --git a/app/src/main/java/io/legado/app/data/entities/Book.kt b/app/src/main/java/io/legado/app/data/entities/Book.kt index 3ed364a07..2da0d8f04 100644 --- a/app/src/main/java/io/legado/app/data/entities/Book.kt +++ b/app/src/main/java/io/legado/app/data/entities/Book.kt @@ -293,6 +293,14 @@ data class Book( return this.durChapterIndex } + fun setTranslationMode(enabled: Boolean) { + config.translationMode = enabled + } + + fun getTranslationMode(): Boolean { + return config.translationMode + } + // dailyChapters 的 setter 和 getter fun setDailyChapters(dailyChapters: Int) { config.dailyChapters = dailyChapters @@ -424,7 +432,9 @@ data class Book( var webtoonSidePaddingDp: Int? = null, var mangaBackground: String? = null, - var fixedType: Boolean = false // 固定书籍类型,不随书源更新 + var fixedType: Boolean = false, // 固定书籍类型,不随书源更新 + + var translationMode: Boolean = false // 是否启用翻译阅读模式 ) : Parcelable diff --git a/app/src/main/java/io/legado/app/data/entities/TranslationCache.kt b/app/src/main/java/io/legado/app/data/entities/TranslationCache.kt new file mode 100644 index 000000000..4ac2aa958 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/TranslationCache.kt @@ -0,0 +1,26 @@ +package io.legado.app.data.entities + +/** + * Chunk-level translation cache record stored in .chunks.jsonl files. + * File path already contains bookUrl/chapterIndex/targetLanguage — chunkIndex is the key. + */ +data class TranslationCache( + val chunkIndex: Int, + val originalChunkContent: String, + val translatedChunkContent: String?, + val status: Int = STATUS_PENDING, + val errorMessage: String? = null, + val originalContentHash: String, + val provider: String = "" +) { + companion object { + const val STATUS_PENDING = 0 + const val STATUS_TRANSLATING = 1 + const val STATUS_SUCCESS = 2 + const val STATUS_FAILED = 3 + } + + val isSuccess: Boolean get() = status == STATUS_SUCCESS + val isFailed: Boolean get() = status == STATUS_FAILED + val isPending: Boolean get() = status == STATUS_PENDING +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/repository/DictionaryRepositoryImpl.kt b/app/src/main/java/io/legado/app/data/repository/DictionaryRepositoryImpl.kt new file mode 100644 index 000000000..586008fb3 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/DictionaryRepositoryImpl.kt @@ -0,0 +1,71 @@ +package io.legado.app.data.repository + +import io.legado.app.data.entities.Book +import io.legado.app.domain.gateway.DictionaryGateway +import io.legado.app.domain.model.BookDictionary +import io.legado.app.domain.model.DictPair +import io.legado.app.help.book.BookHelp +import io.legado.app.utils.GSON +import java.io.File + +class DictionaryRepositoryImpl : DictionaryGateway { + + private companion object { + const val DICT_FILE_NAME = "translation_dictionary.json" + } + + private fun getDictFile(book: Book): File { + val cacheDir = BookHelp.cachePath + val bookFolder = File(cacheDir, book.getFolderName()) + return File(bookFolder, DICT_FILE_NAME) + } + + override fun getBookDictionaries(book: Book): BookDictionary { + val dictFile = getDictFile(book) + return if (dictFile.exists()) { + try { + GSON.fromJson(dictFile.readText(), BookDictionary::class.java) + } catch (e: Exception) { + BookDictionary(book.bookUrl) + } + } else { + BookDictionary(book.bookUrl) + } + } + + override fun updateBookDic(book: Book, newPairs: List) { + val existingDict = getBookDictionaries(book) + val updatedPairs = existingDict.pairs.toMutableList() + + for (newPair in newPairs) { + val existingIndex = updatedPairs.indexOfFirst { + it.original == newPair.original + } + if (existingIndex >= 0) { + updatedPairs[existingIndex] = newPair + } else { + updatedPairs.add(newPair) + } + } + + val updatedDict = existingDict.copy( + pairs = updatedPairs, + updatedAt = System.currentTimeMillis() + ) + + saveDictionary(book, updatedDict) + } + + private fun saveDictionary(book: Book, dictionary: BookDictionary) { + val dictFile = getDictFile(book) + dictFile.parentFile?.mkdirs() + dictFile.writeText(GSON.toJson(dictionary)) + } + + override fun clearBookDictionary(book: Book) { + val dictFile = getDictFile(book) + if (dictFile.exists()) { + dictFile.delete() + } + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt b/app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt new file mode 100644 index 000000000..e34d8ad5c --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt @@ -0,0 +1,378 @@ +package io.legado.app.data.repository + +import io.legado.app.domain.gateway.LlmGateway +import io.legado.app.domain.model.DictPair +import io.legado.app.domain.model.RetryReason +import io.legado.app.domain.model.TranslationConstants +import io.legado.app.domain.model.TranslationConstants.OUTPUT_FORMAT +import io.legado.app.help.http.addHeaders +import io.legado.app.help.http.newCallStrResponse +import io.legado.app.help.http.okHttpClient +import io.legado.app.help.http.postJson +import io.legado.app.utils.GSON +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class LlmTranslateRepositoryImpl : LlmGateway { + + /** + * Get display name for a language code, e.g., "zh" -> "简体中文" + */ + private fun getLanguageDisplayName(code: String): String { + return TranslationConstants.targetLanguages.find { it.first == code }?.second ?: code + } + + override suspend fun translate( + text: String, + targetLanguage: String, + provider: String, + baseUrl: String, + apiKey: String, + model: String, + prompt: String, + dictionaries: List, + onUpdate: ((List) -> Unit)?, + retryReason: RetryReason? + ): Result = withContext(Dispatchers.IO) { + // 如果目标语言是英语,且文本已经是英文(英文字符及标点占比超过80%),跳过翻译直接返回原文 + if (targetLanguage == "en" && isMostlyEnglish(text)) { + return@withContext Result.success(text) + } + + // 如果目标语言是其他语言(如中文),且文本已经是该语言,跳过翻译直接返回原文 + if (targetLanguage == "zh" && isMostlyChinese(text)) { + return@withContext Result.success(text) + } + + try { + when (provider) { + TranslationConstants.PROVIDER_GOOGLE -> translateWithGoogle(text, targetLanguage) + TranslationConstants.PROVIDER_OPENAI -> translateWithOpenAI( + text, + targetLanguage, + baseUrl, + apiKey, + model, + prompt, + dictionaries, + onUpdate, + retryReason + ) + + else -> Result.failure(IllegalArgumentException("Unknown provider: $provider")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * 判断文本是否大部分为英文(英文字母及标点符号占比超过80%) + */ + private fun isMostlyEnglish(text: String): Boolean { + if (text.isEmpty()) return false + val englishChars = + text.count { it in 'A'..'Z' || it in 'a'..'z' || it in ".,!?;:'\"-()[]{}–—…" } + return englishChars.toDouble() / text.length > 0.8 + } + + /** + * 判断文本是否大部分为中文(中文及中文标点占比超过80%) + */ + private fun isMostlyChinese(text: String): Boolean { + if (text.isEmpty()) return false + val chinesePunctuation = "。,!?;:“”‘’()【】《》——…" + val chineseChars = text.count { + it in '一'..'鿿' || it in chinesePunctuation + } + return chineseChars.toDouble() / text.length > 0.8 + } + + /** + * 如果翻译结果是中文,删除其中英文含量超过80%的段落(LLM可能生成中英对照内容) + */ + private fun filterHighEnglishParagraphs(text: String): String { + val paragraphs = text.split("\n") + val filtered = paragraphs.filter { paragraph -> + !isMostlyEnglish(paragraph) + } + return filtered.joinToString("\n") + } + + private suspend fun translateWithGoogle(text: String, targetLanguage: String): Result { + val encodedText = java.net.URLEncoder.encode(text, "UTF-8") + val url = + "https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=$targetLanguage&dj=1&dt=t&ie=UTF-8&q=$encodedText" + val response = okHttpClient.newCallStrResponse { + url(url) + } + return if (response.isSuccessful()) { + try { + val json = GSON.fromJson(response.body, GoogleTranslateResponse::class.java) + val translatedText = json.sentences.mapNotNull { it.trans }.joinToString("") + if (translatedText.isNotEmpty()) { + Result.success(translatedText) + } else { + Result.failure(Exception("Empty translation result")) + } + } catch (e: Exception) { + Result.failure(e) + } + } else { + Result.failure(Exception("HTTP ${response.code()}: ${response.message()}")) + } + } + + private suspend fun translateWithOpenAI( + text: String, + targetLanguage: String, + baseUrl: String, + apiKey: String, + model: String, + prompt: String, + dictionaries: List, + onUpdate: ((List) -> Unit)?, + retryReason: RetryReason? + ): Result { + if (baseUrl.isBlank() || apiKey.isBlank() || model.isBlank()) { + return Result.failure(IllegalArgumentException("OpenAI configuration incomplete: baseUrl, apiKey, and model are required")) + } + + // Build dictionary instruction for consistent terminology + val dictionaryInstruction = buildDictionaryInstruction(dictionaries) + + val systemPrompt = + buildSystemPrompt(prompt, targetLanguage, dictionaryInstruction, OUTPUT_FORMAT) + val requestBody = mapOf( + "model" to model, + "messages" to listOf( + mapOf("role" to "system", "content" to systemPrompt), + mapOf("role" to "user", "content" to "Translate the following text:\n\n$text") + ), + "temperature" to 0.8 + ) + val jsonBody = GSON.toJson(requestBody) + val fullUrl = baseUrl.trimEnd('/') + "/v1/chat/completions" + val response = okHttpClient.newCallStrResponse { + url(fullUrl) + postJson(jsonBody) + addHeaders( + mapOf( + "Authorization" to "Bearer $apiKey", + "Content-Type" to "application/json" + ) + ) + } + return if (response.isSuccessful()) { + try { + val json = GSON.fromJson(response.body, OpenAIResponse::class.java) + val rawContent = json.choices.firstOrNull()?.message?.content + if (rawContent != null) { + // Parse the output with [dictionary] and [result] sections + val parseResult = parseLlmOutput(rawContent, dictionaries) + + // Report extracted pairs for dictionary update (max 10) + if (parseResult.extractedPairs.isNotEmpty()) { + val limitedPairs = parseResult.extractedPairs.take(10) + onUpdate?.invoke(limitedPairs) + } + + // Always return the result, even if dictionary parsing failed + val finalText = if (targetLanguage == "zh") { + filterHighEnglishParagraphs(parseResult.translatedText) + } else { + parseResult.translatedText + } + Result.success(finalText) + } else { + Result.failure(Exception("Empty translation result")) + } + } catch (e: Exception) { + Result.failure(e) + } + } else { + val errorMsg = "HTTP ${response.code()}: ${response.message()}" + Result.failure(Exception(errorMsg)) + } + } + + /** + * Result of parsing LLM output + */ + data class ParseOutputResult( + val translatedText: String, + val extractedPairs: List + ) + + /** + * Parse LLM output that should contain [dictionary] and [result] sections. + * Fallback: if parsing fails or format is wrong, still return the result part. + * Don't use regex for parsing. + */ + private fun parseLlmOutput( + rawOutput: String, + existingDictionaries: List + ): ParseOutputResult { + val existingOriginals = existingDictionaries.map { it.original }.toSet() + + var dictionarySection: String? = null + var resultSection: String? = null + + val lines = rawOutput.split('\n') + var currentSection: String? = null + + for (line in lines) { + val trimmedLine = line.trim() + + // Detect section header + when { + trimmedLine.startsWith("[dictionary]", ignoreCase = true) -> { + currentSection = "dictionary" + } + + trimmedLine.startsWith("[result]", ignoreCase = true) -> { + currentSection = "result" + } + + currentSection == "dictionary" -> { + dictionarySection = (dictionarySection ?: "") + line + "\n" + } + + currentSection == "result" -> { + resultSection = (resultSection ?: "") + line + "\n" + } + } + } + + // Extract pairs from dictionary section + val extractedPairs = mutableListOf() + if (dictionarySection != null) { + extractedPairs.addAll(parseDictionarySection(dictionarySection, existingOriginals)) + } + + // Determine translated text + val translatedText = when { + resultSection != null -> resultSection.trim() + else -> rawOutput.trim() // ultimate fallback: return raw output + } + + return ParseOutputResult(translatedText, extractedPairs) + } + + /** + * Parse dictionary section line by line without regex. + * Format: "Original -> Translation" or "Original: Translation" + */ + private fun parseDictionarySection( + section: String, + existingOriginals: Set + ): List { + val pairs = mutableListOf() + val lines = section.split('\n') + + for (line in lines) { + val trimmedLine = line.trim() + if (trimmedLine.isEmpty()) continue + + // Skip section headers + if (trimmedLine.startsWith("[") || trimmedLine.startsWith( + "dictionary", + ignoreCase = true + ) + ) { + continue + } + + // Try to find separator: "->" or ":" + val separator = when { + trimmedLine.contains(" -> ") -> " -> " + trimmedLine.contains(" ->") -> " ->" + trimmedLine.contains("-> ") -> "-> " + trimmedLine.contains("->") -> "->" + trimmedLine.contains(" : ") -> " : " + trimmedLine.contains(": ") -> ": " + trimmedLine.contains(" :") -> " :" + trimmedLine.contains(":") -> ":" + else -> null + } + + if (separator != null) { + val parts = trimmedLine.split(separator, limit = 2) + if (parts.size == 2) { + val original = parts[0].trim() + val translation = parts[1].trim() + + // Skip if already in existing dictionary + if (original !in existingOriginals && original.isNotEmpty() && translation.isNotEmpty()) { + pairs.add(DictPair(original, translation)) + // Max 10 pairs + if (pairs.size >= 10) break + } + } + } + } + + return pairs + } + + /** + * Build a dictionary instruction string for consistent terminology. + */ + private fun buildDictionaryInstruction(dictionaries: List): String { + if (dictionaries.isEmpty()) return "" + + val terms = dictionaries.joinToString("\n") { "${it.original} -> ${it.translation}" } + return """ + +Terminology Dictionary (use these exact translations): +$terms +""" + } + + /** + * Build the full system prompt with dictionary instructions. + */ + private fun buildSystemPrompt( + prompt: String, + targetLanguage: String, + dictionaryInstruction: String, + outputFormat: String, + ): String { + return buildString { + append(prompt) + append("\n Target language: ").append(getLanguageDisplayName(targetLanguage)) + if (dictionaryInstruction.isNotEmpty()) { + append(dictionaryInstruction) + } + append("\n ").append(outputFormat) + } + } +} + +data class GoogleTranslateResponse( + val sentences: List, + val src: String?, + val spell: GoogleSpell? +) + +data class GoogleSentence( + val trans: String?, + val orig: String?, + val backend: Int? +) + +data class GoogleSpell( + val spell: String? +) + +data class OpenAIResponse( + val choices: List +) + +data class OpenAIChoice( + val message: OpenAIMessage +) + +data class OpenAIMessage( + val content: String +) diff --git a/app/src/main/java/io/legado/app/data/repository/TranslationCacheRepositoryImpl.kt b/app/src/main/java/io/legado/app/data/repository/TranslationCacheRepositoryImpl.kt new file mode 100644 index 000000000..c154c1f31 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/TranslationCacheRepositoryImpl.kt @@ -0,0 +1,193 @@ +package io.legado.app.data.repository + +import com.google.gson.Gson +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.TranslationCache +import io.legado.app.domain.gateway.TranslationCacheGateway +import io.legado.app.help.book.BookHelp +import io.legado.app.utils.MD5Utils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +class TranslationCacheRepositoryImpl : TranslationCacheGateway { + + private val cacheDir: File = File(BookHelp.cachePath) + private val gson = Gson() + + override fun getCacheFile(book: Book, bookChapter: BookChapter, targetLanguage: String): File { + val bookFolder = File(cacheDir, book.getFolderName()) + // getFileName() returns "{index}-{titleMD5}.nb", remove .nb to avoid double extension + val chapterFileName = bookChapter.getFileName().removeSuffix(".nb") + val translationFileName = "$chapterFileName.$targetLanguage.nb" + return File(bookFolder, translationFileName) + } + + private fun getChunkFile(book: Book, bookChapter: BookChapter, targetLanguage: String): File { + val bookFolder = File(cacheDir, book.getFolderName()) + val chapterFileName = bookChapter.getFileName().removeSuffix(".nb") + return File(bookFolder, "$chapterFileName.$targetLanguage.chunks.jsonl") + } + + override suspend fun readTranslation( + book: Book, + bookChapter: BookChapter, + targetLanguage: String + ): String? = withContext(Dispatchers.IO) { + val cacheFile = getCacheFile(book, bookChapter, targetLanguage) + if (cacheFile.exists()) { + val content = cacheFile.readText() + content.ifEmpty { null } + } else { + null + } + } + + override suspend fun writeTranslation( + book: Book, + bookChapter: BookChapter, + targetLanguage: String, + content: String + ) = withContext(Dispatchers.IO) { + val cacheFile = getCacheFile(book, bookChapter, targetLanguage) + cacheFile.parentFile?.mkdirs() + cacheFile.writeText(content) + } + + override suspend fun deleteTranslation( + book: Book, + bookChapter: BookChapter, + targetLanguage: String + ) = withContext(Dispatchers.IO) { + val cacheFile = getCacheFile(book, bookChapter, targetLanguage) + cacheFile.delete() + clearChunkCacheForChapter(book, bookChapter, targetLanguage) + Unit + } + + override suspend fun deleteTranslationForBook(book: Book, targetLanguage: String) = withContext(Dispatchers.IO) { + val bookFolder = File(cacheDir, book.getFolderName()) + if (bookFolder.exists()) { + bookFolder.listFiles()?.filter { it.name.endsWith(".$targetLanguage.nb") }?.forEach { it.delete() } + } + clearChunkCacheForBook(book, targetLanguage) + Unit + } + + override suspend fun deleteAllTranslation() = withContext(Dispatchers.IO) { + clearAllChunkCache() + Unit + } + + override fun getTranslationCacheSize(): Long { + var totalSize = 0L + cacheDir.listFiles()?.forEach { bookFolder -> + bookFolder.listFiles()?.filter { it.name.endsWith(".nb") && it.name.contains(".") }?.forEach { file -> + totalSize += file.length() + } + } + return totalSize + } + + override fun computeContentHash(content: String): String { + return MD5Utils.md5Encode(content) + } + + override fun computeCacheKey( + bookUrl: String, + chapterIndex: Int, + chunkIndex: Int, + targetLanguage: String + ): String { + return "${bookUrl}_${chapterIndex}_${chunkIndex}_$targetLanguage" + } + + private suspend fun readAllChunks(book: Book, bookChapter: BookChapter, targetLanguage: String): Map = withContext(Dispatchers.IO) { + val chunkFile = getChunkFile(book, bookChapter, targetLanguage) + if (!chunkFile.exists()) return@withContext emptyMap() + val result = mutableMapOf() + chunkFile.forEachLine { line -> + try { + val chunk = gson.fromJson(line, TranslationCache::class.java) + result[chunk.chunkIndex] = chunk + } catch (_: Exception) { + // skip malformed line + } + } + result + } + + override suspend fun getCachedChunks( + book: Book, + bookChapter: BookChapter, + targetLanguage: String, + contentHash: String + ): List = withContext(Dispatchers.IO) { + val allChunks = readAllChunks(book, bookChapter, targetLanguage) + allChunks.values + .filter { it.originalContentHash == contentHash && it.isSuccess } + .sortedBy { it.chunkIndex } + } + + override suspend fun getCachedChunk( + book: Book, + bookChapter: BookChapter, + targetLanguage: String, + chunkIndex: Int + ): TranslationCache? = withContext(Dispatchers.IO) { + val allChunks = readAllChunks(book, bookChapter, targetLanguage) + allChunks[chunkIndex] + } + + override suspend fun saveChunk( + book: Book, + bookChapter: BookChapter, + targetLanguage: String, + chunkIndex: Int, + originalChunkContent: String, + originalContentHash: String, + provider: String, + status: Int, + translatedContent: String?, + errorMessage: String? + ) = withContext(Dispatchers.IO) { + val chunk = TranslationCache( + chunkIndex = chunkIndex, + originalChunkContent = originalChunkContent, + translatedChunkContent = translatedContent, + status = status, + errorMessage = errorMessage, + originalContentHash = originalContentHash, + provider = provider + ) + val chunkFile = getChunkFile(book, bookChapter, targetLanguage) + chunkFile.parentFile?.mkdirs() + chunkFile.appendText(gson.toJson(chunk) + "\n") + } + + override suspend fun clearChunkCacheForChapter( + book: Book, + bookChapter: BookChapter, + targetLanguage: String + ) = withContext(Dispatchers.IO) { + val chunkFile = getChunkFile(book, bookChapter, targetLanguage) + chunkFile.delete() + Unit + } + + override suspend fun clearChunkCacheForBook(book: Book, targetLanguage: String) = withContext(Dispatchers.IO) { + val bookFolder = File(cacheDir, book.getFolderName()) + if (bookFolder.exists()) { + bookFolder.listFiles()?.filter { it.name.endsWith(".$targetLanguage.chunks.jsonl") }?.forEach { it.delete() } + } + Unit + } + + override suspend fun clearAllChunkCache() = withContext(Dispatchers.IO) { + cacheDir.listFiles()?.forEach { bookFolder -> + bookFolder.listFiles()?.filter { it.name.endsWith(".chunks.jsonl") }?.forEach { it.delete() } + } + Unit + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/di/appDatabaseModule.kt b/app/src/main/java/io/legado/app/di/appDatabaseModule.kt index f5dffb8c2..a99a74df3 100644 --- a/app/src/main/java/io/legado/app/di/appDatabaseModule.kt +++ b/app/src/main/java/io/legado/app/di/appDatabaseModule.kt @@ -2,29 +2,7 @@ package io.legado.app.di import io.legado.app.data.AppDatabase import io.legado.app.data.appDb -import io.legado.app.data.dao.BookChapterDao -import io.legado.app.data.dao.BookDao -import io.legado.app.data.dao.BookGroupDao -import io.legado.app.data.dao.BookSourceDao -import io.legado.app.data.dao.BookmarkDao -import io.legado.app.data.dao.CacheDao -import io.legado.app.data.dao.CookieDao -import io.legado.app.data.dao.DictRuleDao -import io.legado.app.data.dao.HomepageCustomSetDao -import io.legado.app.data.dao.HomepageModuleDao -import io.legado.app.data.dao.HttpTTSDao -import io.legado.app.data.dao.KeyboardAssistsDao -import io.legado.app.data.dao.ReadRecordDao -import io.legado.app.data.dao.ReplaceRuleDao -import io.legado.app.data.dao.RssArticleDao -import io.legado.app.data.dao.RssReadRecordDao -import io.legado.app.data.dao.RssSourceDao -import io.legado.app.data.dao.RssStarDao -import io.legado.app.data.dao.RuleSubDao -import io.legado.app.data.dao.SearchBookDao -import io.legado.app.data.dao.SearchKeywordDao -import io.legado.app.data.dao.ServerDao -import io.legado.app.data.dao.TxtTocRuleDao +import io.legado.app.data.dao.* import org.koin.dsl.module /** diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index d1c75dc89..95dccf0e0 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -18,10 +18,12 @@ import io.legado.app.data.repository.BookshelfRepository import io.legado.app.data.repository.CacheBookDownloadRepository import io.legado.app.data.repository.DatabaseMaintenanceRepository import io.legado.app.data.repository.DictRuleRepository +import io.legado.app.data.repository.DictionaryRepositoryImpl import io.legado.app.data.repository.DirectLinkUploadRepository import io.legado.app.data.repository.ExploreRepository import io.legado.app.data.repository.ExploreRepositoryImpl import io.legado.app.data.repository.HomepageModulesRepository +import io.legado.app.data.repository.LlmTranslateRepositoryImpl import io.legado.app.data.repository.LocalBookRepository import io.legado.app.data.repository.ReadRecordRepository import io.legado.app.data.repository.RemoteBookRepository @@ -30,6 +32,7 @@ import io.legado.app.data.repository.SearchContentRepository import io.legado.app.data.repository.SearchRepository import io.legado.app.data.repository.SearchRepositoryImpl import io.legado.app.data.repository.SettingsRepository +import io.legado.app.data.repository.TranslationCacheRepositoryImpl import io.legado.app.data.repository.UploadRepository import io.legado.app.data.repository.WebDavBackupRepository import io.legado.app.data.repository.WebDavReadingProgressRepository @@ -39,10 +42,13 @@ import io.legado.app.domain.gateway.BookCacheDownloadGateway import io.legado.app.domain.gateway.BookSearchGateway import io.legado.app.domain.gateway.BookSourceCallbackGateway import io.legado.app.domain.gateway.DatabaseMaintenanceGateway +import io.legado.app.domain.gateway.DictionaryGateway import io.legado.app.domain.gateway.ExploreBooksGateway import io.legado.app.domain.gateway.HomepageModulesGateway +import io.legado.app.domain.gateway.LlmGateway import io.legado.app.domain.gateway.LocalBookGateway import io.legado.app.domain.gateway.ReadingProgressGateway +import io.legado.app.domain.gateway.TranslationCacheGateway import io.legado.app.domain.gateway.WebDavBackupGateway import io.legado.app.domain.repository.BookDomainRepository import io.legado.app.domain.usecase.AddBookUseCase @@ -63,6 +69,7 @@ import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase import io.legado.app.domain.usecase.SaveSearchBooksUseCase import io.legado.app.domain.usecase.SearchBooksUseCase import io.legado.app.domain.usecase.ShrinkDatabaseUseCase +import io.legado.app.domain.usecase.TranslateChapterUseCase import io.legado.app.domain.usecase.UpdateBooksGroupUseCase import io.legado.app.domain.usecase.UploadReadingProgressUseCase import io.legado.app.domain.usecase.WebDavBackupUseCase @@ -164,6 +171,7 @@ val appModule = module { singleOf(::BookshelfManageScreenConfig) single { DirectLinkUploadRepository() } + single { TranslationCacheRepositoryImpl() } single { AppStartupRepository(get()) } single { CacheBookDownloadRepository(get()) } single { BookCacheCleanupRepository(get()) } @@ -184,6 +192,9 @@ val appModule = module { single { get() } single { get() } singleOf(::SearchBooksUseCase) + single { LlmTranslateRepositoryImpl() } + single { DictionaryRepositoryImpl() } + singleOf(::TranslateChapterUseCase) single { ImageLoader.Builder(get()) @@ -234,7 +245,14 @@ val appModule = module { viewModelOf(::ServersViewModel) viewModelOf(::BookInfoViewModel) viewModelOf(::ReadMangaViewModel) - viewModelOf(::ReadBookViewModel) + viewModel { + ReadBookViewModel( + application = get(), + getReadingProgressUseCase = get(), + uploadReadingProgressUseCase = get(), + translateChapterUseCase = get() + ) + } viewModelOf(::ChangeCoverViewModel) viewModelOf(::ChangeBookSourceComposeViewModel) viewModelOf(::ChangeBookSourceViewModel) diff --git a/app/src/main/java/io/legado/app/domain/gateway/DictionaryGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/DictionaryGateway.kt new file mode 100644 index 000000000..2fcb219c0 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/gateway/DictionaryGateway.kt @@ -0,0 +1,11 @@ +package io.legado.app.domain.gateway + +import io.legado.app.data.entities.Book +import io.legado.app.domain.model.BookDictionary +import io.legado.app.domain.model.DictPair + +interface DictionaryGateway { + fun getBookDictionaries(book: Book): BookDictionary + fun updateBookDic(book: Book, newPairs: List) + fun clearBookDictionary(book: Book) +} diff --git a/app/src/main/java/io/legado/app/domain/gateway/LlmGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/LlmGateway.kt new file mode 100644 index 000000000..8276c82be --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/gateway/LlmGateway.kt @@ -0,0 +1,19 @@ +package io.legado.app.domain.gateway + +import io.legado.app.domain.model.DictPair +import io.legado.app.domain.model.RetryReason + +interface LlmGateway { + suspend fun translate( + text: String, + targetLanguage: String, + provider: String, + baseUrl: String, + apiKey: String, + model: String, + prompt: String, + dictionaries: List = emptyList(), + onUpdate: ((List) -> Unit)? = null, + retryReason: RetryReason? = null + ): Result +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/domain/gateway/TranslationCacheGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/TranslationCacheGateway.kt new file mode 100644 index 000000000..9f13713fd --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/gateway/TranslationCacheGateway.kt @@ -0,0 +1,70 @@ +package io.legado.app.domain.gateway + +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.TranslationCache +import java.io.File + +interface TranslationCacheGateway { + fun getCacheFile(book: Book, bookChapter: BookChapter, targetLanguage: String): File + suspend fun readTranslation( + book: Book, + bookChapter: BookChapter, + targetLanguage: String + ): String? + + suspend fun writeTranslation( + book: Book, + bookChapter: BookChapter, + targetLanguage: String, + content: String + ) + + suspend fun deleteTranslation(book: Book, bookChapter: BookChapter, targetLanguage: String) + suspend fun deleteTranslationForBook(book: Book, targetLanguage: String) + suspend fun deleteAllTranslation() + fun getTranslationCacheSize(): Long + fun computeContentHash(content: String): String + fun computeCacheKey( + bookUrl: String, + chapterIndex: Int, + chunkIndex: Int, + targetLanguage: String + ): String + + suspend fun getCachedChunks( + book: Book, + bookChapter: BookChapter, + targetLanguage: String, + contentHash: String + ): List + + suspend fun getCachedChunk( + book: Book, + bookChapter: BookChapter, + targetLanguage: String, + chunkIndex: Int + ): TranslationCache? + + suspend fun saveChunk( + book: Book, + bookChapter: BookChapter, + targetLanguage: String, + chunkIndex: Int, + originalChunkContent: String, + originalContentHash: String, + provider: String, + status: Int, + translatedContent: String?, + errorMessage: String? + ) + + suspend fun clearChunkCacheForChapter( + book: Book, + bookChapter: BookChapter, + targetLanguage: String + ) + + suspend fun clearChunkCacheForBook(book: Book, targetLanguage: String) + suspend fun clearAllChunkCache() +} diff --git a/app/src/main/java/io/legado/app/domain/model/ContentChunker.kt b/app/src/main/java/io/legado/app/domain/model/ContentChunker.kt new file mode 100644 index 000000000..179cbb6a9 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/model/ContentChunker.kt @@ -0,0 +1,199 @@ +package io.legado.app.domain.model + +object ContentChunker { + + private val sentenceDelimiters = listOf('。', '!', '?', '.', '!', '?', ';', ';', '\n') + + fun chunk(text: String, maxCharsPerChunk: Int = 3000): List { + if (text.isBlank()) return emptyList() + + val paragraphs = text.split("\n\n").filter { it.isNotBlank() } + if (paragraphs.isEmpty()) return emptyList() + + val chunks = mutableListOf() + var currentChunk = StringBuilder() + var currentParagraphIndices = mutableListOf() + var currentChunkParagraphCount = 0 + + for ((paragraphIndex, paragraph) in paragraphs.withIndex()) { + if (currentChunk.isEmpty()) { + currentChunk.append(paragraph) + currentParagraphIndices.add(paragraphIndex) + currentChunkParagraphCount = 1 + } else if (currentChunk.length + paragraph.length + 2 <= maxCharsPerChunk) { + currentChunk.append("\n\n").append(paragraph) + currentParagraphIndices.add(paragraphIndex) + currentChunkParagraphCount++ + } else { + if (currentChunk.isNotEmpty()) { + chunks.add( + TextChunk( + chunks.size, + currentChunk.toString(), + currentParagraphIndices.toList() + ) + ) + } + currentChunk = StringBuilder(paragraph) + currentParagraphIndices = mutableListOf(paragraphIndex) + currentChunkParagraphCount = 1 + } + } + + if (currentChunk.isNotEmpty()) { + chunks.add( + TextChunk( + chunks.size, + currentChunk.toString(), + currentParagraphIndices.toList() + ) + ) + } + + val result = mutableListOf() + for (chunk in chunks) { + if (chunk.content.length > maxCharsPerChunk) { + result.addAll(splitOversizedChunk(chunk, maxCharsPerChunk)) + } else { + result.add(chunk) + } + } + + return result.mapIndexed { index, chunk -> chunk.copy(index = index) } + } + + private fun splitOversizedChunk(chunk: TextChunk, maxCharsPerChunk: Int): List { + val result = mutableListOf() + val paragraphs = chunk.content.split("\n\n") + var currentSubChunk = StringBuilder() + var currentParagraphIndices = mutableListOf() + var subChunkIndex = chunk.index * 100 + + for ((idx, paragraph) in paragraphs.withIndex()) { + if (paragraph.length > maxCharsPerChunk) { + if (currentSubChunk.isNotEmpty()) { + result.add( + TextChunk( + subChunkIndex++, + currentSubChunk.toString(), + currentParagraphIndices.toList() + ) + ) + currentSubChunk = StringBuilder() + currentParagraphIndices = mutableListOf() + } + result.addAll( + splitOversizedParagraph( + paragraph, + chunk.paragraphIndices.getOrElse(idx) { idx }, + subChunkIndex, + maxCharsPerChunk + ) + ) + subChunkIndex += 100 + } else if (currentSubChunk.length + paragraph.length + 2 <= maxCharsPerChunk) { + currentParagraphIndices.add(chunk.paragraphIndices.getOrElse(idx) { idx }) + if (currentSubChunk.isNotEmpty()) { + currentSubChunk.append("\n\n") + } + currentSubChunk.append(paragraph) + } else { + if (currentSubChunk.isNotEmpty()) { + result.add( + TextChunk( + subChunkIndex++, + currentSubChunk.toString(), + currentParagraphIndices.toList() + ) + ) + } + currentSubChunk = StringBuilder(paragraph) + currentParagraphIndices = + mutableListOf(chunk.paragraphIndices.getOrElse(idx) { idx }) + } + } + + if (currentSubChunk.isNotEmpty()) { + result.add( + TextChunk( + subChunkIndex, + currentSubChunk.toString(), + currentParagraphIndices.toList() + ) + ) + } + + return result + } + + private fun splitOversizedParagraph( + paragraph: String, + paragraphIndex: Int, + startIndex: Int, + maxCharsPerChunk: Int + ): List { + val result = mutableListOf() + val sentences = mutableListOf() + var currentSentence = StringBuilder() + + for (char in paragraph) { + currentSentence.append(char) + if (char in sentenceDelimiters) { + sentences.add(currentSentence.toString()) + currentSentence = StringBuilder() + } + } + if (currentSentence.isNotEmpty()) { + sentences.add(currentSentence.toString()) + } + + var currentChunk = StringBuilder() + var currentChunkSentences = 0 + var subIndex = startIndex + + for (sentence in sentences) { + if (currentChunk.length + sentence.length <= maxCharsPerChunk) { + currentChunk.append(sentence) + currentChunkSentences++ + } else { + if (currentChunk.isNotEmpty()) { + result.add( + TextChunk( + subIndex++, + currentChunk.toString(), + listOf(paragraphIndex) + ) + ) + currentChunkSentences = 0 + } + if (sentence.length > maxCharsPerChunk) { + var remaining = sentence + while (remaining.length > maxCharsPerChunk) { + result.add( + TextChunk( + subIndex++, + remaining.substring(0, maxCharsPerChunk), + listOf(paragraphIndex) + ) + ) + remaining = remaining.substring(maxCharsPerChunk) + } + currentChunk = StringBuilder(remaining) + } else { + currentChunk = StringBuilder(sentence) + currentChunkSentences = 1 + } + } + } + + if (currentChunk.isNotEmpty()) { + result.add(TextChunk(subIndex, currentChunk.toString(), listOf(paragraphIndex))) + } + + return result + } + + fun merge(chunks: List): String { + return chunks.sortedBy { it.index }.joinToString("\n\n") { it.content } + } +} diff --git a/app/src/main/java/io/legado/app/domain/model/PartialTranslationAssembler.kt b/app/src/main/java/io/legado/app/domain/model/PartialTranslationAssembler.kt new file mode 100644 index 000000000..bdc07b8fa --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/model/PartialTranslationAssembler.kt @@ -0,0 +1,46 @@ +package io.legado.app.domain.model + +/** + * Assembles mixed original/translated content as translation progresses. + * Uses original chunk order and replaces chunks with translations when available. + */ +object PartialTranslationAssembler { + + /** + * Assemble mixed content from original chunks and translated portions. + * + * @param originalChunks The original text chunks in order + * @param translatedMap Map of chunk index to translated content + * @return Mixed content string with translated chunks replacing originals + */ + fun assemble(originalChunks: List, translatedMap: Map): String { + if (originalChunks.isEmpty()) return "" + + val result = StringBuilder() + for ((index, chunk) in originalChunks.withIndex()) { + val translated = translatedMap[chunk.index] + val content = translated ?: chunk.content + + if (result.isNotEmpty()) { + result.append("\n\n") + } + result.append(content) + } + + return result.toString() + } + + /** + * Check if we have any translations yet. + */ + fun hasPartialTranslation(translatedMap: Map): Boolean { + return translatedMap.isNotEmpty() + } + + /** + * Get the count of translated chunks vs total. + */ + fun progress(translatedMap: Map, totalChunks: Int): Pair { + return Pair(translatedMap.size, totalChunks) + } +} diff --git a/app/src/main/java/io/legado/app/domain/model/TranslationConstants.kt b/app/src/main/java/io/legado/app/domain/model/TranslationConstants.kt new file mode 100644 index 000000000..8adce073a --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/model/TranslationConstants.kt @@ -0,0 +1,50 @@ +package io.legado.app.domain.model + +object TranslationConstants { + + const val PROVIDER_OPENAI = "openai" + const val PROVIDER_GOOGLE = "google" + + val providerDisplayNames = listOf("Google Translate", "OpenAI适配接口") + val providerValues = listOf(PROVIDER_GOOGLE, PROVIDER_OPENAI) + + val targetLanguages = listOf( + "zh" to "简体中文", + "en" to "English", + "ja" to "日本語", + "ko" to "한국어", + "fr" to "Français", + "de" to "Deutsch", + "es" to "Español", + "ru" to "Русский", + "ar" to "العربية" + ) + + const val DEFAULT_PROMPT = + """You are a professional literary translator, please translate according to the following requirements: + +1. Keep the original paragraph count and order unchanged +2. Maintain the literary style and tone of the original text +3. Do not summarize, condense, or omit any content +4. Only output the translation result, do not add comments or explanations +5. Keep name consistency across abbreviations/nicknames (e.g., Alexander → Alex → same name). Add nickname mapping to dictionary. + +""" + + const val OUTPUT_FORMAT = """Output is divided into two parts: + +**New** proper nouns, place names that need to be recorded for context, and the translation result. + +Only select the most common and important terms (max 10) to include in the dictionary. + +Output format as follows, IMPORTANT, **dictionary** part must begin with english word **[dictionary]**, MUST NOT start with any other words. **result** part must begin with english word **[result]**, MUST NOT start with any other words: + +[dictionary] +Jack -> 杰克 +Harry Port -> 哈利波特 + +[result] +... + + """ +} diff --git a/app/src/main/java/io/legado/app/domain/model/TranslationModels.kt b/app/src/main/java/io/legado/app/domain/model/TranslationModels.kt new file mode 100644 index 000000000..741e6ae66 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/model/TranslationModels.kt @@ -0,0 +1,61 @@ +package io.legado.app.domain.model + +/** + * A pair of original text and its translation, used for maintaining + * consistent terminology across multiple translation chunks. + */ +data class DictPair( + val original: String, + val translation: String +) + +/** + * Collection of dictionary pairs with metadata. + */ +data class BookDictionary( + val bookUrl: String, + val pairs: List = emptyList(), + val updatedAt: Long = System.currentTimeMillis() +) + +/** + * A chunk of text for translation, with its index and paragraph mapping. + */ +data class TextChunk( + val index: Int, + val content: String, + val paragraphIndices: List +) + +/** + * Reasons for retrying a translation request. + * Used to analyze failures and decide appropriate retry strategies. + */ +enum class RetryReason { + /** Network connectivity issues */ + NETWORK_ERROR, + + /** API returned rate limit error (429) */ + RATE_LIMIT, + + /** API returned server error (5xx) */ + SERVER_ERROR, + + /** API returned authentication/permission error (401, 403) */ + AUTH_ERROR, + + /** Request timeout */ + TIMEOUT, + + /** API returned empty response */ + EMPTY_RESPONSE, + + /** Malformed response that couldn't be parsed */ + PARSE_ERROR, + + /** Unknown error that might be transient */ + UNKNOWN, + + /** No retry needed, permanent failure */ + PERMANENT_FAILURE +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt new file mode 100644 index 000000000..92c9f7225 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt @@ -0,0 +1,275 @@ +package io.legado.app.domain.usecase + +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.TranslationCache +import io.legado.app.domain.gateway.DictionaryGateway +import io.legado.app.domain.gateway.LlmGateway +import io.legado.app.domain.gateway.TranslationCacheGateway +import io.legado.app.domain.model.ContentChunker +import io.legado.app.domain.model.DictPair +import io.legado.app.domain.model.PartialTranslationAssembler +import io.legado.app.domain.model.RetryReason +import io.legado.app.domain.model.TextChunk +import io.legado.app.help.book.BookHelp +import io.legado.app.ui.config.translation.TranslationConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext + +class TranslateChapterUseCase( + private val llmGateway: LlmGateway, + private val translationCacheGateway: TranslationCacheGateway, + private val dictionaryGateway: DictionaryGateway +) { + + data class TranslationProgress( + val currentChunk: Int, + val totalChunks: Int, + val mixedContent: String? = null, + val translatedChunkIndices: Set = emptySet() + ) + + companion object { + private const val MAX_DICTIONARY_PAIRS = 50 + } + + private val dictionaryLock = Any() + + suspend fun execute( + book: Book, + bookChapter: BookChapter, + targetLanguage: String, + onProgress: (TranslationProgress) -> Unit, + onTranslateStarted: () -> Unit + ): Result = withContext(Dispatchers.IO) { + val originalContent = BookHelp.getContent(book, bookChapter) + ?: return@withContext Result.failure(Exception("Failed to read original content")) + + val cachedTranslation = + translationCacheGateway.readTranslation(book, bookChapter, targetLanguage) + if (cachedTranslation != null) { + onProgress(TranslationProgress(1, 1, cachedTranslation, emptySet())) + return@withContext Result.success(cachedTranslation) + } + + val contentHash = translationCacheGateway.computeContentHash(originalContent) + + // Load book dictionary for consistent terminology + val bookDictionary = dictionaryGateway.getBookDictionaries(book) + val dictionaries = bookDictionary.pairs.toMutableList() + + // Callback to update dictionary pairs immediately (persist as soon as discovered) + val onDictionaryUpdate: (List) -> Unit = { newPairs -> + val merged = synchronized(dictionaryLock) { + mergeDictionaryPairs(dictionaries, newPairs) + } + if (merged) { + synchronized(dictionaryLock) { + dictionaryGateway.updateBookDic(book, dictionaries.toList()) + } + } + } + + val chunks = ContentChunker.chunk(originalContent, TranslationConfig.llmMaxCharsPerChunk) + if (chunks.isEmpty()) { + return@withContext Result.failure(Exception("Failed to chunk content")) + } + + val cachedChunks = + translationCacheGateway.getCachedChunks(book, bookChapter, targetLanguage, contentHash) + val cachedChunkMap = cachedChunks.filter { it.isSuccess }.associateBy { it.chunkIndex } + + val translatedChunks = mutableMapOf() + val pendingChunks = mutableListOf() + + // Load already cached chunks + for (chunk in chunks) { + val cached = cachedChunkMap[chunk.index] + if (cached != null && cached.translatedChunkContent != null) { + translatedChunks[chunk.index] = cached.translatedChunkContent + } else { + pendingChunks.add(chunk) + } + } + + // If we have partial cached chunks, report initial mixed content + if (translatedChunks.isNotEmpty()) { + val mixedContent = PartialTranslationAssembler.assemble(chunks, translatedChunks) + onProgress(TranslationProgress( + translatedChunks.size, + chunks.size, + mixedContent, + translatedChunks.keys + )) + } + + if (pendingChunks.isEmpty()) { + val sortedChunks = chunks.sortedBy { it.index }.mapNotNull { translatedChunks[it.index]?.let { content -> TextChunk(it.index, content, it.paragraphIndices) } } + val mergedContent = ContentChunker.merge(sortedChunks) + translationCacheGateway.writeTranslation( + book, + bookChapter, + targetLanguage, + mergedContent + ) + onProgress(TranslationProgress(chunks.size, chunks.size, mergedContent, chunks.map { it.index }.toSet())) + return@withContext Result.success(mergedContent) + } + + onTranslateStarted() + coroutineScope { + val concurrentChunks = TranslationConfig.llmConcurrentChunks.coerceIn(1, 4) + val chunkGroups = pendingChunks.chunked(concurrentChunks) + + for ((groupIndex, group) in chunkGroups.withIndex()) { + val results = group.map { chunk -> + async { + translateAndCacheChunk(chunk, book, bookChapter, targetLanguage, contentHash, dictionaries, onDictionaryUpdate) + } + }.awaitAll() + + for ((chunk, result) in group.zip(results)) { + if (result.isSuccess) { + translatedChunks[chunk.index] = result.getOrThrow() + val mixedContent = PartialTranslationAssembler.assemble(chunks, translatedChunks) + onProgress(TranslationProgress( + translatedChunks.size + cachedChunkMap.size, + chunks.size, + mixedContent, + translatedChunks.keys + )) + } else { + val error = result.exceptionOrNull() ?: Exception("Translation failed") + return@coroutineScope + } + } + } + } + + if (translatedChunks.size != chunks.size) { + return@withContext Result.failure(Exception("Translation incomplete")) + } + + val allTranslatedChunks = chunks.sortedBy { it.index }.mapNotNull { chunk -> + translatedChunks[chunk.index]?.let { content -> TextChunk(chunk.index, content, chunk.paragraphIndices) } + } + val mergedContent = ContentChunker.merge(allTranslatedChunks) + translationCacheGateway.writeTranslation(book, bookChapter, targetLanguage, mergedContent) + + onProgress(TranslationProgress(chunks.size, chunks.size, mergedContent, chunks.map { it.index }.toSet())) + Result.success(mergedContent) + } + + /** + * Merge new pairs into existing list: + * - If original exists, replace the translation + * - If new, add to list + * - Keep at most MAX_DICTIONARY_PAIRS + * @return true if any changes were made + */ + private fun mergeDictionaryPairs(existing: MutableList, newPairs: List): Boolean { + var changed = false + for (newPair in newPairs) { + val existingIndex = existing.indexOfFirst { it.original == newPair.original } + if (existingIndex >= 0) { + if (existing[existingIndex].translation != newPair.translation) { + existing[existingIndex] = newPair + changed = true + } + } else { + existing.add(newPair) + changed = true + } + } + + // Keep only the most recent MAX_DICTIONARY_PAIRS + if (existing.size > MAX_DICTIONARY_PAIRS) { + val trimmed = existing.takeLast(MAX_DICTIONARY_PAIRS) + existing.clear() + existing.addAll(trimmed) + changed = true + } + return changed + } + + private suspend fun translateAndCacheChunk( + chunk: TextChunk, + book: Book, + bookChapter: BookChapter, + targetLanguage: String, + contentHash: String, + dictionaries: MutableList, + onDictionaryUpdate: (List) -> Unit + ): Result { + val existingCache = + translationCacheGateway.getCachedChunk(book, bookChapter, targetLanguage, chunk.index) + if (existingCache?.isSuccess == true && existingCache.translatedChunkContent != null) { + return Result.success(existingCache.translatedChunkContent) + } + + val result = translateChunkWithRetry(chunk, targetLanguage, dictionaries, onDictionaryUpdate) + if (result.isSuccess) { + translationCacheGateway.saveChunk( + book, bookChapter, targetLanguage, + chunk.index, chunk.content, contentHash, + TranslationConfig.llmProvider, + TranslationCache.STATUS_SUCCESS, result.getOrThrow(), null + ) + } else { + val errorMessage = result.exceptionOrNull()?.message ?: "Translation failed" + translationCacheGateway.saveChunk( + book, bookChapter, targetLanguage, + chunk.index, chunk.content, contentHash, + TranslationConfig.llmProvider, + TranslationCache.STATUS_FAILED, null, errorMessage + ) + } + return result + } + + private suspend fun translateChunkWithRetry( + chunk: TextChunk, + targetLanguage: String, + dictionaries: MutableList, + onDictionaryUpdate: (List) -> Unit + ): Result { + var lastError: Exception? = null + var lastRetryReason: RetryReason? = null + for (attempt in 0..TranslationConfig.llmRetryCount) { + val dictSnapshot = synchronized(dictionaryLock) { dictionaries.toList() } + val result = llmGateway.translate( + text = chunk.content, + targetLanguage = targetLanguage, + provider = TranslationConfig.llmProvider, + baseUrl = TranslationConfig.llmBaseUrl, + apiKey = TranslationConfig.llmApiKey, + model = TranslationConfig.llmModel, + prompt = TranslationConfig.llmPrompt, + dictionaries = dictSnapshot, + onUpdate = onDictionaryUpdate, + retryReason = lastRetryReason + ) + if (result.isSuccess) { + return result + } + lastError = result.exceptionOrNull() as? Exception + lastRetryReason = parseRetryReason(lastError) + } + return Result.failure(lastError ?: Exception("Translation failed after retries")) + } + + private fun parseRetryReason(error: Exception?): RetryReason? { + val message = error?.message ?: return null + return when { + message.contains("429") -> RetryReason.RATE_LIMIT + message.contains("500") || message.contains("502") || message.contains("503") || message.contains("504") -> RetryReason.SERVER_ERROR + message.contains("401") || message.contains("403") -> RetryReason.AUTH_ERROR + message.contains("timeout", ignoreCase = true) -> RetryReason.TIMEOUT + message.contains("HTTP") -> RetryReason.UNKNOWN + else -> null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/ReadBook.kt b/app/src/main/java/io/legado/app/model/ReadBook.kt index ea7c6f405..367cb8250 100644 --- a/app/src/main/java/io/legado/app/model/ReadBook.kt +++ b/app/src/main/java/io/legado/app/model/ReadBook.kt @@ -26,6 +26,10 @@ import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.globalExecutor import io.legado.app.model.localBook.TextFile +import io.legado.app.model.translation.TranslationChapterState +import io.legado.app.model.translation.TranslationChapterStatus +import io.legado.app.model.translation.TranslationManager +import kotlinx.coroutines.flow.MutableStateFlow import io.legado.app.model.webBook.WebBook import io.legado.app.service.BaseReadAloudService import io.legado.app.service.CacheBookService @@ -84,6 +88,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent { private val loadingChapters = arrayListOf() private val readRecord = ReadRecord() private val chapterLoadingJobs = ConcurrentHashMap>() + private val translationObserverJobs = ConcurrentHashMap() private val prevChapterLoadingLock = Mutex() private val curChapterLoadingLock = Mutex() private val nextChapterLoadingLock = Mutex() @@ -242,11 +247,20 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent { fun clearTextChapter() { clearExpiredChapterLoadingJob(true) + clearTranslationObserverJobs() prevTextChapter = null curTextChapter = null nextTextChapter = null } + private fun clearTranslationObserverJobs() { + translationObserverJobs.entries.filter { it.key !in durChapterIndex - 1..durChapterIndex + 1 } + .forEach { (index, job) -> + job.cancel() + translationObserverJobs.remove(index) + } + } + fun clearSearchResult() { curTextChapter?.clearSearchResult() prevTextChapter?.clearSearchResult() @@ -672,7 +686,18 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent { return@async } if (addLoading(index)) { - BookHelp.getContent(book, chapter)?.let { + val content = if (book.getTranslationMode()) { + TranslationManager.getCachedTranslation(book, chapter) + ?: run { + TranslationManager.startTranslation(book, chapter)?.let { taskFlow -> + startTranslationObserver(taskFlow, book, chapter) + } + BookHelp.getContent(book, chapter) + } + } else { + BookHelp.getContent(book, chapter) + } + content?.let { contentLoadFinish( book, chapter, @@ -706,7 +731,17 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent { try { val book = book!! val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index)!! - val content = BookHelp.getContent(book, chapter) ?: downloadAwait(chapter) + val content = if (book.getTranslationMode()) { + TranslationManager.getCachedTranslation(book, chapter) + ?: run { + TranslationManager.startTranslation(book, chapter)?.let { taskFlow -> + startTranslationObserver(taskFlow, book, chapter) + } + BookHelp.getContent(book, chapter) ?: downloadAwait(chapter) + } + } else { + BookHelp.getContent(book, chapter) ?: downloadAwait(chapter) + } contentLoadFinishAwait(book, chapter, content, upContent, resetPageOffset) success?.invoke() } catch (e: Exception) { @@ -779,6 +814,34 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent { } } + /** + * Start observing a translation task for real-time UI updates. + * Collects mixedContent updates and calls contentLoadFinish to refresh the page. + * The observer stops automatically when translation completes or fails. + */ + private fun startTranslationObserver(taskFlow: MutableStateFlow, book: Book, chapter: BookChapter) { + val chapterIndex = chapter.index + translationObserverJobs[chapterIndex]?.cancel() + + val job = launch { + taskFlow.collect { state -> + when (state.status) { + TranslationChapterStatus.Translating -> { + state.mixedContent?.let { mixed -> + contentLoadFinish(book, chapter, mixed, upContent = true, resetPageOffset = false) + } + } + else -> { + // no-op + } + } + } + // Clean up when coroutine finishes + translationObserverJobs.remove(chapterIndex) + } + translationObserverJobs[chapterIndex] = job + } + @Synchronized private fun addLoading(index: Int): Boolean { if (loadingChapters.contains(index)) return false diff --git a/app/src/main/java/io/legado/app/model/translation/TranslationChapterState.kt b/app/src/main/java/io/legado/app/model/translation/TranslationChapterState.kt new file mode 100644 index 000000000..d19e1750c --- /dev/null +++ b/app/src/main/java/io/legado/app/model/translation/TranslationChapterState.kt @@ -0,0 +1,34 @@ +package io.legado.app.model.translation + +/** + * Key for per-chapter translation display state. + * Used as task key for looking up ongoing translation tasks in TranslationManager. + */ +data class TranslationChapterKey( + val bookUrl: String, + val chapterIndex: Int +) + +/** + * Per-chapter translation status. + */ +enum class TranslationChapterStatus { + Idle, + Translating, + Translated, + Failed +} + +/** + * Per-chapter translation state stored in TranslationManager. + * Runtime-only, derived from translation cache on app restart. + */ +data class TranslationChapterState( + val key: TranslationChapterKey, + val status: TranslationChapterStatus = TranslationChapterStatus.Idle, + val currentChunk: Int = 0, + val totalChunks: Int = 0, + val mixedContent: String? = null, + val translatedContent: String? = null, + val errorMessage: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/translation/TranslationManager.kt b/app/src/main/java/io/legado/app/model/translation/TranslationManager.kt new file mode 100644 index 000000000..f37c295c9 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/translation/TranslationManager.kt @@ -0,0 +1,179 @@ +package io.legado.app.model.translation + +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.domain.gateway.TranslationCacheGateway +import io.legado.app.domain.usecase.TranslateChapterUseCase +import io.legado.app.help.book.BookHelp +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.ui.config.translation.TranslationConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.util.concurrent.ConcurrentHashMap + +object TranslationManager : KoinComponent { + + private val translationCacheGateway: TranslationCacheGateway by inject() + private val translateChapterUseCase: TranslateChapterUseCase by inject() + + /** Per-chapter task state flows: bookUrl+chapterIndex -> StateFlow (only for in-progress tasks) */ + private val _taskStateFlows = + ConcurrentHashMap>() + + private fun getChapterKey(book: Book, chapter: BookChapter): TranslationChapterKey { + return TranslationChapterKey(book.bookUrl, chapter.index) + } + + /** + * Get task StateFlow for a chapter if translation is in progress. + * Returns null if no in-progress translation exists. + */ + fun getChapterTaskStateFlow(bookUrl: String, chapterIndex: Int): StateFlow? { + val key = TranslationChapterKey(bookUrl, chapterIndex) + return _taskStateFlows[key]?.takeIf { it.value.status == TranslationChapterStatus.Translating } + } + + /** + * Check if translated cache file exists for a chapter. + */ + fun hasTranslatedCache(book: Book, chapter: BookChapter): Boolean { + val cacheFile = + translationCacheGateway.getCacheFile(book, chapter, TranslationConfig.llmTargetLanguage) + return cacheFile.exists() + } + + /** + * Get finished cached translation for a chapter. + */ + fun getCachedTranslation(book: Book, chapter: BookChapter): String? { + val cacheFile = + translationCacheGateway.getCacheFile(book, chapter, TranslationConfig.llmTargetLanguage) + return if (cacheFile.exists()) cacheFile.readText() else null + } + + /** + * Start translation for a chapter. + * - If translation is already in progress, return existing task flow. + * - If cache already exists, do nothing and return null. + * - If original content doesn't exist, return null. + * The returned flow updates with mixedContent during translation. + */ + @Synchronized + fun startTranslation( + book: Book, + chapter: BookChapter, + onTranslateStarted: () -> Unit = {} + ): MutableStateFlow? { + val key = getChapterKey(book, chapter) + + // Check if already translating + _taskStateFlows[key]?.let { taskFlow -> + if (taskFlow.value.status == TranslationChapterStatus.Translating) { + return taskFlow + } + } + + // Skip if cache already exists + if (hasTranslatedCache(book, chapter)) { + return null + } + + // Check if original content exists + if (BookHelp.getContent(book, chapter) == null) { + return null + } + + // Create new task flow + val taskFlow = MutableStateFlow(TranslationChapterState(key, status = TranslationChapterStatus.Idle)) + _taskStateFlows[key] = taskFlow + + // Start translation in background + Coroutine.async { + translateChapter(book, chapter, onTranslateStarted) + } + + return taskFlow + } + + private suspend fun translateChapter( + book: Book, + bookChapter: BookChapter, + onTranslateStarted: () -> Unit + ) = withContext(Dispatchers.IO) { + val key = getChapterKey(book, bookChapter) + val taskFlow = _taskStateFlows[key] ?: return@withContext + + taskFlow.update { it.copy(status = TranslationChapterStatus.Translating) } + + val result = translateChapterUseCase.execute( + book = book, + bookChapter = bookChapter, + targetLanguage = TranslationConfig.llmTargetLanguage, + onProgress = { progress -> + taskFlow.update { + it.copy( + currentChunk = progress.currentChunk, + totalChunks = progress.totalChunks, + mixedContent = progress.mixedContent + ) + } + }, + onTranslateStarted = onTranslateStarted + ) + + result.onSuccess { content -> + taskFlow.update { + it.copy( + status = TranslationChapterStatus.Translated, + translatedContent = content, + mixedContent = null + ) + } + }.onFailure { error -> + taskFlow.update { + it.copy( + status = TranslationChapterStatus.Failed, + errorMessage = error.message ?: "Translation failed" + ) + } + } + } + + /** + * Clear state for a single chapter. + */ + fun clearChapterState(bookUrl: String, chapterIndex: Int) { + val key = TranslationChapterKey(bookUrl, chapterIndex) + _taskStateFlows.remove(key) + } + + /** + * Clear all chapter states. + */ + fun clearAllChapterStates() { + _taskStateFlows.clear() + } + + /** + * Delete translation cache and state for a chapter. + */ + suspend fun deleteTranslationCache(book: Book, bookChapter: BookChapter) { + translationCacheGateway.deleteTranslation( + book, + bookChapter, + TranslationConfig.llmTargetLanguage + ) + translationCacheGateway.clearChunkCacheForChapter( + book, + bookChapter, + TranslationConfig.llmTargetLanguage + ) + clearChapterState(book.bookUrl, bookChapter.index) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/ExportBookService.kt b/app/src/main/java/io/legado/app/service/ExportBookService.kt index fde49a920..92224ac31 100644 --- a/app/src/main/java/io/legado/app/service/ExportBookService.kt +++ b/app/src/main/java/io/legado/app/service/ExportBookService.kt @@ -16,6 +16,7 @@ import io.legado.app.constant.NotificationId import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter +import io.legado.app.domain.gateway.TranslationCacheGateway import io.legado.app.exception.NoStackTraceException import io.legado.app.help.AppWebDav import io.legado.app.help.book.BookHelp @@ -25,6 +26,7 @@ import io.legado.app.help.book.isLocalModified import io.legado.app.help.config.AppConfig import io.legado.app.model.ReadBook import io.legado.app.model.localBook.LocalBook +import io.legado.app.ui.config.translation.TranslationConfig import io.legado.app.ui.main.MainActivity import io.legado.app.utils.FileDoc import io.legado.app.utils.FileUtils @@ -66,6 +68,8 @@ import me.ag2s.epublib.domain.TOCReference import me.ag2s.epublib.epub.EpubWriter import me.ag2s.epublib.epub.EpubWriterProcessor import me.ag2s.epublib.util.ResourceUtil +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import splitties.init.appCtx import splitties.systemservices.notificationManager import java.nio.charset.Charset @@ -76,7 +80,7 @@ import kotlin.math.min /** * 导出书籍服务 */ -class ExportBookService : BaseService() { +class ExportBookService : BaseService(), KoinComponent { companion object { val exportProgress = ConcurrentHashMap() @@ -92,6 +96,16 @@ class ExportBookService : BaseService() { val epubScope: String? = null ) + /** + * Content source for export - Original or Translation with target language. + */ + private enum class ContentSource { + Original, + Translation + } + + private val translationCacheRepository: TranslationCacheGateway by inject() + private val groupKey = "${appCtx.packageName}.exportBook" private val waitExportBooks = linkedMapOf() private var exportJob: Job? = null @@ -199,6 +213,10 @@ class ExportBookService : BaseService() { if (exportConfig.type == "epub") { if (exportConfig.epubScope.isNullOrBlank()) { exportEpub(exportConfig.path, book) + // Also export translation if cache exists + if (hasAnyTranslatedChapter(book, TranslationConfig.llmTargetLanguage)) { + exportEpub(exportConfig.path, book, ContentSource.Translation) + } } else { CustomExporter( exportConfig.epubScope, @@ -207,6 +225,11 @@ class ExportBookService : BaseService() { } } else { exportTxt(exportConfig.path, book) + // Also export translation if cache exists + if (hasAnyTranslatedChapter(book, TranslationConfig.llmTargetLanguage)) { + val fileDoc = FileDoc.fromDir(exportConfig.path) + exportTxt(fileDoc, book, ContentSource.Translation) + } } exportMsg[book.bookUrl] = getString(R.string.export_success) } catch (e: Throwable) { @@ -245,29 +268,36 @@ class ExportBookService : BaseService() { exportMsg.remove(book.bookUrl) notifyExportBookChanged(book.bookUrl) val fileDoc = FileDoc.fromDir(path) - exportTxt(fileDoc, book) + exportTxt(fileDoc, book, ContentSource.Original) } - private suspend fun exportTxt(fileDoc: FileDoc, book: Book) { - val filename = book.getExportFileName("txt") + private suspend fun exportTxt(fileDoc: FileDoc, book: Book, source: ContentSource) { + val targetLanguage = TranslationConfig.llmTargetLanguage + val filename = when (source) { + ContentSource.Original -> book.getExportFileName("txt") + ContentSource.Translation -> getTranslatedFileName(book.getExportFileName("txt"), targetLanguage) + } fileDoc.find(filename)?.delete() val bookDoc = fileDoc.createFileIfNotExist(filename) val charset = Charset.forName(AppConfig.exportCharset) bookDoc.openOutputStream().getOrThrow().bufferedWriter(charset).use { bw -> - getAllContents(book) { text, srcList -> + getAllContents(book, source) { text, srcList -> bw.write(text) - srcList?.forEach { - val vFile = BookHelp.getImage(book, it.src) - if (vFile.exists()) { - fileDoc.createFileIfNotExist( - "${it.index}-${MD5Utils.md5Encode16(it.src)}.jpg", - subDirs = arrayOf( - "${book.name}_${book.author}", - "images", - it.chapterTitle - ) - ).writeFile(vFile) + // Only export images for original source + if (source == ContentSource.Original) { + srcList?.forEach { + val vFile = BookHelp.getImage(book, it.src) + if (vFile.exists()) { + fileDoc.createFileIfNotExist( + "${it.index}-${MD5Utils.md5Encode16(it.src)}.jpg", + subDirs = arrayOf( + "${book.name}_${book.author}", + "images", + it.chapterTitle + ) + ).writeFile(vFile) + } } } } @@ -278,8 +308,32 @@ class ExportBookService : BaseService() { } } + /** + * Get translated filename by inserting target language before extension. + * e.g., "book.txt" -> "book.zh.txt" + */ + private fun getTranslatedFileName(originalName: String, targetLanguage: String): String { + val lastDot = originalName.lastIndexOf('.') + return if (lastDot > 0) { + "${originalName.substring(0, lastDot)}.$targetLanguage${originalName.substring(lastDot)}" + } else { + "$originalName.$targetLanguage" + } + } + + /** + * Check if book has any translated chapters for the target language. + */ + private suspend fun hasAnyTranslatedChapter(book: Book, targetLanguage: String): Boolean { + val chapters = appDb.bookChapterDao.getChapterList(book.bookUrl) + return chapters.any { chapter -> + translationCacheRepository.getCacheFile(book, chapter, targetLanguage).exists() + } + } + private suspend fun getAllContents( book: Book, + source: ContentSource, append: (text: String, srcList: ArrayList?) -> Unit ) = coroutineScope { val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule() @@ -303,7 +357,7 @@ class ExportBookService : BaseService() { emit(chapter) } }.mapAsync(threads) { chapter -> - getExportData(book, chapter, contentProcessor, useReplace) + getExportData(book, chapter, contentProcessor, useReplace, source) }.collectIndexed { index, result -> notifyExportBookChanged(book.bookUrl) exportProgress[book.bookUrl] = index @@ -317,14 +371,19 @@ class ExportBookService : BaseService() { _exportBookUpdateFlow.tryEmit(bookUrl) } - private fun getExportData( + private suspend fun getExportData( book: Book, chapter: BookChapter, contentProcessor: ContentProcessor, - useReplace: Boolean + useReplace: Boolean, + source: ContentSource ): Pair?> { - val content = BookHelp.getContent(book, chapter) - val content1 = contentProcessor + val targetLanguage = TranslationConfig.llmTargetLanguage + val content = when (source) { + ContentSource.Original -> BookHelp.getContent(book, chapter) + ContentSource.Translation -> translationCacheRepository.readTranslation(book, chapter, targetLanguage) + } + val processedContent = contentProcessor .getContent( book, // 不导出vip标识 @@ -335,8 +394,8 @@ class ExportBookService : BaseService() { chineseConvert = false, reSegment = false ).toString() - if (AppConfig.exportPictureFile) { - //txt导出图片文件 + if (AppConfig.exportPictureFile && source == ContentSource.Original) { + //txt导出图片文件 - only for original source val srcList = arrayListOf() content?.split("\n")?.forEachIndexed { index, text -> val matcher = AppPattern.imgPattern.matcher(text) @@ -347,9 +406,9 @@ class ExportBookService : BaseService() { } } } - return Pair("\n\n$content1", srcList) + return Pair("\n\n$processedContent", srcList) } else { - return Pair("\n\n$content1", null) + return Pair("\n\n$processedContent", null) } } @@ -360,11 +419,22 @@ class ExportBookService : BaseService() { exportMsg.remove(book.bookUrl) postEvent(EventBus.EXPORT_BOOK, book.bookUrl) val fileDoc = FileDoc.fromDir(path) - exportEpub(fileDoc, book) + exportEpub(fileDoc, book, ContentSource.Original) } - private suspend fun exportEpub(fileDoc: FileDoc, book: Book) { - val filename = book.getExportFileName("epub") + private suspend fun exportEpub(path: String, book: Book, source: ContentSource) { + exportMsg.remove(book.bookUrl) + postEvent(EventBus.EXPORT_BOOK, book.bookUrl) + val fileDoc = FileDoc.fromDir(path) + exportEpub(fileDoc, book, source) + } + + private suspend fun exportEpub(fileDoc: FileDoc, book: Book, source: ContentSource) { + val targetLanguage = TranslationConfig.llmTargetLanguage + val filename = when (source) { + ContentSource.Original -> book.getExportFileName("epub") + ContentSource.Translation -> getTranslatedFileName(book.getExportFileName("epub"), targetLanguage) + } fileDoc.find(filename)?.delete() val epubBook = EpubBook() @@ -377,7 +447,7 @@ class ExportBookService : BaseService() { val contentModel = setAssets(fileDoc, book, epubBook) //设置正文 - setEpubContent(contentModel, book, epubBook) + setEpubContent(contentModel, book, epubBook, source) val bookDoc = fileDoc.createFileIfNotExist(filename) bookDoc.openOutputStream().getOrThrow().buffered().use { bookOs -> @@ -533,7 +603,8 @@ class ExportBookService : BaseService() { private suspend fun setEpubContent( contentModel: String, book: Book, - epubBook: EpubBook + epubBook: EpubBook, + source: ContentSource = ContentSource.Original ) = coroutineScope { //正文 val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule() @@ -543,18 +614,27 @@ class ExportBookService : BaseService() { } else { 1 } + val targetLanguage = TranslationConfig.llmTargetLanguage var parentSection: TOCReference? = null flow { appDb.bookChapterDao.getChapterList(book.bookUrl).forEach { chapter -> emit(chapter) } }.mapAsyncIndexed(threads) { index, chapter -> - val content = BookHelp.getContent(book, chapter) - val (contentFix, resources) = fixPic( - book, - content ?: if (chapter.isVolume) "" else "null", - chapter - ) + val content = when (source) { + ContentSource.Original -> BookHelp.getContent(book, chapter) + ContentSource.Translation -> translationCacheRepository.readTranslation(book, chapter, targetLanguage) + } + // For translation source, don't extract images (skip fixPic) + val (contentFix, resources) = if (source == ContentSource.Translation) { + Pair(content ?: if (chapter.isVolume) "" else "null", arrayListOf()) + } else { + fixPic( + book, + content ?: if (chapter.isVolume) "" else "null", + chapter + ) + } // 不导出vip标识 chapter.isVip = false val content1 = contentProcessor diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt index db62d85de..1f0c263a9 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt @@ -60,6 +60,8 @@ import io.legado.app.model.ReadAloud import io.legado.app.model.ReadBook import io.legado.app.model.SourceCallBack import io.legado.app.model.analyzeRule.AnalyzeRule +import io.legado.app.model.translation.TranslationChapterStatus +import io.legado.app.model.translation.TranslationManager import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern @@ -139,6 +141,7 @@ import io.legado.app.utils.startActivityForBook import io.legado.app.utils.sysScreenOffTime import io.legado.app.utils.throttle import io.legado.app.utils.toastOnUi +import io.legado.app.utils.longToastOnUi import io.legado.app.utils.visible import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main @@ -714,6 +717,36 @@ class ReadBookActivity : BaseReadBookActivity(), viewModel.refreshContentAll(book) } + override fun onTranslationClick() { + val book = ReadBook.book ?: return + book.setTranslationMode(!book.getTranslationMode()) + book.save() + binding.readMenu.updateTranslationButton(book.getTranslationMode()) + ReadBook.loadContent(false) + } + + override fun onTranslationLongClick() { + val book = ReadBook.book ?: return + val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) ?: return + + if (TranslationManager.hasTranslatedCache(book, chapter)) { + alert(title = getString(R.string.retranslate_chapter), message = getString(R.string.retranslate_confirm)) { + positiveButton(getString(R.string.ok)) { retranslateCurrentChapter() } + negativeButton(getString(R.string.cancel)) + }.show() + } + } + + fun retranslateCurrentChapter() { + val book = ReadBook.book ?: return + lifecycleScope.launch { + TranslationManager.deleteTranslationCache(book, appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) ?: return@launch) + book.setTranslationMode(true) + book.save() + ReadBook.loadContent(false) + } + } + override fun onMenuItemClick(item: MenuItem): Boolean { return onCompatOptionsItemSelected(item) } @@ -816,7 +849,7 @@ class ReadBookActivity : BaseReadBookActivity(), handleKeyPage(PageDirection.NEXT, longPress) return true } - // 手柄方向键控制翻页 + // 手柄方向键控制翻页 KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_LEFT -> { handleKeyPage(PageDirection.PREV, longPress) return true diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt index 94a7322a2..f13f172bd 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt @@ -57,7 +57,8 @@ import kotlin.coroutines.coroutineContext class ReadBookViewModel( application: Application, private val getReadingProgressUseCase: GetReadingProgressUseCase, - private val uploadReadingProgressUseCase: UploadReadingProgressUseCase + private val uploadReadingProgressUseCase: UploadReadingProgressUseCase, + val translateChapterUseCase: io.legado.app.domain.usecase.TranslateChapterUseCase ) : BaseViewModel(application) { val permissionDenialLiveData = MutableLiveData() var isInitFinish = false @@ -133,6 +134,7 @@ class ReadBookViewModel( return } ReadBook.upMsg(null) + if (!isSameBook) { ReadBook.loadContent(resetPageOffset = true) { ReadBook.bookSource?.let { @@ -352,7 +354,7 @@ class ReadBookViewModel( } }.onStart { ReadBook.upMsg(context.getString(R.string.source_auto_changing)) - }.mapParallelSafe(OtherConfig.threadCount) { source -> + }.mapParallelSafe(OtherConfig.threadCount) { source -> val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow() if (book.tocUrl.isEmpty()) { WebBook.getBookInfoAwait(source, book) diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt index 23f859048..04435f25a 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt @@ -640,10 +640,11 @@ class ReadMenu @JvmOverloads constructor( onClick = { runMenuOut{ callBack.openSearchActivity(null) } } ), ToolButton( - id = "auto_page", - iconRes = R.drawable.ic_auto_page, - description = context.getString(R.string.auto_next_page), - onClick = { runMenuOut { callBack.autoPage() } } + id = "translate", + iconRes = R.drawable.ic_translate, + description = context.getString(R.string.translate), + onClick = { runMenuOut { callBack.onTranslationClick() } }, + onLongClick = { runMenuOut { callBack.onTranslationLongClick() } } ), ToolButton( id = "catalog", @@ -709,6 +710,12 @@ class ReadMenu @JvmOverloads constructor( onLongClick = { runMenuOut { callBack.openReplaceRule() } }, onCheck = { runMenuOut { callBack.changeReplaceRuleState() } }, onClick = { } + ), + ToolButton( + id = "auto_page", + iconRes = R.drawable.ic_auto_page, + description = context.getString(R.string.auto_next_page), + onClick = { runMenuOut { callBack.autoPage() } } ) ) } @@ -761,6 +768,17 @@ class ReadMenu @JvmOverloads constructor( } } + fun updateTranslationButton(translationMode: Boolean) { + val btn = buttonMap["translate"] ?: return + + // Update icon based on mode + if (translationMode) { + btn.setIconResource(R.drawable.ic_return) + } else { + btn.setIconResource(R.drawable.ic_translate) + } + } + private fun initAnimation() { menuTopIn.setAnimationListener(menuInListener) menuTopOut.setAnimationListener(menuOutListener) @@ -914,6 +932,8 @@ class ReadMenu @JvmOverloads constructor( fun onMenuShow() fun onMenuHide() fun changeReplaceRuleState() + fun onTranslationClick() + fun onTranslationLongClick() } data class ToolButton( diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt index d78801660..0944a5977 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt @@ -114,7 +114,7 @@ class ToolButtonConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_too } private fun getAllButtonIds(): List { - return listOf("search", "auto_page", "catalog", "read_aloud", "setting", "addBookmark", "theme", "prev_chapter", "next_chapter", "replace", "replace_badge") + return listOf("search", "auto_page", "catalog", "read_aloud", "setting", "addBookmark", "theme", "prev_chapter", "next_chapter", "translate_chapter", "replace", "replace_badge") } private fun getButtonInfo(id: String): Pair { @@ -128,6 +128,7 @@ class ToolButtonConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_too "theme" -> R.drawable.ic_daytime to getString(R.string.day_night_switch) "prev_chapter" -> R.drawable.ic_previous to getString(R.string.previous_chapter) "next_chapter" -> R.drawable.ic_next to getString(R.string.next_chapter) + "translate_chapter" -> R.drawable.ic_translate to getString(R.string.translate_chapter) "replace" -> R.drawable.ic_find_replace to getString(R.string.replace_purify) "replace_badge" -> R.drawable.ic_find_replace to getString(R.string.replace_purify_badge) else -> R.drawable.ic_help to id diff --git a/app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt b/app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt index 288180872..6313b95c5 100644 --- a/app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt @@ -12,10 +12,10 @@ import io.legado.app.R import io.legado.app.ui.theme.adaptiveContentPadding import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.SplicedColumnGroup -import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults +import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -26,7 +26,8 @@ fun ConfigNavScreen( onNavigateToCover: () -> Unit, onNavigateToTheme: () -> Unit, onNavigateToBackup: () -> Unit, - onNavigateToDownloadCache: () -> Unit + onNavigateToDownloadCache: () -> Unit, + onNavigateToTranslation: () -> Unit ) { val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() @@ -75,6 +76,10 @@ fun ConfigNavScreen( title = stringResource(R.string.backup_restore), onClick = onNavigateToBackup ) + ClickableSettingItem( + title = stringResource(R.string.translation_config), + onClick = onNavigateToTranslation + ) } } } diff --git a/app/src/main/java/io/legado/app/ui/config/translation/TranslationConfig.kt b/app/src/main/java/io/legado/app/ui/config/translation/TranslationConfig.kt new file mode 100644 index 000000000..aee966290 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/translation/TranslationConfig.kt @@ -0,0 +1,67 @@ +package io.legado.app.ui.config.translation + +import io.legado.app.constant.PreferKey +import io.legado.app.domain.model.TranslationConstants +import io.legado.app.ui.config.prefDelegate + +object TranslationConfig { + + var llmTranslateEnabled by prefDelegate( + PreferKey.llmTranslateEnabled, + false + ) + + var llmProvider by prefDelegate( + PreferKey.llmProvider, + "google" + ) + + var llmBaseUrl by prefDelegate( + PreferKey.llmBaseUrl, + "" + ) + + var llmApiKey by prefDelegate( + PreferKey.llmApiKey, + "" + ) + + var llmModel by prefDelegate( + PreferKey.llmModel, + "" + ) + + var llmTargetLanguage by prefDelegate( + PreferKey.llmTargetLanguage, + "zh" + ) + + var llmMaxCharsPerChunk by prefDelegate( + PreferKey.llmMaxCharsPerChunk, + 10000 + ) + + var llmConcurrentChunks by prefDelegate( + PreferKey.llmConcurrentChunks, + 1 + ) + + var llmRetryCount by prefDelegate( + PreferKey.llmRetryCount, + 2 + ) + + var llmPrompt by prefDelegate( + PreferKey.llmPrompt, + TranslationConstants.DEFAULT_PROMPT + ) + + // Delegate constants to domain layer + const val PROVIDER_OPENAI = TranslationConstants.PROVIDER_OPENAI + const val PROVIDER_GOOGLE = TranslationConstants.PROVIDER_GOOGLE + val providerDisplayNames get() = TranslationConstants.providerDisplayNames + val providerValues get() = TranslationConstants.providerValues + val targetLanguages get() = TranslationConstants.targetLanguages + const val DEFAULT_PROMPT = TranslationConstants.DEFAULT_PROMPT + const val OUTPUT_FORMAT = TranslationConstants.OUTPUT_FORMAT +} diff --git a/app/src/main/java/io/legado/app/ui/config/translation/TranslationConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/translation/TranslationConfigScreen.kt new file mode 100644 index 000000000..565489c11 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/translation/TranslationConfigScreen.kt @@ -0,0 +1,125 @@ +package io.legado.app.ui.config.translation + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.ui.theme.adaptiveContentPadding +import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.SplicedColumnGroup +import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem +import io.legado.app.ui.widget.components.settingItem.InputSettingItem +import io.legado.app.ui.widget.components.settingItem.SliderSettingItem +import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar +import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults +import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton + +@SuppressLint("RememberReturnType") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TranslationConfigScreen( + onBackClick: () -> Unit +) { + val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() + + var tempPrompt by remember { mutableStateOf(TranslationConfig.llmPrompt) } + + AppScaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + GlassMediumFlexibleTopAppBar( + title = stringResource(R.string.translation_config), + scrollBehavior = scrollBehavior, + navigationIcon = { + TopBarNavigationButton(onClick = onBackClick) + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = adaptiveContentPadding( + top = paddingValues.calculateTopPadding(), + bottom = 120.dp + ) + ) { + item { + SplicedColumnGroup(title = stringResource(R.string.translation_provider)) { + DropdownListSettingItem( + title = stringResource(R.string.llm_provider), + selectedValue = TranslationConfig.llmProvider, + displayEntries = TranslationConfig.providerDisplayNames.toTypedArray(), + entryValues = TranslationConfig.providerValues.toTypedArray(), + onValueChange = { TranslationConfig.llmProvider = it } + ) + } + } + + item { + SplicedColumnGroup(title = stringResource(R.string.translation_options)) { + val languageEntries = TranslationConfig.targetLanguages.map { it.second }.toTypedArray() + val languageValues = TranslationConfig.targetLanguages.map { it.first }.toTypedArray() + DropdownListSettingItem( + title = stringResource(R.string.llm_target_language), + selectedValue = TranslationConfig.llmTargetLanguage, + displayEntries = languageEntries, + entryValues = languageValues, + onValueChange = { TranslationConfig.llmTargetLanguage = it } + ) + + SliderSettingItem( + title = stringResource(R.string.llm_max_chars_per_chunk), + value = TranslationConfig.llmMaxCharsPerChunk.toFloat(), + defaultValue = 10000f, + valueRange = 1000f..10000f, + steps = 17, + onValueChange = { TranslationConfig.llmMaxCharsPerChunk = it.toInt() } + ) + } + } + + if (TranslationConfig.llmProvider == TranslationConfig.PROVIDER_OPENAI) { + item { + SplicedColumnGroup(title = stringResource(R.string.openai_config)) { + InputSettingItem( + title = stringResource(R.string.llm_base_url), + value = TranslationConfig.llmBaseUrl, + onConfirm = { TranslationConfig.llmBaseUrl = it } + ) + + InputSettingItem( + title = stringResource(R.string.llm_api_key), + value = TranslationConfig.llmApiKey, + onConfirm = { TranslationConfig.llmApiKey = it } + ) + + InputSettingItem( + title = stringResource(R.string.llm_model), + value = TranslationConfig.llmModel, + onConfirm = { TranslationConfig.llmModel = it } + ) + + InputSettingItem( + title = stringResource(R.string.llm_prompt), + value = tempPrompt, + onConfirm = { + tempPrompt = it + TranslationConfig.llmPrompt = it + } + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt index 0aa0db1ef..882f9e771 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt @@ -39,6 +39,7 @@ import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigScreen import io.legado.app.ui.config.otherConfig.OtherConfigScreen import io.legado.app.ui.config.readConfig.ReadConfigScreen import io.legado.app.ui.config.themeConfig.ThemeConfigScreen +import io.legado.app.ui.config.translation.TranslationConfigScreen import io.legado.app.ui.config.themeManage.ThemeManageScreen import io.legado.app.ui.rss.article.MainRouteRssSort import io.legado.app.ui.rss.article.RssSortRouteScreen @@ -155,7 +156,8 @@ fun MainActivity.mainEntryProvider( onNavigateToCover = { backStack.add(MainRouteSettingsCover) }, onNavigateToTheme = { backStack.add(MainRouteSettingsTheme) }, onNavigateToBackup = { backStack.add(MainRouteSettingsBackup) }, - onNavigateToDownloadCache = { backStack.add(MainRouteSettingsDownloadCache) } + onNavigateToDownloadCache = { backStack.add(MainRouteSettingsDownloadCache) }, + onNavigateToTranslation = { backStack.add(MainRouteSettingsTranslation) } ) } @@ -187,6 +189,10 @@ fun MainActivity.mainEntryProvider( DownloadCacheConfigScreen(onBackClick = { onNavigateBack() }) } + entry { + TranslationConfigScreen(onBackClick = { onNavigateBack() }) + } + entry { CustomThemeScreen( onBackClick = { onNavigateBack() } diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt b/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt index c08763921..3ed426fc8 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt @@ -36,6 +36,9 @@ data object MainRouteSettingsThemeManage : MainRoute @Serializable data object MainRouteSettingsDownloadCache : MainRoute +@Serializable +data object MainRouteSettingsTranslation : MainRoute + @Serializable data object MainRouteImportLocal : MainRoute @@ -96,6 +99,7 @@ object MainRouteConst { const val ROUTE_SETTINGS_BACKUP = "settings/backup" const val ROUTE_SETTINGS_CUSTOM_THEME = "settings/custom_theme" const val ROUTE_SETTINGS_DOWNLOAD_CACHE = "settings/download_cache" + const val ROUTE_SETTINGS_TRANSLATION = "settings/translation" const val ROUTE_IMPORT_LOCAL = "import/local" const val ROUTE_IMPORT_REMOTE = "import/remote" const val ROUTE_CACHE = "cache" diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt b/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt index fc349eee0..fc9a82973 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt @@ -35,7 +35,8 @@ object MainNavigator { MainRouteSettingsBackup, MainRouteSettingsCustomTheme, MainRouteSettingsThemeManage, - MainRouteSettingsDownloadCache -> { + MainRouteSettingsDownloadCache, + MainRouteSettingsTranslation -> { backStack.clear() backStack.add(MainRouteHome) backStack.add(MainRouteSettings) @@ -233,6 +234,7 @@ object MainNavigator { MainRouteConst.ROUTE_SETTINGS_BACKUP -> MainRouteSettingsBackup MainRouteConst.ROUTE_SETTINGS_CUSTOM_THEME -> MainRouteSettingsCustomTheme MainRouteConst.ROUTE_SETTINGS_DOWNLOAD_CACHE -> MainRouteSettingsDownloadCache + MainRouteConst.ROUTE_SETTINGS_TRANSLATION -> MainRouteSettingsTranslation MainRouteConst.ROUTE_IMPORT_LOCAL -> MainRouteImportLocal MainRouteConst.ROUTE_IMPORT_REMOTE -> MainRouteImportRemote MainRouteConst.ROUTE_CACHE -> MainRouteCache( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt index 85d469fa0..bcf215660 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt @@ -233,6 +233,16 @@ private fun rememberSharedCoverTransitionRadius( return animatedRadiusValue.dp } +/** + * Determine if text is primarily Latin-script. + * Returns true if more than 30% of characters are Latin letters. + */ +private fun isLatinBasedText(text: String?): Boolean { + if (text.isNullOrBlank()) return false + val latinRatio = text.count { it in 'A'..'Z' || it in 'a'..'z' }.toFloat() / text.length + return latinRatio > 0.3f +} + @Composable private fun CoverTextOverlay( name: String?, @@ -251,7 +261,9 @@ private fun CoverTextOverlay( if (isNight) CoverConfig.coverTextColorN else CoverConfig.coverTextColor } val shadowColor = if (isNight) CoverConfig.coverShadowColorN else CoverConfig.coverShadowColor - val isHorizontal = CoverConfig.coverInfoOrientation == "1" + val configIsHorizontal = CoverConfig.coverInfoOrientation == "1" + // If text contains Latin letters, force horizontal layout + val isHorizontal = configIsHorizontal || isLatinBasedText(name) Canvas(modifier = Modifier.fillMaxSize()) { val viewWidth = size.width diff --git a/app/src/main/res/menu/book_read.xml b/app/src/main/res/menu/book_read.xml index d61895a87..c4ecbb066 100644 --- a/app/src/main/res/menu/book_read.xml +++ b/app/src/main/res/menu/book_read.xml @@ -162,4 +162,5 @@ android:title="@string/help" app:showAsAction="never" /> + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3939ea9e4..e07f85bac 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1739,4 +1739,36 @@ · 当前生效 · 已被屏蔽 · 已加入 + + + 翻译 + 翻译设置 + 启用章节翻译 + 翻译提供商 + 提供商 + OpenAI 配置 + 基础 URL + API 密钥 + 模型 + 翻译选项 + 目标语言 + 每块最大字符数 + 并发块数 + 重试次数 + 翻译提示词 + 提示词 + 翻译章节 + 显示原文 + 重新翻译章节 + 这将删除现有翻译并重新翻译。是否继续? + 翻译中: %1$d/%2$d + 翻译失败: %1$s + 无翻译缓存 + 删除翻译缓存 + 缓存大小: %1$s + 清除缓存 + 翻译缓存 + 返回原文 + 翻译中 %1$d%% + 开始进行翻译,请耐心等待…… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 61478d3ef..a92084550 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1681,6 +1681,38 @@ Download Image Cache Network + + + Translation Settings + Enable Chapter Translation + Translation Provider + Provider + OpenAI Configuration + Base URL + API Key + Model + Translation Options + Target Language + Max Chars Per Chunk + Concurrent Chunks + Retry Count + Translation Prompt + Prompt + Translate Chapter + Show Original + Retranslate Chapter + This will delete the existing translation and re-translate. Continue? + Translating: %1$d%% + Translation failed: %1$s + No translation cache + Delete Translation Cache + Cache Size: %1$s + Clear Cache + Translation Cache + Translate + Return to Original + Translating %1$d%% + Translation started, please wait… Failed to load more: %1$s This group already has an infinite loading module Quick Actions