加入小说翻译功能 (#1094)

* feature: add translation full content

---------

Co-authored-by: duanhl <duanhl7749@gmail.com>
Co-authored-by: HapeLee <63206378+HapeLee@users.noreply.github.com>
This commit is contained in:
Duanhl
2026-05-24 03:57:35 +08:00
committed by GitHub
co-authored by duanhl HapeLee
parent c6cf239d49
commit 197039d25a
35 changed files with 2194 additions and 77 deletions
+3
View File
@@ -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,
@@ -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"
@@ -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
@@ -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
}
@@ -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<DictPair>) {
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()
}
}
}
@@ -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<DictPair>,
onUpdate: ((List<DictPair>) -> Unit)?,
retryReason: RetryReason?
): Result<String> = 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<String> {
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<DictPair>,
onUpdate: ((List<DictPair>) -> Unit)?,
retryReason: RetryReason?
): Result<String> {
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<DictPair>
)
/**
* 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<DictPair>
): 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<DictPair>()
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<String>
): List<DictPair> {
val pairs = mutableListOf<DictPair>()
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<DictPair>): 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<GoogleSentence>,
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<OpenAIChoice>
)
data class OpenAIChoice(
val message: OpenAIMessage
)
data class OpenAIMessage(
val content: String
)
@@ -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<Int, TranslationCache> = withContext(Dispatchers.IO) {
val chunkFile = getChunkFile(book, bookChapter, targetLanguage)
if (!chunkFile.exists()) return@withContext emptyMap()
val result = mutableMapOf<Int, TranslationCache>()
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<TranslationCache> = 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
}
}
@@ -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
/**
@@ -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<UploadRepository> { DirectLinkUploadRepository() }
single<TranslationCacheGateway> { TranslationCacheRepositoryImpl() }
single<AppStartupGateway> { AppStartupRepository(get()) }
single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
single<BookCacheCleanupGateway> { BookCacheCleanupRepository(get()) }
@@ -184,6 +192,9 @@ val appModule = module {
single<SearchRepository> { get<SearchRepositoryImpl>() }
single<BookSearchGateway> { get<SearchRepositoryImpl>() }
singleOf(::SearchBooksUseCase)
single<LlmGateway> { LlmTranslateRepositoryImpl() }
single<DictionaryGateway> { DictionaryRepositoryImpl() }
singleOf(::TranslateChapterUseCase)
single<ImageLoader> {
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)
@@ -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<DictPair>)
fun clearBookDictionary(book: Book)
}
@@ -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<DictPair> = emptyList(),
onUpdate: ((List<DictPair>) -> Unit)? = null,
retryReason: RetryReason? = null
): Result<String>
}
@@ -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<TranslationCache>
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()
}
@@ -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<TextChunk> {
if (text.isBlank()) return emptyList()
val paragraphs = text.split("\n\n").filter { it.isNotBlank() }
if (paragraphs.isEmpty()) return emptyList()
val chunks = mutableListOf<TextChunk>()
var currentChunk = StringBuilder()
var currentParagraphIndices = mutableListOf<Int>()
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<TextChunk>()
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<TextChunk> {
val result = mutableListOf<TextChunk>()
val paragraphs = chunk.content.split("\n\n")
var currentSubChunk = StringBuilder()
var currentParagraphIndices = mutableListOf<Int>()
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<TextChunk> {
val result = mutableListOf<TextChunk>()
val sentences = mutableListOf<String>()
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<TextChunk>): String {
return chunks.sortedBy { it.index }.joinToString("\n\n") { it.content }
}
}
@@ -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<TextChunk>, translatedMap: Map<Int, String>): 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<Int, String>): Boolean {
return translatedMap.isNotEmpty()
}
/**
* Get the count of translated chunks vs total.
*/
fun progress(translatedMap: Map<Int, String>, totalChunks: Int): Pair<Int, Int> {
return Pair(translatedMap.size, totalChunks)
}
}
@@ -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:
<example>
[dictionary]
Jack -> 杰克
Harry Port -> 哈利波特
[result]
...
</example>
"""
}
@@ -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<DictPair> = 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<Int>
)
/**
* 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
}
@@ -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<Int> = 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<String> = 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<DictPair>) -> 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<Int, String>()
val pendingChunks = mutableListOf<TextChunk>()
// 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<DictPair>, newPairs: List<DictPair>): 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<DictPair>,
onDictionaryUpdate: (List<DictPair>) -> Unit
): Result<String> {
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<DictPair>,
onDictionaryUpdate: (List<DictPair>) -> Unit
): Result<String> {
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
}
}
}
@@ -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<Int>()
private val readRecord = ReadRecord()
private val chapterLoadingJobs = ConcurrentHashMap<Int, Coroutine<*>>()
private val translationObserverJobs = ConcurrentHashMap<Int, Job>()
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<TranslationChapterState>, 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
@@ -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
)
@@ -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<TranslationChapterKey, MutableStateFlow<TranslationChapterState>>()
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<TranslationChapterState>? {
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<TranslationChapterState>? {
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)
}
}
@@ -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<String, Int>()
@@ -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<String, ExportConfig>()
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<SrcData>?) -> 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<String, ArrayList<SrcData>?> {
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<SrcData>()
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
@@ -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
@@ -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<Int>()
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)
@@ -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(
@@ -114,7 +114,7 @@ class ToolButtonConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_too
}
private fun getAllButtonIds(): List<String> {
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<Int, String> {
@@ -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
@@ -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
)
}
}
}
@@ -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
}
@@ -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
}
)
}
}
}
}
}
}
@@ -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<MainRouteSettingsTranslation> {
TranslationConfigScreen(onBackClick = { onNavigateBack() })
}
entry<MainRouteSettingsCustomTheme> {
CustomThemeScreen(
onBackClick = { onNavigateBack() }
@@ -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"
@@ -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(
@@ -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
+1
View File
@@ -162,4 +162,5 @@
android:title="@string/help"
app:showAsAction="never" />
</menu>
@@ -1739,4 +1739,36 @@
<string name="homepage_status_in_effect"> · 当前生效</string>
<string name="homepage_status_blocked"> · 已被屏蔽</string>
<string name="homepage_status_joined"> · 已加入</string>
<!-- Translation -->
<string name="translate">翻译</string>
<string name="translation_config">翻译设置</string>
<string name="llm_translate_enabled">启用章节翻译</string>
<string name="translation_provider">翻译提供商</string>
<string name="llm_provider">提供商</string>
<string name="openai_config">OpenAI 配置</string>
<string name="llm_base_url">基础 URL</string>
<string name="llm_api_key">API 密钥</string>
<string name="llm_model">模型</string>
<string name="translation_options">翻译选项</string>
<string name="llm_target_language">目标语言</string>
<string name="llm_max_chars_per_chunk">每块最大字符数</string>
<string name="llm_concurrent_chunks">并发块数</string>
<string name="llm_retry_count">重试次数</string>
<string name="translation_prompt">翻译提示词</string>
<string name="llm_prompt">提示词</string>
<string name="translate_chapter">翻译章节</string>
<string name="show_original">显示原文</string>
<string name="retranslate_chapter">重新翻译章节</string>
<string name="retranslate_confirm">这将删除现有翻译并重新翻译。是否继续?</string>
<string name="translation_in_progress">翻译中: %1$d/%2$d</string>
<string name="translation_failed">翻译失败: %1$s</string>
<string name="no_translation_cache">无翻译缓存</string>
<string name="delete_translation_cache">删除翻译缓存</string>
<string name="translation_cache_size">缓存大小: %1$s</string>
<string name="clear_translation_cache">清除缓存</string>
<string name="translation_cache">翻译缓存</string>
<string name="return_to_original">返回原文</string>
<string name="translation_progress">翻译中 %1$d%%</string>
<string name="translation_started">开始进行翻译,请耐心等待……</string>
</resources>
+32
View File
@@ -1681,6 +1681,38 @@
<string name="download_setting">Download</string>
<string name="image_cache">Image Cache</string>
<string name="network">Network</string>
<!-- Translation -->
<string name="translation_config">Translation Settings</string>
<string name="llm_translate_enabled">Enable Chapter Translation</string>
<string name="translation_provider">Translation Provider</string>
<string name="llm_provider">Provider</string>
<string name="openai_config">OpenAI Configuration</string>
<string name="llm_base_url">Base URL</string>
<string name="llm_api_key">API Key</string>
<string name="llm_model">Model</string>
<string name="translation_options">Translation Options</string>
<string name="llm_target_language">Target Language</string>
<string name="llm_max_chars_per_chunk">Max Chars Per Chunk</string>
<string name="llm_concurrent_chunks">Concurrent Chunks</string>
<string name="llm_retry_count">Retry Count</string>
<string name="translation_prompt">Translation Prompt</string>
<string name="llm_prompt">Prompt</string>
<string name="translate_chapter">Translate Chapter</string>
<string name="show_original">Show Original</string>
<string name="retranslate_chapter">Retranslate Chapter</string>
<string name="retranslate_confirm">This will delete the existing translation and re-translate. Continue?</string>
<string name="translation_in_progress">Translating: %1$d%%</string>
<string name="translation_failed">Translation failed: %1$s</string>
<string name="no_translation_cache">No translation cache</string>
<string name="delete_translation_cache">Delete Translation Cache</string>
<string name="translation_cache_size">Cache Size: %1$s</string>
<string name="clear_translation_cache">Clear Cache</string>
<string name="translation_cache">Translation Cache</string>
<string name="translate">Translate</string>
<string name="return_to_original">Return to Original</string>
<string name="translation_progress">Translating %1$d%%</string>
<string name="translation_started">Translation started, please wait…</string>
<string name="homepage_load_more_failed">Failed to load more: %1$s</string>
<string name="homepage_module_duplicate_infinite">This group already has an infinite loading module</string>
<string name="homepage_quick_actions">Quick Actions</string>