加入小说翻译功能 (#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 * extends io.legado.app.help.JsExtensions{*;}
|
||||||
# 数据类
|
# 数据类
|
||||||
-keep class **.data.entities.**{*;}
|
-keep class **.data.entities.**{*;}
|
||||||
|
# Gson反序列化用的数据传输类
|
||||||
|
-keep class io.legado.app.model.translation.**{*;}
|
||||||
|
-keep class io.legado.app.data.entities.TranslationCache{*;}
|
||||||
# hutool-core hutool-crypto
|
# hutool-core hutool-crypto
|
||||||
-keep class
|
-keep class
|
||||||
!cn.hutool.core.util.RuntimeUtil,
|
!cn.hutool.core.util.RuntimeUtil,
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ object PreferKey {
|
|||||||
const val episodeExportFileName = "episodeExportFileName"
|
const val episodeExportFileName = "episodeExportFileName"
|
||||||
const val recordLog = "recordLog"
|
const val recordLog = "recordLog"
|
||||||
const val processText = "process_text"
|
const val processText = "process_text"
|
||||||
const val cleanCache = "cleanCache"
|
|
||||||
const val saveTabPosition = "saveTabPosition"
|
const val saveTabPosition = "saveTabPosition"
|
||||||
const val fontFolder = "fontFolder"
|
const val fontFolder = "fontFolder"
|
||||||
const val backupPath = "backupUri"
|
const val backupPath = "backupUri"
|
||||||
@@ -322,6 +321,18 @@ object PreferKey {
|
|||||||
const val navIconRss = "navIconRss"
|
const val navIconRss = "navIconRss"
|
||||||
const val navIconMy = "navIconMy"
|
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 homepageModuleOrder = "homepageModuleOrder"
|
||||||
const val homepageModuleHidden = "homepageModuleHidden"
|
const val homepageModuleHidden = "homepageModuleHidden"
|
||||||
const val homepageLayoutMode = "homepageLayoutMode"
|
const val homepageLayoutMode = "homepageLayoutMode"
|
||||||
|
|||||||
@@ -293,6 +293,14 @@ data class Book(
|
|||||||
return this.durChapterIndex
|
return this.durChapterIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTranslationMode(enabled: Boolean) {
|
||||||
|
config.translationMode = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTranslationMode(): Boolean {
|
||||||
|
return config.translationMode
|
||||||
|
}
|
||||||
|
|
||||||
// dailyChapters 的 setter 和 getter
|
// dailyChapters 的 setter 和 getter
|
||||||
fun setDailyChapters(dailyChapters: Int) {
|
fun setDailyChapters(dailyChapters: Int) {
|
||||||
config.dailyChapters = dailyChapters
|
config.dailyChapters = dailyChapters
|
||||||
@@ -424,7 +432,9 @@ data class Book(
|
|||||||
var webtoonSidePaddingDp: Int? = null,
|
var webtoonSidePaddingDp: Int? = null,
|
||||||
var mangaBackground: String? = null,
|
var mangaBackground: String? = null,
|
||||||
|
|
||||||
var fixedType: Boolean = false // 固定书籍类型,不随书源更新
|
var fixedType: Boolean = false, // 固定书籍类型,不随书源更新
|
||||||
|
|
||||||
|
var translationMode: Boolean = false // 是否启用翻译阅读模式
|
||||||
|
|
||||||
) : Parcelable
|
) : 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.AppDatabase
|
||||||
import io.legado.app.data.appDb
|
import io.legado.app.data.appDb
|
||||||
import io.legado.app.data.dao.BookChapterDao
|
import io.legado.app.data.dao.*
|
||||||
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 org.koin.dsl.module
|
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.CacheBookDownloadRepository
|
||||||
import io.legado.app.data.repository.DatabaseMaintenanceRepository
|
import io.legado.app.data.repository.DatabaseMaintenanceRepository
|
||||||
import io.legado.app.data.repository.DictRuleRepository
|
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.DirectLinkUploadRepository
|
||||||
import io.legado.app.data.repository.ExploreRepository
|
import io.legado.app.data.repository.ExploreRepository
|
||||||
import io.legado.app.data.repository.ExploreRepositoryImpl
|
import io.legado.app.data.repository.ExploreRepositoryImpl
|
||||||
import io.legado.app.data.repository.HomepageModulesRepository
|
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.LocalBookRepository
|
||||||
import io.legado.app.data.repository.ReadRecordRepository
|
import io.legado.app.data.repository.ReadRecordRepository
|
||||||
import io.legado.app.data.repository.RemoteBookRepository
|
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.SearchRepository
|
||||||
import io.legado.app.data.repository.SearchRepositoryImpl
|
import io.legado.app.data.repository.SearchRepositoryImpl
|
||||||
import io.legado.app.data.repository.SettingsRepository
|
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.UploadRepository
|
||||||
import io.legado.app.data.repository.WebDavBackupRepository
|
import io.legado.app.data.repository.WebDavBackupRepository
|
||||||
import io.legado.app.data.repository.WebDavReadingProgressRepository
|
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.BookSearchGateway
|
||||||
import io.legado.app.domain.gateway.BookSourceCallbackGateway
|
import io.legado.app.domain.gateway.BookSourceCallbackGateway
|
||||||
import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
|
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.ExploreBooksGateway
|
||||||
import io.legado.app.domain.gateway.HomepageModulesGateway
|
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.LocalBookGateway
|
||||||
import io.legado.app.domain.gateway.ReadingProgressGateway
|
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.gateway.WebDavBackupGateway
|
||||||
import io.legado.app.domain.repository.BookDomainRepository
|
import io.legado.app.domain.repository.BookDomainRepository
|
||||||
import io.legado.app.domain.usecase.AddBookUseCase
|
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.SaveSearchBooksUseCase
|
||||||
import io.legado.app.domain.usecase.SearchBooksUseCase
|
import io.legado.app.domain.usecase.SearchBooksUseCase
|
||||||
import io.legado.app.domain.usecase.ShrinkDatabaseUseCase
|
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.UpdateBooksGroupUseCase
|
||||||
import io.legado.app.domain.usecase.UploadReadingProgressUseCase
|
import io.legado.app.domain.usecase.UploadReadingProgressUseCase
|
||||||
import io.legado.app.domain.usecase.WebDavBackupUseCase
|
import io.legado.app.domain.usecase.WebDavBackupUseCase
|
||||||
@@ -164,6 +171,7 @@ val appModule = module {
|
|||||||
singleOf(::BookshelfManageScreenConfig)
|
singleOf(::BookshelfManageScreenConfig)
|
||||||
|
|
||||||
single<UploadRepository> { DirectLinkUploadRepository() }
|
single<UploadRepository> { DirectLinkUploadRepository() }
|
||||||
|
single<TranslationCacheGateway> { TranslationCacheRepositoryImpl() }
|
||||||
single<AppStartupGateway> { AppStartupRepository(get()) }
|
single<AppStartupGateway> { AppStartupRepository(get()) }
|
||||||
single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
|
single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
|
||||||
single<BookCacheCleanupGateway> { BookCacheCleanupRepository(get()) }
|
single<BookCacheCleanupGateway> { BookCacheCleanupRepository(get()) }
|
||||||
@@ -184,6 +192,9 @@ val appModule = module {
|
|||||||
single<SearchRepository> { get<SearchRepositoryImpl>() }
|
single<SearchRepository> { get<SearchRepositoryImpl>() }
|
||||||
single<BookSearchGateway> { get<SearchRepositoryImpl>() }
|
single<BookSearchGateway> { get<SearchRepositoryImpl>() }
|
||||||
singleOf(::SearchBooksUseCase)
|
singleOf(::SearchBooksUseCase)
|
||||||
|
single<LlmGateway> { LlmTranslateRepositoryImpl() }
|
||||||
|
single<DictionaryGateway> { DictionaryRepositoryImpl() }
|
||||||
|
singleOf(::TranslateChapterUseCase)
|
||||||
|
|
||||||
single<ImageLoader> {
|
single<ImageLoader> {
|
||||||
ImageLoader.Builder(get())
|
ImageLoader.Builder(get())
|
||||||
@@ -234,7 +245,14 @@ val appModule = module {
|
|||||||
viewModelOf(::ServersViewModel)
|
viewModelOf(::ServersViewModel)
|
||||||
viewModelOf(::BookInfoViewModel)
|
viewModelOf(::BookInfoViewModel)
|
||||||
viewModelOf(::ReadMangaViewModel)
|
viewModelOf(::ReadMangaViewModel)
|
||||||
viewModelOf(::ReadBookViewModel)
|
viewModel {
|
||||||
|
ReadBookViewModel(
|
||||||
|
application = get(),
|
||||||
|
getReadingProgressUseCase = get(),
|
||||||
|
uploadReadingProgressUseCase = get(),
|
||||||
|
translateChapterUseCase = get()
|
||||||
|
)
|
||||||
|
}
|
||||||
viewModelOf(::ChangeCoverViewModel)
|
viewModelOf(::ChangeCoverViewModel)
|
||||||
viewModelOf(::ChangeBookSourceComposeViewModel)
|
viewModelOf(::ChangeBookSourceComposeViewModel)
|
||||||
viewModelOf(::ChangeBookSourceViewModel)
|
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.coroutine.Coroutine
|
||||||
import io.legado.app.help.globalExecutor
|
import io.legado.app.help.globalExecutor
|
||||||
import io.legado.app.model.localBook.TextFile
|
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.model.webBook.WebBook
|
||||||
import io.legado.app.service.BaseReadAloudService
|
import io.legado.app.service.BaseReadAloudService
|
||||||
import io.legado.app.service.CacheBookService
|
import io.legado.app.service.CacheBookService
|
||||||
@@ -84,6 +88,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
|
|||||||
private val loadingChapters = arrayListOf<Int>()
|
private val loadingChapters = arrayListOf<Int>()
|
||||||
private val readRecord = ReadRecord()
|
private val readRecord = ReadRecord()
|
||||||
private val chapterLoadingJobs = ConcurrentHashMap<Int, Coroutine<*>>()
|
private val chapterLoadingJobs = ConcurrentHashMap<Int, Coroutine<*>>()
|
||||||
|
private val translationObserverJobs = ConcurrentHashMap<Int, Job>()
|
||||||
private val prevChapterLoadingLock = Mutex()
|
private val prevChapterLoadingLock = Mutex()
|
||||||
private val curChapterLoadingLock = Mutex()
|
private val curChapterLoadingLock = Mutex()
|
||||||
private val nextChapterLoadingLock = Mutex()
|
private val nextChapterLoadingLock = Mutex()
|
||||||
@@ -242,11 +247,20 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
|
|||||||
|
|
||||||
fun clearTextChapter() {
|
fun clearTextChapter() {
|
||||||
clearExpiredChapterLoadingJob(true)
|
clearExpiredChapterLoadingJob(true)
|
||||||
|
clearTranslationObserverJobs()
|
||||||
prevTextChapter = null
|
prevTextChapter = null
|
||||||
curTextChapter = null
|
curTextChapter = null
|
||||||
nextTextChapter = 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() {
|
fun clearSearchResult() {
|
||||||
curTextChapter?.clearSearchResult()
|
curTextChapter?.clearSearchResult()
|
||||||
prevTextChapter?.clearSearchResult()
|
prevTextChapter?.clearSearchResult()
|
||||||
@@ -672,7 +686,18 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
|
|||||||
return@async
|
return@async
|
||||||
}
|
}
|
||||||
if (addLoading(index)) {
|
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(
|
contentLoadFinish(
|
||||||
book,
|
book,
|
||||||
chapter,
|
chapter,
|
||||||
@@ -706,7 +731,17 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
|
|||||||
try {
|
try {
|
||||||
val book = book!!
|
val book = book!!
|
||||||
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index)!!
|
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)
|
contentLoadFinishAwait(book, chapter, content, upContent, resetPageOffset)
|
||||||
success?.invoke()
|
success?.invoke()
|
||||||
} catch (e: Exception) {
|
} 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
|
@Synchronized
|
||||||
private fun addLoading(index: Int): Boolean {
|
private fun addLoading(index: Int): Boolean {
|
||||||
if (loadingChapters.contains(index)) return false
|
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.appDb
|
||||||
import io.legado.app.data.entities.Book
|
import io.legado.app.data.entities.Book
|
||||||
import io.legado.app.data.entities.BookChapter
|
import io.legado.app.data.entities.BookChapter
|
||||||
|
import io.legado.app.domain.gateway.TranslationCacheGateway
|
||||||
import io.legado.app.exception.NoStackTraceException
|
import io.legado.app.exception.NoStackTraceException
|
||||||
import io.legado.app.help.AppWebDav
|
import io.legado.app.help.AppWebDav
|
||||||
import io.legado.app.help.book.BookHelp
|
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.help.config.AppConfig
|
||||||
import io.legado.app.model.ReadBook
|
import io.legado.app.model.ReadBook
|
||||||
import io.legado.app.model.localBook.LocalBook
|
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.ui.main.MainActivity
|
||||||
import io.legado.app.utils.FileDoc
|
import io.legado.app.utils.FileDoc
|
||||||
import io.legado.app.utils.FileUtils
|
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.EpubWriter
|
||||||
import me.ag2s.epublib.epub.EpubWriterProcessor
|
import me.ag2s.epublib.epub.EpubWriterProcessor
|
||||||
import me.ag2s.epublib.util.ResourceUtil
|
import me.ag2s.epublib.util.ResourceUtil
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
import splitties.init.appCtx
|
import splitties.init.appCtx
|
||||||
import splitties.systemservices.notificationManager
|
import splitties.systemservices.notificationManager
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
@@ -76,7 +80,7 @@ import kotlin.math.min
|
|||||||
/**
|
/**
|
||||||
* 导出书籍服务
|
* 导出书籍服务
|
||||||
*/
|
*/
|
||||||
class ExportBookService : BaseService() {
|
class ExportBookService : BaseService(), KoinComponent {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val exportProgress = ConcurrentHashMap<String, Int>()
|
val exportProgress = ConcurrentHashMap<String, Int>()
|
||||||
@@ -92,6 +96,16 @@ class ExportBookService : BaseService() {
|
|||||||
val epubScope: String? = null
|
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 groupKey = "${appCtx.packageName}.exportBook"
|
||||||
private val waitExportBooks = linkedMapOf<String, ExportConfig>()
|
private val waitExportBooks = linkedMapOf<String, ExportConfig>()
|
||||||
private var exportJob: Job? = null
|
private var exportJob: Job? = null
|
||||||
@@ -199,6 +213,10 @@ class ExportBookService : BaseService() {
|
|||||||
if (exportConfig.type == "epub") {
|
if (exportConfig.type == "epub") {
|
||||||
if (exportConfig.epubScope.isNullOrBlank()) {
|
if (exportConfig.epubScope.isNullOrBlank()) {
|
||||||
exportEpub(exportConfig.path, book)
|
exportEpub(exportConfig.path, book)
|
||||||
|
// Also export translation if cache exists
|
||||||
|
if (hasAnyTranslatedChapter(book, TranslationConfig.llmTargetLanguage)) {
|
||||||
|
exportEpub(exportConfig.path, book, ContentSource.Translation)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
CustomExporter(
|
CustomExporter(
|
||||||
exportConfig.epubScope,
|
exportConfig.epubScope,
|
||||||
@@ -207,6 +225,11 @@ class ExportBookService : BaseService() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
exportTxt(exportConfig.path, book)
|
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)
|
exportMsg[book.bookUrl] = getString(R.string.export_success)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -245,29 +268,36 @@ class ExportBookService : BaseService() {
|
|||||||
exportMsg.remove(book.bookUrl)
|
exportMsg.remove(book.bookUrl)
|
||||||
notifyExportBookChanged(book.bookUrl)
|
notifyExportBookChanged(book.bookUrl)
|
||||||
val fileDoc = FileDoc.fromDir(path)
|
val fileDoc = FileDoc.fromDir(path)
|
||||||
exportTxt(fileDoc, book)
|
exportTxt(fileDoc, book, ContentSource.Original)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun exportTxt(fileDoc: FileDoc, book: Book) {
|
private suspend fun exportTxt(fileDoc: FileDoc, book: Book, source: ContentSource) {
|
||||||
val filename = book.getExportFileName("txt")
|
val targetLanguage = TranslationConfig.llmTargetLanguage
|
||||||
|
val filename = when (source) {
|
||||||
|
ContentSource.Original -> book.getExportFileName("txt")
|
||||||
|
ContentSource.Translation -> getTranslatedFileName(book.getExportFileName("txt"), targetLanguage)
|
||||||
|
}
|
||||||
fileDoc.find(filename)?.delete()
|
fileDoc.find(filename)?.delete()
|
||||||
|
|
||||||
val bookDoc = fileDoc.createFileIfNotExist(filename)
|
val bookDoc = fileDoc.createFileIfNotExist(filename)
|
||||||
val charset = Charset.forName(AppConfig.exportCharset)
|
val charset = Charset.forName(AppConfig.exportCharset)
|
||||||
bookDoc.openOutputStream().getOrThrow().bufferedWriter(charset).use { bw ->
|
bookDoc.openOutputStream().getOrThrow().bufferedWriter(charset).use { bw ->
|
||||||
getAllContents(book) { text, srcList ->
|
getAllContents(book, source) { text, srcList ->
|
||||||
bw.write(text)
|
bw.write(text)
|
||||||
srcList?.forEach {
|
// Only export images for original source
|
||||||
val vFile = BookHelp.getImage(book, it.src)
|
if (source == ContentSource.Original) {
|
||||||
if (vFile.exists()) {
|
srcList?.forEach {
|
||||||
fileDoc.createFileIfNotExist(
|
val vFile = BookHelp.getImage(book, it.src)
|
||||||
"${it.index}-${MD5Utils.md5Encode16(it.src)}.jpg",
|
if (vFile.exists()) {
|
||||||
subDirs = arrayOf(
|
fileDoc.createFileIfNotExist(
|
||||||
"${book.name}_${book.author}",
|
"${it.index}-${MD5Utils.md5Encode16(it.src)}.jpg",
|
||||||
"images",
|
subDirs = arrayOf(
|
||||||
it.chapterTitle
|
"${book.name}_${book.author}",
|
||||||
)
|
"images",
|
||||||
).writeFile(vFile)
|
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(
|
private suspend fun getAllContents(
|
||||||
book: Book,
|
book: Book,
|
||||||
|
source: ContentSource,
|
||||||
append: (text: String, srcList: ArrayList<SrcData>?) -> Unit
|
append: (text: String, srcList: ArrayList<SrcData>?) -> Unit
|
||||||
) = coroutineScope {
|
) = coroutineScope {
|
||||||
val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
|
val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
|
||||||
@@ -303,7 +357,7 @@ class ExportBookService : BaseService() {
|
|||||||
emit(chapter)
|
emit(chapter)
|
||||||
}
|
}
|
||||||
}.mapAsync(threads) { chapter ->
|
}.mapAsync(threads) { chapter ->
|
||||||
getExportData(book, chapter, contentProcessor, useReplace)
|
getExportData(book, chapter, contentProcessor, useReplace, source)
|
||||||
}.collectIndexed { index, result ->
|
}.collectIndexed { index, result ->
|
||||||
notifyExportBookChanged(book.bookUrl)
|
notifyExportBookChanged(book.bookUrl)
|
||||||
exportProgress[book.bookUrl] = index
|
exportProgress[book.bookUrl] = index
|
||||||
@@ -317,14 +371,19 @@ class ExportBookService : BaseService() {
|
|||||||
_exportBookUpdateFlow.tryEmit(bookUrl)
|
_exportBookUpdateFlow.tryEmit(bookUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getExportData(
|
private suspend fun getExportData(
|
||||||
book: Book,
|
book: Book,
|
||||||
chapter: BookChapter,
|
chapter: BookChapter,
|
||||||
contentProcessor: ContentProcessor,
|
contentProcessor: ContentProcessor,
|
||||||
useReplace: Boolean
|
useReplace: Boolean,
|
||||||
|
source: ContentSource
|
||||||
): Pair<String, ArrayList<SrcData>?> {
|
): Pair<String, ArrayList<SrcData>?> {
|
||||||
val content = BookHelp.getContent(book, chapter)
|
val targetLanguage = TranslationConfig.llmTargetLanguage
|
||||||
val content1 = contentProcessor
|
val content = when (source) {
|
||||||
|
ContentSource.Original -> BookHelp.getContent(book, chapter)
|
||||||
|
ContentSource.Translation -> translationCacheRepository.readTranslation(book, chapter, targetLanguage)
|
||||||
|
}
|
||||||
|
val processedContent = contentProcessor
|
||||||
.getContent(
|
.getContent(
|
||||||
book,
|
book,
|
||||||
// 不导出vip标识
|
// 不导出vip标识
|
||||||
@@ -335,8 +394,8 @@ class ExportBookService : BaseService() {
|
|||||||
chineseConvert = false,
|
chineseConvert = false,
|
||||||
reSegment = false
|
reSegment = false
|
||||||
).toString()
|
).toString()
|
||||||
if (AppConfig.exportPictureFile) {
|
if (AppConfig.exportPictureFile && source == ContentSource.Original) {
|
||||||
//txt导出图片文件
|
//txt导出图片文件 - only for original source
|
||||||
val srcList = arrayListOf<SrcData>()
|
val srcList = arrayListOf<SrcData>()
|
||||||
content?.split("\n")?.forEachIndexed { index, text ->
|
content?.split("\n")?.forEachIndexed { index, text ->
|
||||||
val matcher = AppPattern.imgPattern.matcher(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 {
|
} else {
|
||||||
return Pair("\n\n$content1", null)
|
return Pair("\n\n$processedContent", null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,11 +419,22 @@ class ExportBookService : BaseService() {
|
|||||||
exportMsg.remove(book.bookUrl)
|
exportMsg.remove(book.bookUrl)
|
||||||
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
|
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
|
||||||
val fileDoc = FileDoc.fromDir(path)
|
val fileDoc = FileDoc.fromDir(path)
|
||||||
exportEpub(fileDoc, book)
|
exportEpub(fileDoc, book, ContentSource.Original)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun exportEpub(fileDoc: FileDoc, book: Book) {
|
private suspend fun exportEpub(path: String, book: Book, source: ContentSource) {
|
||||||
val filename = book.getExportFileName("epub")
|
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()
|
fileDoc.find(filename)?.delete()
|
||||||
|
|
||||||
val epubBook = EpubBook()
|
val epubBook = EpubBook()
|
||||||
@@ -377,7 +447,7 @@ class ExportBookService : BaseService() {
|
|||||||
val contentModel = setAssets(fileDoc, book, epubBook)
|
val contentModel = setAssets(fileDoc, book, epubBook)
|
||||||
|
|
||||||
//设置正文
|
//设置正文
|
||||||
setEpubContent(contentModel, book, epubBook)
|
setEpubContent(contentModel, book, epubBook, source)
|
||||||
|
|
||||||
val bookDoc = fileDoc.createFileIfNotExist(filename)
|
val bookDoc = fileDoc.createFileIfNotExist(filename)
|
||||||
bookDoc.openOutputStream().getOrThrow().buffered().use { bookOs ->
|
bookDoc.openOutputStream().getOrThrow().buffered().use { bookOs ->
|
||||||
@@ -533,7 +603,8 @@ class ExportBookService : BaseService() {
|
|||||||
private suspend fun setEpubContent(
|
private suspend fun setEpubContent(
|
||||||
contentModel: String,
|
contentModel: String,
|
||||||
book: Book,
|
book: Book,
|
||||||
epubBook: EpubBook
|
epubBook: EpubBook,
|
||||||
|
source: ContentSource = ContentSource.Original
|
||||||
) = coroutineScope {
|
) = coroutineScope {
|
||||||
//正文
|
//正文
|
||||||
val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
|
val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
|
||||||
@@ -543,18 +614,27 @@ class ExportBookService : BaseService() {
|
|||||||
} else {
|
} else {
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
|
val targetLanguage = TranslationConfig.llmTargetLanguage
|
||||||
var parentSection: TOCReference? = null
|
var parentSection: TOCReference? = null
|
||||||
flow {
|
flow {
|
||||||
appDb.bookChapterDao.getChapterList(book.bookUrl).forEach { chapter ->
|
appDb.bookChapterDao.getChapterList(book.bookUrl).forEach { chapter ->
|
||||||
emit(chapter)
|
emit(chapter)
|
||||||
}
|
}
|
||||||
}.mapAsyncIndexed(threads) { index, chapter ->
|
}.mapAsyncIndexed(threads) { index, chapter ->
|
||||||
val content = BookHelp.getContent(book, chapter)
|
val content = when (source) {
|
||||||
val (contentFix, resources) = fixPic(
|
ContentSource.Original -> BookHelp.getContent(book, chapter)
|
||||||
book,
|
ContentSource.Translation -> translationCacheRepository.readTranslation(book, chapter, targetLanguage)
|
||||||
content ?: if (chapter.isVolume) "" else "null",
|
}
|
||||||
chapter
|
// 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标识
|
// 不导出vip标识
|
||||||
chapter.isVip = false
|
chapter.isVip = false
|
||||||
val content1 = contentProcessor
|
val content1 = contentProcessor
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ import io.legado.app.model.ReadAloud
|
|||||||
import io.legado.app.model.ReadBook
|
import io.legado.app.model.ReadBook
|
||||||
import io.legado.app.model.SourceCallBack
|
import io.legado.app.model.SourceCallBack
|
||||||
import io.legado.app.model.analyzeRule.AnalyzeRule
|
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.setChapter
|
||||||
import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
|
import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
|
||||||
import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern
|
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.sysScreenOffTime
|
||||||
import io.legado.app.utils.throttle
|
import io.legado.app.utils.throttle
|
||||||
import io.legado.app.utils.toastOnUi
|
import io.legado.app.utils.toastOnUi
|
||||||
|
import io.legado.app.utils.longToastOnUi
|
||||||
import io.legado.app.utils.visible
|
import io.legado.app.utils.visible
|
||||||
import kotlinx.coroutines.Dispatchers.IO
|
import kotlinx.coroutines.Dispatchers.IO
|
||||||
import kotlinx.coroutines.Dispatchers.Main
|
import kotlinx.coroutines.Dispatchers.Main
|
||||||
@@ -714,6 +717,36 @@ class ReadBookActivity : BaseReadBookActivity(),
|
|||||||
viewModel.refreshContentAll(book)
|
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 {
|
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||||
return onCompatOptionsItemSelected(item)
|
return onCompatOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
@@ -816,7 +849,7 @@ class ReadBookActivity : BaseReadBookActivity(),
|
|||||||
handleKeyPage(PageDirection.NEXT, longPress)
|
handleKeyPage(PageDirection.NEXT, longPress)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// 手柄方向键控制翻页
|
// 手柄方向键控制翻页
|
||||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_LEFT -> {
|
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||||
handleKeyPage(PageDirection.PREV, longPress)
|
handleKeyPage(PageDirection.PREV, longPress)
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ import kotlin.coroutines.coroutineContext
|
|||||||
class ReadBookViewModel(
|
class ReadBookViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
private val getReadingProgressUseCase: GetReadingProgressUseCase,
|
private val getReadingProgressUseCase: GetReadingProgressUseCase,
|
||||||
private val uploadReadingProgressUseCase: UploadReadingProgressUseCase
|
private val uploadReadingProgressUseCase: UploadReadingProgressUseCase,
|
||||||
|
val translateChapterUseCase: io.legado.app.domain.usecase.TranslateChapterUseCase
|
||||||
) : BaseViewModel(application) {
|
) : BaseViewModel(application) {
|
||||||
val permissionDenialLiveData = MutableLiveData<Int>()
|
val permissionDenialLiveData = MutableLiveData<Int>()
|
||||||
var isInitFinish = false
|
var isInitFinish = false
|
||||||
@@ -133,6 +134,7 @@ class ReadBookViewModel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ReadBook.upMsg(null)
|
ReadBook.upMsg(null)
|
||||||
|
|
||||||
if (!isSameBook) {
|
if (!isSameBook) {
|
||||||
ReadBook.loadContent(resetPageOffset = true) {
|
ReadBook.loadContent(resetPageOffset = true) {
|
||||||
ReadBook.bookSource?.let {
|
ReadBook.bookSource?.let {
|
||||||
@@ -352,7 +354,7 @@ class ReadBookViewModel(
|
|||||||
}
|
}
|
||||||
}.onStart {
|
}.onStart {
|
||||||
ReadBook.upMsg(context.getString(R.string.source_auto_changing))
|
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()
|
val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow()
|
||||||
if (book.tocUrl.isEmpty()) {
|
if (book.tocUrl.isEmpty()) {
|
||||||
WebBook.getBookInfoAwait(source, book)
|
WebBook.getBookInfoAwait(source, book)
|
||||||
|
|||||||
@@ -640,10 +640,11 @@ class ReadMenu @JvmOverloads constructor(
|
|||||||
onClick = { runMenuOut{ callBack.openSearchActivity(null) } }
|
onClick = { runMenuOut{ callBack.openSearchActivity(null) } }
|
||||||
),
|
),
|
||||||
ToolButton(
|
ToolButton(
|
||||||
id = "auto_page",
|
id = "translate",
|
||||||
iconRes = R.drawable.ic_auto_page,
|
iconRes = R.drawable.ic_translate,
|
||||||
description = context.getString(R.string.auto_next_page),
|
description = context.getString(R.string.translate),
|
||||||
onClick = { runMenuOut { callBack.autoPage() } }
|
onClick = { runMenuOut { callBack.onTranslationClick() } },
|
||||||
|
onLongClick = { runMenuOut { callBack.onTranslationLongClick() } }
|
||||||
),
|
),
|
||||||
ToolButton(
|
ToolButton(
|
||||||
id = "catalog",
|
id = "catalog",
|
||||||
@@ -709,6 +710,12 @@ class ReadMenu @JvmOverloads constructor(
|
|||||||
onLongClick = { runMenuOut { callBack.openReplaceRule() } },
|
onLongClick = { runMenuOut { callBack.openReplaceRule() } },
|
||||||
onCheck = { runMenuOut { callBack.changeReplaceRuleState() } },
|
onCheck = { runMenuOut { callBack.changeReplaceRuleState() } },
|
||||||
onClick = { }
|
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() {
|
private fun initAnimation() {
|
||||||
menuTopIn.setAnimationListener(menuInListener)
|
menuTopIn.setAnimationListener(menuInListener)
|
||||||
menuTopOut.setAnimationListener(menuOutListener)
|
menuTopOut.setAnimationListener(menuOutListener)
|
||||||
@@ -914,6 +932,8 @@ class ReadMenu @JvmOverloads constructor(
|
|||||||
fun onMenuShow()
|
fun onMenuShow()
|
||||||
fun onMenuHide()
|
fun onMenuHide()
|
||||||
fun changeReplaceRuleState()
|
fun changeReplaceRuleState()
|
||||||
|
fun onTranslationClick()
|
||||||
|
fun onTranslationLongClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ToolButton(
|
data class ToolButton(
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ class ToolButtonConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_too
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getAllButtonIds(): List<String> {
|
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> {
|
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)
|
"theme" -> R.drawable.ic_daytime to getString(R.string.day_night_switch)
|
||||||
"prev_chapter" -> R.drawable.ic_previous to getString(R.string.previous_chapter)
|
"prev_chapter" -> R.drawable.ic_previous to getString(R.string.previous_chapter)
|
||||||
"next_chapter" -> R.drawable.ic_next to getString(R.string.next_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" -> 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)
|
"replace_badge" -> R.drawable.ic_find_replace to getString(R.string.replace_purify_badge)
|
||||||
else -> R.drawable.ic_help to id
|
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.theme.adaptiveContentPadding
|
||||||
import io.legado.app.ui.widget.components.AppScaffold
|
import io.legado.app.ui.widget.components.AppScaffold
|
||||||
import io.legado.app.ui.widget.components.SplicedColumnGroup
|
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.settingItem.ClickableSettingItem
|
||||||
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
|
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.GlassTopAppBarDefaults
|
||||||
|
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -26,7 +26,8 @@ fun ConfigNavScreen(
|
|||||||
onNavigateToCover: () -> Unit,
|
onNavigateToCover: () -> Unit,
|
||||||
onNavigateToTheme: () -> Unit,
|
onNavigateToTheme: () -> Unit,
|
||||||
onNavigateToBackup: () -> Unit,
|
onNavigateToBackup: () -> Unit,
|
||||||
onNavigateToDownloadCache: () -> Unit
|
onNavigateToDownloadCache: () -> Unit,
|
||||||
|
onNavigateToTranslation: () -> Unit
|
||||||
) {
|
) {
|
||||||
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
||||||
|
|
||||||
@@ -75,6 +76,10 @@ fun ConfigNavScreen(
|
|||||||
title = stringResource(R.string.backup_restore),
|
title = stringResource(R.string.backup_restore),
|
||||||
onClick = onNavigateToBackup
|
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.otherConfig.OtherConfigScreen
|
||||||
import io.legado.app.ui.config.readConfig.ReadConfigScreen
|
import io.legado.app.ui.config.readConfig.ReadConfigScreen
|
||||||
import io.legado.app.ui.config.themeConfig.ThemeConfigScreen
|
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.config.themeManage.ThemeManageScreen
|
||||||
import io.legado.app.ui.rss.article.MainRouteRssSort
|
import io.legado.app.ui.rss.article.MainRouteRssSort
|
||||||
import io.legado.app.ui.rss.article.RssSortRouteScreen
|
import io.legado.app.ui.rss.article.RssSortRouteScreen
|
||||||
@@ -155,7 +156,8 @@ fun MainActivity.mainEntryProvider(
|
|||||||
onNavigateToCover = { backStack.add(MainRouteSettingsCover) },
|
onNavigateToCover = { backStack.add(MainRouteSettingsCover) },
|
||||||
onNavigateToTheme = { backStack.add(MainRouteSettingsTheme) },
|
onNavigateToTheme = { backStack.add(MainRouteSettingsTheme) },
|
||||||
onNavigateToBackup = { backStack.add(MainRouteSettingsBackup) },
|
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() })
|
DownloadCacheConfigScreen(onBackClick = { onNavigateBack() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entry<MainRouteSettingsTranslation> {
|
||||||
|
TranslationConfigScreen(onBackClick = { onNavigateBack() })
|
||||||
|
}
|
||||||
|
|
||||||
entry<MainRouteSettingsCustomTheme> {
|
entry<MainRouteSettingsCustomTheme> {
|
||||||
CustomThemeScreen(
|
CustomThemeScreen(
|
||||||
onBackClick = { onNavigateBack() }
|
onBackClick = { onNavigateBack() }
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ data object MainRouteSettingsThemeManage : MainRoute
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object MainRouteSettingsDownloadCache : MainRoute
|
data object MainRouteSettingsDownloadCache : MainRoute
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object MainRouteSettingsTranslation : MainRoute
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object MainRouteImportLocal : MainRoute
|
data object MainRouteImportLocal : MainRoute
|
||||||
|
|
||||||
@@ -96,6 +99,7 @@ object MainRouteConst {
|
|||||||
const val ROUTE_SETTINGS_BACKUP = "settings/backup"
|
const val ROUTE_SETTINGS_BACKUP = "settings/backup"
|
||||||
const val ROUTE_SETTINGS_CUSTOM_THEME = "settings/custom_theme"
|
const val ROUTE_SETTINGS_CUSTOM_THEME = "settings/custom_theme"
|
||||||
const val ROUTE_SETTINGS_DOWNLOAD_CACHE = "settings/download_cache"
|
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_LOCAL = "import/local"
|
||||||
const val ROUTE_IMPORT_REMOTE = "import/remote"
|
const val ROUTE_IMPORT_REMOTE = "import/remote"
|
||||||
const val ROUTE_CACHE = "cache"
|
const val ROUTE_CACHE = "cache"
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ object MainNavigator {
|
|||||||
MainRouteSettingsBackup,
|
MainRouteSettingsBackup,
|
||||||
MainRouteSettingsCustomTheme,
|
MainRouteSettingsCustomTheme,
|
||||||
MainRouteSettingsThemeManage,
|
MainRouteSettingsThemeManage,
|
||||||
MainRouteSettingsDownloadCache -> {
|
MainRouteSettingsDownloadCache,
|
||||||
|
MainRouteSettingsTranslation -> {
|
||||||
backStack.clear()
|
backStack.clear()
|
||||||
backStack.add(MainRouteHome)
|
backStack.add(MainRouteHome)
|
||||||
backStack.add(MainRouteSettings)
|
backStack.add(MainRouteSettings)
|
||||||
@@ -233,6 +234,7 @@ object MainNavigator {
|
|||||||
MainRouteConst.ROUTE_SETTINGS_BACKUP -> MainRouteSettingsBackup
|
MainRouteConst.ROUTE_SETTINGS_BACKUP -> MainRouteSettingsBackup
|
||||||
MainRouteConst.ROUTE_SETTINGS_CUSTOM_THEME -> MainRouteSettingsCustomTheme
|
MainRouteConst.ROUTE_SETTINGS_CUSTOM_THEME -> MainRouteSettingsCustomTheme
|
||||||
MainRouteConst.ROUTE_SETTINGS_DOWNLOAD_CACHE -> MainRouteSettingsDownloadCache
|
MainRouteConst.ROUTE_SETTINGS_DOWNLOAD_CACHE -> MainRouteSettingsDownloadCache
|
||||||
|
MainRouteConst.ROUTE_SETTINGS_TRANSLATION -> MainRouteSettingsTranslation
|
||||||
MainRouteConst.ROUTE_IMPORT_LOCAL -> MainRouteImportLocal
|
MainRouteConst.ROUTE_IMPORT_LOCAL -> MainRouteImportLocal
|
||||||
MainRouteConst.ROUTE_IMPORT_REMOTE -> MainRouteImportRemote
|
MainRouteConst.ROUTE_IMPORT_REMOTE -> MainRouteImportRemote
|
||||||
MainRouteConst.ROUTE_CACHE -> MainRouteCache(
|
MainRouteConst.ROUTE_CACHE -> MainRouteCache(
|
||||||
|
|||||||
@@ -233,6 +233,16 @@ private fun rememberSharedCoverTransitionRadius(
|
|||||||
return animatedRadiusValue.dp
|
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
|
@Composable
|
||||||
private fun CoverTextOverlay(
|
private fun CoverTextOverlay(
|
||||||
name: String?,
|
name: String?,
|
||||||
@@ -251,7 +261,9 @@ private fun CoverTextOverlay(
|
|||||||
if (isNight) CoverConfig.coverTextColorN else CoverConfig.coverTextColor
|
if (isNight) CoverConfig.coverTextColorN else CoverConfig.coverTextColor
|
||||||
}
|
}
|
||||||
val shadowColor = if (isNight) CoverConfig.coverShadowColorN else CoverConfig.coverShadowColor
|
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()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
val viewWidth = size.width
|
val viewWidth = size.width
|
||||||
|
|||||||
@@ -162,4 +162,5 @@
|
|||||||
android:title="@string/help"
|
android:title="@string/help"
|
||||||
app:showAsAction="never" />
|
app:showAsAction="never" />
|
||||||
|
|
||||||
|
|
||||||
</menu>
|
</menu>
|
||||||
|
|||||||
@@ -1739,4 +1739,36 @@
|
|||||||
<string name="homepage_status_in_effect"> · 当前生效</string>
|
<string name="homepage_status_in_effect"> · 当前生效</string>
|
||||||
<string name="homepage_status_blocked"> · 已被屏蔽</string>
|
<string name="homepage_status_blocked"> · 已被屏蔽</string>
|
||||||
<string name="homepage_status_joined"> · 已加入</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>
|
</resources>
|
||||||
|
|||||||
@@ -1681,6 +1681,38 @@
|
|||||||
<string name="download_setting">Download</string>
|
<string name="download_setting">Download</string>
|
||||||
<string name="image_cache">Image Cache</string>
|
<string name="image_cache">Image Cache</string>
|
||||||
<string name="network">Network</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_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_module_duplicate_infinite">This group already has an infinite loading module</string>
|
||||||
<string name="homepage_quick_actions">Quick Actions</string>
|
<string name="homepage_quick_actions">Quick Actions</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user