加入小说翻译功能 (#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:
Vendored
+3
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user