From 74d04327f54fa32474ae00a52a5d7043dfed0372 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 29 Mar 2026 01:14:09 +0900 Subject: [PATCH 01/14] add richtext translate support --- .../data/datastore/model/AiPromptDefaults.kt | 12 +- .../ui/presenter/status/TranslatePresenter.kt | 22 +- .../flare/ui/render/TranslationJson.kt | 285 ++++++++++++++++++ .../render/TranslationJsonUiRichTextTest.kt | 63 ++++ 4 files changed, 373 insertions(+), 9 deletions(-) create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/render/TranslationJson.kt create mode 100644 shared/src/commonTest/kotlin/dev/dimension/flare/ui/render/TranslationJsonUiRichTextTest.kt diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt index 0047de9e2..5fcb3107d 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt @@ -4,11 +4,13 @@ internal object AiPromptDefaults { const val TRANSLATE_PROMPT: String = "You are a translation assistant. Your task is to translate text from one language to another.\n" + "Make sure to keep the meaning and context of the original content intact.\n" + - "The input is plain text extracted from a social post.\n" + - "Keep mentions, hashtags, URLs, and code-like text unchanged when possible.\n" + - "Return ONLY translated plain text without markdown code fences or explanations.\n" + - "Translate the following text to {target_language}:\n" + - "{source_text}" + "The input is JSON extracted from a social post.\n" + + "Preserve the full JSON structure, block ids, token ids, and token kinds exactly as-is.\n" + + "Only translate token text where kind is \"Translatable\".\n" + + "Keep token text where kind is \"Locked\" unchanged.\n" + + "Return ONLY JSON without markdown code fences or explanations.\n" + + "Translate the following JSON to {target_language}:\n" + + "{source_json}" const val TLDR_PROMPT: String = "Summarize the following text in {target_language}\n" + diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt index 08065dc31..e46ecece5 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt @@ -12,7 +12,9 @@ import dev.dimension.flare.data.network.ktorClient import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.presenter.PresenterBase import dev.dimension.flare.ui.render.UiRichText +import dev.dimension.flare.ui.render.applyTranslationJson import dev.dimension.flare.ui.render.toTranslatableText +import dev.dimension.flare.ui.render.toTranslationJson import dev.dimension.flare.ui.render.toUiPlainText import io.ktor.client.call.body import io.ktor.client.request.get @@ -34,6 +36,7 @@ public class TranslatePresenter( private val appDataStore: AppDataStore by inject() private val onDeviceAI: OnDeviceAI by inject() private val sourceText: String by lazy { source.toTranslatableText() } + private val sourceJson: String by lazy { source.toTranslationJson(targetLanguage) } @Composable override fun body(): UiState { @@ -51,7 +54,7 @@ public class TranslatePresenter( aiConfig.translatePrompt.ifBlank { AiPromptDefaults.TRANSLATE_PROMPT } - val prompt = buildTranslatePrompt(promptTemplate, targetLanguage, source) + val prompt = buildTranslatePrompt(promptTemplate, targetLanguage) when (val type = aiConfig.type) { AppSettings.AiConfig.Type.OnDevice -> onDeviceAI.translate(sourceText, targetLanguage, prompt) ?: legacyGoogleTranslate() @@ -103,19 +106,30 @@ public class TranslatePresenter( private fun buildTranslatePrompt( template: String, targetLanguage: String, - source: UiRichText, ): String = template .replace("{target_language}", targetLanguage) .replace("{source_text}", sourceText) - .replace("{source_html}", sourceText) + .replace("{source_json}", sourceJson) + .replace("{source_html}", sourceJson) + .replace("{source_xml}", sourceJson) + .replace("{source_markup}", sourceJson) private fun toUiRichText(translatedContent: String): UiRichText = translatedContent + .removePrefix("```json") .removePrefix("```html") + .removePrefix("```xml") + .removePrefix("```markup") .removePrefix("```text") .removePrefix("```") .removeSuffix("```") .trim() - .toUiPlainText() + .let { cleaned -> + runCatching { + source.applyTranslationJson(cleaned) + }.getOrElse { + cleaned.toUiPlainText() + } + } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/render/TranslationJson.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/render/TranslationJson.kt new file mode 100644 index 000000000..f3fb54cbf --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/render/TranslationJson.kt @@ -0,0 +1,285 @@ +package dev.dimension.flare.ui.render + +import kotlinx.collections.immutable.toImmutableList +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +internal data class TranslationDocument( + val version: Int? = 1, + val targetLanguage: String? = null, + val blocks: List, +) + +@Serializable +internal data class TranslationBlock( + val id: Int, + val tokens: List, +) + +@Serializable +internal data class TranslationToken( + val id: Int, + val kind: TranslationTokenKind, + val text: String, +) + +@Serializable +internal enum class TranslationTokenKind { + Translatable, + Locked, +} + +internal class TranslationFormatException( + message: String, +) : IllegalArgumentException(message) + +private val translationJson = + Json { + encodeDefaults = true + ignoreUnknownKeys = true + explicitNulls = false + } + +internal fun UiRichText.toTranslationDocument(targetLanguage: String? = null): TranslationDocument { + val projection = toProjectionBlocks() + return TranslationDocument( + version = 1, + targetLanguage = targetLanguage?.takeIf { it.isNotBlank() }, + blocks = + projection.map { block -> + TranslationBlock( + id = block.id, + tokens = + block.pieces.mapNotNull { piece -> + when (piece) { + is TranslationProjectionPiece.StaticImage -> null + is TranslationProjectionPiece.Token -> + TranslationToken( + id = piece.id, + kind = piece.kind, + text = piece.text, + ) + } + }, + ) + }, + ) +} + +internal fun UiRichText.toTranslationJson(targetLanguage: String? = null): String = + translationJson.encodeToString(toTranslationDocument(targetLanguage)) + +internal fun UiRichText.applyTranslationJson(json: String): UiRichText = + applyTranslationDocument( + runCatching { + translationJson.decodeFromString(TranslationDocument.serializer(), json) + }.getOrElse { throwable -> + throw TranslationFormatException(throwable.message ?: "Failed to parse translation json") + }, + ) + +internal fun UiRichText.applyTranslationDocument(document: TranslationDocument): UiRichText { + val projection = toProjectionBlocks() + val projectedBlocksByContent = projection.associateBy { it.content } + val blocksById = document.blocks.associateBy { it.id } + if (blocksById.size != projection.size) { + throw TranslationFormatException( + "Expected ${projection.size} blocks but found ${blocksById.size}", + ) + } + + val translatedContents = + renderRuns.map { content -> + when (content) { + is RenderContent.BlockImage -> content + is RenderContent.Text -> { + val projectedBlock = + projectedBlocksByContent[content] + ?: return@map content + val translatedBlock = + blocksById[projectedBlock.id] + ?: throw TranslationFormatException("Missing block ${projectedBlock.id}") + applyTranslatedBlock(projectedBlock, translatedBlock) + } + } + } + + return uiRichTextOf( + renderRuns = translatedContents, + ) +} + +private fun applyTranslatedBlock( + projectedBlock: TranslationProjectionBlock, + translatedBlock: TranslationBlock, +): RenderContent.Text { + val translatedTokens = translatedBlock.tokens.associateBy { it.id } + val expectedTokens = projectedBlock.pieces.filterIsInstance() + if (translatedTokens.size != expectedTokens.size) { + throw TranslationFormatException( + "Expected ${expectedTokens.size} tokens in block ${projectedBlock.id} but found ${translatedTokens.size}", + ) + } + + val translatedRuns = + buildList { + projectedBlock.pieces.forEach { piece -> + when (piece) { + is TranslationProjectionPiece.StaticImage -> add(piece.run) + is TranslationProjectionPiece.Token -> { + val translatedToken = + translatedTokens[piece.id] + ?: throw TranslationFormatException( + "Missing token ${piece.id} in block ${projectedBlock.id}", + ) + if (translatedToken.kind != piece.kind) { + throw TranslationFormatException( + "Token ${piece.id} in block ${projectedBlock.id} changed kind", + ) + } + if (piece.kind == TranslationTokenKind.Locked && translatedToken.text != piece.text) { + throw TranslationFormatException( + "Locked token ${piece.id} in block ${projectedBlock.id} was modified", + ) + } + add( + RenderRun.Text( + text = translatedToken.text, + style = piece.style, + ), + ) + } + } + } + }.mergeAdjacentTextRuns() + + return RenderContent.Text( + runs = translatedRuns.toImmutableList(), + block = projectedBlock.content.block, + ) +} + +private data class TranslationProjectionBlock( + val id: Int, + val content: RenderContent.Text, + val pieces: List, +) + +private sealed interface TranslationProjectionPiece { + data class Token( + val id: Int, + val kind: TranslationTokenKind, + val style: RenderTextStyle, + val text: String, + ) : TranslationProjectionPiece + + data class StaticImage( + val run: RenderRun.Image, + ) : TranslationProjectionPiece +} + +private val protectedTranslationPattern = + Regex("""https?://\S+|@[A-Za-z0-9._-]+(?:@[A-Za-z0-9.-]+)?|#[\p{L}\p{N}_]+""") + +private fun UiRichText.toProjectionBlocks(): List = + buildList { + var nextBlockId = 0 + renderRuns.forEach { content -> + when (content) { + is RenderContent.BlockImage -> Unit + is RenderContent.Text -> { + val pieces = content.toProjectionPieces() + if (pieces.any { it is TranslationProjectionPiece.Token && it.kind == TranslationTokenKind.Translatable }) { + add( + TranslationProjectionBlock( + id = nextBlockId, + content = content, + pieces = pieces, + ), + ) + nextBlockId += 1 + } + } + } + } + } + +private fun RenderContent.Text.toProjectionPieces(): List { + val pieces = mutableListOf() + var nextTokenId = 0 + runs.forEach { run -> + when (run) { + is RenderRun.Image -> pieces.add(TranslationProjectionPiece.StaticImage(run)) + is RenderRun.Text -> { + tokenizeTranslationText(run.text, run.style).forEach { (kind, text) -> + if (text.isEmpty()) { + return@forEach + } + val last = pieces.lastOrNull() + if (last is TranslationProjectionPiece.Token && last.kind == kind && last.style == run.style) { + pieces[pieces.lastIndex] = last.copy(text = last.text + text) + } else { + pieces.add( + TranslationProjectionPiece.Token( + id = nextTokenId, + kind = kind, + style = run.style, + text = text, + ), + ) + nextTokenId += 1 + } + } + } + } + } + return pieces +} + +private fun tokenizeTranslationText( + text: String, + style: RenderTextStyle, +): List> { + if (text.isEmpty()) { + return emptyList() + } + if (style.code || style.monospace) { + return listOf(TranslationTokenKind.Locked to text) + } + val tokens = mutableListOf>() + var cursor = 0 + protectedTranslationPattern.findAll(text).forEach { match -> + if (match.range.first > cursor) { + val segment = text.substring(cursor, match.range.first) + tokens.add(segment.toTranslationToken()) + } + tokens.add(TranslationTokenKind.Locked to match.value) + cursor = match.range.last + 1 + } + if (cursor < text.length) { + tokens.add(text.substring(cursor).toTranslationToken()) + } + return tokens.filter { it.second.isNotEmpty() } +} + +private fun String.toTranslationToken(): Pair = + if (isBlank()) { + TranslationTokenKind.Locked to this + } else { + TranslationTokenKind.Translatable to this + } + +private fun List.mergeAdjacentTextRuns(): List { + val merged = mutableListOf() + forEach { run -> + val last = merged.lastOrNull() + if (last is RenderRun.Text && run is RenderRun.Text && last.style == run.style) { + merged[merged.lastIndex] = last.copy(text = last.text + run.text) + } else { + merged.add(run) + } + } + return merged +} diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/ui/render/TranslationJsonUiRichTextTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/render/TranslationJsonUiRichTextTest.kt new file mode 100644 index 000000000..cd4122af5 --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/render/TranslationJsonUiRichTextTest.kt @@ -0,0 +1,63 @@ +package dev.dimension.flare.ui.render + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class TranslationJsonUiRichTextTest { + @Test + fun toTranslationJson_exports_projected_document() { + val richText = + parseHtml( + """

Hello world @alice https://example.com #topic

""", + ).toUi() + + assertEquals( + """{"version":1,"targetLanguage":"zh-CN","blocks":[{"id":0,"tokens":[{"id":0,"kind":"Translatable","text":"Hello "},{"id":1,"kind":"Translatable","text":"world"},{"id":2,"kind":"Locked","text":" @alice https://example.com #topic "}]}]}""", + richText.toTranslationJson("zh-CN"), + ) + } + + @Test + fun applyTranslationJson_replaces_text_while_preserving_styles_and_images() { + val richText = + parseHtml( + "

Hello world from Tokyo

Original quote
", + ).toUi() + + val translated = + richText.applyTranslationJson( + """{"version":1,"targetLanguage":"zh-CN","blocks":[{"id":0,"tokens":[{"id":0,"kind":"Translatable","text":"你好 "},{"id":1,"kind":"Translatable","text":"世界"},{"id":2,"kind":"Translatable","text":" 来自东京"}]},{"id":1,"tokens":[{"id":0,"kind":"Translatable","text":"翻译后的引用"}]}]}""", + ) + + assertEquals("你好 世界:wave: 来自东京翻译后的引用", translated.raw) + assertEquals(2, translated.renderRuns.size) + + val first = assertIs(translated.renderRuns[0]) + assertEquals(4, first.runs.size) + assertEquals("你好 ", assertIs(first.runs[0]).text) + val bold = assertIs(first.runs[1]) + assertEquals("世界", bold.text) + assertTrue(bold.style.bold) + val emoji = assertIs(first.runs[2]) + assertEquals(":wave:", emoji.alt) + assertEquals(" 来自东京", assertIs(first.runs[3]).text) + + val quote = assertIs(translated.renderRuns[1]) + assertTrue(quote.block.isBlockQuote) + assertEquals("翻译后的引用", assertIs(quote.runs.single()).text) + } + + @Test + fun applyTranslationJson_rejects_modified_locked_tokens() { + val richText = "Hello @alice".toUiPlainText() + + assertFailsWith { + richText.applyTranslationJson( + """{"version":1,"blocks":[{"id":0,"tokens":[{"id":0,"kind":"Translatable","text":"你好 "},{"id":1,"kind":"Locked","text":"@bob"}]}]}""", + ) + } + } +} From 06cd68dd35654f34c74c422db34e56a3d7dc9905 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 29 Mar 2026 02:48:02 +0900 Subject: [PATCH 02/14] translate enhancement --- .../values-zh-rCN/strings.xml | 2 + .../main/composeResources/values/strings.xml | 2 + .../ui/screen/settings/SettingsScreen.kt | 20 + .../data/database/cache/CacaheDatabase.kt | 6 +- .../data/database/cache/dao/TranslationDao.kt | 90 ++++ .../database/cache/model/DbPagingTimeline.kt | 6 + .../database/cache/model/DbTranslation.kt | 87 +++ .../cache/model/TranslationDisplay.kt | 101 ++++ .../microblog/handler/UserHandler.kt | 92 +++- .../microblog/paging/TimelinePagingMapper.kt | 25 +- .../paging/TimelineRemoteMediator.kt | 10 + .../flare/data/datastore/model/AppSettings.kt | 1 + .../data/network/ai/AiCompletionService.kt | 49 ++ .../flare/data/network/ai/OpenAIService.kt | 13 + .../data/translation/PreTranslationService.kt | 503 ++++++++++++++++++ .../dev/dimension/flare/di/CommonModule.kt | 5 + .../ui/presenter/home/TimelinePresenter.kt | 40 +- .../ui/presenter/server/AiTLDRPresenter.kt | 27 +- .../presenter/settings/AiConfigPresenter.kt | 14 +- .../ui/presenter/status/TranslatePresenter.kt | 29 +- .../database/cache/dao/TranslationDaoTest.kt | 204 +++++++ .../database/cache/mapper/MicroblogTest.kt | 83 +++ .../microblog/MixedRemoteMediatorTest.kt | 195 +++++++ .../microblog/handler/UserHandlerTest.kt | 249 +++++++++ 24 files changed, 1785 insertions(+), 68 deletions(-) create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDao.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbTranslation.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/TranslationDisplay.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/network/ai/AiCompletionService.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt create mode 100644 shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDaoTest.kt diff --git a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml index db933c475..7d11df957 100644 --- a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml +++ b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml @@ -171,6 +171,8 @@ 点击编辑 启用 AI 翻译 将 Google 翻译替换为 AI 翻译,可能需要更长的时间 + 启用预翻译 + 在后台翻译并缓存新加载的时间线和个人资料内容。这会消耗大量 token。 启用 AI 摘要 对长篇文章启用 AI 摘要,仅在超过 500 个字符的帖子中可用 通用 diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index cce3dba88..a047f62c0 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -191,6 +191,8 @@ Tap to edit Enable AI translation Replace Google Translate with AI translation, might take longer time + Enable pre-translation + Translate and cache newly loaded timeline and profile content in the background. This can consume a large amount of tokens. Enable AI Summarization Enable AI summarization for long posts, only available in post that longer than 500 characters diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index e53c5b928..9c559b4ab 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -86,6 +86,7 @@ import dev.dimension.flare.settings_accounts_title import dev.dimension.flare.settings_ai_config_api_key import dev.dimension.flare.settings_ai_config_api_key_hint import dev.dimension.flare.settings_ai_config_description +import dev.dimension.flare.settings_ai_config_enable_pre_translation import dev.dimension.flare.settings_ai_config_enable_tldr import dev.dimension.flare.settings_ai_config_entable_translation import dev.dimension.flare.settings_ai_config_model @@ -94,6 +95,7 @@ import dev.dimension.flare.settings_ai_config_model_error import dev.dimension.flare.settings_ai_config_model_loading import dev.dimension.flare.settings_ai_config_model_no_models import dev.dimension.flare.settings_ai_config_model_select +import dev.dimension.flare.settings_ai_config_pre_translation_description import dev.dimension.flare.settings_ai_config_server import dev.dimension.flare.settings_ai_config_server_hint import dev.dimension.flare.settings_ai_config_server_url_requirement @@ -1422,6 +1424,24 @@ internal fun SettingsScreen( ExpanderItemSeparator() AnimatedVisibility(state.aiConfigState.aiConfig.translation) { Column { + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_ai_config_enable_pre_translation)) + }, + caption = { + Text(stringResource(Res.string.settings_ai_config_pre_translation_description)) + }, + trailing = { + Switcher( + checked = state.aiConfigState.aiConfig.preTranslation, + { + state.aiConfigState.update { copy(preTranslation = it) } + }, + textBefore = true, + ) + }, + ) + ExpanderItemSeparator() ExpanderItem( heading = { Text(stringResource(Res.string.settings_ai_config_translate_prompt)) }, caption = { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt index 3b98e4519..7c40ce9dd 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt @@ -8,7 +8,7 @@ import androidx.room.TypeConverters import androidx.room.immediateTransaction import androidx.room.useWriterConnection -internal const val CACHE_DATABASE_VERSION = 29 +internal const val CACHE_DATABASE_VERSION = 30 @Database( entities = [ @@ -28,6 +28,7 @@ internal const val CACHE_DATABASE_VERSION = 29 dev.dimension.flare.data.database.cache.model.DbListPaging::class, dev.dimension.flare.data.database.cache.model.DbListMember::class, dev.dimension.flare.data.database.cache.model.DbUserRelation::class, + dev.dimension.flare.data.database.cache.model.DbTranslation::class, ], version = CACHE_DATABASE_VERSION, exportSchema = false, @@ -40,6 +41,7 @@ internal const val CACHE_DATABASE_VERSION = 29 dev.dimension.flare.data.database.cache.model.StatusConverter::class, dev.dimension.flare.data.database.cache.model.MessageContentConverters::class, dev.dimension.flare.data.database.cache.model.ListContentConverters::class, + dev.dimension.flare.data.database.cache.model.TranslationConverters::class, ) @ConstructedBy(CacheDatabaseConstructor::class) internal abstract class CacheDatabase : RoomDatabase() { @@ -56,6 +58,8 @@ internal abstract class CacheDatabase : RoomDatabase() { abstract fun messageDao(): dev.dimension.flare.data.database.cache.dao.MessageDao abstract fun listDao(): dev.dimension.flare.data.database.cache.dao.ListDao + + abstract fun translationDao(): dev.dimension.flare.data.database.cache.dao.TranslationDao } // The Room compiler generates the `actual` implementations. diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDao.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDao.kt new file mode 100644 index 000000000..ca1b7455c --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDao.kt @@ -0,0 +1,90 @@ +package dev.dimension.flare.data.database.cache.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import dev.dimension.flare.data.database.cache.model.DbTranslation +import dev.dimension.flare.data.database.cache.model.TranslationEntityType +import dev.dimension.flare.data.database.cache.model.TranslationPayload +import dev.dimension.flare.data.database.cache.model.TranslationStatus +import kotlinx.coroutines.flow.Flow + +@Dao +internal interface TranslationDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(data: DbTranslation) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(data: List) + + @Query( + "SELECT * FROM DbTranslation " + + "WHERE entityType = :entityType AND entityKey = :entityKey AND targetLanguage = :targetLanguage " + + "LIMIT 1", + ) + fun find( + entityType: TranslationEntityType, + entityKey: String, + targetLanguage: String, + ): Flow + + @Query( + "SELECT * FROM DbTranslation " + + "WHERE entityType = :entityType AND entityKey = :entityKey AND targetLanguage = :targetLanguage " + + "LIMIT 1", + ) + suspend fun get( + entityType: TranslationEntityType, + entityKey: String, + targetLanguage: String, + ): DbTranslation? + + @Query( + "SELECT * FROM DbTranslation " + + "WHERE entityType = :entityType AND entityKey IN (:entityKeys) AND targetLanguage = :targetLanguage", + ) + suspend fun getByEntityKeys( + entityType: TranslationEntityType, + entityKeys: List, + targetLanguage: String, + ): List + + @Query( + "UPDATE DbTranslation SET " + + "sourceHash = :sourceHash, " + + "status = :status, " + + "payload = :payload, " + + "statusReason = :statusReason, " + + "attemptCount = :attemptCount, " + + "updatedAt = :updatedAt " + + "WHERE entityType = :entityType AND entityKey = :entityKey AND targetLanguage = :targetLanguage", + ) + suspend fun update( + entityType: TranslationEntityType, + entityKey: String, + targetLanguage: String, + sourceHash: String, + status: TranslationStatus, + payload: TranslationPayload?, + statusReason: String?, + attemptCount: Int, + updatedAt: Long, + ) + + @Query( + "DELETE FROM DbTranslation " + + "WHERE entityType = :entityType AND entityKey = :entityKey AND targetLanguage = :targetLanguage", + ) + suspend fun delete( + entityType: TranslationEntityType, + entityKey: String, + targetLanguage: String, + ) + + @Query("DELETE FROM DbTranslation WHERE targetLanguage = :targetLanguage") + suspend fun deleteByLanguage(targetLanguage: String) + + @Query("DELETE FROM DbTranslation") + suspend fun clear() +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbPagingTimeline.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbPagingTimeline.kt index d778840c3..11a669d8c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbPagingTimeline.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbPagingTimeline.kt @@ -52,6 +52,12 @@ internal data class DbPagingTimelineWithStatus( internal data class DbStatusWithUser( @Embedded val data: DbStatus, + @Relation( + parentColumn = "id", + entityColumn = "entityKey", + entity = DbTranslation::class, + ) + val translations: List = emptyList(), ) internal data class DbStatusReferenceWithStatus( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbTranslation.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbTranslation.kt new file mode 100644 index 000000000..93b82a63b --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbTranslation.kt @@ -0,0 +1,87 @@ +package dev.dimension.flare.data.database.cache.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.TypeConverter +import dev.dimension.flare.common.decodeJson +import dev.dimension.flare.common.encodeJson +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.render.UiRichText +import kotlinx.serialization.Serializable + +@Entity( + indices = [ + Index(value = ["entityType", "entityKey", "targetLanguage"], unique = true), + Index(value = ["entityType", "entityKey"]), + Index(value = ["status"]), + Index(value = ["targetLanguage"]), + ], +) +internal data class DbTranslation( + val entityType: TranslationEntityType, + val entityKey: String, + val targetLanguage: String, + val sourceHash: String, + val status: TranslationStatus, + @ColumnInfo(typeAffinity = ColumnInfo.TEXT) + val payload: TranslationPayload? = null, + val statusReason: String? = null, + val attemptCount: Int = 0, + val updatedAt: Long, + @PrimaryKey + val id: String = "${entityType.name}:$entityKey:$targetLanguage", +) + +@Serializable +internal enum class TranslationEntityType { + Status, + Profile, +} + +@Serializable +internal enum class TranslationStatus { + Pending, + Translating, + Completed, + Failed, + Skipped, +} + +@Serializable +internal data class TranslationPayload( + val content: UiRichText? = null, + val contentWarning: UiRichText? = null, + val title: UiRichText? = null, + val description: UiRichText? = null, +) + +internal class TranslationConverters { + @TypeConverter + fun fromEntityType(value: TranslationEntityType): String = value.name + + @TypeConverter + fun toEntityType(value: String): TranslationEntityType = TranslationEntityType.valueOf(value) + + @TypeConverter + fun fromStatus(value: TranslationStatus): String = value.name + + @TypeConverter + fun toStatus(value: String): TranslationStatus = TranslationStatus.valueOf(value) + + @TypeConverter + fun fromPayload(value: TranslationPayload?): String? = value?.encodeJson(TranslationPayload.serializer()) + + @TypeConverter + fun toPayload(value: String?): TranslationPayload? = value?.decodeJson(TranslationPayload.serializer()) +} + +internal fun DbStatus.translationEntityKey(): String = id + +internal fun DbUser.translationEntityKey(): String = profileTranslationEntityKey(userKey) + +internal fun UiProfile.translationEntityKey(): String = profileTranslationEntityKey(key) + +internal fun profileTranslationEntityKey(userKey: MicroBlogKey): String = "profile:$userKey" diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/TranslationDisplay.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/TranslationDisplay.kt new file mode 100644 index 000000000..9a97c25bd --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/TranslationDisplay.kt @@ -0,0 +1,101 @@ +package dev.dimension.flare.data.database.cache.model + +import dev.dimension.flare.common.encodeJson +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.render.toUiPlainText + +internal data class TranslationDisplayOptions( + val enabled: Boolean, + val targetLanguage: String, +) + +internal fun UiTimelineV2.applyTranslation( + options: TranslationDisplayOptions, + translations: List, +): UiTimelineV2 { + if (!options.enabled) { + return this + } + val payload = translationPayload() ?: return this + val translation = + translations.firstOrNull { + it.targetLanguage == options.targetLanguage && + it.status == TranslationStatus.Completed && + it.sourceHash == payload.sourceHash() + } ?: return this + + return when (this) { + is UiTimelineV2.Feed -> + copy( + title = translation.payload?.title?.raw ?: title, + description = translation.payload?.description?.raw ?: description, + ) + + is UiTimelineV2.Post -> + copy( + content = translation.payload?.content ?: content, + contentWarning = translation.payload?.contentWarning ?: contentWarning, + ) + + is UiTimelineV2.Message -> this + is UiTimelineV2.User -> this + is UiTimelineV2.UserList -> this + } +} + +internal fun UiProfile.applyTranslation( + options: TranslationDisplayOptions, + translation: DbTranslation?, +): UiProfile { + if (!options.enabled) { + return this + } + val payload = translationPayload() + if ( + translation == null || + translation.targetLanguage != options.targetLanguage || + translation.status != TranslationStatus.Completed || + translation.sourceHash != payload.sourceHash() + ) { + return this + } + return copy( + description = translation.payload?.description ?: description, + ) +} + +internal fun UiTimelineV2.translationPayload(): TranslationPayload? = + when (this) { + is UiTimelineV2.Feed -> + TranslationPayload( + title = title?.toUiPlainText(), + description = description?.toUiPlainText(), + ) + + is UiTimelineV2.Post -> + TranslationPayload( + content = content, + contentWarning = contentWarning, + ) + + is UiTimelineV2.Message -> null + is UiTimelineV2.User -> null + is UiTimelineV2.UserList -> null + } + +internal fun UiProfile.translationPayload(): TranslationPayload = + TranslationPayload( + description = description, + ) + +internal fun TranslationPayload.sourceHash(): String = encodeJson(TranslationPayload.serializer()).stableTranslationHash() + +private fun String.stableTranslationHash(): String { + var hash = -0x340d631b8c4674c3L + encodeToByteArray().forEach { byte -> + hash = hash xor (byte.toLong() and 0xffL) + hash *= 0x100000001b3L + } + return hash.toULong().toString(16) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt index 9c586cb2c..3584b3df6 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt @@ -1,13 +1,25 @@ package dev.dimension.flare.data.datasource.microblog.handler import dev.dimension.flare.common.Cacheable +import dev.dimension.flare.common.Locale import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.toDbUser import dev.dimension.flare.data.database.cache.mapper.upsertUser +import dev.dimension.flare.data.database.cache.model.TranslationDisplayOptions +import dev.dimension.flare.data.database.cache.model.TranslationEntityType +import dev.dimension.flare.data.database.cache.model.applyTranslation +import dev.dimension.flare.data.database.cache.model.translationEntityKey import dev.dimension.flare.data.datasource.microblog.loader.UserLoader +import dev.dimension.flare.data.datastore.AppDataStore +import dev.dimension.flare.data.translation.PreTranslationService import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiHandle +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -17,23 +29,38 @@ internal class UserHandler( private val loader: UserLoader, ) : KoinComponent { private val database: CacheDatabase by inject() + private val appDataStore: AppDataStore by inject() + private val preTranslationService: PreTranslationService by inject() + + private val translationDisplayFlow by lazy { + appDataStore.appSettingsStore.data + .map { settings -> + TranslationDisplayOptions( + enabled = settings.aiConfig.translation && settings.aiConfig.preTranslation, + targetLanguage = settings.language.ifBlank { Locale.language }, + ) + }.distinctUntilChanged() + } fun userByHandleAndHost(uiHandle: UiHandle) = Cacheable( fetchSource = { val user = loader.userByHandleAndHost(uiHandle) + val dbUser = user.toDbUser() database.upsertUser( - user.toDbUser(), + dbUser, ) + preTranslationService.enqueueProfile(dbUser) }, cacheSource = { - database - .userDao() - .findByCanonicalHandleAndHost( - canonicalHandle = uiHandle.canonical, - host = uiHandle.normalizedHost, - ).distinctUntilChanged() - .mapNotNull { it?.content } + translatedUserFlow( + database + .userDao() + .findByCanonicalHandleAndHost( + canonicalHandle = uiHandle.canonical, + host = uiHandle.normalizedHost, + ).distinctUntilChanged(), + ) }, ) @@ -41,20 +68,49 @@ internal class UserHandler( Cacheable( fetchSource = { val user = loader.userById(id) + val dbUser = user.toDbUser() database.upsertUser( - user.toDbUser(), + dbUser, ) + preTranslationService.enqueueProfile(dbUser) }, cacheSource = { - database - .userDao() - .findByKey( - MicroBlogKey( - id = id, - host = host, - ), - ).distinctUntilChanged() - .mapNotNull { it?.content } + translatedUserFlow( + database + .userDao() + .findByKey( + MicroBlogKey( + id = id, + host = host, + ), + ).distinctUntilChanged(), + ) }, ) + + @OptIn(ExperimentalCoroutinesApi::class) + private fun translatedUserFlow(userFlow: kotlinx.coroutines.flow.Flow) = + combine(userFlow, translationDisplayFlow) { user, translationDisplayOptions -> + user to translationDisplayOptions + }.flatMapLatest { (user, translationDisplayOptions) -> + if (user == null || !translationDisplayOptions.enabled) { + flowOf(user?.content) + } else { + combine( + flowOf(user), + database + .translationDao() + .find( + entityType = TranslationEntityType.Profile, + entityKey = user.translationEntityKey(), + targetLanguage = translationDisplayOptions.targetLanguage, + ), + ) { dbUser, translation -> + dbUser.content.applyTranslation( + options = translationDisplayOptions, + translation = translation, + ) + } + } + }.mapNotNull { it } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelinePagingMapper.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelinePagingMapper.kt index 4264f0371..d6af80bbf 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelinePagingMapper.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelinePagingMapper.kt @@ -8,6 +8,8 @@ import dev.dimension.flare.data.database.cache.model.DbStatusReference import dev.dimension.flare.data.database.cache.model.DbStatusReferenceWithStatus import dev.dimension.flare.data.database.cache.model.DbStatusWithReference import dev.dimension.flare.data.database.cache.model.DbStatusWithUser +import dev.dimension.flare.data.database.cache.model.TranslationDisplayOptions +import dev.dimension.flare.data.database.cache.model.applyTranslation import dev.dimension.flare.model.DbAccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.ReferenceType @@ -57,16 +59,24 @@ internal object TimelinePagingMapper { item: DbPagingTimelineWithStatus, pagingKey: String, useDbKeyInItemKey: Boolean, + translationDisplayOptions: TranslationDisplayOptions = TranslationDisplayOptions(enabled = false, targetLanguage = ""), ): UiTimelineV2 { - val root = dbStatusWithUserToUiTimeline(item.status.status, pagingKey, useDbKeyInItemKey) + val root = + dbStatusWithUserToUiTimeline( + data = item.status.status, + pagingKey = pagingKey, + useDbKeyInItemKey = useDbKeyInItemKey, + translationDisplayOptions = translationDisplayOptions, + ) val references = item.status.references.mapNotNull { reference -> reference.status?.let { reference.reference.referenceType to dbStatusWithUserToUiTimeline( - it, - pagingKey, - useDbKeyInItemKey, + data = it, + pagingKey = pagingKey, + useDbKeyInItemKey = useDbKeyInItemKey, + translationDisplayOptions = translationDisplayOptions, ) } } @@ -245,8 +255,13 @@ internal object TimelinePagingMapper { data: DbStatusWithUser, pagingKey: String, useDbKeyInItemKey: Boolean, + translationDisplayOptions: TranslationDisplayOptions, ): UiTimelineV2 { - val root = data.data.content + val root = + data.data.content.applyTranslation( + options = translationDisplayOptions, + translations = data.translations, + ) return when (root) { is UiTimelineV2.Feed -> root is UiTimelineV2.Message -> diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelineRemoteMediator.kt index ae5296bf1..1af53df09 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelineRemoteMediator.kt @@ -5,6 +5,8 @@ import dev.dimension.flare.common.SnowflakeIdGenerator import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.saveToDatabase import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus +import dev.dimension.flare.data.translation.NoopPreTranslationService +import dev.dimension.flare.data.translation.PreTranslationService import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiTimelineV2 @@ -15,6 +17,7 @@ internal class TimelineRemoteMediator( private val loader: CacheableRemoteLoader, private val database: CacheDatabase, private val notifyError: (Throwable) -> Unit = {}, + private val preTranslationService: PreTranslationService = NoopPreTranslationService, ) : BasePagingRemoteMediator( database = database, ), @@ -107,6 +110,13 @@ internal class TimelineRemoteMediator( ) } saveToDatabase(database, data) + preTranslationService.enqueueStatuses( + data + .flatMap { item -> + listOfNotNull(item.status.status.data) + + item.status.references.mapNotNull { it.status?.data } + }.distinctBy { it.id }, + ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt index c6d1a344d..6340f805a 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt @@ -25,6 +25,7 @@ public data class AppSettings( val type: Type = Type.OpenAI("", "", ""), val translatePrompt: String = AiPromptDefaults.TRANSLATE_PROMPT, val tldrPrompt: String = AiPromptDefaults.TLDR_PROMPT, + val preTranslation: Boolean = false, ) { @Serializable public sealed interface Type { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/ai/AiCompletionService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/ai/AiCompletionService.kt new file mode 100644 index 000000000..8948d31ce --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/ai/AiCompletionService.kt @@ -0,0 +1,49 @@ +package dev.dimension.flare.data.network.ai + +import dev.dimension.flare.common.OnDeviceAI +import dev.dimension.flare.data.datastore.model.AppSettings + +internal class AiCompletionService( + private val openAIService: OpenAIService, + private val onDeviceAI: OnDeviceAI, +) { + suspend fun translate( + config: AppSettings.AiConfig, + source: String, + targetLanguage: String, + prompt: String, + ): String? = + complete(config.type, prompt) { + onDeviceAI.translate(source, targetLanguage, prompt) + } + + suspend fun tldr( + config: AppSettings.AiConfig, + source: String, + targetLanguage: String, + prompt: String, + ): String? = + complete(config.type, prompt) { + onDeviceAI.tldr(source, targetLanguage, prompt) + } + + private suspend fun complete( + type: AppSettings.AiConfig.Type, + prompt: String, + onDeviceCall: suspend () -> String?, + ): String? = + when (type) { + AppSettings.AiConfig.Type.OnDevice -> + if (onDeviceAI.isAvailable()) { + onDeviceCall() + } else { + null + } + + is AppSettings.AiConfig.Type.OpenAI -> + openAIService.chatCompletionOrNull( + config = type, + prompt = prompt, + ) + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/ai/OpenAIService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/ai/OpenAIService.kt index 44605c271..6719dd496 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/ai/OpenAIService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/ai/OpenAIService.kt @@ -54,6 +54,19 @@ internal class OpenAIService { .orEmpty() .trim() + suspend fun chatCompletionOrNull( + config: AppSettings.AiConfig.Type.OpenAI, + prompt: String, + ): String? = + if (config.serverUrl.isBlank() || config.apiKey.isBlank() || config.model.isBlank()) { + null + } else { + chatCompletion( + config = config, + prompt = prompt, + ) + } + private fun createClient( serverUrl: String, apiKey: String, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt new file mode 100644 index 000000000..ed78ed6cd --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt @@ -0,0 +1,503 @@ +package dev.dimension.flare.data.translation + +import dev.dimension.flare.common.Locale +import dev.dimension.flare.common.decodeJson +import dev.dimension.flare.common.encodeJson +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.model.DbStatus +import dev.dimension.flare.data.database.cache.model.DbTranslation +import dev.dimension.flare.data.database.cache.model.DbUser +import dev.dimension.flare.data.database.cache.model.TranslationEntityType +import dev.dimension.flare.data.database.cache.model.TranslationPayload +import dev.dimension.flare.data.database.cache.model.TranslationStatus +import dev.dimension.flare.data.database.cache.model.sourceHash +import dev.dimension.flare.data.database.cache.model.translationEntityKey +import dev.dimension.flare.data.database.cache.model.translationPayload +import dev.dimension.flare.data.datastore.AppDataStore +import dev.dimension.flare.data.datastore.model.AiPromptDefaults +import dev.dimension.flare.data.datastore.model.AppSettings +import dev.dimension.flare.data.network.ai.AiCompletionService +import dev.dimension.flare.ui.render.TranslationDocument +import dev.dimension.flare.ui.render.applyTranslationDocument +import dev.dimension.flare.ui.render.toTranslationDocument +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.serialization.Serializable +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes + +internal interface PreTranslationService { + fun enqueueStatuses(statuses: List) + + fun enqueueProfile(user: DbUser) +} + +internal data object NoopPreTranslationService : PreTranslationService { + override fun enqueueStatuses(statuses: List) = Unit + + override fun enqueueProfile(user: DbUser) = Unit +} + +internal class AiPreTranslationService( + private val database: CacheDatabase, + private val appDataStore: AppDataStore, + private val aiCompletionService: AiCompletionService, + private val coroutineScope: CoroutineScope, +) : PreTranslationService { + private val semaphore = Semaphore(permits = 1) + + override fun enqueueStatuses(statuses: List) { + val snapshot = statuses.distinctBy { it.id } + if (snapshot.isEmpty()) { + return + } + coroutineScope.launch { + semaphore.withPermit { + processStatusSnapshot(snapshot) + } + } + } + + override fun enqueueProfile(user: DbUser) { + coroutineScope.launch { + semaphore.withPermit { + processProfile(user) + } + } + } + + private suspend fun processStatusSnapshot(statuses: List) { + val settings = preTranslationSettings() ?: return + val candidates = + prepareStatusCandidates( + statuses = statuses, + targetLanguage = settings.targetLanguage, + ) + if (candidates.isEmpty()) { + return + } + candidates.chunkedForBatching().forEach { batch -> + translateBatch( + settings = settings.aiConfig, + targetLanguage = settings.targetLanguage, + candidates = batch, + ) + } + } + + private suspend fun processProfile(user: DbUser) { + val settings = preTranslationSettings() ?: return + val candidate = + prepareProfileCandidate( + user = user, + targetLanguage = settings.targetLanguage, + ) ?: return + translateBatch( + settings = settings.aiConfig, + targetLanguage = settings.targetLanguage, + candidates = listOf(candidate), + ) + } + + private suspend fun preTranslationSettings(): ActivePreTranslationSettings? { + val appSettings = appDataStore.appSettingsStore.data.first() + val targetLanguage = appSettings.language.ifBlank { Locale.language } + val aiConfig = appSettings.aiConfig + if (!aiConfig.translation || !aiConfig.preTranslation || targetLanguage.isBlank()) { + return null + } + return ActivePreTranslationSettings( + targetLanguage = targetLanguage, + aiConfig = aiConfig, + ) + } + + private suspend fun prepareStatusCandidates( + statuses: List, + targetLanguage: String, + ): List { + val deduplicated = statuses.distinctBy { it.translationEntityKey() } + if (deduplicated.isEmpty()) { + return emptyList() + } + val existingByKey = + database + .translationDao() + .getByEntityKeys( + entityType = TranslationEntityType.Status, + entityKeys = deduplicated.map { it.translationEntityKey() }, + targetLanguage = targetLanguage, + ).associateBy { it.entityKey } + val now = Clock.System.now().toEpochMilliseconds() + return buildList { + deduplicated.forEach { status -> + prepareCandidate( + entityType = TranslationEntityType.Status, + entityKey = status.translationEntityKey(), + payload = status.content.translationPayload(), + existing = existingByKey[status.translationEntityKey()], + targetLanguage = targetLanguage, + now = now, + )?.let(::add) + } + } + } + + private suspend fun prepareProfileCandidate( + user: DbUser, + targetLanguage: String, + ): PreparedTranslationCandidate? = + prepareCandidate( + entityType = TranslationEntityType.Profile, + entityKey = user.translationEntityKey(), + payload = user.content.translationPayload(), + existing = + database + .translationDao() + .get( + entityType = TranslationEntityType.Profile, + entityKey = user.translationEntityKey(), + targetLanguage = targetLanguage, + ), + targetLanguage = targetLanguage, + now = Clock.System.now().toEpochMilliseconds(), + ) + + private suspend fun prepareCandidate( + entityType: TranslationEntityType, + entityKey: String, + payload: TranslationPayload?, + existing: DbTranslation?, + targetLanguage: String, + now: Long, + ): PreparedTranslationCandidate? { + if (payload == null) { + return null + } + val sourceDocument = payload.toBatchPayload(targetLanguage) + val sourceHash = payload.sourceHash() + if (sourceDocument.isEmpty()) { + if ( + existing == null || + existing.sourceHash != sourceHash || + existing.status != TranslationStatus.Skipped || + existing.statusReason != SKIPPED_EMPTY_REASON + ) { + database.translationDao().insert( + DbTranslation( + entityType = entityType, + entityKey = entityKey, + targetLanguage = targetLanguage, + sourceHash = sourceHash, + status = TranslationStatus.Skipped, + payload = null, + statusReason = SKIPPED_EMPTY_REASON, + attemptCount = existing?.attemptCount ?: 0, + updatedAt = now, + ), + ) + } + return null + } + if (!shouldTranslate(existing = existing, sourceHash = sourceHash, now = now)) { + return null + } + return PreparedTranslationCandidate( + entityType = entityType, + entityKey = entityKey, + targetLanguage = targetLanguage, + sourceHash = sourceHash, + sourcePayload = payload, + sourceDocument = sourceDocument, + attemptCount = (existing?.attemptCount ?: 0) + 1, + ) + } + + private suspend fun translateBatch( + settings: AppSettings.AiConfig, + targetLanguage: String, + candidates: List, + ) { + if (candidates.isEmpty()) { + return + } + val startedAt = Clock.System.now().toEpochMilliseconds() + database.translationDao().insertAll( + candidates.map { candidate -> + DbTranslation( + entityType = candidate.entityType, + entityKey = candidate.entityKey, + targetLanguage = candidate.targetLanguage, + sourceHash = candidate.sourceHash, + status = TranslationStatus.Translating, + payload = null, + statusReason = null, + attemptCount = candidate.attemptCount, + updatedAt = startedAt, + ) + }, + ) + + runCatching { + val sourceDocument = + PreTranslationBatchDocument( + targetLanguage = targetLanguage, + items = + candidates.map { candidate -> + PreTranslationBatchItem( + entityKey = candidate.entityKey, + payload = candidate.sourceDocument, + ) + }, + ) + val sourceJson = sourceDocument.encodeJson(PreTranslationBatchDocument.serializer()) + val prompt = buildTranslatePrompt(settings.translatePrompt, targetLanguage, sourceJson) + val translatedJson = + aiCompletionService.translate( + config = settings, + source = sourceJson, + targetLanguage = targetLanguage, + prompt = prompt, + ) ?: error("Pre-translation returned empty response") + + applyBatchResult( + translatedJson = translatedJson, + candidates = candidates, + ) + }.getOrElse { throwable -> + val failedAt = Clock.System.now().toEpochMilliseconds() + database.translationDao().insertAll( + candidates.map { candidate -> + DbTranslation( + entityType = candidate.entityType, + entityKey = candidate.entityKey, + targetLanguage = candidate.targetLanguage, + sourceHash = candidate.sourceHash, + status = TranslationStatus.Failed, + payload = null, + statusReason = throwable.message, + attemptCount = candidate.attemptCount, + updatedAt = failedAt, + ) + }, + ) + } + } + + private suspend fun applyBatchResult( + translatedJson: String, + candidates: List, + ) { + val translatedDocument = + cleanJsonResponse(translatedJson).decodeJson(PreTranslationBatchDocument.serializer()) + val translatedItems = translatedDocument.items.associateBy { it.entityKey } + val updatedAt = Clock.System.now().toEpochMilliseconds() + database.translationDao().insertAll( + candidates.map { candidate -> + runCatching { + val translatedItem = + translatedItems[candidate.entityKey] + ?: throw IllegalArgumentException("Missing translated item for ${candidate.entityKey}") + val translatedPayload = + candidate.sourcePayload.applyBatchPayload( + sourceDocument = candidate.sourceDocument, + translatedDocument = translatedItem.payload, + ) + if (translatedPayload == candidate.sourcePayload) { + DbTranslation( + entityType = candidate.entityType, + entityKey = candidate.entityKey, + targetLanguage = candidate.targetLanguage, + sourceHash = candidate.sourceHash, + status = TranslationStatus.Skipped, + payload = null, + statusReason = SKIPPED_UNCHANGED_REASON, + attemptCount = candidate.attemptCount, + updatedAt = updatedAt, + ) + } else { + DbTranslation( + entityType = candidate.entityType, + entityKey = candidate.entityKey, + targetLanguage = candidate.targetLanguage, + sourceHash = candidate.sourceHash, + status = TranslationStatus.Completed, + payload = translatedPayload, + statusReason = null, + attemptCount = candidate.attemptCount, + updatedAt = updatedAt, + ) + } + }.getOrElse { throwable -> + DbTranslation( + entityType = candidate.entityType, + entityKey = candidate.entityKey, + targetLanguage = candidate.targetLanguage, + sourceHash = candidate.sourceHash, + status = TranslationStatus.Failed, + payload = null, + statusReason = throwable.message, + attemptCount = candidate.attemptCount, + updatedAt = updatedAt, + ) + } + }, + ) + } + + private fun buildTranslatePrompt( + configuredPrompt: String, + targetLanguage: String, + sourceJson: String, + ): String { + val template = + configuredPrompt.ifBlank { + AiPromptDefaults.TRANSLATE_PROMPT + } + return template + .replace("{target_language}", targetLanguage) + .replace("{source_text}", sourceJson) + .replace("{source_json}", sourceJson) + .replace("{source_html}", sourceJson) + .replace("{source_xml}", sourceJson) + .replace("{source_markup}", sourceJson) + } + + private fun cleanJsonResponse(content: String): String = + content + .removePrefix("```json") + .removePrefix("```html") + .removePrefix("```xml") + .removePrefix("```markup") + .removePrefix("```text") + .removePrefix("```") + .removeSuffix("```") + .trim() + + private fun shouldTranslate( + existing: DbTranslation?, + sourceHash: String, + now: Long, + ): Boolean { + if (existing == null || existing.sourceHash != sourceHash) { + return true + } + return when (existing.status) { + TranslationStatus.Completed, + TranslationStatus.Skipped, + -> false + + TranslationStatus.Failed -> true + TranslationStatus.Pending, + TranslationStatus.Translating, + -> now - existing.updatedAt >= STALE_TRANSLATION_TIMEOUT.inWholeMilliseconds + } + } +} + +@Serializable +internal data class PreTranslationBatchDocument( + val version: Int = 1, + val targetLanguage: String, + val items: List, +) + +@Serializable +internal data class PreTranslationBatchItem( + val entityKey: String, + val payload: PreTranslationBatchPayload, +) + +@Serializable +internal data class PreTranslationBatchPayload( + val content: TranslationDocument? = null, + val contentWarning: TranslationDocument? = null, + val title: TranslationDocument? = null, + val description: TranslationDocument? = null, +) + +private data class ActivePreTranslationSettings( + val targetLanguage: String, + val aiConfig: AppSettings.AiConfig, +) + +private data class PreparedTranslationCandidate( + val entityType: TranslationEntityType, + val entityKey: String, + val targetLanguage: String, + val sourceHash: String, + val sourcePayload: TranslationPayload, + val sourceDocument: PreTranslationBatchPayload, + val attemptCount: Int, +) + +private const val DEFAULT_PRE_TRANSLATION_MAX_ITEMS = 8 +private const val DEFAULT_PRE_TRANSLATION_MAX_INPUT_TOKENS = 6000 +private const val SKIPPED_EMPTY_REASON = "empty" +private const val SKIPPED_UNCHANGED_REASON = "unchanged" +private val STALE_TRANSLATION_TIMEOUT = 10.minutes + +private fun List.chunkedForBatching(): List> { + val result = mutableListOf>() + val current = mutableListOf() + var currentTokenEstimate = 0 + forEach { candidate -> + val itemTokens = candidate.sourceDocument.estimatedTokens() + val wouldExceedCount = current.size >= DEFAULT_PRE_TRANSLATION_MAX_ITEMS + val wouldExceedTokens = + current.isNotEmpty() && + currentTokenEstimate + itemTokens > DEFAULT_PRE_TRANSLATION_MAX_INPUT_TOKENS + if (wouldExceedCount || wouldExceedTokens) { + result += current.toList() + current.clear() + currentTokenEstimate = 0 + } + current += candidate + currentTokenEstimate += itemTokens + } + if (current.isNotEmpty()) { + result += current.toList() + } + return result +} + +private fun PreTranslationBatchPayload.estimatedTokens(): Int = this.encodeJson(PreTranslationBatchPayload.serializer()).length / 4 + 1 + +private fun PreTranslationBatchPayload.isEmpty(): Boolean = + content == null && contentWarning == null && title == null && description == null + +private fun TranslationPayload.toBatchPayload(targetLanguage: String): PreTranslationBatchPayload = + PreTranslationBatchPayload( + content = content?.toTranslationDocumentOrNull(targetLanguage), + contentWarning = contentWarning?.toTranslationDocumentOrNull(targetLanguage), + title = title?.toTranslationDocumentOrNull(targetLanguage), + description = description?.toTranslationDocumentOrNull(targetLanguage), + ) + +private fun TranslationPayload.applyBatchPayload( + sourceDocument: PreTranslationBatchPayload, + translatedDocument: PreTranslationBatchPayload, +): TranslationPayload = + TranslationPayload( + content = content.applyTranslatedField(sourceDocument.content, translatedDocument.content), + contentWarning = contentWarning.applyTranslatedField(sourceDocument.contentWarning, translatedDocument.contentWarning), + title = title.applyTranslatedField(sourceDocument.title, translatedDocument.title), + description = description.applyTranslatedField(sourceDocument.description, translatedDocument.description), + ) + +private fun dev.dimension.flare.ui.render.UiRichText?.toTranslationDocumentOrNull(targetLanguage: String): TranslationDocument? = + this?.toTranslationDocument(targetLanguage)?.takeUnless { it.blocks.isEmpty() } + +private fun dev.dimension.flare.ui.render.UiRichText?.applyTranslatedField( + sourceDocument: TranslationDocument?, + translatedDocument: TranslationDocument?, +): dev.dimension.flare.ui.render.UiRichText? = + when { + this == null -> null + sourceDocument == null -> this + translatedDocument == null -> throw IllegalArgumentException("Missing translated field") + else -> applyTranslationDocument(translatedDocument) + } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt index b1fa117c3..e55aaa1ff 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt @@ -4,6 +4,7 @@ import dev.dimension.flare.data.database.provideAppDatabase import dev.dimension.flare.data.database.provideCacheDatabase import dev.dimension.flare.data.datasource.nostr.DatabaseNostrCache import dev.dimension.flare.data.datasource.nostr.NostrCache +import dev.dimension.flare.data.network.ai.AiCompletionService import dev.dimension.flare.data.network.ai.OpenAIService import dev.dimension.flare.data.network.rss.Readability import dev.dimension.flare.data.repository.AccountRepository @@ -15,6 +16,8 @@ import dev.dimension.flare.data.repository.DraftSendingRecoveryCoordinator import dev.dimension.flare.data.repository.LocalFilterRepository import dev.dimension.flare.data.repository.SearchHistoryRepository import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.data.translation.AiPreTranslationService +import dev.dimension.flare.data.translation.PreTranslationService import dev.dimension.flare.ui.presenter.compose.ComposeUseCase import dev.dimension.flare.ui.presenter.compose.RestoreDraftUseCase import dev.dimension.flare.ui.presenter.compose.SaveDraftUseCase @@ -59,4 +62,6 @@ internal val commonModule = singleOf(::SettingsRepository) singleOf(::Readability) singleOf(::OpenAIService) + singleOf(::AiCompletionService) + single { AiPreTranslationService(get(), get(), get(), get()) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt index 6e5ff206e..04bf92a29 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt @@ -11,6 +11,7 @@ import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map import dev.dimension.flare.common.InAppNotification +import dev.dimension.flare.common.Locale import dev.dimension.flare.common.Message import dev.dimension.flare.common.PagingState import dev.dimension.flare.common.cachePagingState @@ -19,6 +20,7 @@ import dev.dimension.flare.common.onEmpty import dev.dimension.flare.common.onError import dev.dimension.flare.common.onSuccess import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.model.TranslationDisplayOptions import dev.dimension.flare.data.datasource.microblog.paging.CacheableRemoteLoader import dev.dimension.flare.data.datasource.microblog.paging.NotSupportRemoteLoader import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader @@ -26,8 +28,10 @@ import dev.dimension.flare.data.datasource.microblog.paging.TimelinePagingMapper import dev.dimension.flare.data.datasource.microblog.paging.TimelineRemoteMediator import dev.dimension.flare.data.datasource.microblog.paging.toPagingSource import dev.dimension.flare.data.datasource.microblog.pagingConfig +import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.repository.LocalFilterRepository import dev.dimension.flare.data.repository.LoginExpiredException +import dev.dimension.flare.data.translation.PreTranslationService import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.presenter.PresenterBase import kotlinx.coroutines.CoroutineScope @@ -36,6 +40,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -48,6 +53,8 @@ public abstract class TimelinePresenter : PresenterBase(), KoinComponent { private val database: CacheDatabase by inject() + private val appDataStore: AppDataStore by inject() + private val preTranslationService: PreTranslationService by inject() private val localFilterRepository: LocalFilterRepository by inject() private val inAppNotification: InAppNotification by inject() @@ -56,6 +63,16 @@ public abstract class TimelinePresenter : localFilterRepository.getFlow(forTimeline = true) } + private val translationDisplayFlow by lazy { + appDataStore.appSettingsStore.data + .map { settings -> + TranslationDisplayOptions( + enabled = settings.aiConfig.translation && settings.aiConfig.preTranslation, + targetLanguage = settings.language.ifBlank { Locale.language }, + ) + }.distinctUntilChanged() + } + @OptIn(ExperimentalCoroutinesApi::class) internal fun createPager(scope: CoroutineScope): Flow> = loader @@ -66,9 +83,13 @@ public abstract class TimelinePresenter : } is CacheableRemoteLoader -> { - cachePager( - loader = it, - ).cachedIn(scope) + translationDisplayFlow + .flatMapLatest { translationDisplayOptions -> + cachePager( + loader = it, + translationDisplayOptions = translationDisplayOptions, + ) + }.cachedIn(scope) } else -> { @@ -97,13 +118,17 @@ public abstract class TimelinePresenter : } @OptIn(ExperimentalCoroutinesApi::class) - private fun cachePager(loader: CacheableRemoteLoader): Flow> = + private fun cachePager( + loader: CacheableRemoteLoader, + translationDisplayOptions: TranslationDisplayOptions, + ): Flow> = Pager( config = pagingConfig, remoteMediator = TimelineRemoteMediator( loader = loader, database = database, + preTranslationService = preTranslationService, notifyError = { e -> if (e is LoginExpiredException) { inAppNotification.onError(Message.LoginExpired, e) @@ -118,7 +143,12 @@ public abstract class TimelinePresenter : ).flow.map { pagingData -> withContext(Dispatchers.IO) { pagingData.map { item -> - TimelinePagingMapper.toUi(item, loader.pagingKey, useDbKeyInItemKey) + TimelinePagingMapper.toUi( + item = item, + pagingKey = loader.pagingKey, + useDbKeyInItemKey = useDbKeyInItemKey, + translationDisplayOptions = translationDisplayOptions, + ) } } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/server/AiTLDRPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/server/AiTLDRPresenter.kt index 2a59d710e..3b93c1c9c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/server/AiTLDRPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/server/AiTLDRPresenter.kt @@ -3,11 +3,9 @@ package dev.dimension.flare.ui.presenter.server import androidx.compose.runtime.Composable import androidx.compose.runtime.produceState import dev.dimension.flare.common.Locale -import dev.dimension.flare.common.OnDeviceAI import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.datastore.model.AiPromptDefaults -import dev.dimension.flare.data.datastore.model.AppSettings -import dev.dimension.flare.data.network.ai.OpenAIService +import dev.dimension.flare.data.network.ai.AiCompletionService import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.presenter.PresenterBase import kotlinx.coroutines.flow.first @@ -20,8 +18,7 @@ public class AiTLDRPresenter( ) : PresenterBase>(), KoinComponent { private val appDataStore: AppDataStore by inject() - private val openAIService: OpenAIService by inject() - private val onDeviceAI: OnDeviceAI by inject() + private val aiCompletionService: AiCompletionService by inject() @Composable override fun body(): UiState { @@ -40,20 +37,12 @@ public class AiTLDRPresenter( AiPromptDefaults.TLDR_PROMPT } val prompt = buildTldrPrompt(promptTemplate, targetLanguage, source) - when (val type = aiConfig.type) { - AppSettings.AiConfig.Type.OnDevice -> - onDeviceAI.tldr(source, targetLanguage, prompt) ?: legacyFlareTldr() - is AppSettings.AiConfig.Type.OpenAI -> { - if (type.serverUrl.isBlank() || type.apiKey.isBlank() || type.model.isBlank()) { - legacyFlareTldr() - } else { - openAIService.chatCompletion( - config = type, - prompt = prompt, - ) - } - } - } + aiCompletionService.tldr( + config = aiConfig, + source = source, + targetLanguage = targetLanguage, + prompt = prompt, + ) ?: legacyFlareTldr() }.fold( onSuccess = { UiState.Success(it) }, onFailure = { UiState.Error(it) }, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt index 1f3eb247d..a0de54b46 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt @@ -138,7 +138,12 @@ public class AiConfigPresenter : scope.launch { withContext(Dispatchers.Main) { appDataStore.appSettingsStore.updateData { current -> - current.copy(aiConfig = block.invoke(current.aiConfig)) + current.copy( + aiConfig = + block + .invoke(current.aiConfig) + .normalized(), + ) } } } @@ -167,6 +172,13 @@ public class AiConfigPresenter : } } +private fun AppSettings.AiConfig.normalized(): AppSettings.AiConfig = + if (translation) { + this + } else { + copy(preTranslation = false) + } + private val SERVER_SUGGESTIONS = persistentListOf( "https://api.openai.com/v1/", diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt index e46ecece5..f0a56749b 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt @@ -3,11 +3,9 @@ package dev.dimension.flare.ui.presenter.status import androidx.compose.runtime.Composable import androidx.compose.runtime.produceState import dev.dimension.flare.common.Locale -import dev.dimension.flare.common.OnDeviceAI import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.datastore.model.AiPromptDefaults -import dev.dimension.flare.data.datastore.model.AppSettings -import dev.dimension.flare.data.network.ai.OpenAIService +import dev.dimension.flare.data.network.ai.AiCompletionService import dev.dimension.flare.data.network.ktorClient import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.presenter.PresenterBase @@ -32,9 +30,8 @@ public class TranslatePresenter( private val targetLanguage: String = Locale.language, ) : PresenterBase>(), KoinComponent { - private val openAIService by inject() + private val aiCompletionService by inject() private val appDataStore: AppDataStore by inject() - private val onDeviceAI: OnDeviceAI by inject() private val sourceText: String by lazy { source.toTranslatableText() } private val sourceJson: String by lazy { source.toTranslationJson(targetLanguage) } @@ -55,20 +52,14 @@ public class TranslatePresenter( AiPromptDefaults.TRANSLATE_PROMPT } val prompt = buildTranslatePrompt(promptTemplate, targetLanguage) - when (val type = aiConfig.type) { - AppSettings.AiConfig.Type.OnDevice -> - onDeviceAI.translate(sourceText, targetLanguage, prompt) ?: legacyGoogleTranslate() - is AppSettings.AiConfig.Type.OpenAI -> { - if (type.serverUrl.isBlank() || type.apiKey.isBlank() || type.model.isBlank()) { - legacyGoogleTranslate() - } else { - openAIService.chatCompletion( - config = type, - prompt = prompt, - ) - } - } - }.let(::toUiRichText) + ( + aiCompletionService.translate( + config = aiConfig, + source = sourceText, + targetLanguage = targetLanguage, + prompt = prompt, + ) ?: legacyGoogleTranslate() + ).let(::toUiRichText) }.fold( onSuccess = { UiState.Success(it) }, onFailure = { UiState.Error(it) }, diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDaoTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDaoTest.kt new file mode 100644 index 000000000..a3e70c1a7 --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDaoTest.kt @@ -0,0 +1,204 @@ +package dev.dimension.flare.data.database.cache.dao + +import androidx.room.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import dev.dimension.flare.RobolectricTest +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.model.DbTranslation +import dev.dimension.flare.data.database.cache.model.TranslationEntityType +import dev.dimension.flare.data.database.cache.model.TranslationPayload +import dev.dimension.flare.data.database.cache.model.TranslationStatus +import dev.dimension.flare.data.database.cache.model.profileTranslationEntityKey +import dev.dimension.flare.memoryDatabaseBuilder +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.render.toUiPlainText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class TranslationDaoTest : RobolectricTest() { + private lateinit var db: CacheDatabase + + @BeforeTest + fun setup() { + db = + Room + .memoryDatabaseBuilder() + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.Unconfined) + .build() + } + + @AfterTest + fun tearDown() { + db.close() + } + + @Test + fun insertAndFindStatusTranslation_roundTripsPayload() = + runTest { + val translation = + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = "Specific(account@test.social)_status-1@test.social", + targetLanguage = "zh-CN", + sourceHash = "hash-1", + status = TranslationStatus.Completed, + payload = + TranslationPayload( + content = "你好".toUiPlainText(), + contentWarning = "剧透".toUiPlainText(), + ), + updatedAt = 123L, + ) + + db.translationDao().insert(translation) + + val saved = + db + .translationDao() + .find( + entityType = TranslationEntityType.Status, + entityKey = translation.entityKey, + targetLanguage = "zh-CN", + ).first() + + assertNotNull(saved) + assertEquals(translation.sourceHash, saved.sourceHash) + assertEquals(TranslationStatus.Completed, saved.status) + assertEquals("你好", saved.payload?.content?.raw) + assertEquals("剧透", saved.payload?.contentWarning?.raw) + } + + @Test + fun getByEntityKeys_filtersByLanguage() = + runTest { + val statusKey = "Specific(account@test.social)_status-2@test.social" + db.translationDao().insertAll( + listOf( + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = statusKey, + targetLanguage = "zh-CN", + sourceHash = "hash-1", + status = TranslationStatus.Completed, + payload = TranslationPayload(content = "中文".toUiPlainText()), + updatedAt = 1L, + ), + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = statusKey, + targetLanguage = "ja", + sourceHash = "hash-1", + status = TranslationStatus.Skipped, + statusReason = "same_language", + updatedAt = 2L, + ), + ), + ) + + val zh = + db.translationDao().getByEntityKeys( + entityType = TranslationEntityType.Status, + entityKeys = listOf(statusKey), + targetLanguage = "zh-CN", + ) + val ja = + db.translationDao().getByEntityKeys( + entityType = TranslationEntityType.Status, + entityKeys = listOf(statusKey), + targetLanguage = "ja", + ) + + assertEquals(1, zh.size) + assertEquals(1, ja.size) + assertEquals(TranslationStatus.Completed, zh.single().status) + assertEquals(TranslationStatus.Skipped, ja.single().status) + assertEquals("same_language", ja.single().statusReason) + } + + @Test + fun updateReplacesProfileTranslationStateAndPayload() = + runTest { + val userKey = MicroBlogKey("user-1", "test.social") + val entityKey = profileTranslationEntityKey(userKey) + + db.translationDao().insert( + DbTranslation( + entityType = TranslationEntityType.Profile, + entityKey = entityKey, + targetLanguage = "en", + sourceHash = "hash-old", + status = TranslationStatus.Pending, + updatedAt = 1L, + ), + ) + + db.translationDao().update( + entityType = TranslationEntityType.Profile, + entityKey = entityKey, + targetLanguage = "en", + sourceHash = "hash-new", + status = TranslationStatus.Completed, + payload = TranslationPayload(description = "Translated profile".toUiPlainText()), + statusReason = null, + attemptCount = 2, + updatedAt = 99L, + ) + + val saved = + db.translationDao().get( + entityType = TranslationEntityType.Profile, + entityKey = entityKey, + targetLanguage = "en", + ) + + assertNotNull(saved) + assertEquals("hash-new", saved.sourceHash) + assertEquals(2, saved.attemptCount) + assertEquals(99L, saved.updatedAt) + assertEquals("Translated profile", saved.payload?.description?.raw) + } + + @Test + fun deleteByLanguage_removesOnlyMatchingRows() = + runTest { + db.translationDao().insertAll( + listOf( + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = "status:a", + targetLanguage = "zh-CN", + sourceHash = "hash-a", + status = TranslationStatus.Completed, + payload = TranslationPayload(content = "A".toUiPlainText()), + updatedAt = 1L, + ), + DbTranslation( + entityType = TranslationEntityType.Profile, + entityKey = "profile:b", + targetLanguage = "ja", + sourceHash = "hash-b", + status = TranslationStatus.Completed, + payload = TranslationPayload(description = "B".toUiPlainText()), + updatedAt = 1L, + ), + ), + ) + + db.translationDao().deleteByLanguage("zh-CN") + + val removed = db.translationDao().get(TranslationEntityType.Status, "status:a", "zh-CN") + val kept = db.translationDao().get(TranslationEntityType.Profile, "profile:b", "ja") + + assertNull(removed) + assertNotNull(kept) + assertEquals("B", kept.payload?.description?.raw) + } +} diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt index 568f2c22f..b799e35d3 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt @@ -9,6 +9,14 @@ import dev.dimension.flare.RobolectricTest import dev.dimension.flare.common.TestFormatter import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus +import dev.dimension.flare.data.database.cache.model.DbTranslation +import dev.dimension.flare.data.database.cache.model.TranslationDisplayOptions +import dev.dimension.flare.data.database.cache.model.TranslationEntityType +import dev.dimension.flare.data.database.cache.model.TranslationPayload +import dev.dimension.flare.data.database.cache.model.TranslationStatus +import dev.dimension.flare.data.database.cache.model.sourceHash +import dev.dimension.flare.data.database.cache.model.translationEntityKey +import dev.dimension.flare.data.database.cache.model.translationPayload import dev.dimension.flare.data.datasource.microblog.paging.TimelinePagingMapper import dev.dimension.flare.memoryDatabaseBuilder import dev.dimension.flare.model.AccountType @@ -482,6 +490,81 @@ class MicroblogTest : RobolectricTest() { assertEquals(parentPost.statusKey, post.parents.first().statusKey) } + @Test + fun toUiUsesCompletedTranslationForRootAndReplyReference() = + runTest { + val accountKey = MicroBlogKey(id = "account", host = "test.com") + val rootUser = createUser(MicroBlogKey(id = "root-user-translated", host = "test.com"), "Root User") + val parentUser = createUser(MicroBlogKey(id = "parent-user-translated", host = "test.com"), "Parent User") + val parentPost = + createPost( + accountKey = accountKey, + user = parentUser, + statusKey = MicroBlogKey(id = "parent-status-translated", host = "test.com"), + text = "parent original", + ) + val rootPost = + createPost( + accountKey = accountKey, + user = rootUser, + statusKey = MicroBlogKey(id = "root-status-translated", host = "test.com"), + text = "root original", + parents = listOf(parentPost), + ) + + val mapped = TimelinePagingMapper.toDb(rootPost, pagingKey = "home") + saveToDatabase(db, listOf(mapped)) + val savedParentStatus = assertNotNull(db.statusDao().get(parentPost.statusKey, AccountType.Specific(accountKey)).first()) + db.translationDao().insertAll( + listOf( + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = + mapped.status.status.data + .translationEntityKey(), + targetLanguage = "zh-CN", + sourceHash = rootPost.translationPayload()!!.sourceHash(), + status = TranslationStatus.Completed, + payload = TranslationPayload(content = "根帖子".toUiPlainText()), + updatedAt = 1L, + ), + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = savedParentStatus.translationEntityKey(), + targetLanguage = "zh-CN", + sourceHash = parentPost.translationPayload()!!.sourceHash(), + status = TranslationStatus.Completed, + payload = TranslationPayload(content = "父帖子".toUiPlainText()), + updatedAt = 1L, + ), + ), + ) + + val paging = db.pagingTimelineDao().getPagingSource("home") + val pager = TestPager(config = PagingConfig(pageSize = 20), paging) + val refreshResult = pager.refresh() + val page = assertIs>(refreshResult) + val dbItem = assertNotNull(page.data.firstOrNull()) + + val ui = + TimelinePagingMapper.toUi( + item = dbItem, + pagingKey = "home", + useDbKeyInItemKey = false, + translationDisplayOptions = TranslationDisplayOptions(enabled = true, targetLanguage = "zh-CN"), + ) + val post = assertIs(ui) + + assertEquals("根帖子", post.content.raw) + assertEquals(1, post.parents.size) + assertEquals( + "父帖子", + post.parents + .first() + .content.raw, + ) + } + @Test fun timelinePagingMapperKeepsPostMessageAfterRoundTrip() = runTest { diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt index a58cf03e8..528d8429e 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt @@ -8,13 +8,27 @@ import androidx.paging.PagingState import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import dev.dimension.flare.RobolectricTest +import dev.dimension.flare.common.OnDeviceAI import dev.dimension.flare.common.TestFormatter +import dev.dimension.flare.common.decodeJson +import dev.dimension.flare.common.encodeJson +import dev.dimension.flare.createTestRootPath import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.model.TranslationEntityType +import dev.dimension.flare.data.database.cache.model.TranslationStatus import dev.dimension.flare.data.datasource.microblog.paging.CacheableRemoteLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.datasource.microblog.paging.TimelinePagingMapper import dev.dimension.flare.data.datasource.microblog.paging.TimelineRemoteMediator +import dev.dimension.flare.data.datastore.AppDataStore +import dev.dimension.flare.data.datastore.model.AppSettings +import dev.dimension.flare.data.io.PlatformPathProducer +import dev.dimension.flare.data.network.ai.AiCompletionService +import dev.dimension.flare.data.network.ai.OpenAIService +import dev.dimension.flare.data.translation.AiPreTranslationService +import dev.dimension.flare.data.translation.PreTranslationService +import dev.dimension.flare.deleteTestRootPath import dev.dimension.flare.memoryDatabaseBuilder import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey @@ -24,13 +38,19 @@ import dev.dimension.flare.ui.model.ClickEvent import dev.dimension.flare.ui.model.UiHandle import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.render.TranslationDocument +import dev.dimension.flare.ui.render.TranslationTokenKind import dev.dimension.flare.ui.render.toUi import dev.dimension.flare.ui.render.toUiPlainText import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest +import okio.Path import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import org.koin.dsl.module @@ -47,6 +67,17 @@ import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class) class MixedRemoteMediatorTest : RobolectricTest() { + private val root = createTestRootPath() + private val pathProducer = + object : PlatformPathProducer { + override fun dataStoreFile(fileName: String): Path = root.resolve(fileName) + + override fun draftMediaFile( + groupId: String, + fileName: String, + ): Path = root.resolve("draft_media").resolve(groupId).resolve(fileName) + } + private lateinit var db: CacheDatabase @BeforeTest @@ -70,6 +101,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { fun tearDown() { db.close() stopKoin() + deleteTestRootPath(root) } @Test @@ -336,6 +368,108 @@ class MixedRemoteMediatorTest : RobolectricTest() { assertEquals(listOf(postA.statusKey, postB.statusKey), post.parents.map { it.statusKey }) } + @OptIn(ExperimentalPagingApi::class) + @Test + fun refreshSchedulesPreTranslationForRootAndReplyReference() = + runTest { + val appDataStore = AppDataStore(pathProducer) + appDataStore.appSettingsStore.updateData { + it.copy( + language = "zh-CN", + aiConfig = + AppSettings.AiConfig( + translation = true, + preTranslation = true, + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + val preTranslationService: PreTranslationService = + AiPreTranslationService( + database = db, + appDataStore = appDataStore, + aiCompletionService = AiCompletionService(OpenAIService(), TestOnDeviceAI()), + coroutineScope = CoroutineScope(Dispatchers.Unconfined), + ) + val accountKey = MicroBlogKey(id = "account-pretranslation", host = "test.social") + val accountType = AccountType.Specific(accountKey) + val rootUser = profile(MicroBlogKey("root-pretranslation", "test.social"), "Root") + val parentUser = profile(MicroBlogKey("parent-pretranslation", "test.social"), "Parent") + val parent = + createPost( + user = parentUser, + accountType = accountType, + statusKey = MicroBlogKey(id = "parent-status-pretranslation", host = "test.social"), + text = "parent source", + ) + val rootPost = + createPost( + user = rootUser, + accountType = accountType, + statusKey = MicroBlogKey(id = "root-status-pretranslation", host = "test.social"), + text = "root source", + parents = listOf(parent), + ) + val loader = + FakeLoader("pretranslation") { request -> + when (request) { + PagingRequest.Refresh -> + PagingResult( + data = listOf(rootPost), + nextKey = null, + ) + + is PagingRequest.Append -> error("No append expected") + is PagingRequest.Prepend -> error("No prepend expected") + } + } + val mediator = + TimelineRemoteMediator( + loader = loader, + database = db, + preTranslationService = preTranslationService, + ) + + val mediatorResult = + mediator.load( + loadType = LoadType.REFRESH, + state = + PagingState( + pages = emptyList(), + anchorPosition = null, + config = PagingConfig(pageSize = 20), + leadingPlaceholderCount = 0, + ), + ) + assertTrue(mediatorResult is androidx.paging.RemoteMediator.MediatorResult.Success) + val savedRoot = db.statusDao().get(rootPost.statusKey, accountType).first() + val savedParent = db.statusDao().get(parent.statusKey, accountType).first() + assertNotNull(savedRoot) + assertNotNull(savedParent) + + val rootTranslation = + db + .translationDao() + .find( + entityType = TranslationEntityType.Status, + entityKey = savedRoot.id, + targetLanguage = "zh-CN", + ).filterNotNull() + .first { it.status == TranslationStatus.Completed } + val parentTranslation = + db + .translationDao() + .find( + entityType = TranslationEntityType.Status, + entityKey = savedParent.id, + targetLanguage = "zh-CN", + ).filterNotNull() + .first { it.status == TranslationStatus.Completed } + + assertEquals("root source (zh-CN)", rootTranslation.payload?.content?.raw) + assertEquals("parent source (zh-CN)", parentTranslation.payload?.content?.raw) + } + @Test fun refreshDeduplicatesSamePostReturnedByMultipleSubTimelines() = runTest { @@ -528,3 +662,64 @@ class MixedRemoteMediatorTest : RobolectricTest() { accountType = accountType, ) } + +private class TestOnDeviceAI : OnDeviceAI { + override suspend fun isAvailable(): Boolean = true + + override suspend fun translate( + source: String, + targetLanguage: String, + prompt: String, + ): String? { + val document = + source.decodeJson( + dev.dimension.flare.data.translation.PreTranslationBatchDocument + .serializer(), + ) + return document + .copy( + items = + document.items.map { item -> + item.copy( + payload = item.payload.translated(targetLanguage), + ) + }, + ).encodeJson( + dev.dimension.flare.data.translation.PreTranslationBatchDocument + .serializer(), + ) + } + + override suspend fun tldr( + source: String, + targetLanguage: String, + prompt: String, + ): String? = null +} + +private fun dev.dimension.flare.data.translation.PreTranslationBatchPayload.translated( + targetLanguage: String, +): dev.dimension.flare.data.translation.PreTranslationBatchPayload = + dev.dimension.flare.data.translation.PreTranslationBatchPayload( + content = content?.translated(targetLanguage), + contentWarning = contentWarning?.translated(targetLanguage), + title = title?.translated(targetLanguage), + description = description?.translated(targetLanguage), + ) + +private fun TranslationDocument.translated(targetLanguage: String): TranslationDocument = + copy( + blocks = + blocks.map { block -> + block.copy( + tokens = + block.tokens.map { token -> + if (token.kind == TranslationTokenKind.Translatable) { + token.copy(text = "${token.text} ($targetLanguage)") + } else { + token + } + }, + ) + }, + ) diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt index ceeefab81..401c0950e 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt @@ -5,10 +5,31 @@ import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import dev.dimension.flare.RobolectricTest import dev.dimension.flare.common.CacheState +import dev.dimension.flare.common.OnDeviceAI import dev.dimension.flare.common.TestFormatter +import dev.dimension.flare.common.decodeJson +import dev.dimension.flare.common.encodeJson +import dev.dimension.flare.createTestRootPath import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.model.DbTranslation import dev.dimension.flare.data.database.cache.model.DbUser +import dev.dimension.flare.data.database.cache.model.TranslationEntityType +import dev.dimension.flare.data.database.cache.model.TranslationPayload +import dev.dimension.flare.data.database.cache.model.TranslationStatus +import dev.dimension.flare.data.database.cache.model.sourceHash +import dev.dimension.flare.data.database.cache.model.translationEntityKey +import dev.dimension.flare.data.database.cache.model.translationPayload import dev.dimension.flare.data.datasource.microblog.loader.UserLoader +import dev.dimension.flare.data.datastore.AppDataStore +import dev.dimension.flare.data.datastore.model.AppSettings +import dev.dimension.flare.data.io.PlatformPathProducer +import dev.dimension.flare.data.network.ai.AiCompletionService +import dev.dimension.flare.data.network.ai.OpenAIService +import dev.dimension.flare.data.translation.AiPreTranslationService +import dev.dimension.flare.data.translation.PreTranslationBatchDocument +import dev.dimension.flare.data.translation.PreTranslationBatchPayload +import dev.dimension.flare.data.translation.PreTranslationService +import dev.dimension.flare.deleteTestRootPath import dev.dimension.flare.memoryDatabaseBuilder import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType @@ -16,15 +37,20 @@ import dev.dimension.flare.ui.humanizer.PlatformFormatter import dev.dimension.flare.ui.model.ClickEvent import dev.dimension.flare.ui.model.UiHandle import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.render.TranslationDocument +import dev.dimension.flare.ui.render.TranslationTokenKind import dev.dimension.flare.ui.render.toUiPlainText import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest +import okio.Path import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import org.koin.dsl.module @@ -37,9 +63,22 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class UserHandlerTest : RobolectricTest() { + private val root = createTestRootPath() + private val pathProducer = + object : PlatformPathProducer { + override fun dataStoreFile(fileName: String): Path = root.resolve(fileName) + + override fun draftMediaFile( + groupId: String, + fileName: String, + ): Path = root.resolve("draft_media").resolve(groupId).resolve(fileName) + } + private lateinit var db: CacheDatabase + private lateinit var appDataStore: AppDataStore private lateinit var loader: FakeUserLoader private lateinit var handler: UserHandler + private lateinit var onDeviceAI: FakeOnDeviceAI private val accountKey = MicroBlogKey(id = "account-1", host = "test.social") @@ -51,13 +90,21 @@ class UserHandlerTest : RobolectricTest() { .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.Unconfined) .build() + appDataStore = AppDataStore(pathProducer) loader = FakeUserLoader() + onDeviceAI = FakeOnDeviceAI() startKoin { modules( module { single { db } + single { appDataStore } + single { CoroutineScope(Dispatchers.Unconfined) } + single { onDeviceAI } + single { OpenAIService() } + single { AiCompletionService(get(), get()) } + single { AiPreTranslationService(get(), get(), get(), get()) } single { TestFormatter() } }, ) @@ -70,6 +117,7 @@ class UserHandlerTest : RobolectricTest() { fun tearDown() { db.close() stopKoin() + deleteTestRootPath(root) } @Test @@ -180,6 +228,150 @@ class UserHandlerTest : RobolectricTest() { assertEquals(atHandleProfile.key, atHandleHit.userKey) } + @Test + fun userByIdUsesTranslatedDescriptionWhenPreTranslationEnabled() = + runTest { + val profile = + createProfile(id = "eve", host = "test.social", handle = "@eve@test.social").copy( + description = "Original bio".toUiPlainText(), + ) + db.userDao().insert( + DbUser( + userKey = profile.key, + name = profile.name.raw, + canonicalHandle = profile.handle.canonical, + host = "test.social", + content = profile, + ), + ) + appDataStore.appSettingsStore.updateData { + it.copy( + language = "zh-CN", + aiConfig = + AppSettings.AiConfig( + translation = true, + preTranslation = true, + ), + ) + } + db.translationDao().insert( + DbTranslation( + entityType = TranslationEntityType.Profile, + entityKey = profile.translationEntityKey(), + targetLanguage = "zh-CN", + sourceHash = profile.translationPayload().sourceHash(), + status = TranslationStatus.Completed, + payload = TranslationPayload(description = "翻译后的简介".toUiPlainText()), + updatedAt = 1L, + ), + ) + + val cacheable = handler.userById("eve") + + val latest = + cacheable.data + .filterIsInstance>() + .first() + .data + + assertEquals("翻译后的简介", latest.description?.raw) + } + + @Test + fun userByIdRefreshStoresPreTranslationIntoDatabase() = + runTest { + val expected = + createProfile(id = "pretranslate", host = "test.social", handle = "@pretranslate@test.social").copy( + description = "Original profile bio".toUiPlainText(), + ) + loader.nextById = expected + appDataStore.appSettingsStore.updateData { + it.copy( + language = "zh-CN", + aiConfig = + AppSettings.AiConfig( + translation = true, + preTranslation = true, + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + + val cacheable = handler.userById("pretranslate") + val refreshState = cacheable.refreshState.drop(1).first() + assertTrue(refreshState is LoadState.NotLoading) + + val saved = + db + .translationDao() + .find( + entityType = TranslationEntityType.Profile, + entityKey = expected.translationEntityKey(), + targetLanguage = "zh-CN", + ).filterNotNull() + .first { it.status == TranslationStatus.Completed } + + assertEquals("Original profile bio (zh-CN)", saved.payload?.description?.raw) + } + + @Test + fun userByIdRefreshRetriesFailedPreTranslationOnNextLoad() = + runTest { + val expected = + createProfile(id = "retry-translation", host = "test.social", handle = "@retry-translation@test.social").copy( + description = "Retry profile bio".toUiPlainText(), + ) + loader.nextById = expected + appDataStore.appSettingsStore.updateData { + it.copy( + language = "zh-CN", + aiConfig = + AppSettings.AiConfig( + translation = true, + preTranslation = true, + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + + onDeviceAI.failTranslation = true + handler + .userById("retry-translation") + .refreshState + .drop(1) + .first() + + val failed = + db + .translationDao() + .find( + entityType = TranslationEntityType.Profile, + entityKey = expected.translationEntityKey(), + targetLanguage = "zh-CN", + ).filterNotNull() + .first { it.status == TranslationStatus.Failed } + assertEquals(1, failed.attemptCount) + + onDeviceAI.failTranslation = false + handler + .userById("retry-translation") + .refreshState + .drop(1) + .first() + + val completed = + db + .translationDao() + .find( + entityType = TranslationEntityType.Profile, + entityKey = expected.translationEntityKey(), + targetLanguage = "zh-CN", + ).filterNotNull() + .first { it.status == TranslationStatus.Completed } + assertEquals(2, completed.attemptCount) + assertEquals("Retry profile bio (zh-CN)", completed.payload?.description?.raw) + } + private fun createProfile( id: String, host: String, @@ -224,3 +416,60 @@ class UserHandlerTest : RobolectricTest() { } } } + +private class FakeOnDeviceAI : OnDeviceAI { + var failTranslation: Boolean = false + + override suspend fun isAvailable(): Boolean = true + + override suspend fun translate( + source: String, + targetLanguage: String, + prompt: String, + ): String? { + if (failTranslation) { + error("translation failed") + } + val document = source.decodeJson(PreTranslationBatchDocument.serializer()) + return document + .copy( + items = + document.items.map { item -> + item.copy( + payload = item.payload.translated(targetLanguage), + ) + }, + ).encodeJson(PreTranslationBatchDocument.serializer()) + } + + override suspend fun tldr( + source: String, + targetLanguage: String, + prompt: String, + ): String? = null +} + +private fun PreTranslationBatchPayload.translated(targetLanguage: String): PreTranslationBatchPayload = + PreTranslationBatchPayload( + content = content?.translated(targetLanguage), + contentWarning = contentWarning?.translated(targetLanguage), + title = title?.translated(targetLanguage), + description = description?.translated(targetLanguage), + ) + +private fun TranslationDocument.translated(targetLanguage: String): TranslationDocument = + copy( + blocks = + blocks.map { block -> + block.copy( + tokens = + block.tokens.map { token -> + if (token.kind == TranslationTokenKind.Translatable) { + token.copy(text = "${token.text} ($targetLanguage)") + } else { + token + } + }, + ) + }, + ) From fe84b00aadf1e2f4af69d9ccf3a5542d916ffbbd Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 29 Mar 2026 12:00:42 +0900 Subject: [PATCH 03/14] reduce useless pre translate --- .../component/status/CommonStatusComponent.kt | 3 + .../status/StatusTranslatePresenter.kt | 42 +- .../data/database/cache/CacaheDatabase.kt | 2 +- .../microblog/handler/PostHandler.kt | 79 ++-- .../paging/TimelineRemoteMediator.kt | 2 + .../rss/RssTimelineRemoteMediator.kt | 3 + .../data/datastore/model/AiPromptDefaults.kt | 5 +- .../data/translation/PreTranslationService.kt | 311 +++++++++++++-- .../dev/dimension/flare/ui/model/UiProfile.kt | 2 + .../dimension/flare/ui/model/UiTimelineV2.kt | 2 + .../flare/ui/model/mapper/Bluesky.kt | 13 + .../flare/ui/model/mapper/Mastodon.kt | 1 + .../flare/ui/model/mapper/Misskey.kt | 1 + .../dimension/flare/ui/model/mapper/Rss.kt | 7 + .../dimension/flare/ui/model/mapper/XQT.kt | 2 + .../ui/presenter/home/TimelinePresenter.kt | 56 +-- .../status/StatusContextPresenter.kt | 2 + .../ui/presenter/status/TranslatePresenter.kt | 128 ++++++- .../database/cache/mapper/MicroblogTest.kt | 65 ++++ .../microblog/MixedRemoteMediatorTest.kt | 362 +++++++++++++++++- .../microblog/handler/PostHandlerTest.kt | 232 +++++++---- .../microblog/handler/UserHandlerTest.kt | 4 +- 22 files changed, 1163 insertions(+), 161 deletions(-) diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt index b974fa9cc..0ea59edc1 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt @@ -309,6 +309,7 @@ public fun CommonStatusComponent( if (isDetail && !item.content.isEmpty && appearanceSettings.showTranslateButton) { TranslationComponent( + item = item, statusKey = item.itemKey, contentWarning = item.contentWarning, rawContent = item.content.innerText, @@ -627,6 +628,7 @@ private fun StatusReactionComponent( @Composable private fun TranslationComponent( + item: UiTimelineV2.Post, statusKey: String, contentWarning: UiRichText?, rawContent: String, @@ -700,6 +702,7 @@ private fun TranslationComponent( "translate_${contentWarning}_${rawContent}_${Locale.current.language}_${componentAppearance.aiConfig.translation}", ) { statusTranslatePresenter( + item = item, contentWarning = contentWarning, content = content, targetLanguage = Locale.current.language, diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusTranslatePresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusTranslatePresenter.kt index 55a9b6992..ff25debce 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusTranslatePresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusTranslatePresenter.kt @@ -3,23 +3,56 @@ package dev.dimension.flare.ui.component.status import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.server.AiTLDRPresenter +import dev.dimension.flare.ui.presenter.status.StatusTranslationPayload +import dev.dimension.flare.ui.presenter.status.TranslateCacheTarget import dev.dimension.flare.ui.presenter.status.TranslatePresenter import dev.dimension.flare.ui.render.UiRichText import dev.dimension.flare.ui.render.toTranslatableText @Composable internal fun statusTranslatePresenter( + item: UiTimelineV2.Post, contentWarning: UiRichText?, content: UiRichText, targetLanguage: String, ): TranslateResult { val contentWarningState = contentWarning?.takeIf { !it.isEmpty }?.let { - translateText(it, targetLanguage) + translateText( + text = it, + targetLanguage = targetLanguage, + cacheTarget = + TranslateCacheTarget( + accountType = item.accountType, + statusKey = item.statusKey, + payload = + StatusTranslationPayload( + content = content, + contentWarning = contentWarning, + ), + field = TranslateCacheTarget.Field.ContentWarning, + ), + ) } - val textState = translateText(content, targetLanguage) + val textState = + translateText( + text = content, + targetLanguage = targetLanguage, + cacheTarget = + TranslateCacheTarget( + accountType = item.accountType, + statusKey = item.statusKey, + payload = + StatusTranslationPayload( + content = content, + contentWarning = contentWarning, + ), + field = TranslateCacheTarget.Field.Content, + ), + ) return TranslateResult( contentWarning = contentWarningState, text = textState, @@ -30,9 +63,10 @@ internal fun statusTranslatePresenter( private fun translateText( text: UiRichText, targetLanguage: String, + cacheTarget: TranslateCacheTarget? = null, ) = run { - remember(text, targetLanguage) { - TranslatePresenter(text, targetLanguage) + remember(text, targetLanguage, cacheTarget) { + TranslatePresenter(text, targetLanguage, cacheTarget) }.invoke() } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt index 7c40ce9dd..78b9bb0f3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt @@ -8,7 +8,7 @@ import androidx.room.TypeConverters import androidx.room.immediateTransaction import androidx.room.useWriterConnection -internal const val CACHE_DATABASE_VERSION = 30 +internal const val CACHE_DATABASE_VERSION = 31 @Database( entities = [ diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt index c02d3de16..1cf925182 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt @@ -1,14 +1,18 @@ package dev.dimension.flare.data.datasource.microblog.handler import dev.dimension.flare.common.Cacheable +import dev.dimension.flare.common.Locale import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.connect import dev.dimension.flare.data.database.cache.mapper.saveToDatabase import dev.dimension.flare.data.database.cache.model.DbPagingTimeline import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus +import dev.dimension.flare.data.database.cache.model.TranslationDisplayOptions import dev.dimension.flare.data.datasource.microblog.loader.PostLoader import dev.dimension.flare.data.datasource.microblog.paging.TimelinePagingMapper +import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.repository.tryRun +import dev.dimension.flare.data.translation.PreTranslationService import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.DbAccountType import dev.dimension.flare.model.MicroBlogKey @@ -16,6 +20,7 @@ import dev.dimension.flare.ui.model.UiTimelineV2 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -27,6 +32,18 @@ internal class PostHandler( ) : KoinComponent { private val database: CacheDatabase by inject() private val coroutineScope: CoroutineScope by inject() + private val appDataStore: AppDataStore by inject() + private val preTranslationService: PreTranslationService by inject() + + private val translationDisplayFlow by lazy { + appDataStore.appSettingsStore.data + .map { settings -> + TranslationDisplayOptions( + enabled = settings.aiConfig.translation && settings.aiConfig.preTranslation, + targetLanguage = settings.language.ifBlank { Locale.language }, + ) + }.distinctUntilChanged() + } fun post(postKey: MicroBlogKey): Cacheable { val pagingKey = "post_only_$postKey" @@ -40,33 +57,49 @@ internal class PostHandler( pagingKey, ) saveToDatabase(database, listOf(item)) + preTranslationService.enqueueStatuses( + listOfNotNull(item.status.status.data) + item.status.references.mapNotNull { it.status?.data }, + allowLongText = true, + ) } }, cacheSource = { val dbAccountType = accountType as DbAccountType - database - .statusDao() - .getWithReferences(postKey, dbAccountType) - .combine(database.pagingTimelineDao().get(pagingKey, accountType = dbAccountType)) { status, paging -> - when { - paging != null -> TimelinePagingMapper.toUi(paging, pagingKey, false) - status != null -> - TimelinePagingMapper.toUi( - DbPagingTimelineWithStatus( - timeline = - DbPagingTimeline( - pagingKey = pagingKey, - statusKey = postKey, - sortId = 0, - ), - status = status, - ), - pagingKey, - false, - ) - else -> null - } - }.distinctUntilChanged() + combine( + database + .statusDao() + .getWithReferences(postKey, dbAccountType), + database.pagingTimelineDao().get(pagingKey, accountType = dbAccountType), + translationDisplayFlow, + ) { status, paging, translationDisplayOptions -> + when { + paging != null -> + TimelinePagingMapper.toUi( + item = paging, + pagingKey = pagingKey, + useDbKeyInItemKey = false, + translationDisplayOptions = translationDisplayOptions, + ) + + status != null -> + TimelinePagingMapper.toUi( + DbPagingTimelineWithStatus( + timeline = + DbPagingTimeline( + pagingKey = pagingKey, + statusKey = postKey, + sortId = 0, + ), + status = status, + ), + pagingKey, + false, + translationDisplayOptions = translationDisplayOptions, + ) + + else -> null + } + }.distinctUntilChanged() .mapNotNull { it } }, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelineRemoteMediator.kt index 1af53df09..a25d4cf06 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelineRemoteMediator.kt @@ -16,6 +16,7 @@ import kotlinx.collections.immutable.toImmutableList internal class TimelineRemoteMediator( private val loader: CacheableRemoteLoader, private val database: CacheDatabase, + private val allowLongText: Boolean, private val notifyError: (Throwable) -> Unit = {}, private val preTranslationService: PreTranslationService = NoopPreTranslationService, ) : BasePagingRemoteMediator( @@ -116,6 +117,7 @@ internal class TimelineRemoteMediator( listOfNotNull(item.status.status.data) + item.status.references.mapNotNull { it.status?.data } }.distinctBy { it.id }, + allowLongText = allowLongText, ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/rss/RssTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/rss/RssTimelineRemoteMediator.kt index db59679e9..deb2c634f 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/rss/RssTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/rss/RssTimelineRemoteMediator.kt @@ -39,6 +39,7 @@ internal class RssTimelineRemoteMediator( sourceName = title, sourceIcon = icon, openInBrowser = rssSource?.openInBrowser ?: false, + sourceLanguage = null, ) } @@ -48,6 +49,7 @@ internal class RssTimelineRemoteMediator( sourceName = title, sourceIcon = icon, openInBrowser = rssSource?.openInBrowser ?: false, + sourceLanguage = null, ) } @@ -57,6 +59,7 @@ internal class RssTimelineRemoteMediator( sourceName = title, sourceIcon = icon, openInBrowser = rssSource?.openInBrowser ?: false, + sourceLanguage = response.channel.language, ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt index 5fcb3107d..2075d54d7 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt @@ -5,7 +5,10 @@ internal object AiPromptDefaults { "You are a translation assistant. Your task is to translate text from one language to another.\n" + "Make sure to keep the meaning and context of the original content intact.\n" + "The input is JSON extracted from a social post.\n" + - "Preserve the full JSON structure, block ids, token ids, and token kinds exactly as-is.\n" + + "For each item, either return status=\"Completed\" with a translated payload, " + + "or status=\"Skipped\" without a payload when the source language already " + + "matches {target_language}.\n" + + "Preserve entityKey, block ids, token ids, and token kinds exactly as-is.\n" + "Only translate token text where kind is \"Translatable\".\n" + "Keep token text where kind is \"Locked\" unchanged.\n" + "Return ONLY JSON without markdown code fences or explanations.\n" + diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt index ed78ed6cd..be71cf4af 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt @@ -17,7 +17,11 @@ import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.datastore.model.AiPromptDefaults import dev.dimension.flare.data.datastore.model.AppSettings import dev.dimension.flare.data.network.ai.AiCompletionService +import dev.dimension.flare.ui.render.RenderContent +import dev.dimension.flare.ui.render.RenderRun +import dev.dimension.flare.ui.render.RenderTextStyle import dev.dimension.flare.ui.render.TranslationDocument +import dev.dimension.flare.ui.render.UiRichText import dev.dimension.flare.ui.render.applyTranslationDocument import dev.dimension.flare.ui.render.toTranslationDocument import kotlinx.coroutines.CoroutineScope @@ -30,13 +34,19 @@ import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes internal interface PreTranslationService { - fun enqueueStatuses(statuses: List) + fun enqueueStatuses( + statuses: List, + allowLongText: Boolean = false, + ) fun enqueueProfile(user: DbUser) } internal data object NoopPreTranslationService : PreTranslationService { - override fun enqueueStatuses(statuses: List) = Unit + override fun enqueueStatuses( + statuses: List, + allowLongText: Boolean, + ) = Unit override fun enqueueProfile(user: DbUser) = Unit } @@ -49,14 +59,17 @@ internal class AiPreTranslationService( ) : PreTranslationService { private val semaphore = Semaphore(permits = 1) - override fun enqueueStatuses(statuses: List) { + override fun enqueueStatuses( + statuses: List, + allowLongText: Boolean, + ) { val snapshot = statuses.distinctBy { it.id } if (snapshot.isEmpty()) { return } coroutineScope.launch { semaphore.withPermit { - processStatusSnapshot(snapshot) + processStatusSnapshot(snapshot, allowLongText = allowLongText) } } } @@ -69,12 +82,16 @@ internal class AiPreTranslationService( } } - private suspend fun processStatusSnapshot(statuses: List) { + private suspend fun processStatusSnapshot( + statuses: List, + allowLongText: Boolean, + ) { val settings = preTranslationSettings() ?: return val candidates = prepareStatusCandidates( statuses = statuses, targetLanguage = settings.targetLanguage, + allowLongText = allowLongText, ) if (candidates.isEmpty()) { return @@ -118,6 +135,7 @@ internal class AiPreTranslationService( private suspend fun prepareStatusCandidates( statuses: List, targetLanguage: String, + allowLongText: Boolean, ): List { val deduplicated = statuses.distinctBy { it.translationEntityKey() } if (deduplicated.isEmpty()) { @@ -138,9 +156,11 @@ internal class AiPreTranslationService( entityType = TranslationEntityType.Status, entityKey = status.translationEntityKey(), payload = status.content.translationPayload(), + sourceLanguages = status.content.translationSourceLanguages(), existing = existingByKey[status.translationEntityKey()], targetLanguage = targetLanguage, now = now, + allowLongText = allowLongText, )?.let(::add) } } @@ -154,6 +174,7 @@ internal class AiPreTranslationService( entityType = TranslationEntityType.Profile, entityKey = user.translationEntityKey(), payload = user.content.translationPayload(), + sourceLanguages = user.content.sourceLanguages, existing = database .translationDao() @@ -164,21 +185,73 @@ internal class AiPreTranslationService( ), targetLanguage = targetLanguage, now = Clock.System.now().toEpochMilliseconds(), + allowLongText = true, ) private suspend fun prepareCandidate( entityType: TranslationEntityType, entityKey: String, payload: TranslationPayload?, + sourceLanguages: List, existing: DbTranslation?, targetLanguage: String, now: Long, + allowLongText: Boolean, ): PreparedTranslationCandidate? { if (payload == null) { return null } - val sourceDocument = payload.toBatchPayload(targetLanguage) + if (!allowLongText && payload.content?.isLongText == true) { + return null + } val sourceHash = payload.sourceHash() + if (payload.isNonTranslatableOnly()) { + if ( + existing == null || + existing.sourceHash != sourceHash || + existing.status != TranslationStatus.Skipped || + existing.statusReason != SKIPPED_NON_TRANSLATABLE_ONLY_REASON + ) { + database.translationDao().insert( + DbTranslation( + entityType = entityType, + entityKey = entityKey, + targetLanguage = targetLanguage, + sourceHash = sourceHash, + status = TranslationStatus.Skipped, + payload = null, + statusReason = SKIPPED_NON_TRANSLATABLE_ONLY_REASON, + attemptCount = existing?.attemptCount ?: 0, + updatedAt = now, + ), + ) + } + return null + } + val sourceDocument = payload.toBatchPayload(targetLanguage) + if (shouldSkipForMatchingSourceLanguage(sourceLanguages = sourceLanguages, targetLanguage = targetLanguage)) { + if ( + existing == null || + existing.sourceHash != sourceHash || + existing.status != TranslationStatus.Skipped || + existing.statusReason != SKIPPED_SAME_LANGUAGE_REASON + ) { + database.translationDao().insert( + DbTranslation( + entityType = entityType, + entityKey = entityKey, + targetLanguage = targetLanguage, + sourceHash = sourceHash, + status = TranslationStatus.Skipped, + payload = null, + statusReason = SKIPPED_SAME_LANGUAGE_REASON, + attemptCount = existing?.attemptCount ?: 0, + updatedAt = now, + ), + ) + } + return null + } if (sourceDocument.isEmpty()) { if ( existing == null || @@ -301,35 +374,53 @@ internal class AiPreTranslationService( val translatedItem = translatedItems[candidate.entityKey] ?: throw IllegalArgumentException("Missing translated item for ${candidate.entityKey}") - val translatedPayload = - candidate.sourcePayload.applyBatchPayload( - sourceDocument = candidate.sourceDocument, - translatedDocument = translatedItem.payload, - ) - if (translatedPayload == candidate.sourcePayload) { - DbTranslation( - entityType = candidate.entityType, - entityKey = candidate.entityKey, - targetLanguage = candidate.targetLanguage, - sourceHash = candidate.sourceHash, - status = TranslationStatus.Skipped, - payload = null, - statusReason = SKIPPED_UNCHANGED_REASON, - attemptCount = candidate.attemptCount, - updatedAt = updatedAt, - ) - } else { - DbTranslation( - entityType = candidate.entityType, - entityKey = candidate.entityKey, - targetLanguage = candidate.targetLanguage, - sourceHash = candidate.sourceHash, - status = TranslationStatus.Completed, - payload = translatedPayload, - statusReason = null, - attemptCount = candidate.attemptCount, - updatedAt = updatedAt, - ) + when (translatedItem.status) { + PreTranslationBatchItemStatus.Completed -> { + val translatedPayload = + candidate.sourcePayload.applyBatchPayload( + sourceDocument = candidate.sourceDocument, + translatedDocument = + translatedItem.payload ?: throw IllegalArgumentException("Missing translated payload"), + ) + if (translatedPayload == candidate.sourcePayload) { + DbTranslation( + entityType = candidate.entityType, + entityKey = candidate.entityKey, + targetLanguage = candidate.targetLanguage, + sourceHash = candidate.sourceHash, + status = TranslationStatus.Skipped, + payload = null, + statusReason = SKIPPED_UNCHANGED_REASON, + attemptCount = candidate.attemptCount, + updatedAt = updatedAt, + ) + } else { + DbTranslation( + entityType = candidate.entityType, + entityKey = candidate.entityKey, + targetLanguage = candidate.targetLanguage, + sourceHash = candidate.sourceHash, + status = TranslationStatus.Completed, + payload = translatedPayload, + statusReason = null, + attemptCount = candidate.attemptCount, + updatedAt = updatedAt, + ) + } + } + + PreTranslationBatchItemStatus.Skipped -> + DbTranslation( + entityType = candidate.entityType, + entityKey = candidate.entityKey, + targetLanguage = candidate.targetLanguage, + sourceHash = candidate.sourceHash, + status = TranslationStatus.Skipped, + payload = null, + statusReason = translatedItem.reason ?: SKIPPED_AI_SAME_LANGUAGE_REASON, + attemptCount = candidate.attemptCount, + updatedAt = updatedAt, + ) } }.getOrElse { throwable -> DbTranslation( @@ -408,9 +499,17 @@ internal data class PreTranslationBatchDocument( @Serializable internal data class PreTranslationBatchItem( val entityKey: String, - val payload: PreTranslationBatchPayload, + val status: PreTranslationBatchItemStatus = PreTranslationBatchItemStatus.Completed, + val payload: PreTranslationBatchPayload? = null, + val reason: String? = null, ) +@Serializable +internal enum class PreTranslationBatchItemStatus { + Completed, + Skipped, +} + @Serializable internal data class PreTranslationBatchPayload( val content: TranslationDocument? = null, @@ -436,9 +535,14 @@ private data class PreparedTranslationCandidate( private const val DEFAULT_PRE_TRANSLATION_MAX_ITEMS = 8 private const val DEFAULT_PRE_TRANSLATION_MAX_INPUT_TOKENS = 6000 +private const val SKIPPED_AI_SAME_LANGUAGE_REASON = "ai_same_language" private const val SKIPPED_EMPTY_REASON = "empty" +private const val SKIPPED_NON_TRANSLATABLE_ONLY_REASON = "non_translatable_only" +private const val SKIPPED_SAME_LANGUAGE_REASON = "source_language_matches_target" private const val SKIPPED_UNCHANGED_REASON = "unchanged" private val STALE_TRANSLATION_TIMEOUT = 10.minutes +private val protectedTranslationPattern = + Regex("""https?://\S+|@[A-Za-z0-9._-]+(?:@[A-Za-z0-9.-]+)?|#[\p{L}\p{N}_]+""") private fun List.chunkedForBatching(): List> { val result = mutableListOf>() @@ -501,3 +605,138 @@ private fun dev.dimension.flare.ui.render.UiRichText?.applyTranslatedField( translatedDocument == null -> throw IllegalArgumentException("Missing translated field") else -> applyTranslationDocument(translatedDocument) } + +private fun dev.dimension.flare.ui.model.UiTimelineV2.translationSourceLanguages(): List = + when (this) { + is dev.dimension.flare.ui.model.UiTimelineV2.Feed -> sourceLanguages + is dev.dimension.flare.ui.model.UiTimelineV2.Post -> sourceLanguages + is dev.dimension.flare.ui.model.UiTimelineV2.Message -> emptyList() + is dev.dimension.flare.ui.model.UiTimelineV2.User -> emptyList() + is dev.dimension.flare.ui.model.UiTimelineV2.UserList -> emptyList() + } + +private fun shouldSkipForMatchingSourceLanguage( + sourceLanguages: List, + targetLanguage: String, +): Boolean { + val canonicalTargetLanguage = canonicalTranslationLanguage(targetLanguage) ?: return false + return sourceLanguages + .asSequence() + .mapNotNull(::canonicalTranslationLanguage) + .any { it == canonicalTargetLanguage } +} + +private fun canonicalTranslationLanguage(language: String): String? { + val normalized = language.trim().lowercase().replace('_', '-') + if (normalized.isBlank()) { + return null + } + val parts = normalized.split('-').filter { it.isNotBlank() } + if (parts.isEmpty()) { + return null + } + val primary = parts.first() + if (primary != "zh") { + return primary + } + val regionOrScript = parts.drop(1) + return when { + regionOrScript.any { it == "hant" } || regionOrScript.any { it in setOf("tw", "hk", "mo") } -> "zh-hant" + regionOrScript.any { it == "hans" } || regionOrScript.any { it in setOf("cn", "sg") } -> "zh-hans" + else -> "zh" + } +} + +private fun TranslationPayload.isNonTranslatableOnly(): Boolean { + val fields = listOfNotNull(content, contentWarning, title, description) + return fields.isNotEmpty() && fields.all { it.isNonTranslatableOnly() } +} + +private fun UiRichText.isNonTranslatableOnly(): Boolean { + var hasVisibleContent = false + renderRuns.forEach { content -> + when (content) { + is RenderContent.BlockImage -> hasVisibleContent = true + is RenderContent.Text -> + content.runs.forEach { run -> + when (run) { + is RenderRun.Image -> hasVisibleContent = true + is RenderRun.Text -> { + if (run.text.isBlank()) { + return@forEach + } + hasVisibleContent = true + if (!run.text.isNonTranslatableOnlyText(run.style)) { + return false + } + } + } + } + } + } + return hasVisibleContent +} + +private fun String.isNonTranslatableOnlyText(style: RenderTextStyle): Boolean { + if (isBlank()) { + return false + } + if (style.code || style.monospace) { + return true + } + var hasVisibleContent = false + var cursor = 0 + protectedTranslationPattern.findAll(this).forEach { match -> + if (match.range.first > cursor) { + val segment = substring(cursor, match.range.first) + if (!segment.isBlank()) { + hasVisibleContent = true + if (!segment.isEmojiOnlyText()) { + return false + } + } + } + if (match.value.isNotBlank()) { + hasVisibleContent = true + } + cursor = match.range.last + 1 + } + if (cursor < length) { + val trailing = substring(cursor) + if (!trailing.isBlank()) { + hasVisibleContent = true + if (!trailing.isEmojiOnlyText()) { + return false + } + } + } + return hasVisibleContent +} + +private fun String.isEmojiOnlyText(): Boolean { + if (isBlank()) { + return false + } + var hasEmoji = false + var index = 0 + while (index < length) { + val current = this[index] + when { + current.isWhitespace() -> index += 1 + current in '\uD83C'..'\uD83E' && index + 1 < length && this[index + 1].isLowSurrogate() -> { + hasEmoji = true + index += 2 + } + current.code == 0x200D || + current.code == 0x20E3 || + current.code in 0xFE00..0xFE0F || + current.code in 0x2600..0x27BF -> { + hasEmoji = true + index += 1 + } + + else -> return false + } + } + return hasEmoji +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt index 398a7c83b..b8f286168 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt @@ -23,6 +23,7 @@ public data class UiProfile internal constructor( private val clickEvent: ClickEvent, public val banner: String?, public val description: UiRichText?, + internal val sourceLanguages: SerializableImmutableList = persistentListOf(), public val matrices: Matrices, public val mark: SerializableImmutableList, public val bottomContent: BottomContent?, @@ -60,6 +61,7 @@ public data class UiProfile internal constructor( clickEvent = clickEvent, banner = banner ?: existing.banner, description = description ?: existing.description, + sourceLanguages = if (sourceLanguages.isEmpty()) existing.sourceLanguages else sourceLanguages, matrices = matrices.mergeWith(existing.matrices), mark = (existing.mark + mark).distinct().toPersistentList(), bottomContent = bottomContent ?: existing.bottomContent, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt index d1a98bd79..b89704ba9 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt @@ -125,6 +125,7 @@ public sealed class UiTimelineV2 { val title: String?, val description: String?, val url: String, + internal val sourceLanguages: SerializableImmutableList = persistentListOf(), override val createdAt: UiDateTime, val source: Source, val openInBrowser: Boolean, @@ -190,6 +191,7 @@ public sealed class UiTimelineV2 { val sensitive: Boolean, val contentWarning: UiRichText?, val user: UiProfile?, + internal val sourceLanguages: SerializableImmutableList = persistentListOf(), @Transient val quote: SerializableImmutableList = persistentListOf(), val content: UiRichText, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Bluesky.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Bluesky.kt index ebe86257f..6372c958a 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Bluesky.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Bluesky.kt @@ -144,6 +144,17 @@ internal fun parseBlueskyJson( } } +private fun JsonContent.sourceLanguages(): PersistentList { + val jsonObject = runCatching { decodeAs() }.getOrNull() ?: return persistentListOf() + return jsonObject["langs"] + ?.jsonArray + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?.filter { it.isNotBlank() } + ?.distinct() + ?.toPersistentList() + ?: persistentListOf() +} + internal suspend fun parseBskyFacets( content: String, resolveMentionDid: suspend (handle: String) -> String, @@ -744,6 +755,7 @@ internal fun PostView.render(accountKey: MicroBlogKey): UiTimelineV2.Post { images = findMedias(this), card = findCard(this), statusKey = statusKey, + sourceLanguages = record.sourceLanguages(), content = parseBlueskyJson(record, accountKey), poll = null, quote = listOfNotNull(findQuote(accountKey, this)).toImmutableList(), @@ -1454,6 +1466,7 @@ private fun render( } }, statusKey = statusKey, + sourceLanguages = record.value.value.sourceLanguages(), content = parseBlueskyJson(record.value.value, accountKey), actions = listOfNotNull( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Mastodon.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Mastodon.kt index fbcde9011..fe60173b3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Mastodon.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Mastodon.kt @@ -359,6 +359,7 @@ private fun Status.renderStatus( contentWarning = spoilerText?.takeIf { it.isNotEmpty() && it.isNotBlank() }?.toUiPlainText(), user = actualUser, + sourceLanguages = listOfNotNull(language).toPersistentList(), quote = listOfNotNull(quoteStatus).toImmutableList(), content = parseMastodonContent(this, accountKey, host), card = diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Misskey.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Misskey.kt index 3b68193ac..bca74dc68 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Misskey.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Misskey.kt @@ -865,6 +865,7 @@ internal fun User.render(accountKey: MicroBlogKey): UiProfile { description?.let { parseMisskeyText(it, accountKey, emojis, remoteHost) }, + sourceLanguages = listOfNotNull(lang).toPersistentList(), matrices = UiProfile.Matrices( fansCount = followersCount.toLong(), diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Rss.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Rss.kt index c7cac60ea..a4844ebf7 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Rss.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Rss.kt @@ -9,6 +9,7 @@ import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.render.toUi import io.ktor.http.Url import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toPersistentList import kotlinx.datetime.LocalDateTime import kotlinx.datetime.Month import kotlinx.datetime.UtcOffset @@ -43,6 +44,7 @@ internal fun Feed.Atom.Entry.render( sourceName: String, sourceIcon: String?, openInBrowser: Boolean, + sourceLanguage: String? = null, ): UiTimelineV2 { val descHtml = content?.value?.let { @@ -55,6 +57,7 @@ internal fun Feed.Atom.Entry.render( title = title?.value?.takeIf { it.isNotEmpty() && it.isNotBlank() }, description = descHtml?.text(), url = links.first().href.replace("http://", "https://"), + sourceLanguages = listOfNotNull(sourceLanguage).toPersistentList(), source = UiTimelineV2.Feed.Source( name = sourceName, @@ -83,6 +86,7 @@ internal fun Feed.Rss20.Item.render( sourceName: String, sourceIcon: String?, openInBrowser: Boolean, + sourceLanguage: String? = null, ): UiTimelineV2 { val descHtml = description?.let { @@ -93,6 +97,7 @@ internal fun Feed.Rss20.Item.render( title = title, description = descHtml?.text(), url = link.replace("http://", "https://"), + sourceLanguages = listOfNotNull(sourceLanguage).toPersistentList(), source = UiTimelineV2.Feed.Source( name = sourceName, @@ -121,6 +126,7 @@ internal fun Feed.RDF.Item.render( sourceName: String, sourceIcon: String?, openInBrowser: Boolean, + sourceLanguage: String? = null, ): UiTimelineV2 { val descHtml = description?.let { @@ -131,6 +137,7 @@ internal fun Feed.RDF.Item.render( title = title, description = descHtml?.text(), url = link.replace("http://", "https://"), + sourceLanguages = listOfNotNull(sourceLanguage).toPersistentList(), source = UiTimelineV2.Feed.Source( name = sourceName, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/XQT.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/XQT.kt index 30fb2a0c4..4442aee65 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/XQT.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/XQT.kt @@ -60,6 +60,7 @@ import dev.dimension.flare.ui.route.toUri import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentMap import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone @@ -606,6 +607,7 @@ internal fun Tweet.renderStatus( sensitive = legacy?.possiblySensitive == true, contentWarning = null, user = user, + sourceLanguages = listOfNotNull(legacy?.lang).toPersistentList(), quote = listOfNotNull(actualQuote).toImmutableList(), content = content, actions = diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt index 04bf92a29..7be96388d 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt @@ -73,6 +73,8 @@ public abstract class TimelinePresenter : }.distinctUntilChanged() } + internal open fun allowLongTextTranslationDisplay(loader: RemoteLoader): Boolean = false + @OptIn(ExperimentalCoroutinesApi::class) internal fun createPager(scope: CoroutineScope): Flow> = loader @@ -122,33 +124,37 @@ public abstract class TimelinePresenter : loader: CacheableRemoteLoader, translationDisplayOptions: TranslationDisplayOptions, ): Flow> = - Pager( - config = pagingConfig, - remoteMediator = - TimelineRemoteMediator( - loader = loader, - database = database, - preTranslationService = preTranslationService, - notifyError = { e -> - if (e is LoginExpiredException) { - inAppNotification.onError(Message.LoginExpired, e) - } - }, - ), - pagingSourceFactory = { - database.pagingTimelineDao().getPagingSource( - pagingKey = loader.pagingKey, - ) - }, - ).flow.map { pagingData -> - withContext(Dispatchers.IO) { - pagingData.map { item -> - TimelinePagingMapper.toUi( - item = item, + run { + val allowLongText = allowLongTextTranslationDisplay(loader) + Pager( + config = pagingConfig, + remoteMediator = + TimelineRemoteMediator( + loader = loader, + database = database, + allowLongText = allowLongText, + preTranslationService = preTranslationService, + notifyError = { e -> + if (e is LoginExpiredException) { + inAppNotification.onError(Message.LoginExpired, e) + } + }, + ), + pagingSourceFactory = { + database.pagingTimelineDao().getPagingSource( pagingKey = loader.pagingKey, - useDbKeyInItemKey = useDbKeyInItemKey, - translationDisplayOptions = translationDisplayOptions, ) + }, + ).flow.map { pagingData -> + withContext(Dispatchers.IO) { + pagingData.map { item -> + TimelinePagingMapper.toUi( + item = item, + pagingKey = loader.pagingKey, + useDbKeyInItemKey = useDbKeyInItemKey, + translationDisplayOptions = translationDisplayOptions, + ) + } } } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/StatusContextPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/StatusContextPresenter.kt index 201941ba1..9ddf391e2 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/StatusContextPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/StatusContextPresenter.kt @@ -69,6 +69,8 @@ public class StatusContextPresenter( private val timelinePresenter by lazy { object : TimelinePresenter() { + override fun allowLongTextTranslationDisplay(loader: RemoteLoader): Boolean = true + override val loader: Flow> by lazy { currentStatusFlow .map { statusKey } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt index f0a56749b..7fa905629 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt @@ -3,10 +3,18 @@ package dev.dimension.flare.ui.presenter.status import androidx.compose.runtime.Composable import androidx.compose.runtime.produceState import dev.dimension.flare.common.Locale +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.model.DbTranslation +import dev.dimension.flare.data.database.cache.model.TranslationEntityType +import dev.dimension.flare.data.database.cache.model.TranslationPayload +import dev.dimension.flare.data.database.cache.model.TranslationStatus +import dev.dimension.flare.data.database.cache.model.sourceHash import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.datastore.model.AiPromptDefaults import dev.dimension.flare.data.network.ai.AiCompletionService import dev.dimension.flare.data.network.ktorClient +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.presenter.PresenterBase import dev.dimension.flare.ui.render.UiRichText @@ -24,14 +32,17 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import kotlin.time.Clock public class TranslatePresenter( private val source: UiRichText, private val targetLanguage: String = Locale.language, + private val cacheTarget: TranslateCacheTarget? = null, ) : PresenterBase>(), KoinComponent { private val aiCompletionService by inject() private val appDataStore: AppDataStore by inject() + private val database: CacheDatabase by inject() private val sourceText: String by lazy { source.toTranslatableText() } private val sourceJson: String by lazy { source.toTranslationJson(targetLanguage) } @@ -47,19 +58,27 @@ public class TranslatePresenter( if (!aiConfig.translation) { return@runCatching toUiRichText(legacyGoogleTranslate()) } + cachedTranslation()?.let { + return@runCatching it + } val promptTemplate = aiConfig.translatePrompt.ifBlank { AiPromptDefaults.TRANSLATE_PROMPT } val prompt = buildTranslatePrompt(promptTemplate, targetLanguage) - ( + val aiTranslation = aiCompletionService.translate( config = aiConfig, source = sourceText, targetLanguage = targetLanguage, prompt = prompt, - ) ?: legacyGoogleTranslate() - ).let(::toUiRichText) + ) + if (aiTranslation != null) { + val translated = toUiRichText(aiTranslation) + cacheTranslation(translated) + return@runCatching translated + } + toUiRichText(legacyGoogleTranslate()) }.fold( onSuccess = { UiState.Success(it) }, onFailure = { UiState.Error(it) }, @@ -123,4 +142,107 @@ public class TranslatePresenter( cleaned.toUiPlainText() } } + + private suspend fun cachedTranslation(): UiRichText? { + val target = cacheTarget ?: return null + val translation = + database + .translationDao() + .get( + entityType = TranslationEntityType.Status, + entityKey = target.entityKey(), + targetLanguage = targetLanguage, + ) ?: return null + if (translation.sourceHash != target.sourcePayload().sourceHash()) { + return null + } + return when (translation.status) { + TranslationStatus.Completed -> target.readField(translation.payload) + TranslationStatus.Skipped -> source + TranslationStatus.Pending, + TranslationStatus.Translating, + TranslationStatus.Failed, + -> null + } + } + + private suspend fun cacheTranslation(translated: UiRichText) { + val target = cacheTarget ?: return + val sourcePayload = target.sourcePayload() + val existing = + database + .translationDao() + .get( + entityType = TranslationEntityType.Status, + entityKey = target.entityKey(), + targetLanguage = targetLanguage, + ) + val mergedPayload = + target.mergePayload( + existing = existing?.takeIf { it.sourceHash == sourcePayload.sourceHash() }?.payload, + translated = translated, + ) + database.translationDao().insert( + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = target.entityKey(), + targetLanguage = targetLanguage, + sourceHash = sourcePayload.sourceHash(), + status = TranslationStatus.Completed, + payload = mergedPayload, + statusReason = null, + attemptCount = existing?.attemptCount ?: 0, + updatedAt = Clock.System.now().toEpochMilliseconds(), + ), + ) + } +} + +public data class TranslateCacheTarget( + val accountType: AccountType, + val statusKey: MicroBlogKey, + val payload: StatusTranslationPayload, + val field: Field, +) { + public enum class Field { + Content, + ContentWarning, + } } + +public data class StatusTranslationPayload( + val content: UiRichText, + val contentWarning: UiRichText?, +) + +private fun TranslateCacheTarget.entityKey(): String = "${accountType}_$statusKey" + +private fun TranslateCacheTarget.sourcePayload(): TranslationPayload = + TranslationPayload( + content = payload.content, + contentWarning = payload.contentWarning, + ) + +private fun TranslateCacheTarget.readField(payload: TranslationPayload?): UiRichText? = + when (field) { + TranslateCacheTarget.Field.Content -> payload?.content + TranslateCacheTarget.Field.ContentWarning -> payload?.contentWarning + } + +private fun TranslateCacheTarget.mergePayload( + existing: TranslationPayload?, + translated: UiRichText, +): TranslationPayload = + when (field) { + TranslateCacheTarget.Field.Content -> + TranslationPayload( + content = translated, + contentWarning = existing?.contentWarning, + ) + + TranslateCacheTarget.Field.ContentWarning -> + TranslationPayload( + content = existing?.content, + contentWarning = translated, + ) + } diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt index b799e35d3..b3fafe92b 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt @@ -1043,6 +1043,71 @@ class MicroblogTest : RobolectricTest() { assertEquals(56, savedProfile.matrices.statusesCount) } + @Test + fun toUiDisplaysExistingLongTextTranslationInTimelineAndDetail() = + runTest { + val accountKey = MicroBlogKey(id = "account-longtext", host = "test.com") + val longText = buildString { repeat(520) { append('长') } } + val postUser = createUser(MicroBlogKey(id = "post-user-longtext", host = "test.com"), "Post User") + val post = + createPost( + accountKey = accountKey, + user = postUser, + statusKey = MicroBlogKey(id = "post-status-longtext", host = "test.com"), + text = longText, + ) + + val mapped = TimelinePagingMapper.toDb(post, pagingKey = "home") + saveToDatabase(db, listOf(mapped)) + db.translationDao().insert( + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = + mapped.status.status.data + .translationEntityKey(), + targetLanguage = "zh-CN", + sourceHash = post.translationPayload()!!.sourceHash(), + status = TranslationStatus.Completed, + payload = TranslationPayload(content = "长文译文".toUiPlainText()), + updatedAt = 1L, + ), + ) + + val paging = db.pagingTimelineDao().getPagingSource("home") + val pager = TestPager(config = PagingConfig(pageSize = 20), paging) + val refreshResult = pager.refresh() + val page = assertIs>(refreshResult) + val dbItem = assertNotNull(page.data.firstOrNull()) + + val timelineUi = + TimelinePagingMapper.toUi( + item = dbItem, + pagingKey = "home", + useDbKeyInItemKey = false, + translationDisplayOptions = + TranslationDisplayOptions( + enabled = true, + targetLanguage = "zh-CN", + allowLongText = false, + ), + ) + val detailUi = + TimelinePagingMapper.toUi( + item = dbItem, + pagingKey = "post_only_${post.statusKey}", + useDbKeyInItemKey = false, + translationDisplayOptions = + TranslationDisplayOptions( + enabled = true, + targetLanguage = "zh-CN", + allowLongText = true, + ), + ) + + assertEquals("长文译文", assertIs(timelineUi).content.raw) + assertEquals("长文译文", assertIs(detailUi).content.raw) + } + private fun createUser( key: MicroBlogKey, name: String, diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt index 528d8429e..77c74cbe0 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt @@ -246,7 +246,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { } val mixed = MixedRemoteMediator(db, listOf(first, second)) - val timelineRemoteMediator = TimelineRemoteMediator(loader = mixed, database = db) + val timelineRemoteMediator = TimelineRemoteMediator(loader = mixed, database = db, allowLongText = false) val mediatorResult = timelineRemoteMediator.load( @@ -330,7 +330,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { } } - val mediator = TimelineRemoteMediator(loader = loader, database = db) + val mediator = TimelineRemoteMediator(loader = loader, database = db, allowLongText = false) val mediatorResult = mediator.load( loadType = LoadType.REFRESH, @@ -427,6 +427,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { TimelineRemoteMediator( loader = loader, database = db, + allowLongText = false, preTranslationService = preTranslationService, ) @@ -470,6 +471,323 @@ class MixedRemoteMediatorTest : RobolectricTest() { assertEquals("parent source (zh-CN)", parentTranslation.payload?.content?.raw) } + @OptIn(ExperimentalPagingApi::class) + @Test + fun homeTimelineSkipsPreTranslationForLongTextPosts() = + runTest { + val appDataStore = AppDataStore(pathProducer) + appDataStore.appSettingsStore.updateData { + it.copy( + language = "zh-CN", + aiConfig = + AppSettings.AiConfig( + translation = true, + preTranslation = true, + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + val preTranslationService: PreTranslationService = + AiPreTranslationService( + database = db, + appDataStore = appDataStore, + aiCompletionService = AiCompletionService(OpenAIService(), TestOnDeviceAI()), + coroutineScope = CoroutineScope(Dispatchers.Unconfined), + ) + val accountKey = MicroBlogKey(id = "account-longtext-home", host = "test.social") + val longText = buildString { repeat(520) { append('长') } } + val post = + createPost( + accountType = AccountType.Specific(accountKey), + user = profile(MicroBlogKey("user-longtext-home", "test.social"), "User"), + statusKey = MicroBlogKey("status-longtext-home", "test.social"), + text = longText, + ) + val loader = + FakeLoader("home") { request -> + when (request) { + PagingRequest.Refresh -> + PagingResult( + data = listOf(post), + nextKey = null, + ) + + is PagingRequest.Append -> error("No append expected") + is PagingRequest.Prepend -> error("No prepend expected") + } + } + val mediator = + TimelineRemoteMediator( + loader = loader, + database = db, + allowLongText = false, + preTranslationService = preTranslationService, + ) + + val mediatorResult = + mediator.load( + loadType = LoadType.REFRESH, + state = + PagingState( + pages = emptyList(), + anchorPosition = null, + config = PagingConfig(pageSize = 20), + leadingPlaceholderCount = 0, + ), + ) + assertTrue(mediatorResult is androidx.paging.RemoteMediator.MediatorResult.Success) + + val savedStatus = db.statusDao().get(post.statusKey, AccountType.Specific(accountKey)).first() + assertNotNull(savedStatus) + val translation = + db.translationDao().get( + entityType = TranslationEntityType.Status, + entityKey = savedStatus.id, + targetLanguage = "zh-CN", + ) + assertNull(translation) + } + + @OptIn(ExperimentalPagingApi::class) + @Test + fun homeTimelineSkipsAiTranslationWhenSourceLanguageMatchesTargetLanguage() = + runTest { + val appDataStore = AppDataStore(pathProducer) + appDataStore.appSettingsStore.updateData { + it.copy( + language = "zh-CN", + aiConfig = + AppSettings.AiConfig( + translation = true, + preTranslation = true, + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + val preTranslationService: PreTranslationService = + AiPreTranslationService( + database = db, + appDataStore = appDataStore, + aiCompletionService = AiCompletionService(OpenAIService(), TestOnDeviceAI()), + coroutineScope = CoroutineScope(Dispatchers.Unconfined), + ) + val accountKey = MicroBlogKey(id = "account-same-language", host = "test.social") + val post = + createPost( + accountType = AccountType.Specific(accountKey), + user = profile(MicroBlogKey("user-same-language", "test.social"), "User"), + statusKey = MicroBlogKey("status-same-language", "test.social"), + text = "已经是中文", + ).copy( + sourceLanguages = persistentListOf("zh-CN"), + ) + val loader = + FakeLoader("home") { request -> + when (request) { + PagingRequest.Refresh -> + PagingResult( + data = listOf(post), + nextKey = null, + ) + + is PagingRequest.Append -> error("No append expected") + is PagingRequest.Prepend -> error("No prepend expected") + } + } + val mediator = + TimelineRemoteMediator( + loader = loader, + database = db, + allowLongText = false, + preTranslationService = preTranslationService, + ) + + val mediatorResult = + mediator.load( + loadType = LoadType.REFRESH, + state = + PagingState( + pages = emptyList(), + anchorPosition = null, + config = PagingConfig(pageSize = 20), + leadingPlaceholderCount = 0, + ), + ) + assertTrue(mediatorResult is androidx.paging.RemoteMediator.MediatorResult.Success) + + val savedStatus = db.statusDao().get(post.statusKey, AccountType.Specific(accountKey)).first() + assertNotNull(savedStatus) + val translation = + db.translationDao().get( + entityType = TranslationEntityType.Status, + entityKey = savedStatus.id, + targetLanguage = "zh-CN", + ) + assertNotNull(translation) + assertEquals(TranslationStatus.Skipped, translation.status) + assertEquals("source_language_matches_target", translation.statusReason) + } + + @OptIn(ExperimentalPagingApi::class) + @Test + fun homeTimelineAcceptsAiSkippedTranslationResult() = + runTest { + val appDataStore = AppDataStore(pathProducer) + appDataStore.appSettingsStore.updateData { + it.copy( + language = "zh-CN", + aiConfig = + AppSettings.AiConfig( + translation = true, + preTranslation = true, + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + val preTranslationService: PreTranslationService = + AiPreTranslationService( + database = db, + appDataStore = appDataStore, + aiCompletionService = AiCompletionService(OpenAIService(), SkippingOnDeviceAI()), + coroutineScope = CoroutineScope(Dispatchers.Unconfined), + ) + val accountKey = MicroBlogKey(id = "account-ai-skipped", host = "test.social") + val post = + createPost( + accountType = AccountType.Specific(accountKey), + user = profile(MicroBlogKey("user-ai-skipped", "test.social"), "User"), + statusKey = MicroBlogKey("status-ai-skipped", "test.social"), + text = "already target language", + ) + val loader = + FakeLoader("home") { request -> + when (request) { + PagingRequest.Refresh -> + PagingResult( + data = listOf(post), + nextKey = null, + ) + + is PagingRequest.Append -> error("No append expected") + is PagingRequest.Prepend -> error("No prepend expected") + } + } + val mediator = + TimelineRemoteMediator( + loader = loader, + database = db, + allowLongText = false, + preTranslationService = preTranslationService, + ) + + val mediatorResult = + mediator.load( + loadType = LoadType.REFRESH, + state = + PagingState( + pages = emptyList(), + anchorPosition = null, + config = PagingConfig(pageSize = 20), + leadingPlaceholderCount = 0, + ), + ) + assertTrue(mediatorResult is androidx.paging.RemoteMediator.MediatorResult.Success) + + val savedStatus = db.statusDao().get(post.statusKey, AccountType.Specific(accountKey)).first() + assertNotNull(savedStatus) + val translation = + db + .translationDao() + .find( + entityType = TranslationEntityType.Status, + entityKey = savedStatus.id, + targetLanguage = "zh-CN", + ).filterNotNull() + .first() + assertEquals(TranslationStatus.Skipped, translation.status) + assertEquals("same_language", translation.statusReason) + } + + @OptIn(ExperimentalPagingApi::class) + @Test + fun homeTimelineSkipsPreTranslationForNonTranslatableOnlyPosts() = + runTest { + val appDataStore = AppDataStore(pathProducer) + appDataStore.appSettingsStore.updateData { + it.copy( + language = "zh-CN", + aiConfig = + AppSettings.AiConfig( + translation = true, + preTranslation = true, + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + val preTranslationService: PreTranslationService = + AiPreTranslationService( + database = db, + appDataStore = appDataStore, + aiCompletionService = AiCompletionService(OpenAIService(), TestOnDeviceAI()), + coroutineScope = CoroutineScope(Dispatchers.Unconfined), + ) + val accountKey = MicroBlogKey(id = "account-emoji-only", host = "test.social") + val post = + createPost( + accountType = AccountType.Specific(accountKey), + user = profile(MicroBlogKey("user-emoji-only", "test.social"), "User"), + statusKey = MicroBlogKey("status-emoji-only", "test.social"), + text = "😀🎉✨ #tag https://example.com", + ) + val loader = + FakeLoader("home") { request -> + when (request) { + PagingRequest.Refresh -> + PagingResult( + data = listOf(post), + nextKey = null, + ) + + is PagingRequest.Append -> error("No append expected") + is PagingRequest.Prepend -> error("No prepend expected") + } + } + val mediator = + TimelineRemoteMediator( + loader = loader, + database = db, + allowLongText = false, + preTranslationService = preTranslationService, + ) + + val mediatorResult = + mediator.load( + loadType = LoadType.REFRESH, + state = + PagingState( + pages = emptyList(), + anchorPosition = null, + config = PagingConfig(pageSize = 20), + leadingPlaceholderCount = 0, + ), + ) + assertTrue(mediatorResult is androidx.paging.RemoteMediator.MediatorResult.Success) + + val savedStatus = db.statusDao().get(post.statusKey, AccountType.Specific(accountKey)).first() + assertNotNull(savedStatus) + val translation = + db + .translationDao() + .find( + entityType = TranslationEntityType.Status, + entityKey = savedStatus.id, + targetLanguage = "zh-CN", + ).filterNotNull() + .first() + assertEquals(TranslationStatus.Skipped, translation.status) + assertEquals("non_translatable_only", translation.statusReason) + } + @Test fun refreshDeduplicatesSamePostReturnedByMultipleSubTimelines() = runTest { @@ -681,7 +999,45 @@ private class TestOnDeviceAI : OnDeviceAI { items = document.items.map { item -> item.copy( - payload = item.payload.translated(targetLanguage), + status = dev.dimension.flare.data.translation.PreTranslationBatchItemStatus.Completed, + payload = requireNotNull(item.payload).translated(targetLanguage), + reason = null, + ) + }, + ).encodeJson( + dev.dimension.flare.data.translation.PreTranslationBatchDocument + .serializer(), + ) + } + + override suspend fun tldr( + source: String, + targetLanguage: String, + prompt: String, + ): String? = null +} + +private class SkippingOnDeviceAI : OnDeviceAI { + override suspend fun isAvailable(): Boolean = true + + override suspend fun translate( + source: String, + targetLanguage: String, + prompt: String, + ): String? { + val document = + source.decodeJson( + dev.dimension.flare.data.translation.PreTranslationBatchDocument + .serializer(), + ) + return document + .copy( + items = + document.items.map { item -> + item.copy( + status = dev.dimension.flare.data.translation.PreTranslationBatchItemStatus.Skipped, + payload = null, + reason = "same_language", ) }, ).encodeJson( diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt index 58ff2c91b..2b379dd7a 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt @@ -4,14 +4,28 @@ import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import dev.dimension.flare.RobolectricTest import dev.dimension.flare.common.CacheState +import dev.dimension.flare.common.OnDeviceAI import dev.dimension.flare.common.TestFormatter +import dev.dimension.flare.common.decodeJson +import dev.dimension.flare.common.encodeJson +import dev.dimension.flare.createTestRootPath import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.saveToDatabase import dev.dimension.flare.data.database.cache.model.DbPagingTimeline import dev.dimension.flare.data.database.cache.model.DbStatus import dev.dimension.flare.data.database.cache.model.DbStatusReference +import dev.dimension.flare.data.database.cache.model.TranslationEntityType +import dev.dimension.flare.data.database.cache.model.TranslationStatus import dev.dimension.flare.data.datasource.microblog.loader.PostLoader import dev.dimension.flare.data.datasource.microblog.paging.TimelinePagingMapper +import dev.dimension.flare.data.datastore.AppDataStore +import dev.dimension.flare.data.datastore.model.AppSettings +import dev.dimension.flare.data.io.PlatformPathProducer +import dev.dimension.flare.data.network.ai.AiCompletionService +import dev.dimension.flare.data.network.ai.OpenAIService +import dev.dimension.flare.data.translation.AiPreTranslationService +import dev.dimension.flare.data.translation.PreTranslationService +import dev.dimension.flare.deleteTestRootPath import dev.dimension.flare.memoryDatabaseBuilder import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey @@ -20,6 +34,8 @@ import dev.dimension.flare.model.ReferenceType import dev.dimension.flare.ui.humanizer.PlatformFormatter import dev.dimension.flare.ui.model.ClickEvent import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.render.TranslationDocument +import dev.dimension.flare.ui.render.TranslationTokenKind import dev.dimension.flare.ui.render.toUi import dev.dimension.flare.ui.render.toUiPlainText import kotlinx.collections.immutable.PersistentList @@ -30,9 +46,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import okio.Path import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import org.koin.dsl.module @@ -47,8 +65,21 @@ import kotlin.uuid.Uuid @OptIn(ExperimentalCoroutinesApi::class) class PostHandlerTest : RobolectricTest() { + private val root = createTestRootPath() + private val pathProducer = + object : PlatformPathProducer { + override fun dataStoreFile(fileName: String): Path = root.resolve(fileName) + + override fun draftMediaFile( + groupId: String, + fileName: String, + ): Path = root.resolve("draft_media").resolve(groupId).resolve(fileName) + } + private lateinit var db: CacheDatabase + private lateinit var appDataStore: AppDataStore private lateinit var fakeLoader: FakePostLoader + private lateinit var onDeviceAI: FakePostOnDeviceAI private val accountKey = MicroBlogKey(id = "user-1", host = "test.social") private val accountType = AccountType.Specific(accountKey) @@ -63,27 +94,39 @@ class PostHandlerTest : RobolectricTest() { .setQueryCoroutineContext(Dispatchers.Unconfined) .build() + appDataStore = AppDataStore(pathProducer) fakeLoader = FakePostLoader() + onDeviceAI = FakePostOnDeviceAI() } @AfterTest fun tearDown() { db.close() stopKoin() + deleteTestRootPath(root) + } + + private fun startTestKoin(scope: CoroutineScope) { + startKoin { + modules( + module { + single { db } + single { appDataStore } + single { scope } + single { onDeviceAI } + single { OpenAIService() } + single { AiCompletionService(get(), get()) } + single { AiPreTranslationService(get(), get(), get(), get()) } + single { TestFormatter() } + }, + ) + } } @Test fun postRefreshFetchesAndStoresInDatabase() = runTest { - startKoin { - modules( - module { - single { db } - single { this@runTest } - single { TestFormatter() } - }, - ) - } + startTestKoin(this@runTest) val expected = createPost(statusKey = postKey) fakeLoader.nextStatus = expected @@ -115,15 +158,7 @@ class PostHandlerTest : RobolectricTest() { @Test fun postUsesLocalStatusCacheBeforeRefreshCreatesPagingRow() = runTest { - startKoin { - modules( - module { - single { db } - single { this@runTest } - single { TestFormatter() } - }, - ) - } + startTestKoin(this@runTest) val local = createPost(statusKey = postKey) db.statusDao().insert( @@ -151,15 +186,7 @@ class PostHandlerTest : RobolectricTest() { @Test fun postUsesInnerRepostWhenOnlyLocalStatusCacheExists() = runTest { - startKoin { - modules( - module { - single { db } - single { this@runTest } - single { TestFormatter() } - }, - ) - } + startTestKoin(this@runTest) val repostKey = MicroBlogKey(id = "repost-1", host = "test.social") val repost = createPost(statusKey = repostKey) @@ -201,15 +228,7 @@ class PostHandlerTest : RobolectricTest() { @Test fun postRefreshKeepsLocalParentsWhenRemoteParentsIsEmpty() = runTest { - startKoin { - modules( - module { - single { db } - single { this@runTest } - single { TestFormatter() } - }, - ) - } + startTestKoin(this@runTest) val parentKey = MicroBlogKey(id = "parent-1", host = "test.social") val localWithParents = @@ -247,15 +266,7 @@ class PostHandlerTest : RobolectricTest() { @Test fun postRefreshUsesRemoteParentsWhenRemoteParentsIsNotEmpty() = runTest { - startKoin { - modules( - module { - single { db } - single { this@runTest } - single { TestFormatter() } - }, - ) - } + startTestKoin(this@runTest) val localParentKey = MicroBlogKey(id = "local-parent", host = "test.social") val remoteParentKey = MicroBlogKey(id = "remote-parent", host = "test.social") @@ -294,15 +305,7 @@ class PostHandlerTest : RobolectricTest() { @Test fun deleteSuccessRemovesStatusReferencesAndPaging() = runTest { - startKoin { - modules( - module { - single { db } - single { this@runTest } - single { TestFormatter() } - }, - ) - } + startTestKoin(this@runTest) db.statusDao().insert( DbStatus( @@ -348,15 +351,7 @@ class PostHandlerTest : RobolectricTest() { @Test fun deleteFailureKeepsLocalCache() = runTest { - startKoin { - modules( - module { - single { db } - single { this@runTest } - single { TestFormatter() } - }, - ) - } + startTestKoin(this@runTest) db.statusDao().insert( DbStatus( @@ -388,9 +383,55 @@ class PostHandlerTest : RobolectricTest() { assertTrue(pagingExists) } + @Test + fun postRefreshPreTranslatesLongTextWhenOpenedInDetail() = + runTest { + startTestKoin(this@runTest) + appDataStore.appSettingsStore.updateData { + it.copy( + language = "zh-CN", + aiConfig = + AppSettings.AiConfig( + translation = true, + preTranslation = true, + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + + val longText = buildString { repeat(520) { append('长') } } + fakeLoader.nextStatus = createPost(statusKey = postKey, text = longText) + val handler = PostHandler(accountType = accountType, loader = fakeLoader) + val cacheable = handler.post(postKey) + + val refreshState = cacheable.refreshState.drop(1).first() + assertTrue(refreshState is androidx.paging.LoadState.NotLoading) + + val savedStatus = db.statusDao().get(postKey, accountType).first() + assertNotNull(savedStatus) + val translation = + db + .translationDao() + .find( + entityType = TranslationEntityType.Status, + entityKey = savedStatus.id, + targetLanguage = "zh-CN", + ).filterNotNull() + .first { it.status == TranslationStatus.Completed } + assertEquals("$longText (zh-CN)", translation.payload?.content?.raw) + + val translated = + cacheable.data + .filterIsInstance>() + .first { (it.data as? UiTimelineV2.Post)?.content?.raw == "$longText (zh-CN)" } + .data as UiTimelineV2.Post + assertEquals("$longText (zh-CN)", translated.content.raw) + } + private fun createPost( statusKey: MicroBlogKey, parents: PersistentList = persistentListOf(), + text: String = "post content", ): UiTimelineV2.Post = UiTimelineV2.Post( message = null, @@ -400,7 +441,7 @@ class PostHandlerTest : RobolectricTest() { contentWarning = null, user = null, quote = persistentListOf(), - content = "post content".toUiPlainText(), + content = text.toUiPlainText(), actions = persistentListOf(), poll = null, statusKey = statusKey, @@ -438,3 +479,66 @@ class PostHandlerTest : RobolectricTest() { } } } + +private class FakePostOnDeviceAI : OnDeviceAI { + override suspend fun isAvailable(): Boolean = true + + override suspend fun translate( + source: String, + targetLanguage: String, + prompt: String, + ): String? { + val document = + source.decodeJson( + dev.dimension.flare.data.translation.PreTranslationBatchDocument + .serializer(), + ) + return document + .copy( + items = + document.items.map { item -> + item.copy( + status = dev.dimension.flare.data.translation.PreTranslationBatchItemStatus.Completed, + payload = requireNotNull(item.payload).translated(targetLanguage), + reason = null, + ) + }, + ).encodeJson( + dev.dimension.flare.data.translation.PreTranslationBatchDocument + .serializer(), + ) + } + + override suspend fun tldr( + source: String, + targetLanguage: String, + prompt: String, + ): String? = null +} + +private fun dev.dimension.flare.data.translation.PreTranslationBatchPayload.translated( + targetLanguage: String, +): dev.dimension.flare.data.translation.PreTranslationBatchPayload = + dev.dimension.flare.data.translation.PreTranslationBatchPayload( + content = content?.translated(targetLanguage), + contentWarning = contentWarning?.translated(targetLanguage), + title = title?.translated(targetLanguage), + description = description?.translated(targetLanguage), + ) + +private fun TranslationDocument.translated(targetLanguage: String): TranslationDocument = + copy( + blocks = + blocks.map { block -> + block.copy( + tokens = + block.tokens.map { token -> + if (token.kind == TranslationTokenKind.Translatable) { + token.copy(text = "${token.text} ($targetLanguage)") + } else { + token + } + }, + ) + }, + ) diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt index 401c0950e..ad405acb2 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt @@ -436,7 +436,9 @@ private class FakeOnDeviceAI : OnDeviceAI { items = document.items.map { item -> item.copy( - payload = item.payload.translated(targetLanguage), + status = dev.dimension.flare.data.translation.PreTranslationBatchItemStatus.Completed, + payload = requireNotNull(item.payload).translated(targetLanguage), + reason = null, ) }, ).encodeJson(PreTranslationBatchDocument.serializer()) From 7220f6609cb9cd0d3f4baf9da8d5a285e7392333 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 29 Mar 2026 16:38:24 +0900 Subject: [PATCH 04/14] complete auto translate --- .../ui/screen/settings/AiConfigScreen.kt | 30 + app/src/main/res/values/strings.xml | 2 + .../values-zh-rCN/strings.xml | 5 + .../composeResources/values/strings.xml | 5 + .../flare/ui/component/ProfileHeader.kt | 9 +- .../component/status/CommonStatusComponent.kt | 69 +- .../ui/component/status/FeedComponent.kt | 7 + .../fa-language.symbolset/Contents.json | 12 + .../fa-language.symbolset/fa-language.svg | 84 + iosApp/flare/Localizable.xcstrings | 4951 +++++++++-------- .../UI/Component/CommonProfileHeader.swift | 5 + .../flare/UI/Component/Status/FeedView.swift | 5 + .../Component/Status/StatusActionView.swift | 3 + .../UI/Component/Status/StatusView.swift | 5 + .../Component/TranslateStatusComponent.swift | 17 + iosApp/flare/UI/FlareTheme.swift | 2 +- iosApp/flare/UI/Screen/AiConfigScreen.swift | 48 +- .../dimension/flare/common/Locale.android.kt | 3 +- .../dimension/flare/common/Locale.apple.kt | 3 +- .../data/database/cache/CacaheDatabase.kt | 2 +- .../data/database/cache/dao/StatusDao.kt | 7 + .../data/database/cache/dao/TranslationDao.kt | 34 + .../database/cache/model/DbTranslation.kt | 20 + .../cache/model/TranslationDisplay.kt | 181 +- .../data/datasource/microblog/ActionMenu.kt | 3 + .../microblog/handler/PostHandler.kt | 5 +- .../microblog/handler/UserHandler.kt | 8 +- .../microblog/paging/TimelinePagingMapper.kt | 6 +- .../flare/data/datastore/model/AppSettings.kt | 5 + .../data/translation/PreTranslationService.kt | 221 +- .../dimension/flare/ui/model/DeeplinkEvent.kt | 27 +- .../flare/ui/model/TranslationDisplayState.kt | 8 + .../dev/dimension/flare/ui/model/UiProfile.kt | 3 + .../dimension/flare/ui/model/UiTimelineV2.kt | 4 + .../dimension/flare/ui/model/mapper/VVO.kt | 4 + .../ui/presenter/home/DeepLinkPresenter.kt | 71 +- .../ui/presenter/home/TimelinePresenter.kt | 63 +- .../presenter/settings/AiConfigPresenter.kt | 26 - .../ui/presenter/status/TranslatePresenter.kt | 13 +- .../database/cache/dao/TranslationDaoTest.kt | 113 + .../database/cache/mapper/MicroblogTest.kt | 369 +- .../microblog/MixedRemoteMediatorTest.kt | 191 + .../dev/dimension/flare/common/Locale.jvm.kt | 3 +- .../flare/ui/model/DeeplinkEventTest.kt | 23 + 44 files changed, 4050 insertions(+), 2625 deletions(-) create mode 100644 iosApp/flare/Assets.xcassets/fa-language.symbolset/Contents.json create mode 100644 iosApp/flare/Assets.xcassets/fa-language.symbolset/fa-language.svg create mode 100644 iosApp/flare/UI/Component/TranslateStatusComponent.swift create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/TranslationDisplayState.kt create mode 100644 shared/src/jvmTest/kotlin/dev/dimension/flare/ui/model/DeeplinkEventTest.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt index 02b259857..ec2eea8ed 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt @@ -364,6 +364,36 @@ internal fun AiConfigScreen(onBack: () -> Unit) { ) }, ) + AnimatedVisibility(visible = state.aiConfig.translation) { + SegmentedListItem( + onClick = { + state.update { + copy(preTranslation = !state.aiConfig.preTranslation) + } + }, + shapes = ListItemDefaults.item(), + content = { + Text( + text = stringResource(id = R.string.settings_ai_config_enable_pre_translation), + ) + }, + supportingContent = { + Text( + text = stringResource(id = R.string.settings_ai_config_pre_translation_description), + ) + }, + trailingContent = { + Switch( + checked = state.aiConfig.preTranslation, + onCheckedChange = { + state.update { + copy(preTranslation = it) + } + }, + ) + }, + ) + } AnimatedVisibility(visible = state.aiConfig.translation) { SegmentedListItem( checked = state.textEditDialog?.field == AiConfigEditField.TranslatePrompt, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c961c7984..f56edbf68 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -98,6 +98,8 @@ Replace Google Translate with AI translation, might take longer time Enable AI Summarization Enable AI summarization for long posts, only available in post that longer than 500 characters + Enable pre-translation + Translate and cache newly loaded timeline and profile content in the background. This can consume a large amount of tokens. Translation Prompt Summary Prompt Language diff --git a/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml b/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml index ee327aec3..ef701a005 100644 --- a/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -116,6 +116,11 @@ 回复 %1$s 翻译帖子 摘要帖子 + 已翻译 + 翻译中 + 翻译失败 + 重试翻译 + 显示原文 yyyy年MMMdd日 MMMdd日 yyyy年MMMdd日 HH:mm diff --git a/compose-ui/src/commonMain/composeResources/values/strings.xml b/compose-ui/src/commonMain/composeResources/values/strings.xml index 3bc27441d..77863fa9b 100644 --- a/compose-ui/src/commonMain/composeResources/values/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values/strings.xml @@ -134,6 +134,11 @@ Reply to %1$s Translate Post Summary Post + Translated + Translating + Failed + Retry translation + Show original dd MMM yy dd MMM diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileHeader.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileHeader.kt index 370a955b1..d2b66243c 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileHeader.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileHeader.kt @@ -1,6 +1,7 @@ package dev.dimension.flare.ui.component import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -46,12 +47,13 @@ import dev.dimension.flare.compose.ui.profile_header_button_following import dev.dimension.flare.compose.ui.profile_header_button_is_fans import dev.dimension.flare.compose.ui.profile_header_button_requested import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.component.placeholder import dev.dimension.flare.ui.component.platform.PlatformErrorButton import dev.dimension.flare.ui.component.platform.PlatformFilledTonalButton import dev.dimension.flare.ui.component.platform.PlatformOutlinedButton import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.component.platform.isBigScreen +import dev.dimension.flare.ui.component.status.TranslationDisplayBadge +import dev.dimension.flare.ui.model.TranslationDisplayState import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiRelation import dev.dimension.flare.ui.model.UiState @@ -271,6 +273,11 @@ private fun ProfileHeaderSuccess( ) } } + AnimatedVisibility(user.translationDisplayState != TranslationDisplayState.Hidden) { + TranslationDisplayBadge( + state = user.translationDisplayState, + ) + } }, content = { user.description?.let { diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt index 0ea59edc1..1f75a3cb8 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt @@ -1,5 +1,6 @@ package dev.dimension.flare.ui.component.status +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background @@ -46,16 +47,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.regular.Bookmark import compose.icons.fontawesomeicons.solid.At -import compose.icons.fontawesomeicons.solid.Bookmark import compose.icons.fontawesomeicons.solid.Ellipsis import compose.icons.fontawesomeicons.solid.Globe import compose.icons.fontawesomeicons.solid.Image +import compose.icons.fontawesomeicons.solid.Language import compose.icons.fontawesomeicons.solid.Lock import compose.icons.fontawesomeicons.solid.LockOpen import compose.icons.fontawesomeicons.solid.Reply -import compose.icons.fontawesomeicons.solid.Retweet +import compose.icons.fontawesomeicons.solid.TriangleExclamation import compose.icons.fontawesomeicons.solid.Tv import dev.dimension.flare.compose.ui.Res import dev.dimension.flare.compose.ui.bookmark_add @@ -88,6 +88,8 @@ import dev.dimension.flare.compose.ui.share import dev.dimension.flare.compose.ui.show_media import dev.dimension.flare.compose.ui.status_detail_tldr import dev.dimension.flare.compose.ui.status_detail_translate +import dev.dimension.flare.compose.ui.translation_retry +import dev.dimension.flare.compose.ui.translation_show_original import dev.dimension.flare.compose.ui.unlike import dev.dimension.flare.compose.ui.user_block import dev.dimension.flare.compose.ui.user_block_with_parameter @@ -112,6 +114,7 @@ import dev.dimension.flare.ui.component.RichText import dev.dimension.flare.ui.component.placeholder import dev.dimension.flare.ui.component.platform.PlatformCard import dev.dimension.flare.ui.component.platform.PlatformCheckbox +import dev.dimension.flare.ui.component.platform.PlatformCircularProgressIndicator import dev.dimension.flare.ui.component.platform.PlatformDropdownMenuDivider import dev.dimension.flare.ui.component.platform.PlatformDropdownMenuItem import dev.dimension.flare.ui.component.platform.PlatformDropdownMenuScope @@ -122,6 +125,7 @@ import dev.dimension.flare.ui.component.platform.PlatformTextButton import dev.dimension.flare.ui.component.platform.PlatformTextStyle import dev.dimension.flare.ui.component.toImageVector import dev.dimension.flare.ui.model.ClickContext +import dev.dimension.flare.ui.model.TranslationDisplayState import dev.dimension.flare.ui.model.UiCard import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiPoll @@ -208,6 +212,13 @@ public fun CommonStatusComponent( tint = PlatformTheme.colorScheme.caption, ) } + AnimatedVisibility( + visible = item.translationDisplayState != TranslationDisplayState.Hidden, + ) { + TranslationDisplayBadge( + state = item.translationDisplayState, + ) + } if (appearanceSettings.showPlatformLogo) { FAIcon( imageVector = item.platformType.brandIcon, @@ -626,6 +637,42 @@ private fun StatusReactionComponent( } } +@Composable +internal fun TranslationDisplayBadge( + state: TranslationDisplayState, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + FAIcon( + FontAwesomeIcons.Solid.Language, + contentDescription = null, + tint = PlatformTheme.colorScheme.caption, + ) + AnimatedContent(state) { state -> + when (state) { + TranslationDisplayState.Translating -> + PlatformCircularProgressIndicator( + modifier = Modifier.size(12.dp), + color = PlatformTheme.colorScheme.caption, + ) + + TranslationDisplayState.Translated -> Unit + TranslationDisplayState.Failed -> + FAIcon( + FontAwesomeIcons.Solid.TriangleExclamation, + contentDescription = null, + tint = PlatformTheme.colorScheme.caption, + modifier = Modifier.size(12.dp), + ) + TranslationDisplayState.Hidden -> Unit + } + } + } +} + @Composable private fun TranslationComponent( item: UiTimelineV2.Post, @@ -816,9 +863,13 @@ internal fun StatusActions( when (action) { is ActionMenu.Group -> { StatusActionGroup( - icon = action.displayItem.icon?.toImageVector() ?: FontAwesomeIcons.Solid.Ellipsis, + icon = + action.displayItem.icon?.toImageVector() + ?: FontAwesomeIcons.Solid.Ellipsis, number = action.displayItem.count, - color = action.displayItem.color?.toComposeColor() ?: PlatformContentColor.current, + color = + action.displayItem.color?.toComposeColor() + ?: PlatformContentColor.current, withTextMinWidth = index != items.lastIndex, ) { closeMenu, isMenuShown -> action.actions.fastForEach { subActions -> @@ -837,7 +888,10 @@ internal fun StatusActions( is ActionMenu.Item -> { StatusActionButton( - icon = action.icon?.toImageVector() ?: FontAwesomeIcons.Solid.Ellipsis, // Fallback or handle null + icon = + action.icon?.toImageVector() + ?: FontAwesomeIcons.Solid.Ellipsis, + // Fallback or handle null number = action.count, color = action.color?.toComposeColor() ?: PlatformContentColor.current, withTextMinWidth = index != items.lastIndex, @@ -934,6 +988,9 @@ private fun ActionMenu.Item.Text.asString(): String = ActionMenu.Item.Text.Localized.Type.MuteWithHandleParameter -> Res.string.user_mute_with_parameter ActionMenu.Item.Text.Localized.Type.AcceptFollowRequest -> Res.string.more ActionMenu.Item.Text.Localized.Type.RejectFollowRequest -> Res.string.more + ActionMenu.Item.Text.Localized.Type.RetryTranslation -> Res.string.translation_retry + ActionMenu.Item.Text.Localized.Type.Translate -> Res.string.status_detail_translate + ActionMenu.Item.Text.Localized.Type.ShowOriginal -> Res.string.translation_show_original } stringResource(resource, *parameters.toTypedArray()) } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/FeedComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/FeedComponent.kt index 306cde4a8..f3375600a 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/FeedComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/FeedComponent.kt @@ -1,5 +1,6 @@ package dev.dimension.flare.ui.component.status +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -16,6 +17,7 @@ import dev.dimension.flare.ui.component.DateTimeText import dev.dimension.flare.ui.component.NetworkImage import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.model.ClickContext +import dev.dimension.flare.ui.model.TranslationDisplayState import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.theme.PlatformTheme import dev.dimension.flare.ui.theme.screenHorizontalPadding @@ -56,6 +58,11 @@ internal fun FeedComponent( modifier = Modifier.weight(1f), maxLines = 1, ) + AnimatedVisibility(data.translationDisplayState != TranslationDisplayState.Hidden) { + TranslationDisplayBadge( + state = data.translationDisplayState, + ) + } data.actualCreatedAt?.let { DateTimeText( it, diff --git a/iosApp/flare/Assets.xcassets/fa-language.symbolset/Contents.json b/iosApp/flare/Assets.xcassets/fa-language.symbolset/Contents.json new file mode 100644 index 000000000..265d4ac9c --- /dev/null +++ b/iosApp/flare/Assets.xcassets/fa-language.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "fa-language.svg", + "idiom" : "universal" + } + ] +} diff --git a/iosApp/flare/Assets.xcassets/fa-language.symbolset/fa-language.svg b/iosApp/flare/Assets.xcassets/fa-language.symbolset/fa-language.svg new file mode 100644 index 000000000..d0edf52b6 --- /dev/null +++ b/iosApp/flare/Assets.xcassets/fa-language.symbolset/fa-language.svg @@ -0,0 +1,84 @@ + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.6.0 + Requires Xcode 16 or greater + Generated from square + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/iosApp/flare/Localizable.xcstrings b/iosApp/flare/Localizable.xcstrings index 471f2acd2..cfaf91858 100644 --- a/iosApp/flare/Localizable.xcstrings +++ b/iosApp/flare/Localizable.xcstrings @@ -72,16 +72,16 @@ "value" : "読み込み中" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Laden..." + "value" : "Laster" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Laster" + "value" : "Laden..." } }, "pl" : { @@ -190,13 +190,13 @@ "value" : "%d" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "%d" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "%d" @@ -296,13 +296,13 @@ "value" : "%lld" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "%lld" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "%lld" @@ -420,13 +420,13 @@ "value" : "%1$lld/%2$d" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld/%2$d" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld/%2$d" @@ -544,13 +544,13 @@ "value" : "%1$lld/%2$lld" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld/%2$lld" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld/%2$lld" @@ -704,16 +704,16 @@ "value" : "Flare에 대해 더 알아보기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meer informatie over Flare" + "value" : "Lær mer om Flare" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lær mer om Flare" + "value" : "Meer informatie over Flare" } }, "pl" : { @@ -894,16 +894,16 @@ "value" : "정보" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Informatie" + "value" : "Om" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Om" + "value" : "Informatie" } }, "pl" : { @@ -1048,16 +1048,16 @@ "value" : "フォローリクエストを承認する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Accepteer volgverzoek" + "value" : "Godta følg-forespørsel" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Godta følg-forespørsel" + "value" : "Accepteer volgverzoek" } }, "pl" : { @@ -1184,16 +1184,16 @@ "value" : "アカウントを管理する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beheer je accounts" + "value" : "Administrer kontoene dine" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Administrer kontoene dine" + "value" : "Beheer je accounts" } }, "pl" : { @@ -1314,16 +1314,16 @@ "value" : "アカウント管理" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Account beheer" + "value" : "Konto administrasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Konto administrasjon" + "value" : "Account beheer" } }, "pl" : { @@ -1444,16 +1444,16 @@ "value" : "アカウント" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rekeningen" + "value" : "Kontoer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kontoer" + "value" : "Rekeningen" } }, "pl" : { @@ -1610,16 +1610,16 @@ "value" : "재시도" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opnieuw" + "value" : "Prøv igjen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Prøv igjen" + "value" : "Opnieuw" } }, "pl" : { @@ -1800,16 +1800,16 @@ "value" : "추가" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toevoegen" + "value" : "Legg til" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til" + "value" : "Toevoegen" } }, "pl" : { @@ -1948,16 +1948,16 @@ "value" : "リレーを追加" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voeg relais toe" + "value" : "Legg til relé" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til relé" + "value" : "Voeg relais toe" } }, "pl" : { @@ -2084,16 +2084,16 @@ "value" : "RSSを追加" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "RSS toevoegen" + "value" : "Legg til RSS" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til RSS" + "value" : "RSS toevoegen" } }, "pl" : { @@ -2220,13 +2220,13 @@ "value" : "AI Type" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "AI Type" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "AI Type" @@ -2386,16 +2386,16 @@ "value" : "AI 설정 구성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Configureer AI instellingen" + "value" : "Konfigurer AI-innstillinger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Konfigurer AI-innstillinger" + "value" : "Configureer AI instellingen" } }, "pl" : { @@ -2547,16 +2547,16 @@ "value" : "AI 機能" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "AI functies" + "value" : "Egenskaper av AI" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Egenskaper av AI" + "value" : "AI functies" } }, "pl" : { @@ -2609,6 +2609,50 @@ } } }, + "ai_config_pre_translate" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable pre-translation" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用预翻译" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟用預翻譯" + } + } + } + }, + "ai_config_pre_translate_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Translate and cache newly loaded timeline and profile content in the background. This can consume a large amount of tokens." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在后台翻译并缓存新加载的时间线和个人资料内容。这会消耗大量 token。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在背景翻譯並快取新載入的時間線和個人資料內容。這會消耗大量 token。" + } + } + } + }, "ai_config_server_provider_placeholder" : { "extractionState" : "stale", "localizations" : { @@ -2714,16 +2758,16 @@ "value" : "서버 URL을 입력하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voer de server-URL in" + "value" : "Skriv inn nettadressen til serveren" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv inn nettadressen til serveren" + "value" : "Voer de server-URL in" } }, "pl" : { @@ -2905,16 +2949,16 @@ "value" : "서버 URL" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Server URL" + "value" : "URL til server" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "URL til server" + "value" : "Server URL" } }, "pl" : { @@ -3095,16 +3139,16 @@ "value" : "AI 요약 기능 활성화" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "AI samenvatting inschakelen" + "value" : "Aktiver AI sammendrag" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver AI sammendrag" + "value" : "AI samenvatting inschakelen" } }, "pl" : { @@ -3285,16 +3329,16 @@ "value" : "AI 구성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "AI configuratie" + "value" : "AI Konfigurasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "AI Konfigurasjon" + "value" : "AI configuratie" } }, "pl" : { @@ -3475,16 +3519,16 @@ "value" : "AI 구성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "AI configuratie" + "value" : "Aktiver AI-oversettelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver AI-oversettelse" + "value" : "AI configuratie" } }, "pl" : { @@ -3665,16 +3709,16 @@ "value" : "발견하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontdek feeds" + "value" : "Oppdag fôr" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdag fôr" + "value" : "Ontdek feeds" } }, "pl" : { @@ -3855,16 +3899,16 @@ "value" : "내 피드" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Mijn feeds" + "value" : "Mine fôr" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mine fôr" + "value" : "Mijn feeds" } }, "pl" : { @@ -4015,16 +4059,16 @@ "value" : "すべてのフィード" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Alle feeds" + "value" : "Alle kanaler" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alle kanaler" + "value" : "Alle feeds" } }, "pl" : { @@ -4181,16 +4225,16 @@ "value" : "목록" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Klantenlijst" + "value" : "Liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste" + "value" : "Klantenlijst" } }, "pl" : { @@ -4335,16 +4379,16 @@ "value" : "すべてのRSSフィード" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Alle RSS feeds" + "value" : "Alle RSS-Feeds" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alle RSS-Feeds" + "value" : "Alle RSS feeds" } }, "pl" : { @@ -4459,13 +4503,13 @@ "value" : "ALT" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "ALT" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "ALT" @@ -4625,13 +4669,13 @@ "value" : "안테나" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" @@ -4815,13 +4859,13 @@ "value" : "안테나" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" @@ -4969,16 +5013,16 @@ "value" : "API キー" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "API Sleutel" + "value" : "API-nøkkel (Automatic Translation)" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "API-nøkkel (Automatic Translation)" + "value" : "API Sleutel" } }, "pl" : { @@ -5129,16 +5173,16 @@ "value" : "앱 로깅" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "App logboek" + "value" : "Logg app" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg app" + "value" : "App logboek" } }, "pl" : { @@ -5313,16 +5357,16 @@ "value" : "네트워크 로깅 활성화" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Netwerk loggen inschakelen" + "value" : "Aktiver nettverkslogging" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver nettverkslogging" + "value" : "Netwerk loggen inschakelen" } }, "pl" : { @@ -5467,16 +5511,16 @@ "value" : "絶対タイムスタンプです" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Absolute tijdstempel" + "value" : "Absolutt tidsstempel" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Absolutt tidsstempel" + "value" : "Absolute tijdstempel" } }, "pl" : { @@ -5603,16 +5647,16 @@ "value" : "投稿に絶対タイムスタンプを表示する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geef absolute tijdsaanduiding weer bij berichten" + "value" : "Vis absolutte tidsstempler på innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis absolutte tidsstempler på innlegg" + "value" : "Geef absolute tijdsaanduiding weer bij berichten" } }, "pl" : { @@ -5775,16 +5819,16 @@ "value" : "아바타 모양" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vorm avatar" + "value" : "Profilbilde form" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Profilbilde form" + "value" : "Vorm avatar" } }, "pl" : { @@ -5965,16 +6009,16 @@ "value" : "둥글게" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ronde" + "value" : "Rund" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rund" + "value" : "Ronde" } }, "pl" : { @@ -6155,16 +6199,16 @@ "value" : "아바타의 모양을 변경합니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "De vorm van de avatar wijzigen" + "value" : "Endre formen på avataren" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Endre formen på avataren" + "value" : "De vorm van de avatar wijzigen" } }, "pl" : { @@ -6345,16 +6389,16 @@ "value" : "정사각형" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vierkant" + "value" : "Firkant" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Firkant" + "value" : "Vierkant" } }, "pl" : { @@ -6529,16 +6573,16 @@ "value" : "링크 미리보기 표시" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon linkvoorbeelden" + "value" : "Link forhåndsvisninger i Compat modus" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Link forhåndsvisninger i Compat modus" + "value" : "Toon linkvoorbeelden" } }, "pl" : { @@ -6683,16 +6727,16 @@ "value" : "投稿にシンプルモードでリンクのプレビューを表示する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geef voorbeeld van de link weer in de vereenvoudiging modus van de post" + "value" : "Vis forhåndsvisning av lenker i forenkle modus i innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis forhåndsvisning av lenker i forenkle modus i innlegget" + "value" : "Geef voorbeeld van de link weer in de vereenvoudiging modus van de post" } }, "pl" : { @@ -6849,16 +6893,16 @@ "value" : "Flare의 모양과 느낌을_customize합니다." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Pas het uiterlijk en gevoel van Flare aan" + "value" : "Tilpass utseendet og følelsen til flare" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tilpass utseendet og følelsen til flare" + "value" : "Pas het uiterlijk en gevoel van Flare aan" } }, "pl" : { @@ -7039,16 +7083,16 @@ "value" : "미디어를 전체 크기로 확장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Media uitbreiden naar volledige grootte" + "value" : "Utvid media til full størrelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utvid media til full størrelse" + "value" : "Media uitbreiden naar volledige grootte" } }, "pl" : { @@ -7223,16 +7267,16 @@ "value" : "타임라인의 미디어 비율 유지" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bewaar het aspect van de media op de tijdlijn" + "value" : "Hold medias erfaring i tidslinje" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hold medias erfaring i tidslinje" + "value" : "Bewaar het aspect van de media op de tijdlijn" } }, "pl" : { @@ -7377,13 +7421,13 @@ "value" : "Font Size" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Font Size" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Font Size" @@ -7513,16 +7557,16 @@ "value" : "横広の投稿" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volledige breedte bericht" + "value" : "Full bredde innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Full bredde innlegg" + "value" : "Volledige breedte bericht" } }, "pl" : { @@ -7649,16 +7693,16 @@ "value" : "投稿を横広く表示する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon bericht in volledige breedte" + "value" : "Vis innholdet i full bredde" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis innholdet i full bredde" + "value" : "Toon bericht in volledige breedte" } }, "pl" : { @@ -7785,16 +7829,16 @@ "value" : "appearance_post_action_style" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "verschijn_post_action_stijl" + "value" : "Publiser handlingsstil" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Publiser handlingsstil" + "value" : "verschijn_post_action_stijl" } }, "pl" : { @@ -7915,16 +7959,16 @@ "value" : "投稿のアクションのスタイルを変更する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Wijzig de stijl van de actie van het bericht" + "value" : "Endre stilen til innleggets handling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Endre stilen til innleggets handling" + "value" : "Wijzig de stijl van de actie van het bericht" } }, "pl" : { @@ -8045,13 +8089,13 @@ "value" : "Hidden" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Hidden" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Hidden" @@ -8175,16 +8219,16 @@ "value" : "左揃え" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Links uitgelijnd" + "value" : "Venstre justert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Venstre justert" + "value" : "Links uitgelijnd" } }, "pl" : { @@ -8305,16 +8349,16 @@ "value" : "右揃え" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rechts uitgelijnd" + "value" : "Høyre justert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Høyre justert" + "value" : "Rechts uitgelijnd" } }, "pl" : { @@ -8435,16 +8479,16 @@ "value" : "ストレッチ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Uitrekken" + "value" : "Strekk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Strekk" + "value" : "Uitrekken" } }, "pl" : { @@ -8601,16 +8645,16 @@ "value" : "링크 미리보기 표시" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon linkvoorbeelden" + "value" : "Vis forhåndsvisning av lenker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis forhåndsvisning av lenker" + "value" : "Toon linkvoorbeelden" } }, "pl" : { @@ -8755,16 +8799,16 @@ "value" : "投稿にリンクのプレビューを表示する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon linkvoorbeelden in het bericht" + "value" : "Vis forhåndsvisning av lenker i innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis forhåndsvisning av lenker i innlegget" + "value" : "Toon linkvoorbeelden in het bericht" } }, "pl" : { @@ -8921,16 +8965,16 @@ "value" : "미디어 표시" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Media tonen" + "value" : "Vis media" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis media" + "value" : "Media tonen" } }, "pl" : { @@ -9075,16 +9119,16 @@ "value" : "投稿にメディアを表示する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Media in het bericht weergeven" + "value" : "Vis media i innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis media i innlegget" + "value" : "Media in het bericht weergeven" } }, "pl" : { @@ -9241,16 +9285,16 @@ "value" : "숫자 표시" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Getallen weergeven" + "value" : "Vis tall" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis tall" + "value" : "Getallen weergeven" } }, "pl" : { @@ -9395,16 +9439,16 @@ "value" : "投稿の下部に番号を表示する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Getallen aan de onderkant van het bericht weergeven" + "value" : "Vis tall på bunnen av innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis tall på bunnen av innlegget" + "value" : "Getallen aan de onderkant van het bericht weergeven" } }, "pl" : { @@ -9525,16 +9569,16 @@ "value" : "プラットフォームのロゴを表示" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon platform logo" + "value" : "Vis plattformlogo" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis plattformlogo" + "value" : "Toon platform logo" } }, "pl" : { @@ -9655,16 +9699,16 @@ "value" : "投稿にソース・プラットフォームのロゴを表示する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon het bronlogo van het platform op post" + "value" : "Vis logo for kildeplattformplattformen på innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis logo for kildeplattformplattformen på innlegg" + "value" : "Toon het bronlogo van het platform op post" } }, "pl" : { @@ -9821,16 +9865,16 @@ "value" : "민감한 콘텐츠 표시" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gevoelige inhoud weergeven" + "value" : "Vis sensitivt innhold" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis sensitivt innhold" + "value" : "Gevoelige inhoud weergeven" } }, "pl" : { @@ -10005,16 +10049,16 @@ "value" : "상태에서 항상 민감한 콘텐츠 표시" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gevoelige inhoud altijd in status weergeven" + "value" : "Vis alltid sensitivt innhold i innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis alltid sensitivt innhold i innlegget" + "value" : "Gevoelige inhoud altijd in status weergeven" } }, "pl" : { @@ -10195,16 +10239,16 @@ "value" : "테마" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Thema" + "value" : "Tema" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tema" + "value" : "Thema" } }, "pl" : { @@ -10385,16 +10429,16 @@ "value" : "어두운 색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Donker" + "value" : "Mørk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mørk" + "value" : "Donker" } }, "pl" : { @@ -10575,16 +10619,16 @@ "value" : "앱의 테마 변경" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verander het thema van de app" + "value" : "Endre temaet for appen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Endre temaet for appen" + "value" : "Verander het thema van de app" } }, "pl" : { @@ -10765,16 +10809,16 @@ "value" : "밝은 색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Licht" + "value" : "Lys" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lys" + "value" : "Licht" } }, "pl" : { @@ -10949,16 +10993,16 @@ "value" : "시스템" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Systeem" + "value" : "Systemadministrasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Systemadministrasjon" + "value" : "Systeem" } }, "pl" : { @@ -11139,16 +11183,16 @@ "value" : "모양" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Uiterlijk" + "value" : "Utseende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utseende" + "value" : "Uiterlijk" } }, "pl" : { @@ -11293,16 +11337,16 @@ "value" : "実験的:クロスプラットフォーム投稿UIを使用" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Experimenteel: Gebruik cross-platform post UI" + "value" : "Eksperimentelt: bruk grensesnittet på kryss-plattformen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Eksperimentelt: bruk grensesnittet på kryss-plattformen" + "value" : "Experimenteel: Gebruik cross-platform post UI" } }, "pl" : { @@ -11423,16 +11467,16 @@ "value" : "Android と Desktop から同じポスト UI コードを使用してください。これは実験的なものであり、将来的に削除される可能性があります。" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruik dezelfde gebruikersinterface code van Android en Desktop, dit is experimenteel en kan in de toekomst worden verwijderd." + "value" : "Bruk samme innlegg UI kode fra Android og Desktop, dette er eksperimentell og kan bli fjernet i fremtiden." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bruk samme innlegg UI kode fra Android og Desktop, dette er eksperimentell og kan bli fjernet i fremtiden." + "value" : "Gebruik dezelfde gebruikersinterface code van Android en Desktop, dit is experimenteel en kan in de toekomst worden verwijderd." } }, "pl" : { @@ -11589,16 +11633,16 @@ "value" : "비디오 자동 재생" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Video automatisch afspelen" + "value" : "Video autokjør" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Video autokjør" + "value" : "Video automatisch afspelen" } }, "pl" : { @@ -11779,16 +11823,16 @@ "value" : "항상" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "altijd" + "value" : "Alltid" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alltid" + "value" : "altijd" } }, "pl" : { @@ -11933,16 +11977,16 @@ "value" : "投稿内の動画を自動的に再生" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Video's automatisch afspelen in het bericht" + "value" : "Automatisk spill av videoer i innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Automatisk spill av videoer i innlegget" + "value" : "Video's automatisch afspelen in het bericht" } }, "pl" : { @@ -12099,16 +12143,16 @@ "value" : "절대" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Nooit" + "value" : "Aldri" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aldri" + "value" : "Nooit" } }, "pl" : { @@ -12289,16 +12333,16 @@ "value" : "Wi-Fi 전용" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Alleen wifi" + "value" : "Kun Wi-Fi" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kun Wi-Fi" + "value" : "Alleen wifi" } }, "pl" : { @@ -12479,16 +12523,16 @@ "value" : "차단" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Blokkeren" + "value" : "Blokker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blokker" + "value" : "Blokkeren" } }, "pl" : { @@ -12633,16 +12677,16 @@ "value" : "このユーザーをブロックしてもよろしいですか?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Weet je zeker dat je deze gebruiker wilt blokkeren?" + "value" : "Er du sikker på at du vil blokkere denne brukeren?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Er du sikker på at du vil blokkere denne brukeren?" + "value" : "Weet je zeker dat je deze gebruiker wilt blokkeren?" } }, "pl" : { @@ -12763,16 +12807,16 @@ "value" : "ユーザーをブロック" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruiker blokkeren" + "value" : "Blokker bruker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blokker bruker" + "value" : "Gebruiker blokkeren" } }, "pl" : { @@ -13059,16 +13103,16 @@ "value" : "피드" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Feeds" + "value" : "Strøm" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Strøm" + "value" : "Feeds" } }, "pl" : { @@ -13250,16 +13294,16 @@ "value" : "당신을 팔로우했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "volgt jou nu" + "value" : "fulgte deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "fulgte deg" + "value" : "volgt jou nu" } }, "pl" : { @@ -13441,16 +13485,16 @@ "value" : "고정됨" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vastgezet" + "value" : "Festet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Festet" + "value" : "Vastgezet" } }, "pl" : { @@ -13632,16 +13676,16 @@ "value" : "좋아요를 눌렀습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "favoriet" + "value" : "favorisert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "favorisert" + "value" : "favoriet" } }, "pl" : { @@ -13823,16 +13867,16 @@ "value" : "당신을 언급했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "noemde je" + "value" : "nevnte deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "nevnte deg" + "value" : "noemde je" } }, "pl" : { @@ -14014,16 +14058,16 @@ "value" : "인용했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "geciteerd" + "value" : "sitert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "sitert" + "value" : "geciteerd" } }, "pl" : { @@ -14205,16 +14249,16 @@ "value" : "당신에게 답글을 달았습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "heeft u geantwoord" + "value" : "svarte deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "svarte deg" + "value" : "heeft u geantwoord" } }, "pl" : { @@ -14390,16 +14434,16 @@ "value" : "상태를 부스트했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "boostte een status" + "value" : "repostet et innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "repostet et innlegg" + "value" : "boostte een status" } }, "pl" : { @@ -14574,16 +14618,16 @@ "value" : "스타터팩에 가입했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Starterpack toegetreden" + "value" : "Starterpack er medlem" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Starterpack er medlem" + "value" : "Starterpack toegetreden" } }, "pl" : { @@ -14759,16 +14803,16 @@ "value" : "알 수 없음" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Onbekend" + "value" : "Ukjent" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ukjent" + "value" : "Onbekend" } }, "pl" : { @@ -14949,16 +14993,16 @@ "value" : "신고하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -15133,16 +15177,16 @@ "value" : "이 게시물의 문제는 무엇인가요?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Wat is het probleem met dit bericht?" + "value" : "Hva er problemet med dette innlegget?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hva er problemet med dette innlegget?" + "value" : "Wat is het probleem met dit bericht?" } }, "pl" : { @@ -15317,16 +15361,16 @@ "value" : "오해의 소지가 있는" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Misleidend" + "value" : "Villende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Villende" + "value" : "Misleidend" } }, "pl" : { @@ -15501,16 +15545,16 @@ "value" : "이 게시물은 오해의 소지가 있습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Deze post is misleidend" + "value" : "Dette innlegget er misvisende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Dette innlegget er misvisende" + "value" : "Deze post is misleidend" } }, "pl" : { @@ -15685,16 +15729,16 @@ "value" : "기타" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "anders" + "value" : "Annet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Annet" + "value" : "anders" } }, "pl" : { @@ -15869,16 +15913,16 @@ "value" : "이 옵션에 포함되지 않은 문제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Een probleem niet opgenomen in deze opties" + "value" : "Et problem er ikke inkludert i disse alternativene" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Et problem er ikke inkludert i disse alternativene" + "value" : "Een probleem niet opgenomen in deze opties" } }, "pl" : { @@ -16053,16 +16097,16 @@ "value" : "반사회적 행동" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Anti-sociaal gedrag" + "value" : "Anti-Social Athavior" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Anti-Social Athavior" + "value" : "Anti-sociaal gedrag" } }, "pl" : { @@ -16237,16 +16281,16 @@ "value" : "괴롭힘, 트롤링 또는 편견" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Pesterij, trollen of intolerantie" + "value" : "Trakassering, kontroll eller intoleranse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Trakassering, kontroll eller intoleranse" + "value" : "Pesterij, trollen of intolerantie" } }, "pl" : { @@ -16421,16 +16465,16 @@ "value" : "원치 않는 성적 콘텐츠" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ongewenste seksuele inhoud" + "value" : "Uønsket suell innhold" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Uønsket suell innhold" + "value" : "Ongewenste seksuele inhoud" } }, "pl" : { @@ -16605,16 +16649,16 @@ "value" : "라벨이 없는 누드 또는 포르노그래피" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voedingsstoffen of pornografie niet als zodanig geëtiketteerd" + "value" : "Nudity eller pornografi som ikke er merket slik" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nudity eller pornografi som ikke er merket slik" + "value" : "Voedingsstoffen of pornografie niet als zodanig geëtiketteerd" } }, "pl" : { @@ -16795,16 +16839,16 @@ "value" : "스팸" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Spam" + "value" : "Søppelpost" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Søppelpost" + "value" : "Spam" } }, "pl" : { @@ -16979,16 +17023,16 @@ "value" : "과도한 멘션이나 답글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Overmatige vermeldingen of reacties" + "value" : "Overdreven omtale eller svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Overdreven omtale eller svar" + "value" : "Overmatige vermeldingen of reacties" } }, "pl" : { @@ -17163,16 +17207,16 @@ "value" : "불법 및 긴급" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Illegaal en urgent" + "value" : "Ulovlig og Haster" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ulovlig og Haster" + "value" : "Illegaal en urgent" } }, "pl" : { @@ -17347,16 +17391,16 @@ "value" : "법률 또는 서비스 조건의 명백한 위반" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ernstige schendingen van de wet of de gebruiksvoorwaarden" + "value" : "Anskaffelse av lovbrudd eller tjenestevilkår" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Anskaffelse av lovbrudd eller tjenestevilkår" + "value" : "Ernstige schendingen van de wet of de gebruiksvoorwaarden" } }, "pl" : { @@ -17537,16 +17581,16 @@ "value" : "북마크 추가" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bladwijzer toevoegen" + "value" : "Legg til bokmerke" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til bokmerke" + "value" : "Bladwijzer toevoegen" } }, "pl" : { @@ -17727,16 +17771,16 @@ "value" : "북마크 제거" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bladwijzer verwijderen" + "value" : "Fjern bokmerke" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fjern bokmerke" + "value" : "Bladwijzer verwijderen" } }, "pl" : { @@ -17917,16 +17961,16 @@ "value" : "취소" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "annuleren" + "value" : "Avbryt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avbryt" + "value" : "annuleren" } }, "pl" : { @@ -18071,16 +18115,16 @@ "value" : "チャンネル" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Kanaal" + "value" : "Kanal" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kanal" + "value" : "Kanaal" } }, "pl" : { @@ -18201,16 +18245,16 @@ "value" : "チャンネル" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Kanalen" + "value" : "Kanaler" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kanaler" + "value" : "Kanalen" } }, "pl" : { @@ -18373,16 +18417,16 @@ "value" : "닫기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afsluiten" + "value" : "Lukk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lukk" + "value" : "Afsluiten" } }, "pl" : { @@ -18563,16 +18607,16 @@ "value" : "댓글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opmerking" + "value" : "Kommentar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentar" + "value" : "Opmerking" } }, "pl" : { @@ -18723,13 +18767,13 @@ "value" : "Cnacel" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Cnacel" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Cnacel" @@ -18889,16 +18933,16 @@ "value" : "보내기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verzenden" + "value" : "Sende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sende" + "value" : "Verzenden" } }, "pl" : { @@ -19079,16 +19123,16 @@ "value" : "컨텐츠 경고" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Waarschuwing inhoud" + "value" : "Advarsel for innhold" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Advarsel for innhold" + "value" : "Waarschuwing inhoud" } }, "pl" : { @@ -19269,16 +19313,16 @@ "value" : "미디어를 민감한 내용으로 표시하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Media als gevoelig markeren" + "value" : "Merk media som sensitivt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Merk media som sensitivt" + "value" : "Media als gevoelig markeren" } }, "pl" : { @@ -19459,16 +19503,16 @@ "value" : "무슨 일이 일어나고 있나요?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Wat gebeurt er?" + "value" : "Hva skjer?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hva skjer?" + "value" : "Wat gebeurt er?" } }, "pl" : { @@ -19613,16 +19657,16 @@ "value" : "Option" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Optie" + "value" : "Alternativ" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alternativ" + "value" : "Optie" } }, "pl" : { @@ -19749,16 +19793,16 @@ "value" : "有効期限:" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vervaldatum op:" + "value" : "Utløp på:" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utløp på:" + "value" : "Vervaldatum op:" } }, "pl" : { @@ -19879,16 +19923,16 @@ "value" : "アンケートタイプ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Poll type" + "value" : "Avstemnings type" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avstemnings type" + "value" : "Poll type" } }, "pl" : { @@ -20045,16 +20089,16 @@ "value" : "복수 선택" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meerdere keuzes" + "value" : "Flere valg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Flere valg" + "value" : "Meerdere keuzes" } }, "pl" : { @@ -20235,16 +20279,16 @@ "value" : "단일 선택" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Enkele keuze" + "value" : "Ett valg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ett valg" + "value" : "Enkele keuze" } }, "pl" : { @@ -20425,16 +20469,16 @@ "value" : "작성하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Samenstellen" + "value" : "Skriv" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv" + "value" : "Samenstellen" } }, "pl" : { @@ -20615,16 +20659,16 @@ "value" : "인용하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Offerte" + "value" : "Sitat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sitat" + "value" : "Offerte" } }, "pl" : { @@ -20805,16 +20849,16 @@ "value" : "답글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beantwoorden" + "value" : "Svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Svar" + "value" : "Beantwoorden" } }, "pl" : { @@ -20995,16 +21039,16 @@ "value" : "어두운 색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Donker" + "value" : "Mørk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mørk" + "value" : "Donker" } }, "pl" : { @@ -21185,16 +21229,16 @@ "value" : "브라우저에서 열기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Openen in browser" + "value" : "Åpne i nettleser" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Åpne i nettleser" + "value" : "Openen in browser" } }, "pl" : { @@ -21339,16 +21383,16 @@ "value" : "アカウントを選択" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Selecteer account" + "value" : "Velg konto" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Velg konto" + "value" : "Selecteer account" } }, "pl" : { @@ -21505,16 +21549,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -21695,16 +21739,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -21885,16 +21929,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -22039,16 +22083,16 @@ "value" : "このリストを削除してもよろしいですか?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Weet u zeker dat u deze lijst wilt verwijderen?" + "value" : "Er du sikker på at du vil slette denne listen?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Er du sikker på at du vil slette denne listen?" + "value" : "Weet u zeker dat u deze lijst wilt verwijderen?" } }, "pl" : { @@ -22205,16 +22249,16 @@ "value" : "목록 삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst verwijderen" + "value" : "Slett liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett liste" + "value" : "Lijst verwijderen" } }, "pl" : { @@ -22389,16 +22433,16 @@ "value" : "정말로 이것을 삭제하시겠습니까?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Weet u zeker dat u dit wilt verwijderen?" + "value" : "Er du sikker på at du vil slette dette?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Er du sikker på at du vil slette dette?" + "value" : "Weet u zeker dat u dit wilt verwijderen?" } }, "pl" : { @@ -22543,16 +22587,16 @@ "value" : "説明" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beschrijving" + "value" : "Beskrivelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Beskrivelse" + "value" : "Beschrijving" } }, "pl" : { @@ -22679,16 +22723,16 @@ "value" : "ダイレクトメッセージ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Direct Bericht" + "value" : "Direkte melding" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Direkte melding" + "value" : "Direct Bericht" } }, "pl" : { @@ -22815,16 +22859,16 @@ "value" : "トレンド" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Populair" + "value" : "Populært" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Populært" + "value" : "Populair" } }, "pl" : { @@ -22987,13 +23031,13 @@ "value" : "트렌딩 해시태그" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Trending Hashtags" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Trending Hashtags" @@ -23178,16 +23222,16 @@ "value" : "발견하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontdek" + "value" : "Oppdag" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdag" + "value" : "Ontdek" } }, "pl" : { @@ -23368,16 +23412,16 @@ "value" : "사용자 추천" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruikers aanbevelen" + "value" : "Anbefal brukere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Anbefal brukere" + "value" : "Gebruikers aanbevelen" } }, "pl" : { @@ -23528,16 +23572,16 @@ "value" : "ダイレクトメッセージを書く" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Schrijf direct bericht" + "value" : "Skriv direkte melding" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv direkte melding" + "value" : "Schrijf direct bericht" } }, "pl" : { @@ -23664,16 +23708,16 @@ "value" : "ダイレクトメッセージ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Direct Bericht" + "value" : "Direkte melding" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Direkte melding" + "value" : "Direct Bericht" } }, "pl" : { @@ -23830,16 +23874,16 @@ "value" : "완료" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voltooid" + "value" : "Ferdig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ferdig" + "value" : "Voltooid" } }, "pl" : { @@ -24020,16 +24064,16 @@ "value" : "완료" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voltooid" + "value" : "Ferdig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ferdig" + "value" : "Voltooid" } }, "pl" : { @@ -24168,16 +24212,16 @@ "value" : "ドラフト(下書き)" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Kladversie" + "value" : "Utkast" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utkast" + "value" : "Kladversie" } }, "pl" : { @@ -24292,16 +24336,16 @@ "value" : "Drafts" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Concepten" + "value" : "Utkast" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utkast" + "value" : "Concepten" } }, "pl" : { @@ -24458,202 +24502,202 @@ "value" : "편집" } }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bewerken" - } - }, - "no" : { + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rediger" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bewerken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edytuj" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alterar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Редактирование" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уреди" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redigera" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düzenle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Редагувати" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chỉnh sửa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "編輯" + } + } + } + }, + "Edit" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bewerk" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تحرير" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Редактиране" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upravit" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rediger" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bearbeiten" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Επεξεργασία" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muokkaa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ערוך" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Szerkesztés" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifica" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "編集" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "편집" + } + }, + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Rediger" } }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Edytuj" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alterar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Editar" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Editare" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Редактирование" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Уреди" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Redigera" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Düzenle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Редагувати" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chỉnh sửa" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "编辑" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "編輯" - } - } - } - }, - "Edit" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bewerk" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تحرير" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Редактиране" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Editar" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Upravit" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rediger" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bearbeiten" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Επεξεργασία" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Editar" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Muokkaa" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Editer" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "ערוך" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Szerkesztés" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modifica" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "編集" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "편집" - } - }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Bewerken" } }, - "no" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rediger" - } - }, "pl" : { "stringUnit" : { "state" : "translated", @@ -24796,16 +24840,16 @@ "value" : "説明を編集" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beschrijving bewerken" + "value" : "Rediger beskrivelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger beskrivelse" + "value" : "Beschrijving bewerken" } }, "pl" : { @@ -24962,16 +25006,16 @@ "value" : "목록 편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst bewerken" + "value" : "Rediger liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger liste" + "value" : "Lijst bewerken" } }, "pl" : { @@ -25146,16 +25190,16 @@ "value" : "RSS 소스 편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Wijzig Rss Source" + "value" : "Rediger Rss kilde" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger Rss kilde" + "value" : "Wijzig Rss Source" } }, "pl" : { @@ -25336,16 +25380,16 @@ "value" : "목록에 추가/제거" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toevoegen/Verwijderen uit lijst" + "value" : "Legg til/fjern fra listen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til/fjern fra listen" + "value" : "Toevoegen/Verwijderen uit lijst" } }, "pl" : { @@ -25490,16 +25534,16 @@ "value" : "最近使用したもの" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Recent gebruikt" + "value" : "Nylig brukt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nylig brukt" + "value" : "Recent gebruikt" } }, "pl" : { @@ -25626,16 +25670,16 @@ "value" : "絵文字を検索" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zoek naar Emoji" + "value" : "Søk etter Emoji" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Søk etter Emoji" + "value" : "Zoek naar Emoji" } }, "pl" : { @@ -25762,16 +25806,16 @@ "value" : "終わりに達しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Je bereikt het einde" + "value" : "Du når slutten" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Du når slutten" + "value" : "Je bereikt het einde" } }, "pl" : { @@ -25892,16 +25936,16 @@ "value" : "リレーURLを入力してください" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voer URL voor relais in" + "value" : "Angi relé URL" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Angi relé URL" + "value" : "Voer URL voor relais in" } }, "pl" : { @@ -26058,16 +26102,16 @@ "value" : "오류" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Foutmelding" + "value" : "Feil" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Feil" + "value" : "Foutmelding" } }, "pl" : { @@ -26248,16 +26292,16 @@ "value" : "오류" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Foutmelding" + "value" : "Feil" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Feil" + "value" : "Foutmelding" } }, "pl" : { @@ -26402,16 +26446,16 @@ "value" : "%@ のログインセッションが失効しました。" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "De aanmeldsessie is verlopen voor %@" + "value" : "Login økten er utløpt for %@" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Login økten er utløpt for %@" + "value" : "De aanmeldsessie is verlopen voor %@" } }, "pl" : { @@ -26526,16 +26570,16 @@ "value" : "再ログイン" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Re login" + "value" : "Kjør innlogging" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kjør innlogging" + "value" : "Re login" } }, "pl" : { @@ -26662,16 +26706,16 @@ "value" : "データのエクスポートに失敗しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gegevens exporteren mislukt" + "value" : "Kunne ikke eksportere data" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kunne ikke eksportere data" + "value" : "Gegevens exporteren mislukt" } }, "pl" : { @@ -26792,16 +26836,16 @@ "value" : "モデルの読み込みに失敗しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "modellen laden mislukt" + "value" : "Kan ikke laste inn modeller" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kan ikke laste inn modeller" + "value" : "modellen laden mislukt" } }, "pl" : { @@ -26928,16 +26972,16 @@ "value" : "fx_share" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Fx_share" + "value" : "Del via FxEmbed" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del via FxEmbed" + "value" : "Fx_share" } }, "pl" : { @@ -27094,16 +27138,16 @@ "value" : "북마크" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bladwijzers" + "value" : "Bokmerker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bokmerker" + "value" : "Bladwijzers" } }, "pl" : { @@ -27284,16 +27328,16 @@ "value" : "발견하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontdek" + "value" : "Oppdag" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdag" + "value" : "Ontdek" } }, "pl" : { @@ -27438,16 +27482,16 @@ "value" : "お気に入り" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Favoriete" + "value" : "Favoritt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Favoritt" + "value" : "Favoriete" } }, "pl" : { @@ -27610,16 +27654,16 @@ "value" : "추천" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aanbevolen" + "value" : "Anbefalt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Anbefalt" + "value" : "Aanbevolen" } }, "pl" : { @@ -27800,16 +27844,16 @@ "value" : "피드" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Feeds" + "value" : "Strøm" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Strøm" + "value" : "Feeds" } }, "pl" : { @@ -27990,16 +28034,16 @@ "value" : "홈" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Startpagina" + "value" : "Hjem" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hjem" + "value" : "Startpagina" } }, "pl" : { @@ -28180,16 +28224,16 @@ "value" : "목록" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Klantenlijst" + "value" : "Liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste" + "value" : "Klantenlijst" } }, "pl" : { @@ -28370,16 +28414,16 @@ "value" : "내 정보" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "IK" + "value" : "Meg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Meg" + "value" : "IK" } }, "pl" : { @@ -28524,16 +28568,16 @@ "value" : "Notification" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notificatie" + "value" : "Varsling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Varsling" + "value" : "Notificatie" } }, "pl" : { @@ -28660,16 +28704,16 @@ "value" : "インポート完了" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Importeren voltooid" + "value" : "Import fullført" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Import fullført" + "value" : "Importeren voltooid" } }, "pl" : { @@ -28790,16 +28834,16 @@ "value" : "これはファイルからデータをインポートします。一致するIDを持つ既存のレコードは置き換えられます。続行しますか?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Dit zal gegevens uit het bestand importeren. Bestaande records met bijpassende IDs zullen worden vervangen. Wilt u doorgaan?" + "value" : "Dette vil importere data fra filen. Eksisterende poster med samsvarende ID-er vil bli erstattet. Vil du fortsette?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Dette vil importere data fra filen. Eksisterende poster med samsvarende ID-er vil bli erstattet. Vil du fortsette?" + "value" : "Dit zal gegevens uit het bestand importeren. Bestaande records met bijpassende IDs zullen worden vervangen. Wilt u doorgaan?" } }, "pl" : { @@ -28920,16 +28964,16 @@ "value" : "インポートの確認" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Import bevestigen" + "value" : "Bekreft import" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bekreft import" + "value" : "Import bevestigen" } }, "pl" : { @@ -29050,16 +29094,16 @@ "value" : "データのインポートに失敗しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gegevens importeren mislukt" + "value" : "Kan ikke importere data" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kan ikke importere data" + "value" : "Gegevens importeren mislukt" } }, "pl" : { @@ -29216,16 +29260,16 @@ "value" : "밝은 색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Licht" + "value" : "Lys" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lys" + "value" : "Licht" } }, "pl" : { @@ -29406,16 +29450,16 @@ "value" : "좋아요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "vind-ik-leuk" + "value" : "Lik" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lik" + "value" : "vind-ik-leuk" } }, "pl" : { @@ -29560,16 +29604,16 @@ "value" : "いいね!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Leukgevonden" + "value" : "Likte" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Likte" + "value" : "Leukgevonden" } }, "pl" : { @@ -29726,16 +29770,16 @@ "value" : "목록 설명" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Omschrijving lijst" + "value" : "Liste beskrivelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste beskrivelse" + "value" : "Omschrijving lijst" } }, "pl" : { @@ -29880,16 +29924,16 @@ "value" : "リストアイコン" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst pictogram" + "value" : "Liste ikon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste ikon" + "value" : "Lijst pictogram" } }, "pl" : { @@ -30046,16 +30090,16 @@ "value" : "목록 설명" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Omschrijving lijst" + "value" : "Liste beskrivelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste beskrivelse" + "value" : "Omschrijving lijst" } }, "pl" : { @@ -30236,16 +30280,16 @@ "value" : "목록 구성원 편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijstleden bewerken" + "value" : "Rediger listemedlemmer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger listemedlemmer" + "value" : "Lijstleden bewerken" } }, "pl" : { @@ -30426,16 +30470,16 @@ "value" : "목록 이름" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst naam" + "value" : "Navn på liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Navn på liste" + "value" : "Lijst naam" } }, "pl" : { @@ -30616,16 +30660,16 @@ "value" : "목록 편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst bewerken" + "value" : "Rediger liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger liste" + "value" : "Lijst bewerken" } }, "pl" : { @@ -30770,16 +30814,16 @@ "value" : "ここには何もありません" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Niets hier" + "value" : "Ingenting her" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ingenting her" + "value" : "Niets hier" } }, "pl" : { @@ -30936,16 +30980,16 @@ "value" : "구성원" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "leden" + "value" : "Medlemmer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Medlemmer" + "value" : "leden" } }, "pl" : { @@ -31126,16 +31170,16 @@ "value" : "목록 이름" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst naam" + "value" : "Navn på liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Navn på liste" + "value" : "Lijst naam" } }, "pl" : { @@ -31280,16 +31324,16 @@ "value" : "リストアイコン" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst pictogram" + "value" : "Liste ikon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste ikon" + "value" : "Lijst pictogram" } }, "pl" : { @@ -31446,16 +31490,16 @@ "value" : "목록 설명" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Omschrijving lijst" + "value" : "Liste beskrivelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste beskrivelse" + "value" : "Omschrijving lijst" } }, "pl" : { @@ -31636,16 +31680,16 @@ "value" : "목록 설명" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Omschrijving lijst" + "value" : "Liste beskrivelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste beskrivelse" + "value" : "Omschrijving lijst" } }, "pl" : { @@ -31826,16 +31870,16 @@ "value" : "목록 이름" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst naam" + "value" : "Navn på liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Navn på liste" + "value" : "Lijst naam" } }, "pl" : { @@ -32016,16 +32060,16 @@ "value" : "목록 이름" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst naam" + "value" : "Navn på liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Navn på liste" + "value" : "Lijst naam" } }, "pl" : { @@ -32206,16 +32250,16 @@ "value" : "목록 생성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst aanmaken" + "value" : "Lag oppgaveliste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lag oppgaveliste" + "value" : "Lijst aanmaken" } }, "pl" : { @@ -32360,16 +32404,16 @@ "value" : "モデルを読み込み中..." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Motoren laden..." + "value" : "Laster modeller..." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Laster modeller..." + "value" : "Motoren laden..." } }, "pl" : { @@ -32490,16 +32534,16 @@ "value" : "読み込み中..." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Laden..." + "value" : "Laster..." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Laster..." + "value" : "Laden..." } }, "pl" : { @@ -32662,16 +32706,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -32852,16 +32896,16 @@ "value" : "타임라인을 위한 로컬 필터 설정" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lokale filterinstellingen voor tijdlijn" + "value" : "Lokale filterinnstillinger for tidslinjen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lokale filterinnstillinger for tidslinjen" + "value" : "Lokale filterinstellingen voor tijdlijn" } }, "pl" : { @@ -33042,16 +33086,16 @@ "value" : "편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bewerken" + "value" : "Rediger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger" + "value" : "Bewerken" } }, "pl" : { @@ -33202,16 +33246,16 @@ "value" : "フィルターを編集" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Filter bewerken" + "value" : "Rediger filter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger filter" + "value" : "Filter bewerken" } }, "pl" : { @@ -33368,13 +33412,13 @@ "value" : "키워드" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Keyword" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Keyword" @@ -33522,16 +33566,16 @@ "value" : "キーワードを入力してください" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voer een trefwoord in" + "value" : "Skriv inn et nøkkelord" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv inn et nøkkelord" + "value" : "Voer een trefwoord in" } }, "pl" : { @@ -33658,16 +33702,16 @@ "value" : "通知を有効にする" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Inschakelen in melding" + "value" : "Aktiver i varsel" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver i varsel" + "value" : "Inschakelen in melding" } }, "pl" : { @@ -33788,16 +33832,16 @@ "value" : "フィルタを有効にする" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Filter in" + "value" : "Aktiver filter i" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver filter i" + "value" : "Filter in" } }, "pl" : { @@ -33918,16 +33962,16 @@ "value" : "検索で有効にする" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Inschakelen in zoekopdracht" + "value" : "Aktiver i søk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver i søk" + "value" : "Inschakelen in zoekopdracht" } }, "pl" : { @@ -34048,16 +34092,16 @@ "value" : "タイムラインで有効にする" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Inschakelen op tijdlijn" + "value" : "Aktiver i tidslinjen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver i tidslinjen" + "value" : "Inschakelen op tijdlijn" } }, "pl" : { @@ -34214,16 +34258,16 @@ "value" : "로컬 필터" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lokaal filter" + "value" : "Lokalt filter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lokalt filter" + "value" : "Lokaal filter" } }, "pl" : { @@ -34405,16 +34449,16 @@ "value" : "브라우징 기록 보기 또는 검색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bekijk of zoek uw browsegeschiedenis" + "value" : "Vis eller søk i historikk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis eller søk i historikk" + "value" : "Bekijk of zoek uw browsegeschiedenis" } }, "pl" : { @@ -34547,16 +34591,16 @@ "value" : "Cerca nella cache…" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zoeken in cache…" + "value" : "Søk i mellomlager…" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Søk i mellomlager…" + "value" : "Zoeken in cache…" } }, "pl" : { @@ -34713,16 +34757,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Statuses" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Statuses" } }, "pl" : { @@ -34903,16 +34947,16 @@ "value" : "로컬 기록" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lokale geschiedenis" + "value" : "Lokal historikk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lokal historikk" + "value" : "Lokale geschiedenis" } }, "pl" : { @@ -35093,16 +35137,16 @@ "value" : "사용자" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruikers" + "value" : "Brukere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Brukere" + "value" : "Gebruikers" } }, "pl" : { @@ -35283,16 +35327,16 @@ "value" : "로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aanmelden" + "value" : "Innlogging" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlogging" + "value" : "Aanmelden" } }, "pl" : { @@ -35473,16 +35517,16 @@ "value" : "로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aanmelden" + "value" : "Innlogging" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlogging" + "value" : "Aanmelden" } }, "pl" : { @@ -35627,16 +35671,16 @@ "value" : "ログアウト" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afmelden" + "value" : "Logg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg" + "value" : "Afmelden" } }, "pl" : { @@ -35757,16 +35801,16 @@ "value" : "リレーの管理" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beheer relais" + "value" : "Behandle releer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Behandle releer" + "value" : "Beheer relais" } }, "pl" : { @@ -35923,16 +35967,16 @@ "value" : "고정된 투트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vastgepinde toot" + "value" : "Festet innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Festet innlegg" + "value" : "Vastgepinde toot" } }, "pl" : { @@ -36113,16 +36157,16 @@ "value" : "덜 보기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Minder weergeven" + "value" : "Vis mindre" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis mindre" + "value" : "Minder weergeven" } }, "pl" : { @@ -36303,16 +36347,16 @@ "value" : "더 보기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon meer" + "value" : "Vis mer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis mer" + "value" : "Toon meer" } }, "pl" : { @@ -36493,16 +36537,16 @@ "value" : "좋아요를 눌렀습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "favoriet" + "value" : "favorisert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "favorisert" + "value" : "favoriet" } }, "pl" : { @@ -36683,16 +36727,16 @@ "value" : "당신을 팔로우했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "volgt jou nu" + "value" : "fulgte deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "fulgte deg" + "value" : "volgt jou nu" } }, "pl" : { @@ -36867,16 +36911,16 @@ "value" : "당신을 팔로우 요청했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "verzoek je te volgen" + "value" : "forespørsel om å følge deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "forespørsel om å følge deg" + "value" : "verzoek je te volgen" } }, "pl" : { @@ -37057,16 +37101,16 @@ "value" : "당신을 언급했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "noemde je" + "value" : "nevnte deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "nevnte deg" + "value" : "noemde je" } }, "pl" : { @@ -37241,16 +37285,16 @@ "value" : "참여했던 설문조사가 종료되었습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Een poll waaraan u deelnam, is beëindigd" + "value" : "En avstemming du deltok i er avsluttet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "En avstemming du deltok i er avsluttet" + "value" : "Een poll waaraan u deelnam, is beëindigd" } }, "pl" : { @@ -37431,16 +37475,16 @@ "value" : "리블로그했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "gedeeld" + "value" : "reblogget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "reblogget" + "value" : "gedeeld" } }, "pl" : { @@ -37615,16 +37659,16 @@ "value" : "투트를 부스트했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "toot heeft geboost" + "value" : "repostet et innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "repostet et innlegg" + "value" : "toot heeft geboost" } }, "pl" : { @@ -37799,16 +37843,16 @@ "value" : "투트를 업데이트했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "heeft een toot bijgewerkt" + "value" : "oppdaterte et innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "oppdaterte et innlegg" + "value" : "heeft een toot bijgewerkt" } }, "pl" : { @@ -37983,16 +38027,16 @@ "value" : "정말로 이것을 신고하시겠습니까?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Weet u zeker dat u dit wilt melden?" + "value" : "Er du sikker på at du vil rapportere dette?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Er du sikker på at du vil rapportere dette?" + "value" : "Weet u zeker dat u dit wilt melden?" } }, "pl" : { @@ -38173,16 +38217,16 @@ "value" : "신고하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -38333,16 +38377,16 @@ "value" : "ローカルタイムライン" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lokale tijdlijn" + "value" : "Lokal tidslinje" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lokal tidslinje" + "value" : "Lokale tijdlijn" } }, "pl" : { @@ -38475,16 +38519,16 @@ "value" : "公開タイムライン" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Publieke tijdlijn" + "value" : "Offentlig tidslinje" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Offentlig tidslinje" + "value" : "Publieke tijdlijn" } }, "pl" : { @@ -38641,16 +38685,16 @@ "value" : "팔로워" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgers" + "value" : "Følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følgere" + "value" : "Volgers" } }, "pl" : { @@ -38831,16 +38875,16 @@ "value" : "팔로잉" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgt" + "value" : "Følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger" + "value" : "Volgt" } }, "pl" : { @@ -39016,16 +39060,16 @@ "value" : "브레인 다이버의 링크를 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Plaats de link naar Brain Diver" + "value" : "Post linken til Brain Diver" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post linken til Brain Diver" + "value" : "Plaats de link naar Brain Diver" } }, "pl" : { @@ -39201,13 +39245,13 @@ "value" : "Misskey-Misskey 라투마" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Misskey-Misskey La-Tu-Ma" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Misskey-Misskey La-Tu-Ma" @@ -39386,16 +39430,16 @@ "value" : "브레인 다이버" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Hersenen Duiver" + "value" : "Hjerne sover" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hjerne sover" + "value" : "Hersenen Duiver" } }, "pl" : { @@ -39571,16 +39615,16 @@ "value" : "버블 게임에서 동시에 가장 큰 두 객체" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Twee van de grootste objecten in het bubbelspel op hetzelfde moment" + "value" : "To av de største objektene i boblespillet samtidig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "To av de største objektene i boblespillet samtidig" + "value" : "Twee van de grootste objecten in het bubbelspel op hetzelfde moment" } }, "pl" : { @@ -39756,16 +39800,16 @@ "value" : "이렇게 점심 도시락을 채울 수 있습니다 🤯 🤯 약간." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Je kunt een lunchdoos zoals deze 🤯 🤯 een beetje opvullen." + "value" : "Du kan fylle en lunsj boks som dette 🤯 🤯 en bit." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Du kan fylle en lunsj boks som dette 🤯 🤯 en bit." + "value" : "Je kunt een lunchdoos zoals deze 🤯 🤯 een beetje opvullen." } }, "pl" : { @@ -39941,16 +39985,16 @@ "value" : "더블 🤯" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Dubbel:exploderen_head:" + "value" : "Dobbel🤯" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Dobbel🤯" + "value" : "Dubbel:exploderen_head:" } }, "pl" : { @@ -40126,16 +40170,16 @@ "value" : "버블 게임에서 가장 큰 객체" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Het grootste object in het bubbelspel" + "value" : "Det største objektet i boblespill" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Det største objektet i boblespill" + "value" : "Het grootste object in het bubbelspel" } }, "pl" : { @@ -40311,13 +40355,13 @@ "value" : "🤯" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "🤯" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "🤯" @@ -40496,16 +40540,16 @@ "value" : "여기를 클릭했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Je hebt hier geklikt" + "value" : "Du har klikket her" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Du har klikket her" + "value" : "Je hebt hier geklikt" } }, "pl" : { @@ -40687,16 +40731,16 @@ "value" : "여기를 클릭하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Klik hier" + "value" : "Klikk her" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Klikk her" + "value" : "Klik hier" } }, "pl" : { @@ -40872,16 +40916,16 @@ "value" : "Misskey를 최소 30분 동안 열어 두세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Houd Misskey open voor ten minste 30 minuten" + "value" : "Behold Misskey åpnet i minst 30 minutter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Behold Misskey åpnet i minst 30 minutter" + "value" : "Houd Misskey open voor ten minste 30 minuten" } }, "pl" : { @@ -41063,16 +41107,16 @@ "value" : "짧은 휴식" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Korte pauze" + "value" : "Kort pause" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kort pause" + "value" : "Korte pauze" } }, "pl" : { @@ -41254,16 +41298,16 @@ "value" : "Misskey를 최소 60분 동안 열어 두세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Houd Misskey open voor ten minste 60 minuten" + "value" : "Behold Misskey åpnet i minst 60 minutter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Behold Misskey åpnet i minst 60 minutter" + "value" : "Houd Misskey open voor ten minste 60 minuten" } }, "pl" : { @@ -41445,16 +41489,16 @@ "value" : "Misskey에서는 \"Miss\"가 없습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geen \"Miss\" in Misskey" + "value" : "Ingen \"Misske\" i Misskey" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ingen \"Misske\" i Misskey" + "value" : "Geen \"Miss\" in Misskey" } }, "pl" : { @@ -41630,16 +41674,16 @@ "value" : "30개의 업적 획득" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verdien 30 prestaties" + "value" : "Tjen 30 prestasjoner" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tjen 30 prestasjoner" + "value" : "Verdien 30 prestaties" } }, "pl" : { @@ -41815,16 +41859,16 @@ "value" : "업적 수집가" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Prestatie Verzamelaar" + "value" : "Prestasjon samler" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Prestasjon samler" + "value" : "Prestatie Verzamelaar" } }, "pl" : { @@ -42000,16 +42044,16 @@ "value" : "쿠키를 클릭했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Klikte de cookie" + "value" : "Klikket på infokapselen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Klikket på infokapselen" + "value" : "Klikte de cookie" } }, "pl" : { @@ -42185,16 +42229,16 @@ "value" : "기다려, 정확한 웹사이트에 있나요?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Wacht, ben je op de juiste website?" + "value" : "Vent, er du på riktig nettside?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vent, er du på riktig nettside?" + "value" : "Wacht, ben je op de juiste website?" } }, "pl" : { @@ -42370,16 +42414,16 @@ "value" : "쿠키를 클릭하는 게임" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Een spel waarin je op cookies klikt" + "value" : "Et spill hvor du klikker på informasjonskapsler" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Et spill hvor du klikker på informasjonskapsler" + "value" : "Een spel waarin je op cookies klikt" } }, "pl" : { @@ -42555,16 +42599,16 @@ "value" : "드라이브에서 재귀적으로 중첩된 폴더 만들기 시도" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Poging een resource map te maken in de Drive" + "value" : "Forsøk på å opprette en rekursivt nestet mappe i stasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Forsøk på å opprette en rekursivt nestet mappe i stasjon" + "value" : "Poging een resource map te maken in de Drive" } }, "pl" : { @@ -42740,16 +42784,16 @@ "value" : "순환 참조" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ronde referentie" + "value" : "Sirkulær referanse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sirkulær referanse" + "value" : "Ronde referentie" } }, "pl" : { @@ -42931,16 +42975,16 @@ "value" : "1명의 팔로워 얻기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Krijg 1 volger" + "value" : "Få 1 følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få 1 følger" + "value" : "Krijg 1 volger" } }, "pl" : { @@ -43122,16 +43166,16 @@ "value" : "첫 번째 팔로워" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Eerste volger" + "value" : "Første tilhenger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Første tilhenger" + "value" : "Eerste volger" } }, "pl" : { @@ -43313,16 +43357,16 @@ "value" : "10명의 팔로워 얻기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Krijg 10 volgers" + "value" : "Få 10 følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få 10 følgere" + "value" : "Krijg 10 volgers" } }, "pl" : { @@ -43504,16 +43548,16 @@ "value" : "나를 팔로우하세요!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volg mij!" + "value" : "Følg meg!" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg meg!" + "value" : "Volg mij!" } }, "pl" : { @@ -43695,16 +43739,16 @@ "value" : "50명의 팔로워 얻기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Krijg 50 volgers" + "value" : "Få 50 følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få 50 følgere" + "value" : "Krijg 50 volgers" } }, "pl" : { @@ -43880,16 +43924,16 @@ "value" : "사람들이 몰려옵니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Komt in massa's" + "value" : "Hva skjer med flere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hva skjer med flere" + "value" : "Komt in massa's" } }, "pl" : { @@ -44071,16 +44115,16 @@ "value" : "100명의 팔로워 얻기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Krijg 100 volgers" + "value" : "Få 100 følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få 100 følgere" + "value" : "Krijg 100 volgers" } }, "pl" : { @@ -44262,16 +44306,16 @@ "value" : "인기 있는" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Populair" + "value" : "Populær" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Populær" + "value" : "Populair" } }, "pl" : { @@ -44453,16 +44497,16 @@ "value" : "300명의 팔로워 얻기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Krijg 300 volgers" + "value" : "Få 300 følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få 300 følgere" + "value" : "Krijg 300 volgers" } }, "pl" : { @@ -44638,16 +44682,16 @@ "value" : "한 줄로 서주세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gelieve een enkele regel te vormen" + "value" : "Fyll inn en enkelt linje" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fyll inn en enkelt linje" + "value" : "Gelieve een enkele regel te vormen" } }, "pl" : { @@ -44829,16 +44873,16 @@ "value" : "500명의 팔로워 얻기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Krijg 500 volgers" + "value" : "Få 500 følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få 500 følgere" + "value" : "Krijg 500 volgers" } }, "pl" : { @@ -45020,16 +45064,16 @@ "value" : "라디오 타워" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Radio toren" + "value" : "Radio tårn" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Radio tårn" + "value" : "Radio toren" } }, "pl" : { @@ -45211,16 +45255,16 @@ "value" : "1,000명의 팔로워 얻기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Krijg 1000 volgers" + "value" : "Få 1000 følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få 1000 følgere" + "value" : "Krijg 1000 volgers" } }, "pl" : { @@ -45402,16 +45446,16 @@ "value" : "인플루언서" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bevochtiger" + "value" : "Påvirkning" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Påvirkning" + "value" : "Bevochtiger" } }, "pl" : { @@ -45593,16 +45637,16 @@ "value" : "사용자를 팔로우하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volg een gebruiker" + "value" : "Følg en bruker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg en bruker" + "value" : "Volg een gebruiker" } }, "pl" : { @@ -45778,16 +45822,16 @@ "value" : "첫 번째 사용자를 팔로우하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Je volgt je eerste gebruiker" + "value" : "Følger din første bruker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger din første bruker" + "value" : "Je volgt je eerste gebruiker" } }, "pl" : { @@ -45969,16 +46013,16 @@ "value" : "10명 사용자 팔로우하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volg 10 gebruikers" + "value" : "Følg 10 brukere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg 10 brukere" + "value" : "Volg 10 gebruikers" } }, "pl" : { @@ -46154,16 +46198,16 @@ "value" : "계속... 계속..." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Hou door... ga zo door..." + "value" : "Fortsett med... fortsett å være med..." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fortsett med... fortsett å være med..." + "value" : "Hou door... ga zo door..." } }, "pl" : { @@ -46345,16 +46389,16 @@ "value" : "50명 계정 팔로우하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volg 50 accounts" + "value" : "Følg 50 kontoer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg 50 kontoer" + "value" : "Volg 50 accounts" } }, "pl" : { @@ -46536,16 +46580,16 @@ "value" : "많은 친구들" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Veel vrienden" + "value" : "Masse av venner" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Masse av venner" + "value" : "Veel vrienden" } }, "pl" : { @@ -46727,16 +46771,16 @@ "value" : "100명 계정 팔로우하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volg 100 accounts" + "value" : "Følg 100 kontoer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg 100 kontoer" + "value" : "Volg 100 accounts" } }, "pl" : { @@ -46918,16 +46962,16 @@ "value" : "100명의 친구들" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "100 vrienden" + "value" : "100 venner" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "100 venner" + "value" : "100 vrienden" } }, "pl" : { @@ -47109,16 +47153,16 @@ "value" : "300명 계정 팔로우하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volg 300 accounts" + "value" : "Følg 300 kontoer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg 300 kontoer" + "value" : "Volg 300 accounts" } }, "pl" : { @@ -47294,16 +47338,16 @@ "value" : "친구 과잉" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vriend overbelast" + "value" : "Venn overbelastet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Venn overbelastet" + "value" : "Vriend overbelast" } }, "pl" : { @@ -47479,16 +47523,16 @@ "value" : "숨겨진 보물을 찾았습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Je hebt de verborgen schat gevonden" + "value" : "Du har funnet den skjulte skatten" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Du har funnet den skjulte skatten" + "value" : "Je hebt de verborgen schat gevonden" } }, "pl" : { @@ -47664,16 +47708,16 @@ "value" : "보물 찾기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Schat Jacht" + "value" : "Skatt Jakt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skatt Jakt" + "value" : "Schat Jacht" } }, "pl" : { @@ -47849,16 +47893,16 @@ "value" : "귀하의 홈 타임라인 속도가 분당 20개 노트를 초과하도록 하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Heb de snelheid van je home tijdlijn groter dan 20 npm (notities per minuut)" + "value" : "Få hastigheten på hjemmetidslinjen over 20 npm (notater per minutt)" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få hastigheten på hjemmetidslinjen over 20 npm (notater per minutt)" + "value" : "Heb de snelheid van je home tijdlijn groter dan 20 npm (notities per minuut)" } }, "pl" : { @@ -48034,16 +48078,16 @@ "value" : "흐르는 타임라인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vloeiende tijdlijn" + "value" : "flytende tidslinje" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "flytende tidslinje" + "value" : "Vloeiende tijdlijn" } }, "pl" : { @@ -48219,16 +48263,16 @@ "value" : "\"I ❤ #Misskey\" 게시하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Plaats \"I ❤️ #Misskey\"" + "value" : "Innlegg \"I ❤️ #Misskey\"" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg \"I ❤️ #Misskey\"" + "value" : "Plaats \"I ❤️ #Misskey\"" } }, "pl" : { @@ -48404,16 +48448,16 @@ "value" : "미스키의 개발 팀이 당신의 지원에 매우 감사드립니다!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Misskey's ontwikkelingsteam waardeert je steun zeer!" + "value" : "Misskey's utviklingsteam setter stor pris på din støtte!" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Misskey's utviklingsteam setter stor pris på din støtte!" + "value" : "Misskey's ontwikkelingsteam waardeert je steun zeer!" } }, "pl" : { @@ -48589,16 +48633,16 @@ "value" : "나는 미스키를 사랑해요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ik hou van Misskey" + "value" : "Jeg elsker Misskey" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Jeg elsker Misskey" + "value" : "Ik hou van Misskey" } }, "pl" : { @@ -48744,16 +48788,16 @@ "value" : "10秒ごとに0.005%の確率で獲得できます" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Heeft een kans om te worden verkregen met een kans van 0,005% per 10 seconden" + "value" : "Har en sjanse til å bli oppnådd med en sannsynlighet på 0,005% hvert 10 sekund" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Har en sjanse til å bli oppnådd med en sannsynlighet på 0,005% hvert 10 sekund" + "value" : "Heeft een kans om te worden verkregen met een kans van 0,005% per 10 seconden" } }, "pl" : { @@ -48911,16 +48955,16 @@ "value" : "그냥 운이 좋았다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Eenvoudig Geluk" + "value" : "Bare ren lykke" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bare ren lykke" + "value" : "Eenvoudig Geluk" } }, "pl" : { @@ -49102,16 +49146,16 @@ "value" : "생일에 로그인하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in op je verjaardag" + "value" : "Logg inn på bursdagen din" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn på bursdagen din" + "value" : "Log in op je verjaardag" } }, "pl" : { @@ -49293,16 +49337,16 @@ "value" : "생일 축하합니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gefeliciteerd met je verjaardag" + "value" : "Gratulerer med dagen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gratulerer med dagen" + "value" : "Gefeliciteerd met je verjaardag" } }, "pl" : { @@ -49484,16 +49528,16 @@ "value" : "새해 첫날에 로그인했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ingelogd op de eerste dag van het jaar" + "value" : "Logget på den første dagen av året" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logget på den første dagen av året" + "value" : "Ingelogd op de eerste dag van het jaar" } }, "pl" : { @@ -49669,16 +49713,16 @@ "value" : "이 인스턴스에서 또 다른 훌륭한 해를 기원합니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Tot nog een geweldig jaar op dit exemplaar" + "value" : "Til et annet stort år i dette tilfellet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Til et annet stort år i dette tilfellet" + "value" : "Tot nog een geweldig jaar op dit exemplaar" } }, "pl" : { @@ -49860,16 +49904,16 @@ "value" : "새해 복 많이 받으세요!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gelukkig nieuwjaar!" + "value" : "Godt nytt år!" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Godt nytt år!" + "value" : "Gelukkig nieuwjaar!" } }, "pl" : { @@ -50045,16 +50089,16 @@ "value" : "총 3일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "In totaal 3 dagen inloggen" + "value" : "Logg inn totalt 3 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 3 dager" + "value" : "In totaal 3 dagen inloggen" } }, "pl" : { @@ -50230,16 +50274,16 @@ "value" : "오늘부터 저를 미스키스트라고 부르세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Begint vandaag, noem me Misskist" + "value" : "Starter i dag, bare ring meg Misskist" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Starter i dag, bare ring meg Misskist" + "value" : "Begint vandaag, noem me Misskist" } }, "pl" : { @@ -50421,16 +50465,16 @@ "value" : "초보자 I" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beginner I" + "value" : "Nybegynner jeg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nybegynner jeg" + "value" : "Beginner I" } }, "pl" : { @@ -50606,16 +50650,16 @@ "value" : "총 7일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 7 dagen" + "value" : "Logg inn totalt 7 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 7 dager" + "value" : "Log in voor een totaal van 7 dagen" } }, "pl" : { @@ -50791,16 +50835,16 @@ "value" : "뭔가 익숙해지셨나요?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Heb je het gevoel dat je dingen al opgehangen hebt?" + "value" : "Føler som du har fått heftet på ting enda?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Føler som du har fått heftet på ting enda?" + "value" : "Heb je het gevoel dat je dingen al opgehangen hebt?" } }, "pl" : { @@ -50982,16 +51026,16 @@ "value" : "초보자 II" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beginner II" + "value" : "Nybegynner II" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nybegynner II" + "value" : "Beginner II" } }, "pl" : { @@ -51167,16 +51211,16 @@ "value" : "총 15일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 15 dagen" + "value" : "Logg inn totalt 15 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 15 dager" + "value" : "Log in voor een totaal van 15 dagen" } }, "pl" : { @@ -51358,16 +51402,16 @@ "value" : "초보자 III" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beginner III" + "value" : "Nybegynner III" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nybegynner III" + "value" : "Beginner III" } }, "pl" : { @@ -51543,16 +51587,16 @@ "value" : "총 30일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 30 dagen" + "value" : "Logg inn totalt 30 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 30 dager" + "value" : "Log in voor een totaal van 30 dagen" } }, "pl" : { @@ -51728,13 +51772,13 @@ "value" : "미스키스트 I" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Misskist I" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Misskist I" @@ -51913,16 +51957,16 @@ "value" : "총 60일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 60 dagen" + "value" : "Logg inn totalt 60 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 60 dager" + "value" : "Log in voor een totaal van 60 dagen" } }, "pl" : { @@ -52098,13 +52142,13 @@ "value" : "미스키스트 II" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Misskist II" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Misskist II" @@ -52283,16 +52327,16 @@ "value" : "총 100일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 100 dagen" + "value" : "Logg inn i totalt 100 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn i totalt 100 dager" + "value" : "Log in voor een totaal van 100 dagen" } }, "pl" : { @@ -52468,16 +52512,16 @@ "value" : "폭력적인 미스키스트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gewelddadige Misskist" + "value" : "Voldelig delegasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Voldelig delegasjon" + "value" : "Gewelddadige Misskist" } }, "pl" : { @@ -52653,13 +52697,13 @@ "value" : "미스키스트 III" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Misskist III" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Misskist III" @@ -52838,16 +52882,16 @@ "value" : "총 200일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 200 dagen" + "value" : "Logg inn for totalt 200 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn for totalt 200 dager" + "value" : "Log in voor een totaal van 200 dagen" } }, "pl" : { @@ -53023,16 +53067,16 @@ "value" : "정상 I" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Normaal I" + "value" : "Vanlig 1" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vanlig 1" + "value" : "Normaal I" } }, "pl" : { @@ -53208,16 +53252,16 @@ "value" : "총 300일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 300 dagen" + "value" : "Logg inn totalt 300 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 300 dager" + "value" : "Log in voor een totaal van 300 dagen" } }, "pl" : { @@ -53393,16 +53437,16 @@ "value" : "정상 II" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Normaal II" + "value" : "Vanlig II" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vanlig II" + "value" : "Normaal II" } }, "pl" : { @@ -53578,16 +53622,16 @@ "value" : "총 400일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in totaal 400 dagen in" + "value" : "Logg inn i totalt 400 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn i totalt 400 dager" + "value" : "Log in totaal 400 dagen in" } }, "pl" : { @@ -53763,16 +53807,16 @@ "value" : "정상 III" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Normaal III" + "value" : "Vanlig III" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vanlig III" + "value" : "Normaal III" } }, "pl" : { @@ -53948,16 +53992,16 @@ "value" : "총 500일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 500 dagen" + "value" : "Logg inn totalt 500 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 500 dager" + "value" : "Log in voor een totaal van 500 dagen" } }, "pl" : { @@ -54133,16 +54177,16 @@ "value" : "내 친구들, 내가 노트를 좋아한다고 자주 말해왔습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beste vrienden, er is vaak gezegd dat ik van notities houd" + "value" : "Mine venner har ofte blitt sagt at jeg liker notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mine venner har ofte blitt sagt at jeg liker notater" + "value" : "Beste vrienden, er is vaak gezegd dat ik van notities houd" } }, "pl" : { @@ -54318,16 +54362,16 @@ "value" : "전문가 I" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Expert I" + "value" : "Ekspert 1" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ekspert 1" + "value" : "Expert I" } }, "pl" : { @@ -54503,16 +54547,16 @@ "value" : "총 600일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 600 dagen" + "value" : "Logg inn i totalt 600 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn i totalt 600 dager" + "value" : "Log in voor een totaal van 600 dagen" } }, "pl" : { @@ -54688,16 +54732,16 @@ "value" : "전문가 II" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Expert II" + "value" : "Ekspert II" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ekspert II" + "value" : "Expert II" } }, "pl" : { @@ -54873,16 +54917,16 @@ "value" : "총 700일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in totaal 700 dagen in" + "value" : "Logg inn i til sammen 700 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn i til sammen 700 dager" + "value" : "Log in totaal 700 dagen in" } }, "pl" : { @@ -55058,16 +55102,16 @@ "value" : "전문가 III" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Expert III" + "value" : "Ekspert III" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ekspert III" + "value" : "Expert III" } }, "pl" : { @@ -55243,16 +55287,16 @@ "value" : "총 800일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 800 dagen" + "value" : "Log inn totalt 800 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Log inn totalt 800 dager" + "value" : "Log in voor een totaal van 800 dagen" } }, "pl" : { @@ -55428,16 +55472,16 @@ "value" : "노트의 달인 I" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meester der notities I" + "value" : "Master i Merknader I" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Master i Merknader I" + "value" : "Meester der notities I" } }, "pl" : { @@ -55613,16 +55657,16 @@ "value" : "총 900일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in totaal 900 dagen in" + "value" : "Logg inn totalt 900 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 900 dager" + "value" : "Log in totaal 900 dagen in" } }, "pl" : { @@ -55798,16 +55842,16 @@ "value" : "노트의 달인 II" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meester der notities II" + "value" : "Master i Merknader II" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Master i Merknader II" + "value" : "Meester der notities II" } }, "pl" : { @@ -55983,16 +56027,16 @@ "value" : "총 1,000일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 1.000 dagen" + "value" : "Logg inn totalt 1000 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 1000 dager" + "value" : "Log in voor een totaal van 1.000 dagen" } }, "pl" : { @@ -56174,16 +56218,16 @@ "value" : "Misskey를 사용해 주셔서 감사합니다!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bedankt voor het gebruiken van Misskey!" + "value" : "Takk for at du bruker Misskey!" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Takk for at du bruker Misskey!" + "value" : "Bedankt voor het gebruiken van Misskey!" } }, "pl" : { @@ -56359,16 +56403,16 @@ "value" : "노트의 달인 III" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meester der notities III" + "value" : "Master i note III" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Master i note III" + "value" : "Meester der notities III" } }, "pl" : { @@ -56544,16 +56588,16 @@ "value" : "계정을 고양이로 표시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Markeer jouw account als een kat" + "value" : "Merk kontoen din som en katt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Merk kontoen din som en katt" + "value" : "Markeer jouw account als een kat" } }, "pl" : { @@ -56729,16 +56773,16 @@ "value" : "나중에 이름을 정할게요." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ik zal je later een naam geven." + "value" : "Jeg skal gi deg et navn senere." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Jeg skal gi deg et navn senere." + "value" : "Ik zal je later een naam geven." } }, "pl" : { @@ -56920,16 +56964,16 @@ "value" : "나는 고양이입니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ik ben een kat" + "value" : "Jeg er en katt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Jeg er en katt" + "value" : "Ik ben een kat" } }, "pl" : { @@ -57105,16 +57149,16 @@ "value" : "다른 사람이 당신의 노트를 즐겨찾기하도록 하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Laat iemand anders een van je notities favoriet maken" + "value" : "Har noen andre satt på en av notatene dine" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Har noen andre satt på en av notatene dine" + "value" : "Laat iemand anders een van je notities favoriet maken" } }, "pl" : { @@ -57290,16 +57334,16 @@ "value" : "별 찾기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zoek sterren" + "value" : "Søker etter stjerner" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Søker etter stjerner" + "value" : "Zoek sterren" } }, "pl" : { @@ -57475,16 +57519,16 @@ "value" : "첫 번째 노트를 클립하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Knip je eerste notitie" + "value" : "Klipp din første notat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Klipp din første notat" + "value" : "Knip je eerste notitie" } }, "pl" : { @@ -57660,16 +57704,16 @@ "value" : "필요하다... 클립하다..." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Mosterd... klem..." + "value" : "Må... klipp..." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Må... klipp..." + "value" : "Mosterd... klem..." } }, "pl" : { @@ -57845,16 +57889,16 @@ "value" : "게시 후 1분 이내에 노트를 삭제하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Een notitie verwijderen binnen een minuut na het plaatsen ervan" + "value" : "Slett et notat i løpet av ett minutt etter å poste det" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett et notat i løpet av ett minutt etter å poste det" + "value" : "Een notitie verwijderen binnen een minuut na het plaatsen ervan" } }, "pl" : { @@ -58030,16 +58074,16 @@ "value" : "신경 쓰지 마세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Onthoud" + "value" : "Glem det" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Glem det" + "value" : "Onthoud" } }, "pl" : { @@ -58215,16 +58259,16 @@ "value" : "첫 번째 노트를 즐겨찾기에 추가하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Favoriet je eerste notitie" + "value" : "Favoritt ditt første notat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Favoritt ditt første notat" + "value" : "Favoriet je eerste notitie" } }, "pl" : { @@ -58400,13 +58444,13 @@ "value" : "별을 바라보는 사람" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Stargazer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Stargazer" @@ -58585,16 +58629,16 @@ "value" : "첫 번째 노트를 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Plaats je eerste notitie" + "value" : "Legg inn ditt første notat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg inn ditt første notat" + "value" : "Plaats je eerste notitie" } }, "pl" : { @@ -58770,16 +58814,16 @@ "value" : "Misskey와 즐거운 시간을 보내세요!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Veel tijd met Misskey!" + "value" : "Ha en god tid med Misskey!" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ha en god tid med Misskey!" + "value" : "Veel tijd met Misskey!" } }, "pl" : { @@ -58955,16 +58999,16 @@ "value" : "내 msky 설정 중입니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "stel gewoon mijn msky op" + "value" : "Nettopp msky min" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nettopp msky min" + "value" : "stel gewoon mijn msky op" } }, "pl" : { @@ -59146,16 +59190,16 @@ "value" : "10개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Plaats 10 notities" + "value" : "Post 10 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 10 notater" + "value" : "Plaats 10 notities" } }, "pl" : { @@ -59331,16 +59375,16 @@ "value" : "몇 개의 노트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Sommige notities" + "value" : "Noen notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Noen notater" + "value" : "Sommige notities" } }, "pl" : { @@ -59522,16 +59566,16 @@ "value" : "100개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 100 notities" + "value" : "Post 100 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 100 notater" + "value" : "Post 100 notities" } }, "pl" : { @@ -59713,16 +59757,16 @@ "value" : "많은 노트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Veel notities" + "value" : "Mange notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mange notater" + "value" : "Veel notities" } }, "pl" : { @@ -59904,16 +59948,16 @@ "value" : "500개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Plaats 500 notities" + "value" : "Post 500 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 500 notater" + "value" : "Plaats 500 notities" } }, "pl" : { @@ -60089,16 +60133,16 @@ "value" : "노트에 담긴" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gedekt in notities" + "value" : "Dekket i notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Dekket i notater" + "value" : "Gedekt in notities" } }, "pl" : { @@ -60280,16 +60324,16 @@ "value" : "1,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 1.000 notities" + "value" : "Post 1000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 1000 notater" + "value" : "Post 1.000 notities" } }, "pl" : { @@ -60471,16 +60515,16 @@ "value" : "산더미 같은 노트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Een berg notities" + "value" : "A mountain of notes" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "A mountain of notes" + "value" : "Een berg notities" } }, "pl" : { @@ -60662,16 +60706,16 @@ "value" : "5,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 5.000 notities" + "value" : "Post 5000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 5000 notater" + "value" : "Post 5.000 notities" } }, "pl" : { @@ -60847,16 +60891,16 @@ "value" : "넘쳐나는 노트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notities overvloeien" + "value" : "Noter som flyter over" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Noter som flyter over" + "value" : "Notities overvloeien" } }, "pl" : { @@ -61038,16 +61082,16 @@ "value" : "10,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 10.000 notities" + "value" : "Post 10000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 10000 notater" + "value" : "Post 10.000 notities" } }, "pl" : { @@ -61223,16 +61267,16 @@ "value" : "슈퍼노트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Supernotitie" + "value" : "Supernota" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Supernota" + "value" : "Supernotitie" } }, "pl" : { @@ -61414,16 +61458,16 @@ "value" : "20,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 20.000 notities" + "value" : "Post 20 000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 20 000 notater" + "value" : "Post 20.000 notities" } }, "pl" : { @@ -61605,16 +61649,16 @@ "value" : "더 많은 노트가 필요해요..." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Nood... aantekeningen..." + "value" : "Trenger mer... mer... notater..." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Trenger mer... mer... notater..." + "value" : "Nood... aantekeningen..." } }, "pl" : { @@ -61796,16 +61840,16 @@ "value" : "30,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 30.000 notities" + "value" : "Post 30.000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 30.000 notater" + "value" : "Post 30.000 notities" } }, "pl" : { @@ -61987,16 +62031,16 @@ "value" : "노트 노트 노트!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notities notities" + "value" : "Notater notater!" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Notater notater!" + "value" : "Notities notities" } }, "pl" : { @@ -62178,16 +62222,16 @@ "value" : "40,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 40.000 notities" + "value" : "Post 40.000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 40.000 notater" + "value" : "Post 40.000 notities" } }, "pl" : { @@ -62369,16 +62413,16 @@ "value" : "노트 공장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notitie fabriek" + "value" : "Merknader fabrikk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Merknader fabrikk" + "value" : "Notitie fabriek" } }, "pl" : { @@ -62560,16 +62604,16 @@ "value" : "50,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 50.000 notities" + "value" : "Post 50,000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 50,000 notater" + "value" : "Post 50.000 notities" } }, "pl" : { @@ -62751,16 +62795,16 @@ "value" : "노트의 행성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Planet van notities" + "value" : "Planet of notes" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Planet of notes" + "value" : "Planet van notities" } }, "pl" : { @@ -62942,16 +62986,16 @@ "value" : "60,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 60.000 notities" + "value" : "Post 60.000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 60.000 notater" + "value" : "Post 60.000 notities" } }, "pl" : { @@ -63133,16 +63177,16 @@ "value" : "노트 퀘이사" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Quasar Notitie" + "value" : "Merk kvasar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Merk kvasar" + "value" : "Quasar Notitie" } }, "pl" : { @@ -63324,16 +63368,16 @@ "value" : "70,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 70,000 notities" + "value" : "Post 70,000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 70,000 notater" + "value" : "Post 70,000 notities" } }, "pl" : { @@ -63515,16 +63559,16 @@ "value" : "노트 블랙홀" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zwart gat notitie" + "value" : "Noter svart hull" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Noter svart hull" + "value" : "Zwart gat notitie" } }, "pl" : { @@ -63706,16 +63750,16 @@ "value" : "80,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 80,000 notities" + "value" : "Post 80,000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 80,000 notater" + "value" : "Post 80,000 notities" } }, "pl" : { @@ -63897,16 +63941,16 @@ "value" : "노트 은하" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notitie sterrenstelsel" + "value" : "Note galakse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Note galakse" + "value" : "Notitie sterrenstelsel" } }, "pl" : { @@ -64088,16 +64132,16 @@ "value" : "90,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 90,000 notities" + "value" : "Post 90.000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 90.000 notater" + "value" : "Post 90,000 notities" } }, "pl" : { @@ -64279,16 +64323,16 @@ "value" : "노트 우주" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Noot universum" + "value" : "Notat univers" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Notat univers" + "value" : "Noot universum" } }, "pl" : { @@ -64470,16 +64514,16 @@ "value" : "100,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 100.000 notities" + "value" : "Post 100 000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 100 000 notater" + "value" : "Post 100.000 notities" } }, "pl" : { @@ -64655,16 +64699,16 @@ "value" : "당신은 할 말이 많습니다." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "U hebt zeker veel te zeggen." + "value" : "Du er sikker på at du har mye å si." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Du er sikker på at du har mye å si." + "value" : "U hebt zeker veel te zeggen." } }, "pl" : { @@ -64846,16 +64890,16 @@ "value" : "모든 노트는 우리에게 속해있습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "ALLES UW MET BELONG OM TE ONS" + "value" : "ALLE DIN merk – FEIL TIL USAs" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "ALLE DIN merk – FEIL TIL USAs" + "value" : "ALLES UW MET BELONG OM TE ONS" } }, "pl" : { @@ -65031,16 +65075,16 @@ "value" : "동시에 3개 이상의 창을 엽니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Heb ten minste 3 vensters open op hetzelfde moment" + "value" : "Ha minst tre vinduer åpne samtidig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ha minst tre vinduer åpne samtidig" + "value" : "Heb ten minste 3 vensters open op hetzelfde moment" } }, "pl" : { @@ -65216,16 +65260,16 @@ "value" : "멀티윈도우" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Multi-venster" + "value" : "Multi-Vindu" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Multi-Vindu" + "value" : "Multi-venster" } }, "pl" : { @@ -65401,16 +65445,16 @@ "value" : "스크래치패드에서 \"hello world\" 출력하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Uitvoer \"hallo wereld\" in het Scratchpad" + "value" : "Utgang \"hallo verden\" på Scratchpad" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utgang \"hallo verden\" på Scratchpad" + "value" : "Uitvoer \"hallo wereld\" in het Scratchpad" } }, "pl" : { @@ -65592,16 +65636,16 @@ "value" : "안녕하세요, 세계!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Hallo, wereld!" + "value" : "Hallo, verden!" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hallo, verden!" + "value" : "Hallo, wereld!" } }, "pl" : { @@ -65777,16 +65821,16 @@ "value" : "계정 생성 후 1년이 지났습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Eén jaar is verstreken sinds uw account is aangemaakt" + "value" : "Ett år har gått siden din konto ble opprettet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ett år har gått siden din konto ble opprettet" + "value" : "Eén jaar is verstreken sinds uw account is aangemaakt" } }, "pl" : { @@ -65962,16 +66006,16 @@ "value" : "1주년" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Één Verjaardag" + "value" : "Et års jubileum" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Et års jubileum" + "value" : "Één Verjaardag" } }, "pl" : { @@ -66147,16 +66191,16 @@ "value" : "계정 생성 후 2년이 지났습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Twee jaar zijn verstreken sinds het aanmaken van uw account" + "value" : "Det er gått 2 år siden din konto ble opprettet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Det er gått 2 år siden din konto ble opprettet" + "value" : "Twee jaar zijn verstreken sinds het aanmaken van uw account" } }, "pl" : { @@ -66332,16 +66376,16 @@ "value" : "2주년" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Tweejarig Verjaardag" + "value" : "To års jubileum" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "To års jubileum" + "value" : "Tweejarig Verjaardag" } }, "pl" : { @@ -66517,16 +66561,16 @@ "value" : "계정 생성 후 3년이 지났습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Drie jaar zijn verstreken sinds de aanmaak van uw account" + "value" : "Det er gått tre år siden din konto ble opprettet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Det er gått tre år siden din konto ble opprettet" + "value" : "Drie jaar zijn verstreken sinds de aanmaak van uw account" } }, "pl" : { @@ -66702,16 +66746,16 @@ "value" : "3주년" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Drie jaar Verjaardag" + "value" : "Tre års jubileum" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tre års jubileum" + "value" : "Drie jaar Verjaardag" } }, "pl" : { @@ -66887,16 +66931,16 @@ "value" : "00:00에 노트를 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Plaats een notitie om 00:00" + "value" : "Skriv en melding klokken 00:00" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv en melding klokken 00:00" + "value" : "Plaats een notitie om 00:00" } }, "pl" : { @@ -67072,13 +67116,13 @@ "value" : "클릭 클릭 클릭 쨍" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Click Click Click Claaang" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Click Click Click Claaang" @@ -67257,16 +67301,16 @@ "value" : "시계 맞추기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Sprekende Klok" + "value" : "Snakker klokke" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Snakker klokke" + "value" : "Sprekende Klok" } }, "pl" : { @@ -67442,16 +67486,16 @@ "value" : "늦은 밤에 노트를 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Plaats een notitie laat in de nacht" + "value" : "Legg inn en melding sent om natten" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg inn en melding sent om natten" + "value" : "Plaats een notitie laat in de nacht" } }, "pl" : { @@ -67627,16 +67671,16 @@ "value" : "이제 잘 시간입니다." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Het is hoog tijd om naar bed te gaan." + "value" : "Det er på tide å gå til sengs." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Det er på tide å gå til sengs." + "value" : "Het is hoog tijd om naar bed te gaan." } }, "pl" : { @@ -67812,16 +67856,16 @@ "value" : "야행성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Nachtelijk" + "value" : "Nattlig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nattlig" + "value" : "Nachtelijk" } }, "pl" : { @@ -67997,16 +68041,16 @@ "value" : "프로필을 설정하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Stel je profiel in" + "value" : "Sett opp din profil" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sett opp din profil" + "value" : "Stel je profiel in" } }, "pl" : { @@ -68182,16 +68226,16 @@ "value" : "잘 준비했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Goed bereid" + "value" : "Godt forberedt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Godt forberedt" + "value" : "Goed bereid" } }, "pl" : { @@ -68367,16 +68411,16 @@ "value" : "게시된 후 3초 이내에 100자 이상의 메모에 반응하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Reageer op een notitie die meer dan 100 tekens lang is binnen 3 seconden nadat deze is gepost" + "value" : "React på et notat som er over 100 tegn innenfor 3 sekunder etter at det er postet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "React på et notat som er over 100 tegn innenfor 3 sekunder etter at det er postet" + "value" : "Reageer op een notitie die meer dan 100 tekens lang is binnen 3 seconden nadat deze is gepost" } }, "pl" : { @@ -68552,16 +68596,16 @@ "value" : "정말로 그걸 읽었나요?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Heb je dat echt gelezen?" + "value" : "Har du virkelig lest det?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Har du virkelig lest det?" + "value" : "Heb je dat echt gelezen?" } }, "pl" : { @@ -68737,16 +68781,16 @@ "value" : "자신의 노트를 인용하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Citeer uw eigen notitie" + "value" : "Siter ditt eget notat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Siter ditt eget notat" + "value" : "Citeer uw eigen notitie" } }, "pl" : { @@ -68922,16 +68966,16 @@ "value" : "자기 참조" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zelfreferentie" + "value" : "Selv-referanse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Selv-referanse" + "value" : "Zelfreferentie" } }, "pl" : { @@ -69113,16 +69157,16 @@ "value" : "이름을 \"syuilo\"로 설정하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Stel je naam in op \"syuilo\"" + "value" : "Sett ditt navn til \"syuilo\"" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sett ditt navn til \"syuilo\"" + "value" : "Stel je naam in op \"syuilo\"" } }, "pl" : { @@ -69304,16 +69348,16 @@ "value" : "신의 복잡성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "God Complex" + "value" : "Kompleks Gud" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kompleks Gud" + "value" : "God Complex" } }, "pl" : { @@ -69489,16 +69533,16 @@ "value" : "극도로 짧은 시간 안에 알림 테스트를 반복적으로 트리거하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Trigger de notificatie-test herhaaldelijk binnen extreem korte tijd" + "value" : "Utløs varslingstesten gjentatte ganger i løpet av svært kort tid" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utløs varslingstesten gjentatte ganger i løpet av svært kort tid" + "value" : "Trigger de notificatie-test herhaaldelijk binnen extreem korte tijd" } }, "pl" : { @@ -69674,16 +69718,16 @@ "value" : "테스트 오버플로우" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Overloop testen" + "value" : "Prøving av overflyt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Prøving av overflyt" + "value" : "Overloop testen" } }, "pl" : { @@ -69859,16 +69903,16 @@ "value" : "튜토리얼 완료" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Tutorial voltooid" + "value" : "Opplæring fullført" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Opplæring fullført" + "value" : "Tutorial voltooid" } }, "pl" : { @@ -70044,16 +70088,16 @@ "value" : "Misskey 초급 과정 졸업장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Misskey Elementary Cursus Diploma" + "value" : "Misskey Elementary Course Diploma" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Misskey Elementary Course Diploma" + "value" : "Misskey Elementary Cursus Diploma" } }, "pl" : { @@ -70229,16 +70273,16 @@ "value" : "업적 목록을 최소 3분 동안 봅니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bekijk je lijst met prestaties voor ten minste 3 minuten" + "value" : "Se listen over prestasjoner i minst 3 minutter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Se listen over prestasjoner i minst 3 minutter" + "value" : "Bekijk je lijst met prestaties voor ten minste 3 minuten" } }, "pl" : { @@ -70414,16 +70458,16 @@ "value" : "좋아요 업적" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Likes Prestaties" + "value" : "Liker Prestasjoner" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liker Prestasjoner" + "value" : "Likes Prestaties" } }, "pl" : { @@ -70599,16 +70643,16 @@ "value" : "귀하의 인스턴스 차트를 봅니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bekijk de grafieken van je instantie" + "value" : "Vis din instans sine karter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis din instans sine karter" + "value" : "Bekijk de grafieken van je instantie" } }, "pl" : { @@ -70784,16 +70828,16 @@ "value" : "분석가" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Analist" + "value" : "Analytiker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Analytiker" + "value" : "Analist" } }, "pl" : { @@ -70938,16 +70982,16 @@ "value" : "お気に入り" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Favoriete" + "value" : "Favoritt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Favoritt" + "value" : "Favoriete" } }, "pl" : { @@ -71110,16 +71154,16 @@ "value" : "팔로우" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgen" + "value" : "Følg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg" + "value" : "Volgen" } }, "pl" : { @@ -71264,16 +71308,16 @@ "value" : "お気に入り" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Favorieten" + "value" : "Favoritter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Favoritter" + "value" : "Favorieten" } }, "pl" : { @@ -71436,16 +71480,16 @@ "value" : "추천" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aanbevolen" + "value" : "Anbefalt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Anbefalt" + "value" : "Aanbevolen" } }, "pl" : { @@ -71626,16 +71670,16 @@ "value" : "팔로잉" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgt" + "value" : "Følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger" + "value" : "Volgt" } }, "pl" : { @@ -71780,16 +71824,16 @@ "value" : "所有しています" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Eigendom" + "value" : "Eid" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Eid" + "value" : "Eigendom" } }, "pl" : { @@ -71916,13 +71960,13 @@ "value" : "Unfavourite" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Unfavourite" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Unfavourite" @@ -72052,16 +72096,16 @@ "value" : "フォローを解除" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontvolgen" + "value" : "Ikke følg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ikke følg" + "value" : "Ontvolgen" } }, "pl" : { @@ -72472,13 +72516,13 @@ "value" : "앱" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "App" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "App" @@ -72663,16 +72707,16 @@ "value" : "당신을 팔로우했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "volgt jou nu" + "value" : "fulgte deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "fulgte deg" + "value" : "volgt jou nu" } }, "pl" : { @@ -72847,16 +72891,16 @@ "value" : "당신의 팔로우 요청을 수락했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "heeft je volgverzoek geaccepteerd" + "value" : "aksepterte din forespørsel" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "aksepterte din forespørsel" + "value" : "heeft je volgverzoek geaccepteerd" } }, "pl" : { @@ -73038,16 +73082,16 @@ "value" : "당신을 언급했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "noemde je" + "value" : "nevnte deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "nevnte deg" + "value" : "noemde je" } }, "pl" : { @@ -73223,16 +73267,16 @@ "value" : "참여했던 설문조사가 종료되었습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Een poll waaraan u deelnam, is beëindigd" + "value" : "En avstemming du deltok i er avsluttet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "En avstemming du deltok i er avsluttet" + "value" : "Een poll waaraan u deelnam, is beëindigd" } }, "pl" : { @@ -73413,16 +73457,16 @@ "value" : "인용했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "geciteerd" + "value" : "sitert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "sitert" + "value" : "geciteerd" } }, "pl" : { @@ -73603,16 +73647,16 @@ "value" : "다시 게시했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "gepost" + "value" : "repostet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "repostet" + "value" : "gepost" } }, "pl" : { @@ -73788,16 +73832,16 @@ "value" : "당신을 팔로우 요청했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "aangevraagd om je te volgen" + "value" : "bedt om å følge deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "bedt om å følge deg" + "value" : "aangevraagd om je te volgen" } }, "pl" : { @@ -73943,13 +73987,13 @@ "value" : "renote" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "renote" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "renote" @@ -74103,16 +74147,16 @@ "value" : "답장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beantwoorden" + "value" : "svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "svar" + "value" : "Beantwoorden" } }, "pl" : { @@ -74258,16 +74302,16 @@ "value" : "Unknwn" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontbrekend" + "value" : "Uknuste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Uknuste" + "value" : "Ontbrekend" } }, "pl" : { @@ -74418,16 +74462,16 @@ "value" : "이 게시물의 문제는 무엇인가요?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Wat is het probleem met dit bericht?" + "value" : "Hva er problemet med dette innlegget?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hva er problemet med dette innlegget?" + "value" : "Wat is het probleem met dit bericht?" } }, "pl" : { @@ -74572,16 +74616,16 @@ "value" : "ここに問題を入力してください" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voer het probleem hier in" + "value" : "Skriv inn problemet her" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv inn problemet her" + "value" : "Voer het probleem hier in" } }, "pl" : { @@ -74738,16 +74782,16 @@ "value" : "신고하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -74928,16 +74972,16 @@ "value" : "혼합" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gemengd" + "value" : "Blandet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blandet" + "value" : "Gemengd" } }, "pl" : { @@ -75082,16 +75126,16 @@ "value" : "モデル" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Model" + "value" : "Modell" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Modell" + "value" : "Model" } }, "pl" : { @@ -75248,16 +75292,16 @@ "value" : "더보기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meer" + "value" : "Mer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mer" + "value" : "Meer" } }, "pl" : { @@ -75439,16 +75483,16 @@ "value" : "더보기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meer" + "value" : "Mer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mer" + "value" : "Meer" } }, "pl" : { @@ -75629,16 +75673,16 @@ "value" : "음소거" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Dempen" + "value" : "Demp" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Demp" + "value" : "Dempen" } }, "pl" : { @@ -75783,16 +75827,16 @@ "value" : "このユーザーをミュートしてもよろしいですか?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Weet je zeker dat je deze gebruiker wilt muten?" + "value" : "Er du sikker på at du vil dempe denne brukeren?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Er du sikker på at du vil dempe denne brukeren?" + "value" : "Weet je zeker dat je deze gebruiker wilt muten?" } }, "pl" : { @@ -75913,16 +75957,16 @@ "value" : "ユーザーをミュート" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruiker dempen" + "value" : "Demp bruker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Demp bruker" + "value" : "Gebruiker dempen" } }, "pl" : { @@ -76161,16 +76205,16 @@ "value" : "%@ から新規作成" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Nieuw van %@" + "value" : "Ny fra %@" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ny fra %@" + "value" : "Nieuw van %@" } }, "pl" : { @@ -76279,16 +76323,16 @@ "value" : "下書きはありません" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geen Concepten" + "value" : "Ingen utkast" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ingen utkast" + "value" : "Geen Concepten" } }, "pl" : { @@ -76409,16 +76453,16 @@ "value" : "利用可能なモデルがありません" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geen modellen beschikbaar" + "value" : "Ingen modeller tilgjengelig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ingen modeller tilgjengelig" + "value" : "Geen modellen beschikbaar" } }, "pl" : { @@ -76533,16 +76577,16 @@ "value" : "リレーが設定されていません" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geen relais geconfigureerd" + "value" : "Ingen releer konfigurert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ingen releer konfigurert" + "value" : "Geen relais geconfigureerd" } }, "pl" : { @@ -76657,16 +76701,16 @@ "value" : "%@ はまだ完了していません。" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Nog niet klaar voor %@" + "value" : "Ikke ferdig enda for %@" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ikke ferdig enda for %@" + "value" : "Nog niet klaar voor %@" } }, "pl" : { @@ -76775,16 +76819,16 @@ "value" : "未設定" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Niet ingesteld" + "value" : "Ikke angitt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ikke angitt" + "value" : "Niet ingesteld" } }, "pl" : { @@ -76917,16 +76961,16 @@ "value" : "投稿を送信できませんでした" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bericht versturen mislukt" + "value" : "Kunne ikke sende innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kunne ikke sende innlegg" + "value" : "Bericht versturen mislukt" } }, "pl" : { @@ -77053,16 +77097,16 @@ "value" : "投稿を送信しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bericht verzonden" + "value" : "Post sendt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post sendt" + "value" : "Bericht verzonden" } }, "pl" : { @@ -77219,16 +77263,16 @@ "value" : "로그인 만료, 다시 로그인 해주세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Inloggen verlopen, gelieve opnieuw in te loggen" + "value" : "Innlogging utløpt, vennligst logg inn igjen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlogging utløpt, vennligst logg inn igjen" + "value" : "Inloggen verlopen, gelieve opnieuw in te loggen" } }, "pl" : { @@ -77379,16 +77423,16 @@ "value" : "画像の保存に失敗しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opslaan van afbeelding mislukt" + "value" : "Kunne ikke lagre bilde" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kunne ikke lagre bilde" + "value" : "Opslaan van afbeelding mislukt" } }, "pl" : { @@ -77515,16 +77559,16 @@ "value" : "画像をライブラリに保存しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afbeelding opgeslagen in bibliotheek" + "value" : "Bilde lagret i biblioteket" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bilde lagret i biblioteket" + "value" : "Afbeelding opgeslagen in bibliotheek" } }, "pl" : { @@ -77681,16 +77725,16 @@ "value" : "모두" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Allemaal" + "value" : "Alle" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alle" + "value" : "Allemaal" } }, "pl" : { @@ -77871,16 +77915,16 @@ "value" : "댓글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opmerking" + "value" : "Kommentar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentar" + "value" : "Opmerking" } }, "pl" : { @@ -78061,16 +78105,16 @@ "value" : "좋아요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "vind-ik-leuk" + "value" : "Lik" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lik" + "value" : "vind-ik-leuk" } }, "pl" : { @@ -78215,16 +78259,16 @@ "value" : "メンション" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vermelding" + "value" : "Nevn" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nevn" + "value" : "Vermelding" } }, "pl" : { @@ -78345,16 +78389,16 @@ "value" : "Notification" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notificatie" + "value" : "Varsling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Varsling" + "value" : "Notificatie" } }, "pl" : { @@ -78517,13 +78561,13 @@ "value" : "확인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Ok" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Ok" @@ -78701,13 +78745,13 @@ "value" : "확인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Ok" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Ok" @@ -78849,16 +78893,16 @@ "value" : "デバイス上" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Op apparaat" + "value" : "På enhet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "På enhet" + "value" : "Op apparaat" } }, "pl" : { @@ -78979,16 +79023,16 @@ "value" : "ブラウダーで開く" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Openen in blader" + "value" : "Åpne i nettleser" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Åpne i nettleser" + "value" : "Openen in blader" } }, "pl" : { @@ -79103,16 +79147,16 @@ "value" : "OpenAI 対応" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "OpenAI Compatibel" + "value" : "OpenAI kompatibelt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "OpenAI kompatibelt" + "value" : "OpenAI Compatibel" } }, "pl" : { @@ -79233,16 +79277,16 @@ "value" : "翻訳と概要に使用されるOpenAIモデル" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "OpenAI-model gebruikt voor vertaling en samenvatting" + "value" : "OpenAI-modell brukt for oversettelse og sammendrag" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "OpenAI-modell brukt for oversettelse og sammendrag" + "value" : "OpenAI-model gebruikt voor vertaling en samenvatting" } }, "pl" : { @@ -79363,16 +79407,16 @@ "value" : "OPMLからインポート" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Importeren uit OPML" + "value" : "Importer fra OPML" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Importer fra OPML" + "value" : "Importeren uit OPML" } }, "pl" : { @@ -79499,16 +79543,16 @@ "value" : "OPMLからインポート" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Importeren uit OPML" + "value" : "Importer fra OPML" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Importer fra OPML" + "value" : "Importeren uit OPML" } }, "pl" : { @@ -79635,16 +79679,16 @@ "value" : "このページにアクセスするには再ログインが必要です" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "U moet opnieuw inloggen om toegang te krijgen tot deze pagina" + "value" : "Du må logge inn på nytt for å få tilgang til denne siden" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Du må logge inn på nytt for å få tilgang til denne siden" + "value" : "U moet opnieuw inloggen om toegang te krijgen tot deze pagina" } }, "pl" : { @@ -79765,16 +79809,16 @@ "value" : "アクセスが拒否されました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toestemming geweigerd" + "value" : "Tilgang nektet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tilgang nektet" + "value" : "Toestemming geweigerd" } }, "pl" : { @@ -79925,16 +79969,16 @@ "value" : "투표가 만료됨" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Poll verlopen" + "value" : "Avstemningen utløpt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avstemningen utløpt" + "value" : "Poll verlopen" } }, "pl" : { @@ -80079,16 +80123,16 @@ "value" : "有効期限:" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verlopen op" + "value" : "Utløpt på" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utløpt på" + "value" : "Verlopen op" } }, "pl" : { @@ -80245,16 +80289,16 @@ "value" : "투표" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Stemming" + "value" : "Stem" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Stem" + "value" : "Stemming" } }, "pl" : { @@ -80435,16 +80479,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Berichten" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Berichten" } }, "pl" : { @@ -80625,16 +80669,16 @@ "value" : "좋아요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vind-ik-leuk" + "value" : "Liker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liker" + "value" : "Vind-ik-leuk" } }, "pl" : { @@ -80815,16 +80859,16 @@ "value" : "미디어" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Medium" + "value" : "Medier" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Medier" + "value" : "Medium" } }, "pl" : { @@ -81005,16 +81049,16 @@ "value" : "게시물 및 회신" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Berichten en antwoorden" + "value" : "Innlegg og svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg og svar" + "value" : "Berichten en antwoorden" } }, "pl" : { @@ -81195,16 +81239,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Berichten" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Berichten" } }, "pl" : { @@ -81385,16 +81429,16 @@ "value" : "인용하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Offerte" + "value" : "Sitat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sitat" + "value" : "Offerte" } }, "pl" : { @@ -81575,16 +81619,16 @@ "value" : "반응 추가" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Reactie toevoegen" + "value" : "Legg til reaksjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til reaksjon" + "value" : "Reactie toevoegen" } }, "pl" : { @@ -81765,16 +81809,16 @@ "value" : "반응 제거" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Reactie verwijderen" + "value" : "Fjern reaksjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fjern reaksjon" + "value" : "Reactie verwijderen" } }, "pl" : { @@ -81949,16 +81993,16 @@ "value" : "거부" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afwijzen" + "value" : "Avvis" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avvis" + "value" : "Afwijzen" } }, "pl" : { @@ -82139,16 +82183,16 @@ "value" : "차단됨" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geblokkeerd" + "value" : "Blokkert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blokkert" + "value" : "Geblokkeerd" } }, "pl" : { @@ -82329,16 +82373,16 @@ "value" : "팔로우" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgen" + "value" : "Følg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg" + "value" : "Volgen" } }, "pl" : { @@ -82519,16 +82563,16 @@ "value" : "팔로잉" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgt" + "value" : "Følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger" + "value" : "Volgt" } }, "pl" : { @@ -82709,16 +82753,16 @@ "value" : "당신을 팔로우합니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgt jou" + "value" : "Følger deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger deg" + "value" : "Volgt jou" } }, "pl" : { @@ -82899,16 +82943,16 @@ "value" : "요청됨" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aangevraagd" + "value" : "Forespurt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Forespurt" + "value" : "Aangevraagd" } }, "pl" : { @@ -83089,16 +83133,16 @@ "value" : "답장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beantwoorden" + "value" : "Svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Svar" + "value" : "Beantwoorden" } }, "pl" : { @@ -83273,16 +83317,16 @@ "value" : "%@에게 답장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Antwoord op %@" + "value" : "Svar til %@" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Svar til %@" + "value" : "Antwoord op %@" } }, "pl" : { @@ -83463,16 +83507,16 @@ "value" : "보고서" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -83647,16 +83691,16 @@ "value" : "재시도" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opnieuw" + "value" : "Prøv igjen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Prøv igjen" + "value" : "Opnieuw" } }, "pl" : { @@ -83732,6 +83776,9 @@ } } } + }, + "Retry translation" : { + }, "retweet" : { "localizations" : { @@ -83837,16 +83884,16 @@ "value" : "리트윗" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Retweet" + "value" : "Gjenkjenn" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gjenkjenn" + "value" : "Retweet" } }, "pl" : { @@ -84021,16 +84068,16 @@ "value" : "리트윗 제거" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijder retweet" + "value" : "Fjern innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fjern innlegg" + "value" : "Verwijder retweet" } }, "pl" : { @@ -84211,16 +84258,16 @@ "value" : "추가" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toevoegen" + "value" : "Legg til" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til" + "value" : "Toevoegen" } }, "pl" : { @@ -84365,16 +84412,16 @@ "value" : "RSS ソースを検出しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gedetecteerde RSS-bron" + "value" : "Oppdaget RSS kilde" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdaget RSS kilde" + "value" : "Gedetecteerde RSS-bron" } }, "pl" : { @@ -84507,16 +84554,16 @@ "value" : "RssHub를 사용하려면 RssHub 호스트를 설정하거나 공개 RssHub 서버를 선택해야 합니다." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "U moet de RssHub host instellen als u RssHub wilt gebruiken, of selecteer de publieke RssHub server" + "value" : "Du må angi RssHUB-vert hvis du vil bruke RsssHub, eller velge den offentlige RssHUB-serveren" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Du må angi RssHUB-vert hvis du vil bruke RsssHub, eller velge den offentlige RssHUB-serveren" + "value" : "U moet de RssHub host instellen als u RssHub wilt gebruiken, of selecteer de publieke RssHub server" } }, "pl" : { @@ -84649,16 +84696,16 @@ "value" : "ここにRssHubホストを入力してください" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voer hier de host van RssHub in" + "value" : "Vennligst skriv Rsshule-vert her" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vennligst skriv Rsshule-vert her" + "value" : "Voer hier de host van RssHub in" } }, "pl" : { @@ -84791,16 +84838,16 @@ "value" : "RssHub 호스트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "RssHub host" + "value" : "RssHUB-vert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "RssHUB-vert" + "value" : "RssHub host" } }, "pl" : { @@ -84933,16 +84980,16 @@ "value" : "RSS ソース名" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "RSS bronnaam" + "value" : "RSS kilde navn" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "RSS kilde navn" + "value" : "RSS bronnaam" } }, "pl" : { @@ -85063,16 +85110,16 @@ "value" : "開く" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Openen in" + "value" : "Åpne i" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Åpne i" + "value" : "Openen in" } }, "pl" : { @@ -85229,13 +85276,13 @@ "value" : "앱" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "App" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "App" @@ -85383,16 +85430,16 @@ "value" : "ブラウザー" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "browser" + "value" : "Nettleser" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nettleser" + "value" : "browser" } }, "pl" : { @@ -85531,16 +85578,16 @@ "value" : "검색된 RSS 소스" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontdekte Rss Bronnen" + "value" : "Oppdaget Rss kilder" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdaget Rss kilder" + "value" : "Ontdekte Rss Bronnen" } }, "pl" : { @@ -85673,13 +85720,13 @@ "value" : "RSS" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "RSS" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "RSS" @@ -85803,13 +85850,13 @@ "value" : "RSS URL" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "RSS URL" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "RSS URL" @@ -85939,16 +85986,16 @@ "value" : "ここにURLを入力してください" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voer de URL hier in" + "value" : "Skriv inn URL'en her" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv inn URL'en her" + "value" : "Voer de URL hier in" } }, "pl" : { @@ -86063,16 +86110,16 @@ "value" : "ドラフトを保存" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Concept opslaan" + "value" : "Lagre utkast" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lagre utkast" + "value" : "Concept opslaan" } }, "pl" : { @@ -86187,16 +86234,16 @@ "value" : "出発前に現在のコンテンツを下書きとして保存しますか?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Sla je huidige inhoud op als een concept voordat je vertrekt?" + "value" : "Lagre gjeldende innhold som utkast før du drar?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lagre gjeldende innhold som utkast før du drar?" + "value" : "Sla je huidige inhoud op als een concept voordat je vertrekt?" } }, "pl" : { @@ -86317,16 +86364,16 @@ "value" : "保存が完了しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opslaan voltooid" + "value" : "Lagring fullført" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lagring fullført" + "value" : "Opslaan voltooid" } }, "pl" : { @@ -86447,16 +86494,16 @@ "value" : "データの保存に失敗しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gegevens opslaan mislukt" + "value" : "Klarte ikke å lagre data" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Klarte ikke å lagre data" + "value" : "Gegevens opslaan mislukt" } }, "pl" : { @@ -86577,16 +86624,16 @@ "value" : "スクリーンショットを保存" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Schermafdruk opslaan" + "value" : "Lagre skjermbilde" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lagre skjermbilde" + "value" : "Schermafdruk opslaan" } }, "pl" : { @@ -86749,16 +86796,16 @@ "value" : "검색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zoeken" + "value" : "Søk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Søk" + "value" : "Zoeken" } }, "pl" : { @@ -86939,16 +86986,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Statuses" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Statuses" } }, "pl" : { @@ -87129,16 +87176,16 @@ "value" : "검색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zoeken" + "value" : "Søk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Søk" + "value" : "Zoeken" } }, "pl" : { @@ -87319,16 +87366,16 @@ "value" : "사용자" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruikers" + "value" : "Brukere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Brukere" + "value" : "Gebruikers" } }, "pl" : { @@ -87473,16 +87520,16 @@ "value" : "AIプロバイダを選択" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Selecteer AI provider" + "value" : "Velg AI-leverandør" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Velg AI-leverandør" + "value" : "Selecteer AI provider" } }, "pl" : { @@ -87603,16 +87650,16 @@ "value" : "アイコンを選択" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Selecteer pictogram" + "value" : "Velg ikon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Velg ikon" + "value" : "Selecteer pictogram" } }, "pl" : { @@ -87727,16 +87774,16 @@ "value" : "モデルを選択" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Selecteer model" + "value" : "Velg modell" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Velg modell" + "value" : "Selecteer model" } }, "pl" : { @@ -87893,16 +87940,16 @@ "value" : "보내기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verzenden" + "value" : "Sende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sende" + "value" : "Verzenden" } }, "pl" : { @@ -88083,16 +88130,16 @@ "value" : "메시지 보내기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verstuur bericht" + "value" : "Send melding" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Send melding" + "value" : "Verstuur bericht" } }, "pl" : { @@ -88244,16 +88291,16 @@ "value" : "表示" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Weergeven" + "value" : "Vis" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis" + "value" : "Weergeven" } }, "pl" : { @@ -88410,16 +88457,16 @@ "value" : "서버 URL" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Server URL" + "value" : "URL til server" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "URL til server" + "value" : "Server URL" } }, "pl" : { @@ -88558,16 +88605,16 @@ "value" : "サーバー URL は '/' で終わり、OpenAI 互換の v1/chat/completions API をサポートする必要があります。" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Server-URL moet eindigen met '/' en de OpenAI-compatibele v1/chat/aanvullingen API ondersteunen." + "value" : "Server-URL må avsluttes med '/' og støtte OpenAI-kompatibelt v1/chat/completions API." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Server-URL må avsluttes med '/' og støtte OpenAI-kompatibelt v1/chat/completions API." + "value" : "Server-URL moet eindigen met '/' en de OpenAI-compatibele v1/chat/aanvullingen API ondersteunen." } }, "pl" : { @@ -88689,16 +88736,16 @@ "value" : "RSS ソースを管理" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Uw RSS-bronnen beheren" + "value" : "Rediger RSS-kildene dine" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger RSS-kildene dine" + "value" : "Uw RSS-bronnen beheren" } }, "pl" : { @@ -88819,16 +88866,16 @@ "value" : "RSS管理" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "RSS Management" + "value" : "RSS administrasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "RSS administrasjon" + "value" : "RSS Management" } }, "pl" : { @@ -88955,16 +89002,16 @@ "value" : "データをエクスポート" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gegevens exporteren" + "value" : "Eksporter data" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Eksporter data" + "value" : "Gegevens exporteren" } }, "pl" : { @@ -89085,16 +89132,16 @@ "value" : "データベースと設定を含むすべてのデータをエクスポート" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Exporteer alle gegevens inclusief database en instellingen" + "value" : "Eksporter alle data inkludert database og innstillinger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Eksporter alle data inkludert database og innstillinger" + "value" : "Exporteer alle gegevens inclusief database en instellingen" } }, "pl" : { @@ -89215,16 +89262,16 @@ "value" : "データのインポート" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gegevens importeren" + "value" : "Importer data" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Importer data" + "value" : "Gegevens importeren" } }, "pl" : { @@ -89345,16 +89392,16 @@ "value" : "ファイルからデータをインポート (既存のデータとマージ、重複を置き換えます)" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gegevens importeren uit bestand (samengevoegd met bestaande gegevens, vervangt duplicaten)" + "value" : "Importer data fra fil (sammenslåing med eksisterende data, erstatter duplikater)" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Importer data fra fil (sammenslåing med eksisterende data, erstatter duplikater)" + "value" : "Gegevens importeren uit bestand (samengevoegd met bestaande gegevens, vervangt duplicaten)" } }, "pl" : { @@ -89511,16 +89558,16 @@ "value" : "설정" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Instellingen" + "value" : "Innstillinger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innstillinger" + "value" : "Instellingen" } }, "pl" : { @@ -89701,16 +89748,16 @@ "value" : "공유" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Delen" + "value" : "Del" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del" + "value" : "Delen" } }, "pl" : { @@ -89855,16 +89902,16 @@ "value" : "リンクを共有" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Link delen" + "value" : "Del kobling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del kobling" + "value" : "Link delen" } }, "pl" : { @@ -89991,16 +90038,16 @@ "value" : "スクリーンショットを共有" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Schermafdruk delen" + "value" : "Del skjermbilde" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del skjermbilde" + "value" : "Schermafdruk delen" } }, "pl" : { @@ -90127,16 +90174,16 @@ "value" : "Fixvxでシェア" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Delen via Fixvx" + "value" : "Del via Fixvx" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del via Fixvx" + "value" : "Delen via Fixvx" } }, "pl" : { @@ -90263,16 +90310,16 @@ "value" : "FxEmbedで共有" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Delen via FxEmbed" + "value" : "Del via FxEmbed" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del via FxEmbed" + "value" : "Delen via FxEmbed" } }, "pl" : { @@ -90324,6 +90371,9 @@ } } } + }, + "Show original" : { + }, "show_media_button" : { "comment" : "Button to show media attachments", @@ -90430,16 +90480,16 @@ "value" : "미디어 표시" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Media tonen" + "value" : "Vis media" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis media" + "value" : "Media tonen" } }, "pl" : { @@ -90602,16 +90652,16 @@ "value" : "소셜" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Sociaal" + "value" : "Sosial" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sosial" + "value" : "Sociaal" } }, "pl" : { @@ -90744,16 +90794,16 @@ "value" : "ステータス詳細" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Status detail" + "value" : "Post detaljer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post detaljer" + "value" : "Status detail" } }, "pl" : { @@ -90910,16 +90960,16 @@ "value" : "공유" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Delen" + "value" : "Del" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del" + "value" : "Delen" } }, "pl" : { @@ -91070,16 +91120,16 @@ "value" : "サマリー投稿" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Samenvatting bericht" + "value" : "Sammendrag post" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sammendrag post" + "value" : "Samenvatting bericht" } }, "pl" : { @@ -91206,16 +91256,16 @@ "value" : "投稿を翻訳" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bericht vertalen" + "value" : "Oversett innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oversett innlegg" + "value" : "Bericht vertalen" } }, "pl" : { @@ -91372,16 +91422,16 @@ "value" : "팔로워" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgers" + "value" : "Følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følgere" + "value" : "Volgers" } }, "pl" : { @@ -91526,16 +91576,16 @@ "value" : "フォロワーだけがこの投稿を見ることができます" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Alleen volgers kunnen dit bericht zien" + "value" : "Bare tilhengere kan se denne posten" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bare tilhengere kan se denne posten" + "value" : "Alleen volgers kunnen dit bericht zien" } }, "pl" : { @@ -91692,16 +91742,16 @@ "value" : "홈" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Startpagina" + "value" : "Hjem" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hjem" + "value" : "Startpagina" } }, "pl" : { @@ -91846,16 +91896,16 @@ "value" : "このインスタンス上のユーザーのみがこの投稿を見ることができます" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Alleen gebruikers op dit exemplaar kunnen dit bericht zien" + "value" : "Bare brukere i denne forekomsten kan se dette innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bare brukere i denne forekomsten kan se dette innlegget" + "value" : "Alleen gebruikers op dit exemplaar kunnen dit bericht zien" } }, "pl" : { @@ -92012,16 +92062,16 @@ "value" : "공개" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Openbaar" + "value" : "Offentlig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Offentlig" + "value" : "Openbaar" } }, "pl" : { @@ -92166,16 +92216,16 @@ "value" : "誰でもこの投稿を見ることができます。" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Iedereen kan dit bericht zien en opnieuw plaatsen" + "value" : "Alle kan se og poste dette innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alle kan se og poste dette innlegget" + "value" : "Iedereen kan dit bericht zien en opnieuw plaatsen" } }, "pl" : { @@ -92332,16 +92382,16 @@ "value" : "명시된 사람만" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opgegeven" + "value" : "Spesifisert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Spesifisert" + "value" : "Opgegeven" } }, "pl" : { @@ -92486,16 +92536,16 @@ "value" : "メンションされたユーザーのみがこの投稿を見ることができます" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Alleen vermelde gebruikers kunnen dit bericht zien" + "value" : "Kun nevnte brukere kan se dette innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kun nevnte brukere kan se dette innlegget" + "value" : "Alleen vermelde gebruikers kunnen dit bericht zien" } }, "pl" : { @@ -92616,16 +92666,16 @@ "value" : "確認する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bevestigen" + "value" : "Slett databasemellomlager?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett databasemellomlager?" + "value" : "Bevestigen" } }, "pl" : { @@ -92746,16 +92796,16 @@ "value" : "%1$lld ユーザーと %2$lld ステータスが削除されます" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "%1$lld gebruikers en %2$lld statussen worden verwijderd" + "value" : "Slett database cache, %1$lld brukere og %2$lld innlegg vil bli slettet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett database cache, %1$lld brukere og %2$lld innlegg vil bli slettet" + "value" : "%1$lld gebruikers en %2$lld statussen worden verwijderd" } }, "pl" : { @@ -92912,16 +92962,16 @@ "value" : "이미지 캐시 지우기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afbeeldingencache legen" + "value" : "Tøm bildebuffer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tøm bildebuffer" + "value" : "Afbeeldingencache legen" } }, "pl" : { @@ -93066,16 +93116,16 @@ "value" : "確認する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bevestigen" + "value" : "Slett bilde-hurtiglager?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett bilde-hurtiglager?" + "value" : "Bevestigen" } }, "pl" : { @@ -93232,16 +93282,16 @@ "value" : "Flare의 저장소 관리" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beheer Flare's opslag" + "value" : "Behandle flammetall lagring" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Behandle flammetall lagring" + "value" : "Beheer Flare's opslag" } }, "pl" : { @@ -93422,16 +93472,16 @@ "value" : "저장소" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opslagruimte" + "value" : "Lagring" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lagring" + "value" : "Opslagruimte" } }, "pl" : { @@ -93606,16 +93656,16 @@ "value" : "앱 로깅" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "App logboek" + "value" : "Logg app" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg app" + "value" : "App logboek" } }, "pl" : { @@ -93754,16 +93804,16 @@ "value" : "候補" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Suggesties" + "value" : "Forslag" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Forslag" + "value" : "Suggesties" } }, "pl" : { @@ -93884,16 +93934,16 @@ "value" : "AIで長いテキストをまとめます" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vat lange tekst samen met AI" + "value" : "oppsummerer lang tekst med AI" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "oppsummerer lang tekst med AI" + "value" : "Vat lange tekst samen met AI" } }, "pl" : { @@ -94014,16 +94064,16 @@ "value" : "この記事を要約" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Dit artikel samenvatten" + "value" : "oppsummerer denne artikkelen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "oppsummerer denne artikkelen" + "value" : "Dit artikel samenvatten" } }, "pl" : { @@ -94144,16 +94194,16 @@ "value" : "概要のプロンプト表示" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Samenvatting Prompt" + "value" : "Ledetekst i sammendrag" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ledetekst i sammendrag" + "value" : "Samenvatting Prompt" } }, "pl" : { @@ -94304,16 +94354,16 @@ "value" : "시스템" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Systeem" + "value" : "Systemadministrasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Systemadministrasjon" + "value" : "Systeem" } }, "pl" : { @@ -94458,16 +94508,16 @@ "value" : "フレアの許可または言語を更新" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toestemming of taal van Vlam bijwerken" + "value" : "Oppdatere flammets tillatelse eller språk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdatere flammets tillatelse eller språk" + "value" : "Toestemming of taal van Vlam bijwerken" } }, "pl" : { @@ -94588,16 +94638,16 @@ "value" : "システム設定" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Systeem instellingen" + "value" : "System innstillinger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "System innstillinger" + "value" : "Systeem instellingen" } }, "pl" : { @@ -94718,16 +94768,16 @@ "value" : "グループを追加" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Groep toevoegen" + "value" : "Legg til gruppe" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til gruppe" + "value" : "Groep toevoegen" } }, "pl" : { @@ -94848,16 +94898,16 @@ "value" : "タブを追加" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Tabblad toevoegen" + "value" : "Legg til fane" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til fane" + "value" : "Tabblad toevoegen" } }, "pl" : { @@ -95014,16 +95064,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -95204,16 +95254,16 @@ "value" : "편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bewerken" + "value" : "Rediger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger" + "value" : "Bewerken" } }, "pl" : { @@ -95358,16 +95408,16 @@ "value" : "グループを編集" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Groep bewerken" + "value" : "Rediger gruppe" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger gruppe" + "value" : "Groep bewerken" } }, "pl" : { @@ -95488,13 +95538,13 @@ "value" : "Tab icon" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Tab icon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Tab icon" @@ -95624,16 +95674,16 @@ "value" : "タブのタイトル" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Tabblad titel" + "value" : "Tittel på fane" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tittel på fane" + "value" : "Tabblad titel" } }, "pl" : { @@ -95760,16 +95810,16 @@ "value" : "ここにタブのタイトルを入力してください" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voer de tab titel hier in" + "value" : "Skriv inn fanetittel her" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv inn fanetittel her" + "value" : "Voer de tab titel hier in" } }, "pl" : { @@ -95890,16 +95940,16 @@ "value" : "ユーザーのアバターを表示" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon gebruikersafbeelding" + "value" : "Vis brukerens profilbilde" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis brukerens profilbilde" + "value" : "Toon gebruikersafbeelding" } }, "pl" : { @@ -96056,16 +96106,16 @@ "value" : "혼합된 타임라인은 모든 탭의 타임라인 결과를 하나의 탭으로 혼합합니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gemengde tijdlijn mengt alle resultaten van de tabbladen in één tabblad" + "value" : "Blandet tidslinje vil blande alle faners tidslinjen resulterer i en fane" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blandet tidslinje vil blande alle faners tidslinjen resulterer i en fane" + "value" : "Gemengde tijdlijn mengt alle resultaten van de tabbladen in één tabblad" } }, "pl" : { @@ -96210,16 +96260,16 @@ "value" : "混合タイムラインタブを有効にする" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Inschakelen gemengde tijdlijn tabblad" + "value" : "Aktiver blandet tidslinjefanel" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver blandet tidslinjefanel" + "value" : "Inschakelen gemengde tijdlijn tabblad" } }, "pl" : { @@ -96340,16 +96390,16 @@ "value" : "グループ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Groeperen" + "value" : "Gruppe" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gruppe" + "value" : "Groeperen" } }, "pl" : { @@ -96470,16 +96520,16 @@ "value" : "このグループにはタブがありません" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geen tabbladen in deze groep" + "value" : "Ingen faner i denne gruppen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ingen faner i denne gruppen" + "value" : "Geen tabbladen in deze groep" } }, "pl" : { @@ -96600,16 +96650,16 @@ "value" : "グループ名" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Groep Naam" + "value" : "Gruppens navn" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gruppens navn" + "value" : "Groep Naam" } }, "pl" : { @@ -96737,16 +96787,16 @@ "value" : "メインタブ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Belangrijkste tabbladen" + "value" : "Hovedfaner" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hovedfaner" + "value" : "Belangrijkste tabbladen" } }, "pl" : { @@ -96903,16 +96953,16 @@ "value" : "탭 설정" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Tabblad instellingen" + "value" : "Fane innstillinger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fane innstillinger" + "value" : "Tabblad instellingen" } }, "pl" : { @@ -97051,13 +97101,13 @@ "value" : "Tabs" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Tabs" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Tabs" @@ -97217,16 +97267,16 @@ "value" : "테마" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Thema" + "value" : "Tema" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tema" + "value" : "Thema" } }, "pl" : { @@ -97302,6 +97352,9 @@ } } } + }, + "Translate" : { + }, "Translate Prompt" : { "localizations" : { @@ -97371,16 +97424,16 @@ "value" : "翻訳プロンプトの翻訳" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Prompt vertalen" + "value" : "Oversett prompt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oversett prompt" + "value" : "Prompt vertalen" } }, "pl" : { @@ -97501,16 +97554,16 @@ "value" : "テキストをAIで翻訳" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vertaal tekst met AI" + "value" : "Oversett tekst med AI" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oversett tekst med AI" + "value" : "Vertaal tekst met AI" } }, "pl" : { @@ -97667,16 +97720,16 @@ "value" : "차단 해제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Deblokkeer" + "value" : "Avblokker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avblokker" + "value" : "Deblokkeer" } }, "pl" : { @@ -97857,16 +97910,16 @@ "value" : "좋아요 취소" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Anders dan" + "value" : "Ulikt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ulikt" + "value" : "Anders dan" } }, "pl" : { @@ -98041,16 +98094,16 @@ "value" : "음소거 해제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Deblokkeer" + "value" : "Udemp" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Udemp" + "value" : "Deblokkeer" } }, "pl" : { @@ -98231,16 +98284,16 @@ "value" : "팔로워" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volger" + "value" : "Følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger" + "value" : "Volger" } }, "pl" : { @@ -98421,16 +98474,16 @@ "value" : "팔로잉" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgt" + "value" : "Følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger" + "value" : "Volgt" } }, "pl" : { @@ -98570,16 +98623,16 @@ "value" : "保存した下書きの表示と管理" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bekijk en beheer opgeslagen concepten" + "value" : "Vis og administrer lagrede utkast" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis og administrer lagrede utkast" + "value" : "Bekijk en beheer opgeslagen concepten" } }, "pl" : { @@ -98737,16 +98790,16 @@ "value" : "당신의 상태에 좋아요를 눌렀습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vind je status leuk" + "value" : "Likte ditt innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Likte ditt innlegg" + "value" : "Vind je status leuk" } }, "pl" : { @@ -98927,16 +98980,16 @@ "value" : "댓글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opmerkingen" + "value" : "Kommentarer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentarer" + "value" : "Opmerkingen" } }, "pl" : { @@ -99081,16 +99134,16 @@ "value" : "リポスト" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Herposten" + "value" : "Tilbakestilling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tilbakestilling" + "value" : "Herposten" } }, "pl" : { @@ -99247,16 +99300,16 @@ "value" : "상태" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "status" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "status" } }, "pl" : { @@ -99438,16 +99491,16 @@ "value" : "당신을 언급했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "vermeldt je" + "value" : "nevner deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "nevner deg" + "value" : "vermeldt je" } }, "pl" : { @@ -99623,16 +99676,16 @@ "value" : "리트윗했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "geretweet" + "value" : "retweeted" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "retweeted" + "value" : "geretweet" } }, "pl" : { diff --git a/iosApp/flare/UI/Component/CommonProfileHeader.swift b/iosApp/flare/UI/Component/CommonProfileHeader.swift index 2cae8d920..a3a1a9334 100644 --- a/iosApp/flare/UI/Component/CommonProfileHeader.swift +++ b/iosApp/flare/UI/Component/CommonProfileHeader.swift @@ -176,6 +176,11 @@ struct CommonProfileHeader: View { case .bot: Image("fa-robot") } } + if user.translationDisplayState != .hidden { + TranslateStatusComponent(data: user.translationDisplayState) + .font(.caption) + .foregroundStyle(.secondary) + } } if let desc = user.description_ { RichText(text: desc) diff --git a/iosApp/flare/UI/Component/Status/FeedView.swift b/iosApp/flare/UI/Component/Status/FeedView.swift index fcd72be98..89cef08db 100644 --- a/iosApp/flare/UI/Component/Status/FeedView.swift +++ b/iosApp/flare/UI/Component/Status/FeedView.swift @@ -22,6 +22,11 @@ struct FeedView: View { .font(.footnote) .fixedSize(horizontal: false, vertical: true) Spacer() + if data.translationDisplayState != .hidden { + TranslateStatusComponent(data: data.translationDisplayState) + .font(.caption) + .foregroundStyle(.secondary) + } if let date = data.actualCreatedAt { DateTimeText(data: date) .font(.footnote) diff --git a/iosApp/flare/UI/Component/Status/StatusActionView.swift b/iosApp/flare/UI/Component/Status/StatusActionView.swift index 0550d8d2c..fccc48382 100644 --- a/iosApp/flare/UI/Component/Status/StatusActionView.swift +++ b/iosApp/flare/UI/Component/Status/StatusActionView.swift @@ -222,6 +222,9 @@ extension ActionMenuItemText { case .muteWithHandleParameter: return "mute_user_with_handle \(localized.parameters.first ?? "")" case .acceptFollowRequest: return "accept_follow_request" case .rejectFollowRequest: return "reject_follow_request" + case .retryTranslation: return LocalizedStringResource(stringLiteral: "Retry translation") + case .translate: return LocalizedStringResource(stringLiteral: "Translate") + case .showOriginal: return LocalizedStringResource(stringLiteral: "Show original") } } } diff --git a/iosApp/flare/UI/Component/Status/StatusView.swift b/iosApp/flare/UI/Component/Status/StatusView.swift index 0b73e448f..072931523 100644 --- a/iosApp/flare/UI/Component/Status/StatusView.swift +++ b/iosApp/flare/UI/Component/Status/StatusView.swift @@ -250,6 +250,11 @@ struct StatusView: View { .font(.caption) .foregroundStyle(.secondary) } + if data.translationDisplayState != .hidden { + TranslateStatusComponent(data: data.translationDisplayState) + .font(.caption) + .foregroundStyle(.secondary) + } if showPlatformLogo { switch data.platformType { case .mastodon: diff --git a/iosApp/flare/UI/Component/TranslateStatusComponent.swift b/iosApp/flare/UI/Component/TranslateStatusComponent.swift new file mode 100644 index 000000000..b8896f9cd --- /dev/null +++ b/iosApp/flare/UI/Component/TranslateStatusComponent.swift @@ -0,0 +1,17 @@ +import KotlinSharedUI +import SwiftUI + +struct TranslateStatusComponent: View { + let data: TranslationDisplayState + + var body: some View { + HStack { + Image(.faLanguage) + switch data { + case .failed: Image(.faCircleExclamation) + case .translating: ProgressView().progressViewStyle(.circular).scaledToFit().frame(width: 12, height: 12) + default: EmptyView() + } + } + } +} diff --git a/iosApp/flare/UI/FlareTheme.swift b/iosApp/flare/UI/FlareTheme.swift index 4e53a3f08..004440fc0 100644 --- a/iosApp/flare/UI/FlareTheme.swift +++ b/iosApp/flare/UI/FlareTheme.swift @@ -28,7 +28,7 @@ private struct AppearanceSettingsKey: EnvironmentKey { static let defaultValue = AppearanceSettings.companion.Default } private struct AiConfigKey: EnvironmentKey { - static let defaultValue = AppSettings.AiConfig(translation: false, tldr: true) + static let defaultValue = AppSettings.AiConfig.companion.default } extension EnvironmentValues { var appearanceSettings: AppearanceSettings { diff --git a/iosApp/flare/UI/Screen/AiConfigScreen.swift b/iosApp/flare/UI/Screen/AiConfigScreen.swift index 37131ad11..f6d95c819 100644 --- a/iosApp/flare/UI/Screen/AiConfigScreen.swift +++ b/iosApp/flare/UI/Screen/AiConfigScreen.swift @@ -74,7 +74,7 @@ struct AiConfigScreen: View { let selectedModel = openAIValue(presenter.state.aiConfig.type).model Picker( selection: Binding( - get: { String(selectedModel) }, + get: { selectedModel }, set: { model in if model.hasPrefix("__meta__") { return @@ -93,7 +93,8 @@ struct AiConfigScreen: View { model: model ), translatePrompt: current.translatePrompt, - tldrPrompt: current.tldrPrompt + tldrPrompt: current.tldrPrompt, + preTranslation: current.preTranslation ) } } @@ -137,7 +138,8 @@ struct AiConfigScreen: View { tldr: current.tldr, type: current.type, translatePrompt: current.translatePrompt, - tldrPrompt: current.tldrPrompt + tldrPrompt: current.tldrPrompt, + preTranslation: current.preTranslation ) } } @@ -147,6 +149,30 @@ struct AiConfigScreen: View { Text("Translate text with AI") } + if presenter.state.aiConfig.translation { + Toggle( + isOn: Binding( + get: { presenter.state.aiConfig.preTranslation }, + set: { newValue in + presenter.state.update { current in + current.doCopy( + translation: current.translation, + tldr: current.tldr, + type: current.type, + translatePrompt: current.translatePrompt, + tldrPrompt: current.tldrPrompt, + preTranslation: newValue + ) + } + } + ) + ) { + Text("ai_config_pre_translate") + Text("ai_config_pre_translate_description") + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + if presenter.state.aiConfig.translation { Button { beginEditing( @@ -175,7 +201,8 @@ struct AiConfigScreen: View { tldr: newValue, type: current.type, translatePrompt: current.translatePrompt, - tldrPrompt: current.tldrPrompt + tldrPrompt: current.tldrPrompt, + preTranslation: current.preTranslation, ) } } @@ -206,6 +233,7 @@ struct AiConfigScreen: View { } .animation(.easeInOut(duration: 0.2), value: isOpenAIType(presenter.state.aiConfig.type)) .animation(.easeInOut(duration: 0.2), value: presenter.state.aiConfig.translation) + .animation(.easeInOut(duration: 0.2), value: presenter.state.aiConfig.preTranslation) .animation(.easeInOut(duration: 0.2), value: presenter.state.aiConfig.tldr) .sheet(item: $editingField) { field in NavigationStack { @@ -373,7 +401,8 @@ struct AiConfigScreen: View { tldr: current.tldr, type: openAI.doCopy(serverUrl: value, apiKey: openAI.apiKey, model: openAI.model), translatePrompt: current.translatePrompt, - tldrPrompt: current.tldrPrompt + tldrPrompt: current.tldrPrompt, + preTranslation: current.preTranslation ) } case .apiKey: @@ -386,7 +415,8 @@ struct AiConfigScreen: View { tldr: current.tldr, type: openAI.doCopy(serverUrl: openAI.serverUrl, apiKey: value, model: openAI.model), translatePrompt: current.translatePrompt, - tldrPrompt: current.tldrPrompt + tldrPrompt: current.tldrPrompt, + preTranslation: current.preTranslation ) } case .translatePrompt: @@ -395,7 +425,8 @@ struct AiConfigScreen: View { tldr: current.tldr, type: current.type, translatePrompt: value, - tldrPrompt: current.tldrPrompt + tldrPrompt: current.tldrPrompt, + preTranslation: current.preTranslation ) case .tldrPrompt: return current.doCopy( @@ -403,7 +434,8 @@ struct AiConfigScreen: View { tldr: current.tldr, type: current.type, translatePrompt: current.translatePrompt, - tldrPrompt: value + tldrPrompt: value, + preTranslation: current.preTranslation ) } } diff --git a/shared/src/androidMain/kotlin/dev/dimension/flare/common/Locale.android.kt b/shared/src/androidMain/kotlin/dev/dimension/flare/common/Locale.android.kt index 007ff7bca..1ea2cc8a7 100644 --- a/shared/src/androidMain/kotlin/dev/dimension/flare/common/Locale.android.kt +++ b/shared/src/androidMain/kotlin/dev/dimension/flare/common/Locale.android.kt @@ -3,5 +3,6 @@ package dev.dimension.flare.common import java.util.Locale internal actual object Locale { - actual val language: String = Locale.getDefault().language + actual val language: String + get() = Locale.getDefault().language } diff --git a/shared/src/appleMain/kotlin/dev/dimension/flare/common/Locale.apple.kt b/shared/src/appleMain/kotlin/dev/dimension/flare/common/Locale.apple.kt index ff47c3c2f..146583d73 100644 --- a/shared/src/appleMain/kotlin/dev/dimension/flare/common/Locale.apple.kt +++ b/shared/src/appleMain/kotlin/dev/dimension/flare/common/Locale.apple.kt @@ -5,5 +5,6 @@ import platform.Foundation.currentLocale import platform.Foundation.languageCode internal actual object Locale { - actual val language: String = NSLocale.currentLocale.languageCode + actual val language: String + get() = NSLocale.currentLocale.languageCode } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt index 78b9bb0f3..9bf461ad3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt @@ -8,7 +8,7 @@ import androidx.room.TypeConverters import androidx.room.immediateTransaction import androidx.room.useWriterConnection -internal const val CACHE_DATABASE_VERSION = 31 +internal const val CACHE_DATABASE_VERSION = 32 @Database( entities = [ diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/StatusDao.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/StatusDao.kt index 222a916df..44fb2fbe3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/StatusDao.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/StatusDao.kt @@ -33,6 +33,13 @@ internal interface StatusDao { accountType: DbAccountType, ): Flow + @Transaction + @Query("SELECT * FROM DbStatus WHERE statusKey = :statusKey AND accountType = :accountType") + suspend fun getWithReferencesSync( + statusKey: MicroBlogKey, + accountType: DbAccountType, + ): DbStatusWithReference? + @Query("SELECT * FROM DbStatus WHERE accountType = :accountType AND statusKey IN (:statusKeys)") suspend fun getByKeys( statusKeys: List, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDao.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDao.kt index ca1b7455c..330e39c4c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDao.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import dev.dimension.flare.data.database.cache.model.DbTranslation +import dev.dimension.flare.data.database.cache.model.TranslationDisplayMode import dev.dimension.flare.data.database.cache.model.TranslationEntityType import dev.dimension.flare.data.database.cache.model.TranslationPayload import dev.dimension.flare.data.database.cache.model.TranslationStatus @@ -54,6 +55,7 @@ internal interface TranslationDao { "UPDATE DbTranslation SET " + "sourceHash = :sourceHash, " + "status = :status, " + + "displayMode = :displayMode, " + "payload = :payload, " + "statusReason = :statusReason, " + "attemptCount = :attemptCount, " + @@ -66,12 +68,44 @@ internal interface TranslationDao { targetLanguage: String, sourceHash: String, status: TranslationStatus, + displayMode: TranslationDisplayMode, payload: TranslationPayload?, statusReason: String?, attemptCount: Int, updatedAt: Long, ) + @Query( + "UPDATE DbTranslation SET " + + "displayMode = :displayMode, " + + "updatedAt = :updatedAt " + + "WHERE entityType = :entityType AND entityKey = :entityKey AND targetLanguage = :targetLanguage", + ) + suspend fun updateDisplayMode( + entityType: TranslationEntityType, + entityKey: String, + targetLanguage: String, + displayMode: TranslationDisplayMode, + updatedAt: Long, + ) + + @Query( + "UPDATE DbTranslation SET " + + "status = :failedStatus, " + + "payload = NULL, " + + "statusReason = :statusReason, " + + "updatedAt = :updatedAt " + + "WHERE (status = :pendingStatus OR status = :translatingStatus) AND updatedAt < :staleBefore", + ) + suspend fun markStaleInFlightAsFailed( + staleBefore: Long, + statusReason: String, + updatedAt: Long, + failedStatus: TranslationStatus = TranslationStatus.Failed, + pendingStatus: TranslationStatus = TranslationStatus.Pending, + translatingStatus: TranslationStatus = TranslationStatus.Translating, + ) + @Query( "DELETE FROM DbTranslation " + "WHERE entityType = :entityType AND entityKey = :entityKey AND targetLanguage = :targetLanguage", diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbTranslation.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbTranslation.kt index 93b82a63b..ad163849b 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbTranslation.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbTranslation.kt @@ -7,6 +7,7 @@ import androidx.room.PrimaryKey import androidx.room.TypeConverter import dev.dimension.flare.common.decodeJson import dev.dimension.flare.common.encodeJson +import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.render.UiRichText @@ -26,6 +27,7 @@ internal data class DbTranslation( val targetLanguage: String, val sourceHash: String, val status: TranslationStatus, + val displayMode: TranslationDisplayMode = TranslationDisplayMode.Auto, @ColumnInfo(typeAffinity = ColumnInfo.TEXT) val payload: TranslationPayload? = null, val statusReason: String? = null, @@ -50,6 +52,13 @@ internal enum class TranslationStatus { Skipped, } +@Serializable +internal enum class TranslationDisplayMode { + Auto, + Original, + Translated, +} + @Serializable internal data class TranslationPayload( val content: UiRichText? = null, @@ -71,6 +80,12 @@ internal class TranslationConverters { @TypeConverter fun toStatus(value: String): TranslationStatus = TranslationStatus.valueOf(value) + @TypeConverter + fun fromDisplayMode(value: TranslationDisplayMode): String = value.name + + @TypeConverter + fun toDisplayMode(value: String): TranslationDisplayMode = TranslationDisplayMode.valueOf(value) + @TypeConverter fun fromPayload(value: TranslationPayload?): String? = value?.encodeJson(TranslationPayload.serializer()) @@ -80,6 +95,11 @@ internal class TranslationConverters { internal fun DbStatus.translationEntityKey(): String = id +internal fun statusTranslationEntityKey( + accountType: AccountType, + statusKey: MicroBlogKey, +): String = "${accountType}_$statusKey" + internal fun DbUser.translationEntityKey(): String = profileTranslationEntityKey(userKey) internal fun UiProfile.translationEntityKey(): String = profileTranslationEntityKey(key) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/TranslationDisplay.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/TranslationDisplay.kt index 9a97c25bd..c7413c6be 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/TranslationDisplay.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/TranslationDisplay.kt @@ -1,42 +1,86 @@ package dev.dimension.flare.data.database.cache.model +import dev.dimension.flare.common.Locale import dev.dimension.flare.common.encodeJson +import dev.dimension.flare.data.datasource.microblog.ActionMenu +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.DeeplinkEvent +import dev.dimension.flare.ui.model.TranslationDisplayState import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.render.toUiPlainText +import kotlinx.collections.immutable.toPersistentList internal data class TranslationDisplayOptions( - val enabled: Boolean, - val targetLanguage: String, + val translationEnabled: Boolean, + val autoDisplayEnabled: Boolean, ) internal fun UiTimelineV2.applyTranslation( options: TranslationDisplayOptions, translations: List, ): UiTimelineV2 { - if (!options.enabled) { + if (!options.translationEnabled) { return this } val payload = translationPayload() ?: return this val translation = translations.firstOrNull { - it.targetLanguage == options.targetLanguage && - it.status == TranslationStatus.Completed && + it.targetLanguage == Locale.language && it.sourceHash == payload.sourceHash() - } ?: return this + } return when (this) { is UiTimelineV2.Feed -> copy( - title = translation.payload?.title?.raw ?: title, - description = translation.payload?.description?.raw ?: description, + title = + translation + .takeIf { it?.status == TranslationStatus.Completed } + ?.payload + ?.title + ?.raw ?: title, + description = + translation + .takeIf { it?.status == TranslationStatus.Completed } + ?.payload + ?.description + ?.raw ?: description, + translationDisplayState = translation.toDisplayState(), ) is UiTimelineV2.Post -> - copy( - content = translation.payload?.content ?: content, - contentWarning = translation.payload?.contentWarning ?: contentWarning, - ) + run { + val displayMode = translation?.displayMode ?: TranslationDisplayMode.Auto + val translatedPayload = translation?.takeIf { it.status == TranslationStatus.Completed }?.payload + val shouldShowTranslated = + translatedPayload != null && + when (displayMode) { + TranslationDisplayMode.Translated -> true + TranslationDisplayMode.Original -> false + TranslationDisplayMode.Auto -> options.autoDisplayEnabled + } + val displayState = + when { + translation?.status == TranslationStatus.Completed && shouldShowTranslated -> TranslationDisplayState.Translated + translation?.status == TranslationStatus.Completed -> TranslationDisplayState.Hidden + else -> translation.toDisplayState() + } + val menuAction = + when { + translation?.status == TranslationStatus.Failed -> TranslationMenuAction.Retry + shouldShowTranslated -> TranslationMenuAction.ShowOriginal + translation?.status == TranslationStatus.Pending || translation?.status == TranslationStatus.Translating -> null + translation?.status == TranslationStatus.Skipped -> null + else -> TranslationMenuAction.Translate + } + copy( + content = if (shouldShowTranslated) translatedPayload.content ?: content else content, + contentWarning = if (shouldShowTranslated) translatedPayload.contentWarning ?: contentWarning else contentWarning, + translationDisplayState = displayState, + actions = actions.withTranslationMenuAction(menuAction, accountType, statusKey), + ) + } is UiTimelineV2.Message -> this is UiTimelineV2.User -> this @@ -48,20 +92,19 @@ internal fun UiProfile.applyTranslation( options: TranslationDisplayOptions, translation: DbTranslation?, ): UiProfile { - if (!options.enabled) { + if (!options.autoDisplayEnabled) { return this } val payload = translationPayload() - if ( - translation == null || - translation.targetLanguage != options.targetLanguage || - translation.status != TranslationStatus.Completed || - translation.sourceHash != payload.sourceHash() - ) { - return this - } + val matchedTranslation = + translation?.takeIf { + it.targetLanguage == Locale.language && + it.sourceHash == payload.sourceHash() + } + val displayState = matchedTranslation.toDisplayState() return copy( - description = translation.payload?.description ?: description, + description = matchedTranslation.takeIf { it?.status == TranslationStatus.Completed }?.payload?.description ?: description, + translationDisplayState = displayState, ) } @@ -91,6 +134,100 @@ internal fun UiProfile.translationPayload(): TranslationPayload = internal fun TranslationPayload.sourceHash(): String = encodeJson(TranslationPayload.serializer()).stableTranslationHash() +private fun DbTranslation?.toDisplayState(): TranslationDisplayState = + when (this?.status) { + TranslationStatus.Pending, + TranslationStatus.Translating, + -> TranslationDisplayState.Translating + + TranslationStatus.Completed -> TranslationDisplayState.Translated + TranslationStatus.Failed -> TranslationDisplayState.Failed + TranslationStatus.Skipped, + null, + -> TranslationDisplayState.Hidden + } + +private fun List.withTranslationMenuAction( + action: TranslationMenuAction?, + accountType: dev.dimension.flare.model.AccountType, + statusKey: dev.dimension.flare.model.MicroBlogKey, +) = if (action == null) { + this.toPersistentList() +} else if (accountType is AccountType.Specific) { + map { menu -> + menu.prependTranslationAction( + accountKey = accountType.accountKey, + statusKey = statusKey, + translationAction = action, + ) + }.toPersistentList() +} else { + this.toPersistentList() +} + +private fun ActionMenu.prependTranslationAction( + accountKey: dev.dimension.flare.model.MicroBlogKey, + statusKey: dev.dimension.flare.model.MicroBlogKey, + translationAction: TranslationMenuAction, +): ActionMenu = + when (this) { + is ActionMenu.Group -> + if (displayItem.text.isMoreMenuText()) { + val localAction = + ActionMenu.Item( + text = + ActionMenu.Item.Text.Localized( + when (translationAction) { + TranslationMenuAction.Retry -> ActionMenu.Item.Text.Localized.Type.RetryTranslation + TranslationMenuAction.Translate -> ActionMenu.Item.Text.Localized.Type.Translate + TranslationMenuAction.ShowOriginal -> ActionMenu.Item.Text.Localized.Type.ShowOriginal + }, + ), + clickEvent = + ClickEvent.Deeplink( + DeeplinkEvent( + accountKey = accountKey, + translationEvent = + when (translationAction) { + TranslationMenuAction.Retry -> DeeplinkEvent.TranslationEvent.RetryTranslation(statusKey) + TranslationMenuAction.Translate -> DeeplinkEvent.TranslationEvent.Translate(statusKey) + TranslationMenuAction.ShowOriginal -> DeeplinkEvent.TranslationEvent.ShowOriginal(statusKey) + }, + ), + ), + ) + copy( + actions = + ( + listOf(localAction) + + actions.filterNot { + (it as? ActionMenu.Item)?.text.let { text -> + val localized = text as? ActionMenu.Item.Text.Localized + localized?.type == ActionMenu.Item.Text.Localized.Type.RetryTranslation || + localized?.type == ActionMenu.Item.Text.Localized.Type.Translate || + localized?.type == ActionMenu.Item.Text.Localized.Type.ShowOriginal + } + } + ).toPersistentList(), + ) + } else { + this + } + + is ActionMenu.Item, + ActionMenu.Divider, + -> this + } + +private fun ActionMenu.Item.Text?.isMoreMenuText(): Boolean = + (this as? ActionMenu.Item.Text.Localized)?.type == ActionMenu.Item.Text.Localized.Type.More + +private enum class TranslationMenuAction { + Retry, + Translate, + ShowOriginal, +} + private fun String.stableTranslationHash(): String { var hash = -0x340d631b8c4674c3L encodeToByteArray().forEach { byte -> diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ActionMenu.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ActionMenu.kt index c11a5c7ec..c8f4857eb 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ActionMenu.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ActionMenu.kt @@ -105,6 +105,9 @@ public sealed class ActionMenu { MuteWithHandleParameter, AcceptFollowRequest, RejectFollowRequest, + RetryTranslation, + Translate, + ShowOriginal, } } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt index 1cf925182..8e7c9066d 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt @@ -1,7 +1,6 @@ package dev.dimension.flare.data.datasource.microblog.handler import dev.dimension.flare.common.Cacheable -import dev.dimension.flare.common.Locale import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.connect import dev.dimension.flare.data.database.cache.mapper.saveToDatabase @@ -39,8 +38,8 @@ internal class PostHandler( appDataStore.appSettingsStore.data .map { settings -> TranslationDisplayOptions( - enabled = settings.aiConfig.translation && settings.aiConfig.preTranslation, - targetLanguage = settings.language.ifBlank { Locale.language }, + translationEnabled = settings.aiConfig.translation, + autoDisplayEnabled = settings.aiConfig.translation && settings.aiConfig.preTranslation, ) }.distinctUntilChanged() } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt index 3584b3df6..6578a54ce 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt @@ -36,8 +36,8 @@ internal class UserHandler( appDataStore.appSettingsStore.data .map { settings -> TranslationDisplayOptions( - enabled = settings.aiConfig.translation && settings.aiConfig.preTranslation, - targetLanguage = settings.language.ifBlank { Locale.language }, + translationEnabled = settings.aiConfig.translation, + autoDisplayEnabled = settings.aiConfig.translation && settings.aiConfig.preTranslation, ) }.distinctUntilChanged() } @@ -93,7 +93,7 @@ internal class UserHandler( combine(userFlow, translationDisplayFlow) { user, translationDisplayOptions -> user to translationDisplayOptions }.flatMapLatest { (user, translationDisplayOptions) -> - if (user == null || !translationDisplayOptions.enabled) { + if (user == null || !translationDisplayOptions.autoDisplayEnabled) { flowOf(user?.content) } else { combine( @@ -103,7 +103,7 @@ internal class UserHandler( .find( entityType = TranslationEntityType.Profile, entityKey = user.translationEntityKey(), - targetLanguage = translationDisplayOptions.targetLanguage, + targetLanguage = Locale.language, ), ) { dbUser, translation -> dbUser.content.applyTranslation( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelinePagingMapper.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelinePagingMapper.kt index d6af80bbf..ea44457a3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelinePagingMapper.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelinePagingMapper.kt @@ -59,7 +59,11 @@ internal object TimelinePagingMapper { item: DbPagingTimelineWithStatus, pagingKey: String, useDbKeyInItemKey: Boolean, - translationDisplayOptions: TranslationDisplayOptions = TranslationDisplayOptions(enabled = false, targetLanguage = ""), + translationDisplayOptions: TranslationDisplayOptions = + TranslationDisplayOptions( + translationEnabled = false, + autoDisplayEnabled = false, + ), ): UiTimelineV2 { val root = dbStatusWithUserToUiTimeline( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt index 6340f805a..174a3baeb 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt @@ -27,6 +27,11 @@ public data class AppSettings( val tldrPrompt: String = AiPromptDefaults.TLDR_PROMPT, val preTranslation: Boolean = false, ) { + public companion object { + // for iOS + public val default: AiConfig = AiConfig() + } + @Serializable public sealed interface Type { @Serializable diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt index be71cf4af..90be741f3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt @@ -7,16 +7,19 @@ import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.model.DbStatus import dev.dimension.flare.data.database.cache.model.DbTranslation import dev.dimension.flare.data.database.cache.model.DbUser +import dev.dimension.flare.data.database.cache.model.TranslationDisplayMode import dev.dimension.flare.data.database.cache.model.TranslationEntityType import dev.dimension.flare.data.database.cache.model.TranslationPayload import dev.dimension.flare.data.database.cache.model.TranslationStatus import dev.dimension.flare.data.database.cache.model.sourceHash +import dev.dimension.flare.data.database.cache.model.statusTranslationEntityKey import dev.dimension.flare.data.database.cache.model.translationEntityKey import dev.dimension.flare.data.database.cache.model.translationPayload import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.datastore.model.AiPromptDefaults import dev.dimension.flare.data.datastore.model.AppSettings import dev.dimension.flare.data.network.ai.AiCompletionService +import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.ui.render.RenderContent import dev.dimension.flare.ui.render.RenderRun import dev.dimension.flare.ui.render.RenderTextStyle @@ -40,6 +43,17 @@ internal interface PreTranslationService { ) fun enqueueProfile(user: DbUser) + + fun retryStatus( + accountType: dev.dimension.flare.model.AccountType, + statusKey: dev.dimension.flare.model.MicroBlogKey, + ) + + fun setStatusDisplayMode( + accountType: dev.dimension.flare.model.AccountType, + statusKey: dev.dimension.flare.model.MicroBlogKey, + mode: TranslationDisplayMode, + ) } internal data object NoopPreTranslationService : PreTranslationService { @@ -49,6 +63,17 @@ internal data object NoopPreTranslationService : PreTranslationService { ) = Unit override fun enqueueProfile(user: DbUser) = Unit + + override fun retryStatus( + accountType: dev.dimension.flare.model.AccountType, + statusKey: dev.dimension.flare.model.MicroBlogKey, + ) = Unit + + override fun setStatusDisplayMode( + accountType: dev.dimension.flare.model.AccountType, + statusKey: dev.dimension.flare.model.MicroBlogKey, + mode: TranslationDisplayMode, + ) = Unit } internal class AiPreTranslationService( @@ -59,6 +84,12 @@ internal class AiPreTranslationService( ) : PreTranslationService { private val semaphore = Semaphore(permits = 1) + init { + coroutineScope.launch { + cleanupStaleInFlightTranslations() + } + } + override fun enqueueStatuses( statuses: List, allowLongText: Boolean, @@ -68,62 +99,110 @@ internal class AiPreTranslationService( return } coroutineScope.launch { + val settings = activeTranslationSettings(requirePreTranslation = true) ?: return@launch + val candidates = + prepareStatusCandidates( + statuses = snapshot, + targetLanguage = settings.targetLanguage, + allowLongText = allowLongText, + ) + if (candidates.isEmpty()) { + return@launch + } + markPending(candidates) semaphore.withPermit { - processStatusSnapshot(snapshot, allowLongText = allowLongText) + translatePreparedCandidates( + settings = settings.aiConfig, + targetLanguage = settings.targetLanguage, + candidates = candidates, + ) } } } override fun enqueueProfile(user: DbUser) { coroutineScope.launch { + val settings = activeTranslationSettings(requirePreTranslation = true) ?: return@launch + val candidate = + prepareProfileCandidate( + user = user, + targetLanguage = settings.targetLanguage, + ) ?: return@launch + markPending(listOf(candidate)) semaphore.withPermit { - processProfile(user) + translatePreparedCandidates( + settings = settings.aiConfig, + targetLanguage = settings.targetLanguage, + candidates = listOf(candidate), + ) } } } - private suspend fun processStatusSnapshot( - statuses: List, - allowLongText: Boolean, + override fun retryStatus( + accountType: dev.dimension.flare.model.AccountType, + statusKey: dev.dimension.flare.model.MicroBlogKey, ) { - val settings = preTranslationSettings() ?: return - val candidates = - prepareStatusCandidates( - statuses = statuses, - targetLanguage = settings.targetLanguage, - allowLongText = allowLongText, - ) - if (candidates.isEmpty()) { - return - } - candidates.chunkedForBatching().forEach { batch -> - translateBatch( - settings = settings.aiConfig, - targetLanguage = settings.targetLanguage, - candidates = batch, + coroutineScope.launch { + val settings = activeTranslationSettings(requirePreTranslation = false) ?: return@launch + setStatusDisplayMode( + accountType = accountType, + statusKey = statusKey, + mode = TranslationDisplayMode.Translated, ) + val dbAccountType = accountType as? dev.dimension.flare.model.DbAccountType ?: return@launch + val status = + database + .statusDao() + .getWithReferencesSync( + statusKey = statusKey, + accountType = dbAccountType, + ) ?: return@launch + val candidates = + prepareStatusCandidates( + statuses = listOfNotNull(status.status.data) + status.references.mapNotNull { it.status?.data }, + targetLanguage = settings.targetLanguage, + allowLongText = true, + preferredDisplayMode = TranslationDisplayMode.Translated, + ) + if (candidates.isEmpty()) { + return@launch + } + markPending(candidates) + semaphore.withPermit { + translatePreparedCandidates( + settings = settings.aiConfig, + targetLanguage = settings.targetLanguage, + candidates = candidates, + ) + } } } - private suspend fun processProfile(user: DbUser) { - val settings = preTranslationSettings() ?: return - val candidate = - prepareProfileCandidate( - user = user, - targetLanguage = settings.targetLanguage, - ) ?: return - translateBatch( - settings = settings.aiConfig, - targetLanguage = settings.targetLanguage, - candidates = listOf(candidate), - ) + override fun setStatusDisplayMode( + accountType: dev.dimension.flare.model.AccountType, + statusKey: dev.dimension.flare.model.MicroBlogKey, + mode: TranslationDisplayMode, + ) { + coroutineScope.launch { + val targetLanguage = currentTargetLanguage() + database + .translationDao() + .updateDisplayMode( + entityType = TranslationEntityType.Status, + entityKey = statusTranslationEntityKey(accountType, statusKey), + targetLanguage = targetLanguage, + displayMode = mode, + updatedAt = Clock.System.now().toEpochMilliseconds(), + ) + } } - private suspend fun preTranslationSettings(): ActivePreTranslationSettings? { + private suspend fun activeTranslationSettings(requirePreTranslation: Boolean): ActivePreTranslationSettings? { val appSettings = appDataStore.appSettingsStore.data.first() - val targetLanguage = appSettings.language.ifBlank { Locale.language } + val targetLanguage = currentTargetLanguage() val aiConfig = appSettings.aiConfig - if (!aiConfig.translation || !aiConfig.preTranslation || targetLanguage.isBlank()) { + if (!aiConfig.translation || (requirePreTranslation && !aiConfig.preTranslation) || targetLanguage.isBlank()) { return null } return ActivePreTranslationSettings( @@ -132,10 +211,22 @@ internal class AiPreTranslationService( ) } + private fun currentTargetLanguage(): String = Locale.language + + private suspend fun cleanupStaleInFlightTranslations() { + val updatedAt = Clock.System.now().toEpochMilliseconds() + database.translationDao().markStaleInFlightAsFailed( + staleBefore = updatedAt - STALE_TRANSLATION_TIMEOUT.inWholeMilliseconds, + statusReason = FAILED_STALE_IN_FLIGHT_REASON, + updatedAt = updatedAt, + ) + } + private suspend fun prepareStatusCandidates( statuses: List, targetLanguage: String, allowLongText: Boolean, + preferredDisplayMode: TranslationDisplayMode? = null, ): List { val deduplicated = statuses.distinctBy { it.translationEntityKey() } if (deduplicated.isEmpty()) { @@ -161,6 +252,7 @@ internal class AiPreTranslationService( targetLanguage = targetLanguage, now = now, allowLongText = allowLongText, + preferredDisplayMode = preferredDisplayMode, )?.let(::add) } } @@ -197,6 +289,7 @@ internal class AiPreTranslationService( targetLanguage: String, now: Long, allowLongText: Boolean, + preferredDisplayMode: TranslationDisplayMode? = null, ): PreparedTranslationCandidate? { if (payload == null) { return null @@ -205,6 +298,7 @@ internal class AiPreTranslationService( return null } val sourceHash = payload.sourceHash() + val displayMode = preferredDisplayMode ?: existing?.displayMode ?: TranslationDisplayMode.Auto if (payload.isNonTranslatableOnly()) { if ( existing == null || @@ -219,6 +313,7 @@ internal class AiPreTranslationService( targetLanguage = targetLanguage, sourceHash = sourceHash, status = TranslationStatus.Skipped, + displayMode = displayMode, payload = null, statusReason = SKIPPED_NON_TRANSLATABLE_ONLY_REASON, attemptCount = existing?.attemptCount ?: 0, @@ -243,6 +338,7 @@ internal class AiPreTranslationService( targetLanguage = targetLanguage, sourceHash = sourceHash, status = TranslationStatus.Skipped, + displayMode = displayMode, payload = null, statusReason = SKIPPED_SAME_LANGUAGE_REASON, attemptCount = existing?.attemptCount ?: 0, @@ -266,6 +362,7 @@ internal class AiPreTranslationService( targetLanguage = targetLanguage, sourceHash = sourceHash, status = TranslationStatus.Skipped, + displayMode = displayMode, payload = null, statusReason = SKIPPED_EMPTY_REASON, attemptCount = existing?.attemptCount ?: 0, @@ -286,9 +383,47 @@ internal class AiPreTranslationService( sourcePayload = payload, sourceDocument = sourceDocument, attemptCount = (existing?.attemptCount ?: 0) + 1, + displayMode = displayMode, ) } + private suspend fun markPending(candidates: List) { + if (candidates.isEmpty()) { + return + } + val updatedAt = Clock.System.now().toEpochMilliseconds() + database.translationDao().insertAll( + candidates.map { candidate -> + DbTranslation( + entityType = candidate.entityType, + entityKey = candidate.entityKey, + targetLanguage = candidate.targetLanguage, + sourceHash = candidate.sourceHash, + status = TranslationStatus.Pending, + displayMode = candidate.displayMode, + payload = null, + statusReason = null, + attemptCount = candidate.attemptCount, + updatedAt = updatedAt, + ) + }, + ) + } + + private suspend fun translatePreparedCandidates( + settings: AppSettings.AiConfig, + targetLanguage: String, + candidates: List, + ) { + candidates.chunkedForBatching().forEach { batch -> + translateBatch( + settings = settings, + targetLanguage = targetLanguage, + candidates = batch, + ) + } + } + private suspend fun translateBatch( settings: AppSettings.AiConfig, targetLanguage: String, @@ -306,6 +441,7 @@ internal class AiPreTranslationService( targetLanguage = candidate.targetLanguage, sourceHash = candidate.sourceHash, status = TranslationStatus.Translating, + displayMode = candidate.displayMode, payload = null, statusReason = null, attemptCount = candidate.attemptCount, @@ -314,7 +450,7 @@ internal class AiPreTranslationService( }, ) - runCatching { + tryRun { val sourceDocument = PreTranslationBatchDocument( targetLanguage = targetLanguage, @@ -350,6 +486,7 @@ internal class AiPreTranslationService( targetLanguage = candidate.targetLanguage, sourceHash = candidate.sourceHash, status = TranslationStatus.Failed, + displayMode = candidate.displayMode, payload = null, statusReason = throwable.message, attemptCount = candidate.attemptCount, @@ -370,7 +507,7 @@ internal class AiPreTranslationService( val updatedAt = Clock.System.now().toEpochMilliseconds() database.translationDao().insertAll( candidates.map { candidate -> - runCatching { + tryRun { val translatedItem = translatedItems[candidate.entityKey] ?: throw IllegalArgumentException("Missing translated item for ${candidate.entityKey}") @@ -389,6 +526,7 @@ internal class AiPreTranslationService( targetLanguage = candidate.targetLanguage, sourceHash = candidate.sourceHash, status = TranslationStatus.Skipped, + displayMode = candidate.displayMode, payload = null, statusReason = SKIPPED_UNCHANGED_REASON, attemptCount = candidate.attemptCount, @@ -401,6 +539,7 @@ internal class AiPreTranslationService( targetLanguage = candidate.targetLanguage, sourceHash = candidate.sourceHash, status = TranslationStatus.Completed, + displayMode = candidate.displayMode, payload = translatedPayload, statusReason = null, attemptCount = candidate.attemptCount, @@ -416,6 +555,7 @@ internal class AiPreTranslationService( targetLanguage = candidate.targetLanguage, sourceHash = candidate.sourceHash, status = TranslationStatus.Skipped, + displayMode = candidate.displayMode, payload = null, statusReason = translatedItem.reason ?: SKIPPED_AI_SAME_LANGUAGE_REASON, attemptCount = candidate.attemptCount, @@ -429,6 +569,7 @@ internal class AiPreTranslationService( targetLanguage = candidate.targetLanguage, sourceHash = candidate.sourceHash, status = TranslationStatus.Failed, + displayMode = candidate.displayMode, payload = null, statusReason = throwable.message, attemptCount = candidate.attemptCount, @@ -484,7 +625,7 @@ internal class AiPreTranslationService( TranslationStatus.Failed -> true TranslationStatus.Pending, TranslationStatus.Translating, - -> now - existing.updatedAt >= STALE_TRANSLATION_TIMEOUT.inWholeMilliseconds + -> false } } } @@ -492,7 +633,7 @@ internal class AiPreTranslationService( @Serializable internal data class PreTranslationBatchDocument( val version: Int = 1, - val targetLanguage: String, + val targetLanguage: String = "", val items: List, ) @@ -531,10 +672,12 @@ private data class PreparedTranslationCandidate( val sourcePayload: TranslationPayload, val sourceDocument: PreTranslationBatchPayload, val attemptCount: Int, + val displayMode: TranslationDisplayMode, ) private const val DEFAULT_PRE_TRANSLATION_MAX_ITEMS = 8 private const val DEFAULT_PRE_TRANSLATION_MAX_INPUT_TOKENS = 6000 +private const val FAILED_STALE_IN_FLIGHT_REASON = "stale_in_flight" private const val SKIPPED_AI_SAME_LANGUAGE_REASON = "ai_same_language" private const val SKIPPED_EMPTY_REASON = "empty" private const val SKIPPED_NON_TRANSLATABLE_ONLY_REASON = "non_translatable_only" diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/DeeplinkEvent.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/DeeplinkEvent.kt index e187ca441..8cef658de 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/DeeplinkEvent.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/DeeplinkEvent.kt @@ -12,8 +12,15 @@ import kotlinx.serialization.protobuf.ProtoBuf @OptIn(ExperimentalSerializationApi::class) internal data class DeeplinkEvent( val accountKey: MicroBlogKey, - val postEvent: PostEvent, + val translationEvent: TranslationEvent? = null, + val postEvent: PostEvent? = null, ) { + init { + require((translationEvent == null) xor (postEvent == null)) { + "Exactly one deeplink event payload must be provided" + } + } + companion object { const val SCHEME = "flare-event" @@ -26,4 +33,22 @@ internal data class DeeplinkEvent( } fun toUri(): String = "$SCHEME://${ProtoBuf.encodeToHexString(this)}" + + @Serializable + sealed interface TranslationEvent { + @Serializable + data class RetryTranslation( + val statusKey: MicroBlogKey, + ) : TranslationEvent + + @Serializable + data class Translate( + val statusKey: MicroBlogKey, + ) : TranslationEvent + + @Serializable + data class ShowOriginal( + val statusKey: MicroBlogKey, + ) : TranslationEvent + } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/TranslationDisplayState.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/TranslationDisplayState.kt new file mode 100644 index 000000000..e363348f8 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/TranslationDisplayState.kt @@ -0,0 +1,8 @@ +package dev.dimension.flare.ui.model + +public enum class TranslationDisplayState { + Hidden, + Translating, + Translated, + Failed, +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt index b8f286168..f25e4917e 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt @@ -11,6 +11,7 @@ import dev.dimension.flare.ui.render.toUiPlainText import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient @Serializable @Immutable @@ -24,6 +25,8 @@ public data class UiProfile internal constructor( public val banner: String?, public val description: UiRichText?, internal val sourceLanguages: SerializableImmutableList = persistentListOf(), + @Transient + public val translationDisplayState: TranslationDisplayState = TranslationDisplayState.Hidden, public val matrices: Matrices, public val mark: SerializableImmutableList, public val bottomContent: BottomContent?, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt index b89704ba9..3ed7c1121 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt @@ -126,6 +126,8 @@ public sealed class UiTimelineV2 { val description: String?, val url: String, internal val sourceLanguages: SerializableImmutableList = persistentListOf(), + @Transient + public val translationDisplayState: TranslationDisplayState = TranslationDisplayState.Hidden, override val createdAt: UiDateTime, val source: Source, val openInBrowser: Boolean, @@ -193,6 +195,8 @@ public sealed class UiTimelineV2 { val user: UiProfile?, internal val sourceLanguages: SerializableImmutableList = persistentListOf(), @Transient + public val translationDisplayState: TranslationDisplayState = TranslationDisplayState.Hidden, + @Transient val quote: SerializableImmutableList = persistentListOf(), val content: UiRichText, val actions: SerializableImmutableList, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/VVO.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/VVO.kt index 0c5f83e36..744bd3460 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/VVO.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/VVO.kt @@ -28,6 +28,7 @@ import dev.dimension.flare.ui.render.toUiPlainText import dev.dimension.flare.ui.route.DeeplinkRoute import dev.dimension.flare.ui.route.toUri import io.ktor.http.decodeURLPart +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlin.time.Clock @@ -191,6 +192,7 @@ private fun Status.renderStatusV2(accountKey: MicroBlogKey): UiTimelineV2.Post { user = displayUser, quote = listOfNotNull(retweetedStatus?.renderStatusV2(accountKey)).toImmutableList(), content = renderVVOText(text.orEmpty(), accountKey), + sourceLanguages = persistentListOf("zh-CN"), actions = listOfNotNull( if (canReblog) { @@ -354,6 +356,7 @@ private fun Comment.renderStatusV2(accountKey: MicroBlogKey): UiTimelineV2.Post user = user, quote = quote, content = renderVVOText(text.orEmpty(), accountKey), + sourceLanguages = persistentListOf("zh-CN"), actions = listOfNotNull( statusMid?.let { @@ -455,6 +458,7 @@ internal fun User.render(accountKey: MicroBlogKey): UiProfile { nameInternal = screenName.toString().toUiPlainText(), description = description?.toUiPlainText(), banner = coverImagePhone, + sourceLanguages = persistentListOf("zh-CN"), matrices = UiProfile.Matrices( followsCount = followCount ?: 0, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/DeepLinkPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/DeepLinkPresenter.kt index bcdd99c53..4b4ee7abe 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/DeepLinkPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/DeepLinkPresenter.kt @@ -7,9 +7,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import dev.dimension.flare.common.deeplink.DeepLinkMapping +import dev.dimension.flare.data.database.cache.model.TranslationDisplayMode import dev.dimension.flare.data.datasource.microblog.datasource.PostDataSource import dev.dimension.flare.data.repository.AccountRepository import dev.dimension.flare.data.repository.accountServiceFlow +import dev.dimension.flare.data.translation.PreTranslationService import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.spec import dev.dimension.flare.ui.model.DeeplinkEvent @@ -32,6 +34,7 @@ public class DeepLinkPresenter( ) : PresenterBase(), KoinComponent { private val accountRepository: AccountRepository by inject() + private val preTranslationService: PreTranslationService by inject() @androidx.compose.runtime.Immutable public interface State { @@ -55,26 +58,64 @@ public class DeepLinkPresenter( if (DeeplinkEvent.isDeeplinkEvent(url)) { val event = DeeplinkEvent.parse(url) if (event != null) { - accountServiceFlow( - accountType = AccountType.Specific(event.accountKey), - repository = accountRepository, - ).firstOrNull()?.let { service -> - if (service is PostDataSource) { - service.postEventHandler.handleEvent(event.postEvent) - } + when { + event.postEvent != null -> + accountServiceFlow( + accountType = AccountType.Specific(event.accountKey), + repository = accountRepository, + ).firstOrNull()?.let { service -> + if (service is PostDataSource) { + service.postEventHandler.handleEvent(event.postEvent) + } + } + + event.translationEvent is DeeplinkEvent.TranslationEvent.RetryTranslation -> + with(event.translationEvent) { + preTranslationService.setStatusDisplayMode( + accountType = AccountType.Specific(event.accountKey), + statusKey = statusKey, + mode = TranslationDisplayMode.Translated, + ) + preTranslationService.retryStatus( + accountType = AccountType.Specific(event.accountKey), + statusKey = statusKey, + ) + } + + event.translationEvent is DeeplinkEvent.TranslationEvent.Translate -> + with(event.translationEvent) { + preTranslationService.setStatusDisplayMode( + accountType = AccountType.Specific(event.accountKey), + statusKey = statusKey, + mode = TranslationDisplayMode.Translated, + ) + preTranslationService.retryStatus( + accountType = AccountType.Specific(event.accountKey), + statusKey = statusKey, + ) + } + + event.translationEvent is DeeplinkEvent.TranslationEvent.ShowOriginal -> + preTranslationService.setStatusDisplayMode( + accountType = AccountType.Specific(event.accountKey), + statusKey = event.translationEvent.statusKey, + mode = TranslationDisplayMode.Original, + ) } } pendingUrl = null } else if (DeeplinkRoute.isDeeplink(url)) { DeeplinkRoute.parse(url)?.let { - if (it is DeeplinkRoute.OpenLinkDirectly) { - withContext(Dispatchers.Main) { - onLink(it.url) - } - } else { - withContext(Dispatchers.Main) { - onRoute(it) - } + when (it) { + is DeeplinkRoute.OpenLinkDirectly -> + withContext(Dispatchers.Main) { + onLink(it.url) + } + + else -> + withContext(Dispatchers.Main) { + onRoute(it) + } } } pendingUrl = null diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt index 7be96388d..e1bd7ead1 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt @@ -11,7 +11,6 @@ import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map import dev.dimension.flare.common.InAppNotification -import dev.dimension.flare.common.Locale import dev.dimension.flare.common.Message import dev.dimension.flare.common.PagingState import dev.dimension.flare.common.cachePagingState @@ -20,6 +19,7 @@ import dev.dimension.flare.common.onEmpty import dev.dimension.flare.common.onError import dev.dimension.flare.common.onSuccess import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus import dev.dimension.flare.data.database.cache.model.TranslationDisplayOptions import dev.dimension.flare.data.datasource.microblog.paging.CacheableRemoteLoader import dev.dimension.flare.data.datasource.microblog.paging.NotSupportRemoteLoader @@ -59,16 +59,16 @@ public abstract class TimelinePresenter : private val localFilterRepository: LocalFilterRepository by inject() private val inAppNotification: InAppNotification by inject() - private val filterFlow by lazy { + private val filterFlow: Flow> by lazy { localFilterRepository.getFlow(forTimeline = true) } - private val translationDisplayFlow by lazy { + private val translationSettingsFlow: Flow by lazy { appDataStore.appSettingsStore.data .map { settings -> TranslationDisplayOptions( - enabled = settings.aiConfig.translation && settings.aiConfig.preTranslation, - targetLanguage = settings.language.ifBlank { Locale.language }, + translationEnabled = settings.aiConfig.translation, + autoDisplayEnabled = settings.aiConfig.translation && settings.aiConfig.preTranslation, ) }.distinctUntilChanged() } @@ -78,25 +78,35 @@ public abstract class TimelinePresenter : @OptIn(ExperimentalCoroutinesApi::class) internal fun createPager(scope: CoroutineScope): Flow> = loader - .flatMapLatest { - when (it) { + .flatMapLatest { remoteLoader -> + when (remoteLoader) { is NotSupportRemoteLoader -> { PagingData.emptyFlow(isError = false) } is CacheableRemoteLoader -> { - translationDisplayFlow - .flatMapLatest { translationDisplayOptions -> - cachePager( - loader = it, - translationDisplayOptions = translationDisplayOptions, - ) - }.cachedIn(scope) + cachePager( + loader = remoteLoader, + ).cachedIn(scope).flatMapLatest { pagingData -> + translationSettingsFlow + .map { translationDisplayOptions -> + withContext(Dispatchers.IO) { + pagingData.map { item -> + TimelinePagingMapper.toUi( + item = item, + pagingKey = remoteLoader.pagingKey, + useDbKeyInItemKey = useDbKeyInItemKey, + translationDisplayOptions = translationDisplayOptions, + ) + } + } + } + } } else -> { networkPager( - loader = it, + loader = remoteLoader, ).cachedIn(scope) } }.flatMapLatest { pager -> @@ -107,7 +117,9 @@ public abstract class TimelinePresenter : true } else { !filterList.any { filter -> - item.searchText.orEmpty().contains(filter, ignoreCase = true) + item.searchText + .orEmpty() + .contains(filter, ignoreCase = true) } } }.map { @@ -119,11 +131,7 @@ public abstract class TimelinePresenter : emitAll(PagingData.emptyFlow(isError = true)) } - @OptIn(ExperimentalCoroutinesApi::class) - private fun cachePager( - loader: CacheableRemoteLoader, - translationDisplayOptions: TranslationDisplayOptions, - ): Flow> = + private fun cachePager(loader: CacheableRemoteLoader): Flow> = run { val allowLongText = allowLongTextTranslationDisplay(loader) Pager( @@ -145,18 +153,7 @@ public abstract class TimelinePresenter : pagingKey = loader.pagingKey, ) }, - ).flow.map { pagingData -> - withContext(Dispatchers.IO) { - pagingData.map { item -> - TimelinePagingMapper.toUi( - item = item, - pagingKey = loader.pagingKey, - useDbKeyInItemKey = useDbKeyInItemKey, - translationDisplayOptions = translationDisplayOptions, - ) - } - } - } + ).flow } protected open suspend fun transform(data: UiTimelineV2): UiTimelineV2 = data diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt index a0de54b46..71757f221 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -78,31 +77,6 @@ public class AiConfigPresenter : }.toImmutableList() } - LaunchedEffect(Unit) { - snapshotFlow { aiConfig.type as? AppSettings.AiConfig.Type.OpenAI } - .map { (it?.serverUrl ?: "") to (it?.apiKey ?: "") } - .distinctUntilChanged() - .drop(1) - .collectLatest { - withContext(Dispatchers.Main) { - appDataStore.appSettingsStore.updateData { - it.copy( - aiConfig = - it.aiConfig.copy( - type = - if (it.aiConfig.type is AppSettings.AiConfig.Type.OpenAI) { - it.aiConfig.type.copy( - model = "", - ) - } else { - it.aiConfig.type - }, - ), - ) - } - } - } - } LaunchedEffect(Unit) { snapshotFlow { aiConfig.type as? AppSettings.AiConfig.Type.OpenAI } .map { (it?.serverUrl ?: "") to (it?.apiKey ?: "") } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt index 7fa905629..2e86b6f91 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.produceState import dev.dimension.flare.common.Locale import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.model.DbTranslation +import dev.dimension.flare.data.database.cache.model.TranslationDisplayMode import dev.dimension.flare.data.database.cache.model.TranslationEntityType import dev.dimension.flare.data.database.cache.model.TranslationPayload import dev.dimension.flare.data.database.cache.model.TranslationStatus @@ -13,6 +14,7 @@ import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.datastore.model.AiPromptDefaults import dev.dimension.flare.data.network.ai.AiCompletionService import dev.dimension.flare.data.network.ktorClient +import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiState @@ -50,16 +52,16 @@ public class TranslatePresenter( override fun body(): UiState { return produceState>(initialValue = UiState.Loading()) { value = - runCatching { + tryRun { val aiConfig = appDataStore.appSettingsStore.data .first() .aiConfig if (!aiConfig.translation) { - return@runCatching toUiRichText(legacyGoogleTranslate()) + return@tryRun toUiRichText(legacyGoogleTranslate()) } cachedTranslation()?.let { - return@runCatching it + return@tryRun it } val promptTemplate = aiConfig.translatePrompt.ifBlank { @@ -76,7 +78,7 @@ public class TranslatePresenter( if (aiTranslation != null) { val translated = toUiRichText(aiTranslation) cacheTranslation(translated) - return@runCatching translated + return@tryRun translated } toUiRichText(legacyGoogleTranslate()) }.fold( @@ -136,7 +138,7 @@ public class TranslatePresenter( .removeSuffix("```") .trim() .let { cleaned -> - runCatching { + tryRun { source.applyTranslationJson(cleaned) }.getOrElse { cleaned.toUiPlainText() @@ -189,6 +191,7 @@ public class TranslatePresenter( targetLanguage = targetLanguage, sourceHash = sourcePayload.sourceHash(), status = TranslationStatus.Completed, + displayMode = TranslationDisplayMode.Translated, payload = mergedPayload, statusReason = null, attemptCount = existing?.attemptCount ?: 0, diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDaoTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDaoTest.kt index a3e70c1a7..bd46c7bc1 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDaoTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDaoTest.kt @@ -5,6 +5,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver import dev.dimension.flare.RobolectricTest import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.model.DbTranslation +import dev.dimension.flare.data.database.cache.model.TranslationDisplayMode import dev.dimension.flare.data.database.cache.model.TranslationEntityType import dev.dimension.flare.data.database.cache.model.TranslationPayload import dev.dimension.flare.data.database.cache.model.TranslationStatus @@ -146,6 +147,7 @@ class TranslationDaoTest : RobolectricTest() { targetLanguage = "en", sourceHash = "hash-new", status = TranslationStatus.Completed, + displayMode = TranslationDisplayMode.Translated, payload = TranslationPayload(description = "Translated profile".toUiPlainText()), statusReason = null, attemptCount = 2, @@ -161,11 +163,122 @@ class TranslationDaoTest : RobolectricTest() { assertNotNull(saved) assertEquals("hash-new", saved.sourceHash) + assertEquals(TranslationDisplayMode.Translated, saved.displayMode) assertEquals(2, saved.attemptCount) assertEquals(99L, saved.updatedAt) assertEquals("Translated profile", saved.payload?.description?.raw) } + @Test + fun updateDisplayMode_onlyChangesDisplayMode() = + runTest { + val entityKey = "status:display-mode" + db.translationDao().insert( + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = entityKey, + targetLanguage = "en", + sourceHash = "hash-old", + status = TranslationStatus.Completed, + payload = TranslationPayload(content = "Hello".toUiPlainText()), + updatedAt = 1L, + ), + ) + + db.translationDao().updateDisplayMode( + entityType = TranslationEntityType.Status, + entityKey = entityKey, + targetLanguage = "en", + displayMode = TranslationDisplayMode.Original, + updatedAt = 77L, + ) + + val saved = + db.translationDao().get( + entityType = TranslationEntityType.Status, + entityKey = entityKey, + targetLanguage = "en", + ) + + assertNotNull(saved) + assertEquals(TranslationDisplayMode.Original, saved.displayMode) + assertEquals(TranslationStatus.Completed, saved.status) + assertEquals("hash-old", saved.sourceHash) + assertEquals("Hello", saved.payload?.content?.raw) + assertEquals(77L, saved.updatedAt) + } + + @Test + fun markStaleInFlightAsFailed_updatesOnlyOldPendingAndTranslating() = + runTest { + db.translationDao().insertAll( + listOf( + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = "status:pending-stale", + targetLanguage = "en", + sourceHash = "hash-pending", + status = TranslationStatus.Pending, + updatedAt = 10L, + ), + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = "status:translating-stale", + targetLanguage = "en", + sourceHash = "hash-translating", + status = TranslationStatus.Translating, + updatedAt = 20L, + ), + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = "status:translating-fresh", + targetLanguage = "en", + sourceHash = "hash-fresh", + status = TranslationStatus.Translating, + updatedAt = 200L, + ), + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = "status:completed", + targetLanguage = "en", + sourceHash = "hash-completed", + status = TranslationStatus.Completed, + payload = TranslationPayload(content = "done".toUiPlainText()), + updatedAt = 5L, + ), + ), + ) + + db.translationDao().markStaleInFlightAsFailed( + staleBefore = 100L, + statusReason = "stale_in_flight", + updatedAt = 999L, + ) + + val pendingStale = db.translationDao().get(TranslationEntityType.Status, "status:pending-stale", "en") + val translatingStale = db.translationDao().get(TranslationEntityType.Status, "status:translating-stale", "en") + val translatingFresh = db.translationDao().get(TranslationEntityType.Status, "status:translating-fresh", "en") + val completed = db.translationDao().get(TranslationEntityType.Status, "status:completed", "en") + + assertNotNull(pendingStale) + assertEquals(TranslationStatus.Failed, pendingStale.status) + assertEquals("stale_in_flight", pendingStale.statusReason) + assertEquals(999L, pendingStale.updatedAt) + + assertNotNull(translatingStale) + assertEquals(TranslationStatus.Failed, translatingStale.status) + assertEquals("stale_in_flight", translatingStale.statusReason) + assertEquals(999L, translatingStale.updatedAt) + + assertNotNull(translatingFresh) + assertEquals(TranslationStatus.Translating, translatingFresh.status) + assertEquals(200L, translatingFresh.updatedAt) + + assertNotNull(completed) + assertEquals(TranslationStatus.Completed, completed.status) + assertEquals("done", completed.payload?.content?.raw) + } + @Test fun deleteByLanguage_removesOnlyMatchingRows() = runTest { diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt index b3fafe92b..65a80ea93 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt @@ -10,6 +10,7 @@ import dev.dimension.flare.common.TestFormatter import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus import dev.dimension.flare.data.database.cache.model.DbTranslation +import dev.dimension.flare.data.database.cache.model.TranslationDisplayMode import dev.dimension.flare.data.database.cache.model.TranslationDisplayOptions import dev.dimension.flare.data.database.cache.model.TranslationEntityType import dev.dimension.flare.data.database.cache.model.TranslationPayload @@ -17,6 +18,7 @@ import dev.dimension.flare.data.database.cache.model.TranslationStatus import dev.dimension.flare.data.database.cache.model.sourceHash import dev.dimension.flare.data.database.cache.model.translationEntityKey import dev.dimension.flare.data.database.cache.model.translationPayload +import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.data.datasource.microblog.paging.TimelinePagingMapper import dev.dimension.flare.memoryDatabaseBuilder import dev.dimension.flare.model.AccountType @@ -24,6 +26,7 @@ import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.ReferenceType import dev.dimension.flare.ui.humanizer.PlatformFormatter import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.TranslationDisplayState import dev.dimension.flare.ui.model.UiHandle import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiTimelineV2 @@ -44,6 +47,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock @@ -551,7 +555,11 @@ class MicroblogTest : RobolectricTest() { item = dbItem, pagingKey = "home", useDbKeyInItemKey = false, - translationDisplayOptions = TranslationDisplayOptions(enabled = true, targetLanguage = "zh-CN"), + translationDisplayOptions = + TranslationDisplayOptions( + translationEnabled = true, + autoDisplayEnabled = true, + ), ) val post = assertIs(ui) @@ -1086,9 +1094,8 @@ class MicroblogTest : RobolectricTest() { useDbKeyInItemKey = false, translationDisplayOptions = TranslationDisplayOptions( - enabled = true, - targetLanguage = "zh-CN", - allowLongText = false, + translationEnabled = true, + autoDisplayEnabled = true, ), ) val detailUi = @@ -1098,9 +1105,8 @@ class MicroblogTest : RobolectricTest() { useDbKeyInItemKey = false, translationDisplayOptions = TranslationDisplayOptions( - enabled = true, - targetLanguage = "zh-CN", - allowLongText = true, + translationEnabled = true, + autoDisplayEnabled = true, ), ) @@ -1108,6 +1114,355 @@ class MicroblogTest : RobolectricTest() { assertEquals("长文译文", assertIs(detailUi).content.raw) } + @Test + fun toUiMarksPendingTranslationAsTranslating() = + runTest { + val accountKey = MicroBlogKey(id = "account-pending", host = "test.com") + val postUser = createUser(MicroBlogKey(id = "post-user-pending", host = "test.com"), "Post User") + val post = + createPost( + accountKey = accountKey, + user = postUser, + statusKey = MicroBlogKey(id = "post-status-pending", host = "test.com"), + text = "pending source", + ) + + val mapped = TimelinePagingMapper.toDb(post, pagingKey = "home") + saveToDatabase(db, listOf(mapped)) + db.translationDao().insert( + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = + mapped.status.status.data + .translationEntityKey(), + targetLanguage = "zh-CN", + sourceHash = post.translationPayload()!!.sourceHash(), + status = TranslationStatus.Pending, + payload = null, + updatedAt = Clock.System.now().toEpochMilliseconds(), + ), + ) + + val paging = db.pagingTimelineDao().getPagingSource("home") + val pager = TestPager(config = PagingConfig(pageSize = 20), paging) + val refreshResult = pager.refresh() + val page = assertIs>(refreshResult) + val dbItem = assertNotNull(page.data.firstOrNull()) + + val timelineUi = + assertIs( + TimelinePagingMapper.toUi( + item = dbItem, + pagingKey = "home", + useDbKeyInItemKey = false, + translationDisplayOptions = + TranslationDisplayOptions( + translationEnabled = true, + autoDisplayEnabled = true, + ), + ), + ) + + assertEquals("pending source", timelineUi.content.raw) + assertEquals(TranslationDisplayState.Translating, timelineUi.translationDisplayState) + } + + @Test + fun toUiPrependsRetryTranslationToMoreMenuWhenTranslationFailed() = + runTest { + val accountKey = MicroBlogKey(id = "account-failed", host = "test.com") + val postUser = createUser(MicroBlogKey(id = "post-user-failed", host = "test.com"), "Post User") + val post = + createPost( + accountKey = accountKey, + user = postUser, + statusKey = MicroBlogKey(id = "post-status-failed", host = "test.com"), + text = "failed source", + ).copy( + actions = + persistentListOf( + ActionMenu.Group( + displayItem = + ActionMenu.Item( + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.More), + clickEvent = ClickEvent.Noop, + ), + actions = + persistentListOf( + ActionMenu.Item( + text = ActionMenu.Item.Text.Raw("Existing action"), + clickEvent = ClickEvent.Noop, + ), + ), + ), + ), + ) + + val mapped = TimelinePagingMapper.toDb(post, pagingKey = "home") + saveToDatabase(db, listOf(mapped)) + db.translationDao().insert( + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = + mapped.status.status.data + .translationEntityKey(), + targetLanguage = "zh-CN", + sourceHash = post.translationPayload()!!.sourceHash(), + status = TranslationStatus.Failed, + payload = null, + updatedAt = 1L, + ), + ) + + val paging = db.pagingTimelineDao().getPagingSource("home") + val pager = TestPager(config = PagingConfig(pageSize = 20), paging) + val refreshResult = pager.refresh() + val page = assertIs>(refreshResult) + val dbItem = assertNotNull(page.data.firstOrNull()) + + val timelineUi = + assertIs( + TimelinePagingMapper.toUi( + item = dbItem, + pagingKey = "home", + useDbKeyInItemKey = false, + translationDisplayOptions = + TranslationDisplayOptions( + translationEnabled = true, + autoDisplayEnabled = true, + ), + ), + ) + + assertEquals(TranslationDisplayState.Failed, timelineUi.translationDisplayState) + val moreAction = assertIs(timelineUi.actions.first()) + val retryAction = assertIs(moreAction.actions.first()) + val retryText = assertIs(retryAction.text) + assertEquals(ActionMenu.Item.Text.Localized.Type.RetryTranslation, retryText.type) + assertNull(retryAction.icon) + } + + @Test + fun toUiPrependsShowOriginalWhenTranslatedContentIsDisplayed() = + runTest { + val accountKey = MicroBlogKey(id = "account-translated", host = "test.com") + val postUser = createUser(MicroBlogKey(id = "post-user-translated", host = "test.com"), "Post User") + val post = + createPost( + accountKey = accountKey, + user = postUser, + statusKey = MicroBlogKey(id = "post-status-translated", host = "test.com"), + text = "source content", + ).copy( + actions = + persistentListOf( + ActionMenu.Group( + displayItem = + ActionMenu.Item( + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.More), + clickEvent = ClickEvent.Noop, + ), + actions = persistentListOf(), + ), + ), + ) + + val mapped = TimelinePagingMapper.toDb(post, pagingKey = "home") + saveToDatabase(db, listOf(mapped)) + db.translationDao().insert( + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = + mapped.status.status.data + .translationEntityKey(), + targetLanguage = "zh-CN", + sourceHash = post.translationPayload()!!.sourceHash(), + status = TranslationStatus.Completed, + payload = TranslationPayload(content = "translated content".toUiPlainText()), + updatedAt = 1L, + ), + ) + + val dbItem = + assertNotNull( + ( + assertIs>( + TestPager(config = PagingConfig(pageSize = 20), db.pagingTimelineDao().getPagingSource("home")).refresh(), + ) + ).data.firstOrNull(), + ) + + val timelineUi = + assertIs( + TimelinePagingMapper.toUi( + item = dbItem, + pagingKey = "home", + useDbKeyInItemKey = false, + translationDisplayOptions = + TranslationDisplayOptions( + translationEnabled = true, + autoDisplayEnabled = true, + ), + ), + ) + + assertEquals("translated content", timelineUi.content.raw) + val moreAction = assertIs(timelineUi.actions.first()) + val firstAction = assertIs(moreAction.actions.first()) + assertEquals( + ActionMenu.Item.Text.Localized.Type.ShowOriginal, + assertIs(firstAction.text).type, + ) + } + + @Test + fun toUiPrependsTranslateWhenOriginalModeIsForced() = + runTest { + val accountKey = MicroBlogKey(id = "account-original", host = "test.com") + val postUser = createUser(MicroBlogKey(id = "post-user-original", host = "test.com"), "Post User") + val statusKey = MicroBlogKey(id = "post-status-original", host = "test.com") + val post = + createPost( + accountKey = accountKey, + user = postUser, + statusKey = statusKey, + text = "source content", + ).copy( + actions = + persistentListOf( + ActionMenu.Group( + displayItem = + ActionMenu.Item( + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.More), + clickEvent = ClickEvent.Noop, + ), + actions = persistentListOf(), + ), + ), + ) + + val mapped = TimelinePagingMapper.toDb(post, pagingKey = "home") + saveToDatabase(db, listOf(mapped)) + db.translationDao().insert( + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = + mapped.status.status.data + .translationEntityKey(), + targetLanguage = "zh-CN", + sourceHash = post.translationPayload()!!.sourceHash(), + status = TranslationStatus.Completed, + displayMode = TranslationDisplayMode.Original, + payload = TranslationPayload(content = "translated content".toUiPlainText()), + updatedAt = 1L, + ), + ) + + val dbItem = + assertNotNull( + ( + assertIs>( + TestPager(config = PagingConfig(pageSize = 20), db.pagingTimelineDao().getPagingSource("home")).refresh(), + ) + ).data.firstOrNull(), + ) + + val timelineUi = + assertIs( + TimelinePagingMapper.toUi( + item = dbItem, + pagingKey = "home", + useDbKeyInItemKey = false, + translationDisplayOptions = + TranslationDisplayOptions( + translationEnabled = true, + autoDisplayEnabled = true, + ), + ), + ) + + assertEquals("source content", timelineUi.content.raw) + val moreAction = assertIs(timelineUi.actions.first()) + val firstAction = assertIs(moreAction.actions.first()) + assertEquals( + ActionMenu.Item.Text.Localized.Type.Translate, + assertIs(firstAction.text).type, + ) + } + + @Test + fun toUiStillPrependsTranslateWhenPreTranslationDisplayIsDisabled() = + runTest { + val accountKey = MicroBlogKey(id = "account-pretranslation-off", host = "test.com") + val postUser = createUser(MicroBlogKey(id = "post-user-pretranslation-off", host = "test.com"), "Post User") + val post = + createPost( + accountKey = accountKey, + user = postUser, + statusKey = MicroBlogKey(id = "post-status-pretranslation-off", host = "test.com"), + text = "source content", + ).copy( + actions = + persistentListOf( + ActionMenu.Group( + displayItem = + ActionMenu.Item( + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.More), + clickEvent = ClickEvent.Noop, + ), + actions = persistentListOf(), + ), + ), + ) + + val mapped = TimelinePagingMapper.toDb(post, pagingKey = "home") + saveToDatabase(db, listOf(mapped)) + db.translationDao().insert( + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = + mapped.status.status.data + .translationEntityKey(), + targetLanguage = "zh-CN", + sourceHash = post.translationPayload()!!.sourceHash(), + status = TranslationStatus.Completed, + payload = TranslationPayload(content = "translated content".toUiPlainText()), + updatedAt = 1L, + ), + ) + + val dbItem = + assertNotNull( + ( + assertIs>( + TestPager(config = PagingConfig(pageSize = 20), db.pagingTimelineDao().getPagingSource("home")).refresh(), + ) + ).data.firstOrNull(), + ) + + val timelineUi = + assertIs( + TimelinePagingMapper.toUi( + item = dbItem, + pagingKey = "home", + useDbKeyInItemKey = false, + translationDisplayOptions = + TranslationDisplayOptions( + translationEnabled = true, + autoDisplayEnabled = false, + ), + ), + ) + + assertEquals("source content", timelineUi.content.raw) + val moreAction = assertIs(timelineUi.actions.first()) + val firstAction = assertIs(moreAction.actions.first()) + assertEquals( + ActionMenu.Item.Text.Localized.Type.Translate, + assertIs(firstAction.text).type, + ) + } + private fun createUser( key: MicroBlogKey, name: String, diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt index 77c74cbe0..2029d7126 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt @@ -44,12 +44,17 @@ import dev.dimension.flare.ui.render.toUi import dev.dimension.flare.ui.render.toUiPlainText import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield import okio.Path import org.koin.core.context.startKoin import org.koin.core.context.stopKoin @@ -788,6 +793,151 @@ class MixedRemoteMediatorTest : RobolectricTest() { assertEquals("non_translatable_only", translation.statusReason) } + @Test + fun preTranslationBatchDocumentAllowsMissingTargetLanguageInResponse() { + val document = + """{"version":1,"items":[]}""".decodeJson( + dev.dimension.flare.data.translation.PreTranslationBatchDocument + .serializer(), + ) + + assertEquals("", document.targetLanguage) + assertTrue(document.items.isEmpty()) + } + + @Test + fun preTranslationServiceMarksStaleInFlightTranslationsAsFailedOnStartup() { + runBlocking { + db.translationDao().insert( + dev.dimension.flare.data.database.cache.model.DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = "status:stale-in-flight", + targetLanguage = "zh-CN", + sourceHash = "hash-stale", + status = TranslationStatus.Translating, + updatedAt = 1L, + ), + ) + + val appDataStore = AppDataStore(pathProducer) + appDataStore.appSettingsStore.updateData { + it.copy( + language = "zh-CN", + aiConfig = + AppSettings.AiConfig( + translation = true, + preTranslation = true, + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + + AiPreTranslationService( + database = db, + appDataStore = appDataStore, + aiCompletionService = AiCompletionService(OpenAIService(), TestOnDeviceAI()), + coroutineScope = scope, + ) + + yield() + + val cleaned = + db.translationDao().get( + entityType = TranslationEntityType.Status, + entityKey = "status:stale-in-flight", + targetLanguage = "zh-CN", + ) + assertNotNull(cleaned) + assertEquals(TranslationStatus.Failed, cleaned.status) + assertEquals("stale_in_flight", cleaned.statusReason) + + scope.coroutineContext[Job]?.cancel() + } + } + + @Test + fun queuedPreTranslationWritesPendingBeforeExecutionStarts() { + runBlocking { + val appDataStore = AppDataStore(pathProducer) + appDataStore.appSettingsStore.updateData { + it.copy( + language = "zh-CN", + aiConfig = + AppSettings.AiConfig( + translation = true, + preTranslation = true, + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + val started = CompletableDeferred() + val release = CompletableDeferred() + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + val preTranslationService: PreTranslationService = + AiPreTranslationService( + database = db, + appDataStore = appDataStore, + aiCompletionService = AiCompletionService(OpenAIService(), BlockingOnDeviceAI(started, release)), + coroutineScope = scope, + ) + val accountKey = MicroBlogKey(id = "account-pending-queue", host = "test.social") + val accountType = AccountType.Specific(accountKey) + val firstStatus = + TimelinePagingMapper + .toDb( + createPost( + accountType = accountType, + user = profile(MicroBlogKey("user-pending-1", "test.social"), "User"), + statusKey = MicroBlogKey("status-pending-1", "test.social"), + text = "first source", + ), + pagingKey = "home", + ).status.status.data + val secondStatus = + TimelinePagingMapper + .toDb( + createPost( + accountType = accountType, + user = profile(MicroBlogKey("user-pending-2", "test.social"), "User"), + statusKey = MicroBlogKey("status-pending-2", "test.social"), + text = "second source", + ), + pagingKey = "home", + ).status.status.data + + preTranslationService.enqueueStatuses(listOf(firstStatus), allowLongText = false) + started.await() + + preTranslationService.enqueueStatuses(listOf(secondStatus), allowLongText = false) + yield() + + var pendingTranslation = + db.translationDao().get( + entityType = TranslationEntityType.Status, + entityKey = secondStatus.id, + targetLanguage = "zh-CN", + ) + repeat(10) { + if (pendingTranslation != null) { + return@repeat + } + yield() + pendingTranslation = + db.translationDao().get( + entityType = TranslationEntityType.Status, + entityKey = secondStatus.id, + targetLanguage = "zh-CN", + ) + } + assertNotNull(pendingTranslation) + assertEquals(TranslationStatus.Pending, pendingTranslation.status) + + release.complete(Unit) + scope.coroutineContext[Job]?.cancel() + } + } + @Test fun refreshDeduplicatesSamePostReturnedByMultipleSubTimelines() = runTest { @@ -1017,6 +1167,47 @@ private class TestOnDeviceAI : OnDeviceAI { ): String? = null } +private class BlockingOnDeviceAI( + private val started: CompletableDeferred, + private val release: CompletableDeferred, +) : OnDeviceAI { + override suspend fun isAvailable(): Boolean = true + + override suspend fun translate( + source: String, + targetLanguage: String, + prompt: String, + ): String? { + started.complete(Unit) + release.await() + val document = + source.decodeJson( + dev.dimension.flare.data.translation.PreTranslationBatchDocument + .serializer(), + ) + return document + .copy( + items = + document.items.map { item -> + item.copy( + status = dev.dimension.flare.data.translation.PreTranslationBatchItemStatus.Completed, + payload = requireNotNull(item.payload).translated(targetLanguage), + reason = null, + ) + }, + ).encodeJson( + dev.dimension.flare.data.translation.PreTranslationBatchDocument + .serializer(), + ) + } + + override suspend fun tldr( + source: String, + targetLanguage: String, + prompt: String, + ): String? = null +} + private class SkippingOnDeviceAI : OnDeviceAI { override suspend fun isAvailable(): Boolean = true diff --git a/shared/src/jvmMain/kotlin/dev/dimension/flare/common/Locale.jvm.kt b/shared/src/jvmMain/kotlin/dev/dimension/flare/common/Locale.jvm.kt index 007ff7bca..1ea2cc8a7 100644 --- a/shared/src/jvmMain/kotlin/dev/dimension/flare/common/Locale.jvm.kt +++ b/shared/src/jvmMain/kotlin/dev/dimension/flare/common/Locale.jvm.kt @@ -3,5 +3,6 @@ package dev.dimension.flare.common import java.util.Locale internal actual object Locale { - actual val language: String = Locale.getDefault().language + actual val language: String + get() = Locale.getDefault().language } diff --git a/shared/src/jvmTest/kotlin/dev/dimension/flare/ui/model/DeeplinkEventTest.kt b/shared/src/jvmTest/kotlin/dev/dimension/flare/ui/model/DeeplinkEventTest.kt new file mode 100644 index 000000000..bd510a1de --- /dev/null +++ b/shared/src/jvmTest/kotlin/dev/dimension/flare/ui/model/DeeplinkEventTest.kt @@ -0,0 +1,23 @@ +package dev.dimension.flare.ui.model + +import dev.dimension.flare.model.MicroBlogKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class DeeplinkEventTest { + @Test + fun retryTranslationRoundTrips() { + val event = + DeeplinkEvent( + accountKey = MicroBlogKey("account", "example.com"), + translationEvent = + DeeplinkEvent.TranslationEvent.RetryTranslation( + statusKey = MicroBlogKey("status", "example.com"), + ), + ) + + val parsed = assertNotNull(DeeplinkEvent.parse(event.toUri())) + assertEquals(event, parsed) + } +} From 36c8c8b77e5ab5e20e39f35ac7e259ea949b497f Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 29 Mar 2026 17:20:38 +0900 Subject: [PATCH 05/14] fix test --- iosApp/flare/UI/Component/Status/StatusActionView.swift | 6 +++--- .../kotlin/dev/dimension/flare/common/Locale.android.kt | 2 +- .../kotlin/dev/dimension/flare/common/Locale.apple.kt | 4 ++-- .../jvmMain/kotlin/dev/dimension/flare/common/Locale.jvm.kt | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/iosApp/flare/UI/Component/Status/StatusActionView.swift b/iosApp/flare/UI/Component/Status/StatusActionView.swift index fccc48382..73c643f6b 100644 --- a/iosApp/flare/UI/Component/Status/StatusActionView.swift +++ b/iosApp/flare/UI/Component/Status/StatusActionView.swift @@ -222,9 +222,9 @@ extension ActionMenuItemText { case .muteWithHandleParameter: return "mute_user_with_handle \(localized.parameters.first ?? "")" case .acceptFollowRequest: return "accept_follow_request" case .rejectFollowRequest: return "reject_follow_request" - case .retryTranslation: return LocalizedStringResource(stringLiteral: "Retry translation") - case .translate: return LocalizedStringResource(stringLiteral: "Translate") - case .showOriginal: return LocalizedStringResource(stringLiteral: "Show original") + case .retryTranslation: return "Retry translation" + case .translate: return "Translate" + case .showOriginal: return "Show original" } } } diff --git a/shared/src/androidMain/kotlin/dev/dimension/flare/common/Locale.android.kt b/shared/src/androidMain/kotlin/dev/dimension/flare/common/Locale.android.kt index 1ea2cc8a7..c5b212248 100644 --- a/shared/src/androidMain/kotlin/dev/dimension/flare/common/Locale.android.kt +++ b/shared/src/androidMain/kotlin/dev/dimension/flare/common/Locale.android.kt @@ -4,5 +4,5 @@ import java.util.Locale internal actual object Locale { actual val language: String - get() = Locale.getDefault().language + get() = Locale.getDefault().toLanguageTag() } diff --git a/shared/src/appleMain/kotlin/dev/dimension/flare/common/Locale.apple.kt b/shared/src/appleMain/kotlin/dev/dimension/flare/common/Locale.apple.kt index 146583d73..251d2458a 100644 --- a/shared/src/appleMain/kotlin/dev/dimension/flare/common/Locale.apple.kt +++ b/shared/src/appleMain/kotlin/dev/dimension/flare/common/Locale.apple.kt @@ -2,9 +2,9 @@ package dev.dimension.flare.common import platform.Foundation.NSLocale import platform.Foundation.currentLocale -import platform.Foundation.languageCode +import platform.Foundation.localeIdentifier internal actual object Locale { actual val language: String - get() = NSLocale.currentLocale.languageCode + get() = NSLocale.currentLocale.localeIdentifier.replace('_', '-') } diff --git a/shared/src/jvmMain/kotlin/dev/dimension/flare/common/Locale.jvm.kt b/shared/src/jvmMain/kotlin/dev/dimension/flare/common/Locale.jvm.kt index 1ea2cc8a7..c5b212248 100644 --- a/shared/src/jvmMain/kotlin/dev/dimension/flare/common/Locale.jvm.kt +++ b/shared/src/jvmMain/kotlin/dev/dimension/flare/common/Locale.jvm.kt @@ -4,5 +4,5 @@ import java.util.Locale internal actual object Locale { actual val language: String - get() = Locale.getDefault().language + get() = Locale.getDefault().toLanguageTag() } From 9a726bebc8ee27acda113f7bdc3db046c5e157ba Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 29 Mar 2026 18:12:51 +0900 Subject: [PATCH 06/14] fix test --- .../database/cache/mapper/MicroblogTest.kt | 17 ++++++------ .../microblog/MixedRemoteMediatorTest.kt | 27 ++++++++++--------- .../microblog/handler/PostHandlerTest.kt | 9 ++++--- .../microblog/handler/UserHandlerTest.kt | 13 ++++----- 4 files changed, 35 insertions(+), 31 deletions(-) diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt index 65a80ea93..3d5cecabf 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt @@ -6,6 +6,7 @@ import androidx.paging.testing.TestPager import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import dev.dimension.flare.RobolectricTest +import dev.dimension.flare.common.Locale import dev.dimension.flare.common.TestFormatter import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus @@ -526,7 +527,7 @@ class MicroblogTest : RobolectricTest() { entityKey = mapped.status.status.data .translationEntityKey(), - targetLanguage = "zh-CN", + targetLanguage = Locale.language, sourceHash = rootPost.translationPayload()!!.sourceHash(), status = TranslationStatus.Completed, payload = TranslationPayload(content = "根帖子".toUiPlainText()), @@ -535,7 +536,7 @@ class MicroblogTest : RobolectricTest() { DbTranslation( entityType = TranslationEntityType.Status, entityKey = savedParentStatus.translationEntityKey(), - targetLanguage = "zh-CN", + targetLanguage = Locale.language, sourceHash = parentPost.translationPayload()!!.sourceHash(), status = TranslationStatus.Completed, payload = TranslationPayload(content = "父帖子".toUiPlainText()), @@ -1073,7 +1074,7 @@ class MicroblogTest : RobolectricTest() { entityKey = mapped.status.status.data .translationEntityKey(), - targetLanguage = "zh-CN", + targetLanguage = Locale.language, sourceHash = post.translationPayload()!!.sourceHash(), status = TranslationStatus.Completed, payload = TranslationPayload(content = "长文译文".toUiPlainText()), @@ -1135,7 +1136,7 @@ class MicroblogTest : RobolectricTest() { entityKey = mapped.status.status.data .translationEntityKey(), - targetLanguage = "zh-CN", + targetLanguage = Locale.language, sourceHash = post.translationPayload()!!.sourceHash(), status = TranslationStatus.Pending, payload = null, @@ -1206,7 +1207,7 @@ class MicroblogTest : RobolectricTest() { entityKey = mapped.status.status.data .translationEntityKey(), - targetLanguage = "zh-CN", + targetLanguage = Locale.language, sourceHash = post.translationPayload()!!.sourceHash(), status = TranslationStatus.Failed, payload = null, @@ -1275,7 +1276,7 @@ class MicroblogTest : RobolectricTest() { entityKey = mapped.status.status.data .translationEntityKey(), - targetLanguage = "zh-CN", + targetLanguage = Locale.language, sourceHash = post.translationPayload()!!.sourceHash(), status = TranslationStatus.Completed, payload = TranslationPayload(content = "translated content".toUiPlainText()), @@ -1349,7 +1350,7 @@ class MicroblogTest : RobolectricTest() { entityKey = mapped.status.status.data .translationEntityKey(), - targetLanguage = "zh-CN", + targetLanguage = Locale.language, sourceHash = post.translationPayload()!!.sourceHash(), status = TranslationStatus.Completed, displayMode = TranslationDisplayMode.Original, @@ -1423,7 +1424,7 @@ class MicroblogTest : RobolectricTest() { entityKey = mapped.status.status.data .translationEntityKey(), - targetLanguage = "zh-CN", + targetLanguage = Locale.language, sourceHash = post.translationPayload()!!.sourceHash(), status = TranslationStatus.Completed, payload = TranslationPayload(content = "translated content".toUiPlainText()), diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt index 2029d7126..a1a04ecfa 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt @@ -8,6 +8,7 @@ import androidx.paging.PagingState import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import dev.dimension.flare.RobolectricTest +import dev.dimension.flare.common.Locale import dev.dimension.flare.common.OnDeviceAI import dev.dimension.flare.common.TestFormatter import dev.dimension.flare.common.decodeJson @@ -459,7 +460,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { .find( entityType = TranslationEntityType.Status, entityKey = savedRoot.id, - targetLanguage = "zh-CN", + targetLanguage = Locale.language, ).filterNotNull() .first { it.status == TranslationStatus.Completed } val parentTranslation = @@ -468,12 +469,12 @@ class MixedRemoteMediatorTest : RobolectricTest() { .find( entityType = TranslationEntityType.Status, entityKey = savedParent.id, - targetLanguage = "zh-CN", + targetLanguage = Locale.language, ).filterNotNull() .first { it.status == TranslationStatus.Completed } - assertEquals("root source (zh-CN)", rootTranslation.payload?.content?.raw) - assertEquals("parent source (zh-CN)", parentTranslation.payload?.content?.raw) + assertEquals("root source (${Locale.language})", rootTranslation.payload?.content?.raw) + assertEquals("parent source (${Locale.language})", parentTranslation.payload?.content?.raw) } @OptIn(ExperimentalPagingApi::class) @@ -548,7 +549,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { db.translationDao().get( entityType = TranslationEntityType.Status, entityKey = savedStatus.id, - targetLanguage = "zh-CN", + targetLanguage = Locale.language, ) assertNull(translation) } @@ -584,7 +585,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { statusKey = MicroBlogKey("status-same-language", "test.social"), text = "已经是中文", ).copy( - sourceLanguages = persistentListOf("zh-CN"), + sourceLanguages = persistentListOf(Locale.language), ) val loader = FakeLoader("home") { request -> @@ -626,7 +627,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { db.translationDao().get( entityType = TranslationEntityType.Status, entityKey = savedStatus.id, - targetLanguage = "zh-CN", + targetLanguage = Locale.language, ) assertNotNull(translation) assertEquals(TranslationStatus.Skipped, translation.status) @@ -706,7 +707,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { .find( entityType = TranslationEntityType.Status, entityKey = savedStatus.id, - targetLanguage = "zh-CN", + targetLanguage = Locale.language, ).filterNotNull() .first() assertEquals(TranslationStatus.Skipped, translation.status) @@ -786,7 +787,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { .find( entityType = TranslationEntityType.Status, entityKey = savedStatus.id, - targetLanguage = "zh-CN", + targetLanguage = Locale.language, ).filterNotNull() .first() assertEquals(TranslationStatus.Skipped, translation.status) @@ -812,7 +813,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { dev.dimension.flare.data.database.cache.model.DbTranslation( entityType = TranslationEntityType.Status, entityKey = "status:stale-in-flight", - targetLanguage = "zh-CN", + targetLanguage = Locale.language, sourceHash = "hash-stale", status = TranslationStatus.Translating, updatedAt = 1L, @@ -846,7 +847,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { db.translationDao().get( entityType = TranslationEntityType.Status, entityKey = "status:stale-in-flight", - targetLanguage = "zh-CN", + targetLanguage = Locale.language, ) assertNotNull(cleaned) assertEquals(TranslationStatus.Failed, cleaned.status) @@ -916,7 +917,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { db.translationDao().get( entityType = TranslationEntityType.Status, entityKey = secondStatus.id, - targetLanguage = "zh-CN", + targetLanguage = Locale.language, ) repeat(10) { if (pendingTranslation != null) { @@ -927,7 +928,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { db.translationDao().get( entityType = TranslationEntityType.Status, entityKey = secondStatus.id, - targetLanguage = "zh-CN", + targetLanguage = Locale.language, ) } assertNotNull(pendingTranslation) diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt index 2b379dd7a..c03f17021 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt @@ -4,6 +4,7 @@ import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import dev.dimension.flare.RobolectricTest import dev.dimension.flare.common.CacheState +import dev.dimension.flare.common.Locale import dev.dimension.flare.common.OnDeviceAI import dev.dimension.flare.common.TestFormatter import dev.dimension.flare.common.decodeJson @@ -415,17 +416,17 @@ class PostHandlerTest : RobolectricTest() { .find( entityType = TranslationEntityType.Status, entityKey = savedStatus.id, - targetLanguage = "zh-CN", + targetLanguage = Locale.language, ).filterNotNull() .first { it.status == TranslationStatus.Completed } - assertEquals("$longText (zh-CN)", translation.payload?.content?.raw) + assertEquals("$longText (${Locale.language})", translation.payload?.content?.raw) val translated = cacheable.data .filterIsInstance>() - .first { (it.data as? UiTimelineV2.Post)?.content?.raw == "$longText (zh-CN)" } + .first { (it.data as? UiTimelineV2.Post)?.content?.raw == "$longText (${Locale.language})" } .data as UiTimelineV2.Post - assertEquals("$longText (zh-CN)", translated.content.raw) + assertEquals("$longText (${Locale.language})", translated.content.raw) } private fun createPost( diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt index ad405acb2..dfb44ad70 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt @@ -5,6 +5,7 @@ import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import dev.dimension.flare.RobolectricTest import dev.dimension.flare.common.CacheState +import dev.dimension.flare.common.Locale import dev.dimension.flare.common.OnDeviceAI import dev.dimension.flare.common.TestFormatter import dev.dimension.flare.common.decodeJson @@ -258,7 +259,7 @@ class UserHandlerTest : RobolectricTest() { DbTranslation( entityType = TranslationEntityType.Profile, entityKey = profile.translationEntityKey(), - targetLanguage = "zh-CN", + targetLanguage = Locale.language, sourceHash = profile.translationPayload().sourceHash(), status = TranslationStatus.Completed, payload = TranslationPayload(description = "翻译后的简介".toUiPlainText()), @@ -307,11 +308,11 @@ class UserHandlerTest : RobolectricTest() { .find( entityType = TranslationEntityType.Profile, entityKey = expected.translationEntityKey(), - targetLanguage = "zh-CN", + targetLanguage = Locale.language, ).filterNotNull() .first { it.status == TranslationStatus.Completed } - assertEquals("Original profile bio (zh-CN)", saved.payload?.description?.raw) + assertEquals("Original profile bio (${Locale.language})", saved.payload?.description?.raw) } @Test @@ -347,7 +348,7 @@ class UserHandlerTest : RobolectricTest() { .find( entityType = TranslationEntityType.Profile, entityKey = expected.translationEntityKey(), - targetLanguage = "zh-CN", + targetLanguage = Locale.language, ).filterNotNull() .first { it.status == TranslationStatus.Failed } assertEquals(1, failed.attemptCount) @@ -365,11 +366,11 @@ class UserHandlerTest : RobolectricTest() { .find( entityType = TranslationEntityType.Profile, entityKey = expected.translationEntityKey(), - targetLanguage = "zh-CN", + targetLanguage = Locale.language, ).filterNotNull() .first { it.status == TranslationStatus.Completed } assertEquals(2, completed.attemptCount) - assertEquals("Retry profile bio (zh-CN)", completed.payload?.description?.raw) + assertEquals("Retry profile bio (${Locale.language})", completed.payload?.description?.raw) } private fun createProfile( From d2acc6e2fcb556478eaa5dca5b4404a1abab410e Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 29 Mar 2026 18:51:22 +0900 Subject: [PATCH 07/14] fix test --- .../microblog/MixedRemoteMediatorTest.kt | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt index a1a04ecfa..6f056d538 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt @@ -15,6 +15,7 @@ import dev.dimension.flare.common.decodeJson import dev.dimension.flare.common.encodeJson import dev.dimension.flare.createTestRootPath import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.model.DbTranslation import dev.dimension.flare.data.database.cache.model.TranslationEntityType import dev.dimension.flare.data.database.cache.model.TranslationStatus import dev.dimension.flare.data.datasource.microblog.paging.CacheableRemoteLoader @@ -51,10 +52,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.yield import okio.Path import org.koin.core.context.startKoin @@ -911,25 +914,18 @@ class MixedRemoteMediatorTest : RobolectricTest() { started.await() preTranslationService.enqueueStatuses(listOf(secondStatus), allowLongText = false) - yield() - var pendingTranslation = - db.translationDao().get( - entityType = TranslationEntityType.Status, - entityKey = secondStatus.id, - targetLanguage = Locale.language, - ) - repeat(10) { - if (pendingTranslation != null) { - return@repeat + var pendingTranslation: DbTranslation? = null + withTimeout(5_000) { + while (pendingTranslation == null) { + delay(50) + pendingTranslation = + db.translationDao().get( + entityType = TranslationEntityType.Status, + entityKey = secondStatus.id, + targetLanguage = Locale.language, + ) } - yield() - pendingTranslation = - db.translationDao().get( - entityType = TranslationEntityType.Status, - entityKey = secondStatus.id, - targetLanguage = Locale.language, - ) } assertNotNull(pendingTranslation) assertEquals(TranslationStatus.Pending, pendingTranslation.status) From ca8da09fa84a9081753fc35b376875b5b2d364d7 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 29 Mar 2026 22:16:08 +0900 Subject: [PATCH 08/14] add retry for pretranslate --- .../data/translation/PreTranslationService.kt | 85 +++++++++++++------ .../microblog/handler/UserHandlerTest.kt | 48 +++++++++++ 2 files changed, 106 insertions(+), 27 deletions(-) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt index 90be741f3..4ee4e00d8 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt @@ -28,12 +28,14 @@ import dev.dimension.flare.ui.render.UiRichText import dev.dimension.flare.ui.render.applyTranslationDocument import dev.dimension.flare.ui.render.toTranslationDocument import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.serialization.Serializable import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes internal interface PreTranslationService { @@ -450,33 +452,11 @@ internal class AiPreTranslationService( }, ) - tryRun { - val sourceDocument = - PreTranslationBatchDocument( - targetLanguage = targetLanguage, - items = - candidates.map { candidate -> - PreTranslationBatchItem( - entityKey = candidate.entityKey, - payload = candidate.sourceDocument, - ) - }, - ) - val sourceJson = sourceDocument.encodeJson(PreTranslationBatchDocument.serializer()) - val prompt = buildTranslatePrompt(settings.translatePrompt, targetLanguage, sourceJson) - val translatedJson = - aiCompletionService.translate( - config = settings, - source = sourceJson, - targetLanguage = targetLanguage, - prompt = prompt, - ) ?: error("Pre-translation returned empty response") - - applyBatchResult( - translatedJson = translatedJson, - candidates = candidates, - ) - }.getOrElse { throwable -> + runBatchTranslationWithRetry( + settings = settings, + targetLanguage = targetLanguage, + candidates = candidates, + ).getOrElse { throwable -> val failedAt = Clock.System.now().toEpochMilliseconds() database.translationDao().insertAll( candidates.map { candidate -> @@ -497,6 +477,55 @@ internal class AiPreTranslationService( } } + private suspend fun runBatchTranslationWithRetry( + settings: AppSettings.AiConfig, + targetLanguage: String, + candidates: List, + ): Result { + var lastFailure: Throwable? = null + repeat(PRE_TRANSLATION_BATCH_MAX_ATTEMPTS) { attempt -> + val result = + tryRun { + val sourceDocument = + PreTranslationBatchDocument( + targetLanguage = targetLanguage, + items = + candidates.map { candidate -> + PreTranslationBatchItem( + entityKey = candidate.entityKey, + payload = candidate.sourceDocument, + ) + }, + ) + val sourceJson = sourceDocument.encodeJson(PreTranslationBatchDocument.serializer()) + val prompt = buildTranslatePrompt(settings.translatePrompt, targetLanguage, sourceJson) + val translatedJson = + aiCompletionService.translate( + config = settings, + source = sourceJson, + targetLanguage = targetLanguage, + prompt = prompt, + ) ?: error("Pre-translation returned empty response") + + applyBatchResult( + translatedJson = translatedJson, + candidates = candidates, + ) + } + result + .onSuccess { + return Result.success(Unit) + }.onFailure { throwable -> + lastFailure = throwable + if (attempt < PRE_TRANSLATION_BATCH_MAX_ATTEMPTS - 1) { + delay(PRE_TRANSLATION_BATCH_RETRY_DELAY) + } + } + } + + return Result.failure(requireNotNull(lastFailure)) + } + private suspend fun applyBatchResult( translatedJson: String, candidates: List, @@ -677,12 +706,14 @@ private data class PreparedTranslationCandidate( private const val DEFAULT_PRE_TRANSLATION_MAX_ITEMS = 8 private const val DEFAULT_PRE_TRANSLATION_MAX_INPUT_TOKENS = 6000 +private const val PRE_TRANSLATION_BATCH_MAX_ATTEMPTS = 2 private const val FAILED_STALE_IN_FLIGHT_REASON = "stale_in_flight" private const val SKIPPED_AI_SAME_LANGUAGE_REASON = "ai_same_language" private const val SKIPPED_EMPTY_REASON = "empty" private const val SKIPPED_NON_TRANSLATABLE_ONLY_REASON = "non_translatable_only" private const val SKIPPED_SAME_LANGUAGE_REASON = "source_language_matches_target" private const val SKIPPED_UNCHANGED_REASON = "unchanged" +private val PRE_TRANSLATION_BATCH_RETRY_DELAY = 500.milliseconds private val STALE_TRANSLATION_TIMEOUT = 10.minutes private val protectedTranslationPattern = Regex("""https?://\S+|@[A-Za-z0-9._-]+(?:@[A-Za-z0-9.-]+)?|#[\p{L}\p{N}_]+""") diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt index dfb44ad70..ad50062ea 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt @@ -373,6 +373,47 @@ class UserHandlerTest : RobolectricTest() { assertEquals("Retry profile bio (${Locale.language})", completed.payload?.description?.raw) } + @Test + fun userByIdRefreshAutoRetriesTransientPreTranslationFailure() = + runTest { + val expected = + createProfile(id = "auto-retry-translation", host = "test.social", handle = "@auto-retry-translation@test.social").copy( + description = "Auto retry profile bio".toUiPlainText(), + ) + loader.nextById = expected + appDataStore.appSettingsStore.updateData { + it.copy( + language = "zh-CN", + aiConfig = + AppSettings.AiConfig( + translation = true, + preTranslation = true, + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + + onDeviceAI.remainingTranslationFailures = 1 + handler + .userById("auto-retry-translation") + .refreshState + .drop(1) + .first() + + val completed = + db + .translationDao() + .find( + entityType = TranslationEntityType.Profile, + entityKey = expected.translationEntityKey(), + targetLanguage = Locale.language, + ).filterNotNull() + .first { it.status == TranslationStatus.Completed } + assertEquals(1, completed.attemptCount) + assertEquals(2, onDeviceAI.translationCallCount) + assertEquals("Auto retry profile bio (${Locale.language})", completed.payload?.description?.raw) + } + private fun createProfile( id: String, host: String, @@ -420,6 +461,8 @@ class UserHandlerTest : RobolectricTest() { private class FakeOnDeviceAI : OnDeviceAI { var failTranslation: Boolean = false + var remainingTranslationFailures: Int = 0 + var translationCallCount: Int = 0 override suspend fun isAvailable(): Boolean = true @@ -428,6 +471,11 @@ private class FakeOnDeviceAI : OnDeviceAI { targetLanguage: String, prompt: String, ): String? { + translationCallCount++ + if (remainingTranslationFailures > 0) { + remainingTranslationFailures-- + error("translation failed") + } if (failTranslation) { error("translation failed") } From a648b7c7ea4f66bd758e727e9e0bb46094189dc7 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 29 Mar 2026 22:58:37 +0900 Subject: [PATCH 09/14] add google translate as pre translate --- .../dev/dimension/flare/ui/AppContainer.kt | 12 +- .../ui/screen/settings/AiConfigScreen.kt | 124 +++++++++++---- .../dimension/flare/ui/theme/FlareTheme.kt | 4 +- .../ui/screen/settings/SettingsScreen.kt | 94 ++++++++--- .../dimension/flare/ui/theme/FlareTheme.kt | 4 +- iosApp/flare/UI/Screen/AiConfigScreen.swift | 82 +++++----- .../microblog/handler/PostHandler.kt | 4 +- .../microblog/handler/UserHandler.kt | 4 +- .../flare/data/datastore/model/AppSettings.kt | 24 +++ .../data/translation/PreTranslationService.kt | 35 ++-- .../data/translation/TranslationProvider.kt | 149 ++++++++++++++++++ .../ui/presenter/home/TimelinePresenter.kt | 4 +- .../presenter/settings/AiConfigPresenter.kt | 63 +++++++- .../ui/presenter/status/TranslatePresenter.kt | 59 ++----- .../microblog/MixedRemoteMediatorTest.kt | 21 +-- .../microblog/handler/PostHandlerTest.kt | 3 +- .../microblog/handler/UserHandlerTest.kt | 12 +- 17 files changed, 497 insertions(+), 201 deletions(-) create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProvider.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt b/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt index 9289972df..45f2ecbf3 100644 --- a/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt +++ b/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt @@ -64,7 +64,7 @@ fun FlareApp(content: @Composable () -> Unit) { LocalUriHandler provides uriHandler, LocalAppearanceSettings provides appearanceSettings, LocalComponentAppearance provides - remember(appearanceSettings, appSettings.aiConfig) { + remember(appearanceSettings, appSettings.translateConfig, appSettings.aiConfig.tldr) { ComponentAppearance( dynamicTheme = appearanceSettings.dynamicTheme, avatarShape = @@ -84,11 +84,11 @@ fun FlareApp(content: @Composable () -> Unit) { }, expandMediaSize = appearanceSettings.expandMediaSize, compatLinkPreview = appearanceSettings.compatLinkPreview, - aiConfig = - ComponentAppearance.AiConfig( - translation = appSettings.aiConfig.translation, - tldr = appSettings.aiConfig.tldr, - ), + aiConfig = + ComponentAppearance.AiConfig( + translation = true, + tldr = appSettings.aiConfig.tldr, + ), fullWidthPost = appearanceSettings.fullWidthPost, postActionStyle = appearanceSettings.postActionStyle, absoluteTimestamp = appearanceSettings.absoluteTimestamp, diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt index ec2eea8ed..491d38382 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt @@ -46,6 +46,7 @@ import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.settings.AiConfigPresenter import dev.dimension.flare.ui.presenter.settings.AiTypeOption +import dev.dimension.flare.ui.presenter.settings.TranslateProviderOption import dev.dimension.flare.ui.theme.first import dev.dimension.flare.ui.theme.item import dev.dimension.flare.ui.theme.last @@ -91,6 +92,11 @@ internal fun AiConfigScreen(onBack: () -> Unit) { val apiKeyHint = stringResource(id = R.string.settings_ai_config_api_key_hint) val translatePromptTitle = stringResource(id = R.string.settings_ai_config_translate_prompt) val tldrPromptTitle = stringResource(id = R.string.settings_ai_config_tldr_prompt) + val selectedTranslateProvider = + when (state.translateConfig.provider) { + AppSettings.TranslateConfig.Provider.AI -> TranslateProviderOption.AI + AppSettings.TranslateConfig.Provider.Google -> TranslateProviderOption.Google + } val selectedType = when (state.aiConfig.type) { is AppSettings.AiConfig.Type.OpenAI -> AiTypeOption.OpenAI @@ -336,39 +342,70 @@ internal fun AiConfigScreen(onBack: () -> Unit) { ) } Spacer(modifier = Modifier.height(12.dp)) - SegmentedListItem( - onClick = { - state.update { - copy(translation = !state.aiConfig.translation) - } - }, - shapes = ListItemDefaults.first(), - content = { - Text( - text = stringResource(id = R.string.settings_ai_config_entable_translation), - ) - }, - supportingContent = { - Text( - text = stringResource(id = R.string.settings_ai_config_translation_description), - ) - }, - trailingContent = { - Switch( - checked = state.aiConfig.translation, - onCheckedChange = { - state.update { - copy(translation = it) + AnimatedVisibility(visible = true) { + SegmentedListItem( + checked = state.showProviderDropdown, + onCheckedChange = { checked -> + state.setShowProviderDropdown(checked) + }, + shapes = ListItemDefaults.first(), + content = { + Text(text = "Translation Provider") + }, + supportingContent = { + Text( + text = "Choose which service handles translation", + style = MaterialTheme.typography.bodySmall, + ) + }, + trailingContent = { + Box { + TextButton( + onClick = { + state.setShowProviderDropdown(true) + }, + ) { + Text( + text = + when (selectedTranslateProvider) { + TranslateProviderOption.AI -> "AI" + TranslateProviderOption.Google -> "Google Translate" + }, + ) } - }, - ) - }, - ) - AnimatedVisibility(visible = state.aiConfig.translation) { + FlareDropdownMenu( + expanded = state.showProviderDropdown, + onDismissRequest = { + state.setShowProviderDropdown(false) + }, + ) { + state.supportedTranslateProviders.forEach { provider -> + DropdownMenuItem( + text = { + Text( + text = + when (provider) { + TranslateProviderOption.AI -> "AI" + TranslateProviderOption.Google -> "Google Translate" + }, + ) + }, + onClick = { + state.setShowProviderDropdown(false) + state.selectTranslateProvider(provider) + }, + ) + } + } + } + }, + ) + } + AnimatedVisibility(visible = true) { SegmentedListItem( onClick = { - state.update { - copy(preTranslation = !state.aiConfig.preTranslation) + state.updateTranslateConfig { + copy(preTranslate = !state.translateConfig.preTranslate) } }, shapes = ListItemDefaults.item(), @@ -384,17 +421,20 @@ internal fun AiConfigScreen(onBack: () -> Unit) { }, trailingContent = { Switch( - checked = state.aiConfig.preTranslation, + checked = state.translateConfig.preTranslate, onCheckedChange = { - state.update { - copy(preTranslation = it) + state.updateTranslateConfig { + copy(preTranslate = it) } }, ) }, ) } - AnimatedVisibility(visible = state.aiConfig.translation) { + AnimatedVisibility( + visible = + selectedTranslateProvider == TranslateProviderOption.AI, + ) { SegmentedListItem( checked = state.textEditDialog?.field == AiConfigEditField.TranslatePrompt, onCheckedChange = { checked -> @@ -527,25 +567,37 @@ private fun presenter() = val businessState = remember { AiConfigPresenter() }.invoke() var showTypeDropdown by remember { mutableStateOf(false) } var showModelDropdown by remember { mutableStateOf(false) } + var showProviderDropdown by remember { mutableStateOf(false) } var textEditDialog by remember { mutableStateOf(null) } object { val aiConfig = businessState.aiConfig + val translateConfig = businessState.translateConfig val openAIModels = businessState.openAIModels val supportedTypes = businessState.supportedTypes + val supportedTranslateProviders = businessState.supportedTranslateProviders val serverSuggestions = businessState.serverSuggestions val showTypeDropdown = showTypeDropdown val showModelDropdown = showModelDropdown + val showProviderDropdown = showProviderDropdown val textEditDialog = textEditDialog fun update(block: AppSettings.AiConfig.() -> AppSettings.AiConfig) { businessState.update(block) } + fun updateTranslateConfig(block: AppSettings.TranslateConfig.() -> AppSettings.TranslateConfig) { + businessState.updateTranslateConfig(block) + } + fun selectType(type: AiTypeOption) { businessState.selectType(type) } + fun selectTranslateProvider(type: TranslateProviderOption) { + businessState.selectTranslateProvider(type) + } + fun setShowTypeDropdown(value: Boolean) { showTypeDropdown = value } @@ -554,6 +606,10 @@ private fun presenter() = showModelDropdown = value } + fun setShowProviderDropdown(value: Boolean) { + showProviderDropdown = value + } + fun setTextEditDialog(value: TextEditDialogState?) { textEditDialog = value } diff --git a/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index f837e5f09..08b5723c3 100644 --- a/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -34,7 +34,7 @@ internal fun ProvideThemeSettings(content: @Composable () -> Unit) { CompositionLocalProvider( LocalAppearanceSettings provides appearanceSettings, LocalComponentAppearance provides - remember(appearanceSettings, appSettings.aiConfig) { + remember(appearanceSettings, appSettings.translateConfig, appSettings.aiConfig.tldr) { ComponentAppearance( dynamicTheme = appearanceSettings.dynamicTheme, avatarShape = @@ -51,7 +51,7 @@ internal fun ProvideThemeSettings(content: @Composable () -> Unit) { compatLinkPreview = appearanceSettings.compatLinkPreview, aiConfig = ComponentAppearance.AiConfig( - translation = appSettings.aiConfig.translation, + translation = true, tldr = appSettings.aiConfig.tldr, ), fullWidthPost = appearanceSettings.fullWidthPost, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index 9c559b4ab..42bf93ccd 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -190,6 +190,7 @@ import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.settings.AiConfigPresenter import dev.dimension.flare.ui.presenter.settings.AiTypeOption +import dev.dimension.flare.ui.presenter.settings.TranslateProviderOption import dev.dimension.flare.ui.presenter.settings.StoragePresenter import dev.dimension.flare.ui.presenter.settings.StorageState import dev.dimension.flare.ui.theme.LocalComposeWindow @@ -1206,6 +1207,11 @@ internal fun SettingsScreen( val apiKeyHint = stringResource(Res.string.settings_ai_config_api_key_hint) val translatePromptTitle = stringResource(Res.string.settings_ai_config_translate_prompt) val tldrPromptTitle = stringResource(Res.string.settings_ai_config_tldr_prompt) + val selectedTranslateProvider = + when (state.aiConfigState.translateConfig.provider) { + AppSettings.TranslateConfig.Provider.AI -> TranslateProviderOption.AI + AppSettings.TranslateConfig.Provider.Google -> TranslateProviderOption.Google + } val selectedType = when (state.aiConfigState.aiConfig.type) { is AppSettings.AiConfig.Type.OpenAI -> AiTypeOption.OpenAI @@ -1404,26 +1410,54 @@ internal fun SettingsScreen( ExpanderItemSeparator() } } - ExpanderItem( - heading = { - Text(stringResource(Res.string.settings_ai_config_entable_translation)) - }, - caption = { - Text(stringResource(Res.string.settings_ai_config_translation_description)) - }, - trailing = { - Switcher( - checked = state.aiConfigState.aiConfig.translation, - { - state.aiConfigState.update { copy(translation = it) } + AnimatedVisibility(true) { + Column { + ExpanderItem( + heading = { + Text("Translation Provider") + }, + caption = { + Text("Choose which service handles translation") + }, + trailing = { + DropDownButton( + onClick = { + state.aiConfigState.setShowProviderDropdown(!state.aiConfigState.showProviderDropdown) + }, + ) { + Text( + when (selectedTranslateProvider) { + TranslateProviderOption.AI -> "AI" + TranslateProviderOption.Google -> "Google Translate" + }, + ) + } + MenuFlyout( + visible = state.aiConfigState.showProviderDropdown, + onDismissRequest = { state.aiConfigState.setShowProviderDropdown(false) }, + placement = FlyoutPlacement.BottomAlignedEnd, + modifier = Modifier.heightIn(max = 200.dp), + ) { + state.aiConfigState.supportedTranslateProviders.forEach { provider -> + MenuFlyoutItem( + text = { + Text( + when (provider) { + TranslateProviderOption.AI -> "AI" + TranslateProviderOption.Google -> "Google Translate" + }, + ) + }, + onClick = { + state.aiConfigState.selectTranslateProvider(provider) + state.aiConfigState.setShowProviderDropdown(false) + }, + ) + } + } }, - textBefore = true, ) - }, - ) - ExpanderItemSeparator() - AnimatedVisibility(state.aiConfigState.aiConfig.translation) { - Column { + ExpanderItemSeparator() ExpanderItem( heading = { Text(stringResource(Res.string.settings_ai_config_enable_pre_translation)) @@ -1433,15 +1467,19 @@ internal fun SettingsScreen( }, trailing = { Switcher( - checked = state.aiConfigState.aiConfig.preTranslation, + checked = state.aiConfigState.translateConfig.preTranslate, { - state.aiConfigState.update { copy(preTranslation = it) } + state.aiConfigState.updateTranslateConfig { copy(preTranslate = it) } }, textBefore = true, ) }, ) ExpanderItemSeparator() + } + } + AnimatedVisibility(selectedTranslateProvider == TranslateProviderOption.AI) { + Column { ExpanderItem( heading = { Text(stringResource(Res.string.settings_ai_config_translate_prompt)) }, caption = { @@ -1872,15 +1910,19 @@ private fun aiConfigPresenter() = val state = remember { AiConfigPresenter() }.invoke() var showTypeDropdown by remember { mutableStateOf(false) } var showModelDropdown by remember { mutableStateOf(false) } + var showProviderDropdown by remember { mutableStateOf(false) } var textEditDialog by remember { mutableStateOf(null) } object { val aiConfig = state.aiConfig + val translateConfig = state.translateConfig val openAIModels = state.openAIModels val supportedTypes = state.supportedTypes + val supportedTranslateProviders = state.supportedTranslateProviders val serverSuggestions = state.serverSuggestions val expanded = expanded val showTypeDropdown = showTypeDropdown val showModelDropdown = showModelDropdown + val showProviderDropdown = showProviderDropdown val textEditDialog = textEditDialog fun setExpanded(value: Boolean) { @@ -1891,10 +1933,18 @@ private fun aiConfigPresenter() = state.update(block) } + fun updateTranslateConfig(block: AppSettings.TranslateConfig.() -> AppSettings.TranslateConfig) { + state.updateTranslateConfig(block) + } + fun selectType(type: AiTypeOption) { state.selectType(type) } + fun selectTranslateProvider(type: TranslateProviderOption) { + state.selectTranslateProvider(type) + } + fun setShowTypeDropdown(value: Boolean) { showTypeDropdown = value } @@ -1903,6 +1953,10 @@ private fun aiConfigPresenter() = showModelDropdown = value } + fun setShowProviderDropdown(value: Boolean) { + showProviderDropdown = value + } + fun setTextEditDialog(value: TextEditDialogState?) { textEditDialog = value } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index 4105cec2c..25c694b6d 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -373,7 +373,7 @@ internal fun ProvideThemeSettings(content: @Composable () -> Unit) { CompositionLocalProvider( LocalAppearanceSettings provides appearanceSettings, LocalComponentAppearance provides - remember(appearanceSettings, appSettings.aiConfig) { + remember(appearanceSettings, appSettings.translateConfig, appSettings.aiConfig.tldr) { ComponentAppearance( dynamicTheme = appearanceSettings.dynamicTheme, avatarShape = @@ -395,7 +395,7 @@ internal fun ProvideThemeSettings(content: @Composable () -> Unit) { compatLinkPreview = appearanceSettings.compatLinkPreview, aiConfig = ComponentAppearance.AiConfig( - translation = appSettings.aiConfig.translation, + translation = true, tldr = appSettings.aiConfig.tldr, ), fullWidthPost = appearanceSettings.fullWidthPost, diff --git a/iosApp/flare/UI/Screen/AiConfigScreen.swift b/iosApp/flare/UI/Screen/AiConfigScreen.swift index f6d95c819..3cab4c16b 100644 --- a/iosApp/flare/UI/Screen/AiConfigScreen.swift +++ b/iosApp/flare/UI/Screen/AiConfigScreen.swift @@ -128,52 +128,41 @@ struct AiConfigScreen: View { } Section { + Picker( + selection: Binding( + get: { translateProviderOption(provider: presenter.state.translateConfig.provider) }, + set: { provider in + presenter.state.selectTranslateProvider(type: provider) + } + ) + ) { + ForEach(presenter.state.supportedTranslateProviders, id: \.name) { provider in + Text(translateProviderOptionTitle(option: provider)).tag(provider) + } + } label: { + Text("Translation Provider") + Text("Choose which service handles translation") + } + Toggle( isOn: Binding( - get: { presenter.state.aiConfig.translation }, + get: { presenter.state.translateConfig.preTranslate }, set: { newValue in - presenter.state.update { current in + presenter.state.updateTranslateConfig { current in current.doCopy( - translation: newValue, - tldr: current.tldr, - type: current.type, - translatePrompt: current.translatePrompt, - tldrPrompt: current.tldrPrompt, - preTranslation: current.preTranslation + preTranslate: newValue, + provider: current.provider ) } } ) ) { - Text("ai_config_translate") - Text("Translate text with AI") + Text("ai_config_pre_translate") + Text("ai_config_pre_translate_description") } + .transition(.opacity.combined(with: .move(edge: .top))) - if presenter.state.aiConfig.translation { - Toggle( - isOn: Binding( - get: { presenter.state.aiConfig.preTranslation }, - set: { newValue in - presenter.state.update { current in - current.doCopy( - translation: current.translation, - tldr: current.tldr, - type: current.type, - translatePrompt: current.translatePrompt, - tldrPrompt: current.tldrPrompt, - preTranslation: newValue - ) - } - } - ) - ) { - Text("ai_config_pre_translate") - Text("ai_config_pre_translate_description") - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - - if presenter.state.aiConfig.translation { + if translateProviderOption(provider: presenter.state.translateConfig.provider) == .ai { Button { beginEditing( field: .translatePrompt, @@ -232,8 +221,7 @@ struct AiConfigScreen: View { } } .animation(.easeInOut(duration: 0.2), value: isOpenAIType(presenter.state.aiConfig.type)) - .animation(.easeInOut(duration: 0.2), value: presenter.state.aiConfig.translation) - .animation(.easeInOut(duration: 0.2), value: presenter.state.aiConfig.preTranslation) + .animation(.easeInOut(duration: 0.2), value: presenter.state.translateConfig.preTranslate) .animation(.easeInOut(duration: 0.2), value: presenter.state.aiConfig.tldr) .sheet(item: $editingField) { field in NavigationStack { @@ -323,6 +311,15 @@ struct AiConfigScreen: View { } } + private func translateProviderOption(provider: any AppSettingsTranslateConfigProvider) -> TranslateProviderOption { + switch onEnum(of: provider) { + case .ai: + return .ai + case .google: + return .google + } + } + private func aiTypeOptionTitle(option: AiTypeOption) -> LocalizedStringResource { switch option { case .onDevice: @@ -332,6 +329,17 @@ struct AiConfigScreen: View { } } + private func translateProviderOptionTitle(option: TranslateProviderOption) -> LocalizedStringResource { + switch option { + case .ai: + return "AI" + case .google: + return "Google Translate" + default: + return "AI" + } + } + private func displayText(_ value: String) -> String { value.isEmpty ? String(localized: "Not set") : value } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt index 8e7c9066d..1cc008a2d 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt @@ -38,8 +38,8 @@ internal class PostHandler( appDataStore.appSettingsStore.data .map { settings -> TranslationDisplayOptions( - translationEnabled = settings.aiConfig.translation, - autoDisplayEnabled = settings.aiConfig.translation && settings.aiConfig.preTranslation, + translationEnabled = true, + autoDisplayEnabled = settings.translateConfig.preTranslate, ) }.distinctUntilChanged() } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt index 6578a54ce..dbdee7daf 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt @@ -36,8 +36,8 @@ internal class UserHandler( appDataStore.appSettingsStore.data .map { settings -> TranslationDisplayOptions( - translationEnabled = settings.aiConfig.translation, - autoDisplayEnabled = settings.aiConfig.translation && settings.aiConfig.preTranslation, + translationEnabled = true, + autoDisplayEnabled = settings.translateConfig.preTranslate, ) }.distinctUntilChanged() } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt index 174a3baeb..c7dc5b25b 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt @@ -17,14 +17,38 @@ public data class AppSettings( val version: String, val aiConfig: AiConfig = AiConfig(), val language: String = "", + val translateConfig: TranslateConfig = TranslateConfig(), ) { + @Serializable + public data class TranslateConfig( + val preTranslate: Boolean = false, + val provider: Provider = Provider.Google, + ) { + @Serializable + public sealed interface Provider { + @Serializable + public data object AI : Provider + + @Serializable + public data object Google : Provider + } + } + @Serializable public data class AiConfig( + @Deprecated( + message = "Translation is always enabled.", + level = DeprecationLevel.ERROR, + ) val translation: Boolean = false, val tldr: Boolean = false, val type: Type = Type.OpenAI("", "", ""), val translatePrompt: String = AiPromptDefaults.TRANSLATE_PROMPT, val tldrPrompt: String = AiPromptDefaults.TLDR_PROMPT, + @Deprecated( + message = "Use AppSettings.translateConfig.preTranslate instead.", + level = DeprecationLevel.ERROR, + ) val preTranslation: Boolean = false, ) { public companion object { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt index 4ee4e00d8..1b7067205 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt @@ -114,7 +114,7 @@ internal class AiPreTranslationService( markPending(candidates) semaphore.withPermit { translatePreparedCandidates( - settings = settings.aiConfig, + settings = settings.appSettings, targetLanguage = settings.targetLanguage, candidates = candidates, ) @@ -133,7 +133,7 @@ internal class AiPreTranslationService( markPending(listOf(candidate)) semaphore.withPermit { translatePreparedCandidates( - settings = settings.aiConfig, + settings = settings.appSettings, targetLanguage = settings.targetLanguage, candidates = listOf(candidate), ) @@ -173,7 +173,7 @@ internal class AiPreTranslationService( markPending(candidates) semaphore.withPermit { translatePreparedCandidates( - settings = settings.aiConfig, + settings = settings.appSettings, targetLanguage = settings.targetLanguage, candidates = candidates, ) @@ -203,13 +203,19 @@ internal class AiPreTranslationService( private suspend fun activeTranslationSettings(requirePreTranslation: Boolean): ActivePreTranslationSettings? { val appSettings = appDataStore.appSettingsStore.data.first() val targetLanguage = currentTargetLanguage() - val aiConfig = appSettings.aiConfig - if (!aiConfig.translation || (requirePreTranslation && !aiConfig.preTranslation) || targetLanguage.isBlank()) { + val translateConfig = appSettings.translateConfig + val canTranslate = + if (requirePreTranslation) { + translateConfig.preTranslate + } else { + true + } + if (!canTranslate || targetLanguage.isBlank()) { return null } return ActivePreTranslationSettings( targetLanguage = targetLanguage, - aiConfig = aiConfig, + appSettings = appSettings, ) } @@ -413,7 +419,7 @@ internal class AiPreTranslationService( } private suspend fun translatePreparedCandidates( - settings: AppSettings.AiConfig, + settings: AppSettings, targetLanguage: String, candidates: List, ) { @@ -427,7 +433,7 @@ internal class AiPreTranslationService( } private suspend fun translateBatch( - settings: AppSettings.AiConfig, + settings: AppSettings, targetLanguage: String, candidates: List, ) { @@ -478,7 +484,7 @@ internal class AiPreTranslationService( } private suspend fun runBatchTranslationWithRetry( - settings: AppSettings.AiConfig, + settings: AppSettings, targetLanguage: String, candidates: List, ): Result { @@ -498,11 +504,12 @@ internal class AiPreTranslationService( }, ) val sourceJson = sourceDocument.encodeJson(PreTranslationBatchDocument.serializer()) - val prompt = buildTranslatePrompt(settings.translatePrompt, targetLanguage, sourceJson) + val prompt = buildTranslatePrompt(settings.aiConfig.translatePrompt, targetLanguage, sourceJson) val translatedJson = - aiCompletionService.translate( - config = settings, - source = sourceJson, + settings.translateBatchDocumentJson( + aiCompletionService = aiCompletionService, + sourceJson = sourceJson, + sourceDocument = sourceDocument, targetLanguage = targetLanguage, prompt = prompt, ) ?: error("Pre-translation returned empty response") @@ -690,7 +697,7 @@ internal data class PreTranslationBatchPayload( private data class ActivePreTranslationSettings( val targetLanguage: String, - val aiConfig: AppSettings.AiConfig, + val appSettings: AppSettings, ) private data class PreparedTranslationCandidate( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProvider.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProvider.kt new file mode 100644 index 000000000..1371c7761 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProvider.kt @@ -0,0 +1,149 @@ +package dev.dimension.flare.data.translation + +import dev.dimension.flare.common.decodeJson +import dev.dimension.flare.common.encodeJson +import dev.dimension.flare.data.datastore.model.AppSettings +import dev.dimension.flare.data.network.ai.AiCompletionService +import dev.dimension.flare.data.network.ktorClient +import dev.dimension.flare.ui.render.TranslationDocument +import dev.dimension.flare.ui.render.TranslationTokenKind +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.url +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive + +internal suspend fun AppSettings.translateDocumentJson( + aiCompletionService: AiCompletionService, + sourceText: String, + sourceJson: String, + targetLanguage: String, + prompt: String, +): String? = + when (translateConfig.provider) { + AppSettings.TranslateConfig.Provider.AI -> + aiCompletionService.translate( + config = aiConfig, + source = sourceText, + targetLanguage = targetLanguage, + prompt = prompt, + ) + + AppSettings.TranslateConfig.Provider.Google -> + GoogleWebTranslationProvider.translateDocumentJson( + sourceJson = sourceJson, + targetLanguage = targetLanguage, + ) + } + +internal suspend fun AppSettings.translateBatchDocumentJson( + aiCompletionService: AiCompletionService, + sourceJson: String, + sourceDocument: PreTranslationBatchDocument, + targetLanguage: String, + prompt: String, +): String? = + when (translateConfig.provider) { + AppSettings.TranslateConfig.Provider.AI -> + aiCompletionService.translate( + config = aiConfig, + source = sourceJson, + targetLanguage = targetLanguage, + prompt = prompt, + ) + + AppSettings.TranslateConfig.Provider.Google -> + GoogleWebTranslationProvider.translateBatchDocumentJson( + sourceDocument = sourceDocument, + targetLanguage = targetLanguage, + ) + } + +private object GoogleWebTranslationProvider { + suspend fun translateDocumentJson( + sourceJson: String, + targetLanguage: String, + ): String = + sourceJson + .decodeJson(TranslationDocument.serializer()) + .translate(targetLanguage) + .encodeJson(TranslationDocument.serializer()) + + suspend fun translateBatchDocumentJson( + sourceDocument: PreTranslationBatchDocument, + targetLanguage: String, + ): String = + sourceDocument + .translate(targetLanguage) + .encodeJson(PreTranslationBatchDocument.serializer()) + + private suspend fun PreTranslationBatchDocument.translate(targetLanguage: String): PreTranslationBatchDocument = + copy( + targetLanguage = targetLanguage, + items = + items.map { item -> + item.copy( + payload = item.payload?.translate(targetLanguage), + ) + }, + ) + + private suspend fun PreTranslationBatchPayload.translate(targetLanguage: String): PreTranslationBatchPayload = + PreTranslationBatchPayload( + content = content?.translate(targetLanguage), + contentWarning = contentWarning?.translate(targetLanguage), + title = title?.translate(targetLanguage), + description = description?.translate(targetLanguage), + ) + + private suspend fun TranslationDocument.translate(targetLanguage: String): TranslationDocument = + copy( + targetLanguage = targetLanguage, + blocks = + blocks.map { block -> + block.copy( + tokens = + block.tokens.map { token -> + if (token.kind != TranslationTokenKind.Translatable || token.text.isBlank()) { + token + } else { + token.copy( + text = + translateText( + sourceText = token.text, + targetLanguage = targetLanguage, + ), + ) + } + }, + ) + }, + ) + + private suspend fun translateText( + sourceText: String, + targetLanguage: String, + ): String { + val response = + ktorClient() + .get { + url("https://translate.google.com/translate_a/single") + parameter("client", "gtx") + parameter("sl", "auto") + parameter("tl", targetLanguage) + parameter("dt", "t") + parameter("q", sourceText) + parameter("ie", "UTF-8") + parameter("oe", "UTF-8") + }.body() + return buildString { + response.firstOrNull()?.jsonArray?.forEach { item -> + item.jsonArray.firstOrNull()?.let { content -> + append(content.jsonPrimitive.content) + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt index e1bd7ead1..cc17e1650 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt @@ -67,8 +67,8 @@ public abstract class TimelinePresenter : appDataStore.appSettingsStore.data .map { settings -> TranslationDisplayOptions( - translationEnabled = settings.aiConfig.translation, - autoDisplayEnabled = settings.aiConfig.translation && settings.aiConfig.preTranslation, + translationEnabled = true, + autoDisplayEnabled = settings.translateConfig.preTranslate, ) }.distinctUntilChanged() } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt index 71757f221..65743eeb7 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt @@ -35,6 +35,11 @@ public enum class AiTypeOption { OpenAI, } +public enum class TranslateProviderOption { + AI, + Google, +} + public class AiConfigPresenter : PresenterBase(), KoinComponent { @@ -45,27 +50,42 @@ public class AiConfigPresenter : @Immutable public interface State { public val aiConfig: AppSettings.AiConfig + public val translateConfig: AppSettings.TranslateConfig public val openAIModels: UiState> public val supportedTypes: ImmutableList + public val supportedTranslateProviders: ImmutableList public val serverSuggestions: ImmutableList public fun update(block: AppSettings.AiConfig.() -> AppSettings.AiConfig) + public fun updateTranslateConfig(block: AppSettings.TranslateConfig.() -> AppSettings.TranslateConfig) + public fun selectType(type: AiTypeOption) + + public fun selectTranslateProvider(type: TranslateProviderOption) } @OptIn(FlowPreview::class) @Composable override fun body(): State { val scope = rememberCoroutineScope() - val aiConfig by remember { appDataStore.appSettingsStore.data.map { it.aiConfig } } - .collectAsState(AppSettings.AiConfig()) + val appSettings by remember { appDataStore.appSettingsStore.data } + .collectAsState(AppSettings(version = "")) + val aiConfig = appSettings.aiConfig + val translateConfig = appSettings.translateConfig var openAIModels by remember { mutableStateOf>>(UiState.Success(persistentListOf())) } var supportedTypes by remember { mutableStateOf>(persistentListOf(AiTypeOption.OpenAI)) } + val supportedTranslateProviders = + remember { + persistentListOf( + TranslateProviderOption.AI, + TranslateProviderOption.Google, + ) + } LaunchedEffect(Unit) { supportedTypes = @@ -104,8 +124,10 @@ public class AiConfigPresenter : } return object : State { override val aiConfig: AppSettings.AiConfig = aiConfig + override val translateConfig: AppSettings.TranslateConfig = translateConfig override val openAIModels: UiState> = openAIModels override val supportedTypes: ImmutableList = supportedTypes + override val supportedTranslateProviders: ImmutableList = supportedTranslateProviders override val serverSuggestions: ImmutableList = SERVER_SUGGESTIONS override fun update(block: AppSettings.AiConfig.() -> AppSettings.AiConfig) { @@ -123,6 +145,21 @@ public class AiConfigPresenter : } } + override fun updateTranslateConfig(block: AppSettings.TranslateConfig.() -> AppSettings.TranslateConfig) { + scope.launch { + withContext(Dispatchers.Main) { + appDataStore.appSettingsStore.updateData { current -> + current.copy( + translateConfig = + block + .invoke(current.translateConfig) + .normalized(), + ) + } + } + } + } + override fun selectType(type: AiTypeOption) { update { when (type) { @@ -142,16 +179,26 @@ public class AiConfigPresenter : } } } + + override fun selectTranslateProvider(type: TranslateProviderOption) { + updateTranslateConfig { + copy( + provider = + when (type) { + TranslateProviderOption.AI -> AppSettings.TranslateConfig.Provider.AI + TranslateProviderOption.Google -> AppSettings.TranslateConfig.Provider.Google + }, + ) + } + } } } } -private fun AppSettings.AiConfig.normalized(): AppSettings.AiConfig = - if (translation) { - this - } else { - copy(preTranslation = false) - } +private fun AppSettings.AiConfig.normalized(): AppSettings.AiConfig = this + +private fun AppSettings.TranslateConfig.normalized(): AppSettings.TranslateConfig = + this private val SERVER_SUGGESTIONS = persistentListOf( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt index 2e86b6f91..ae3279b41 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt @@ -13,8 +13,8 @@ import dev.dimension.flare.data.database.cache.model.sourceHash import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.datastore.model.AiPromptDefaults import dev.dimension.flare.data.network.ai.AiCompletionService -import dev.dimension.flare.data.network.ktorClient import dev.dimension.flare.data.repository.tryRun +import dev.dimension.flare.data.translation.translateDocumentJson import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiState @@ -24,14 +24,7 @@ import dev.dimension.flare.ui.render.applyTranslationJson import dev.dimension.flare.ui.render.toTranslatableText import dev.dimension.flare.ui.render.toTranslationJson import dev.dimension.flare.ui.render.toUiPlainText -import io.ktor.client.call.body -import io.ktor.client.request.get -import io.ktor.client.request.parameter -import io.ktor.client.request.url import kotlinx.coroutines.flow.first -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.time.Clock @@ -53,34 +46,31 @@ public class TranslatePresenter( return produceState>(initialValue = UiState.Loading()) { value = tryRun { - val aiConfig = + val settings = appDataStore.appSettingsStore.data .first() - .aiConfig - if (!aiConfig.translation) { - return@tryRun toUiRichText(legacyGoogleTranslate()) - } cachedTranslation()?.let { return@tryRun it } val promptTemplate = - aiConfig.translatePrompt.ifBlank { + settings.aiConfig.translatePrompt.ifBlank { AiPromptDefaults.TRANSLATE_PROMPT } val prompt = buildTranslatePrompt(promptTemplate, targetLanguage) - val aiTranslation = - aiCompletionService.translate( - config = aiConfig, - source = sourceText, + val translatedContent = + settings.translateDocumentJson( + aiCompletionService = aiCompletionService, + sourceText = sourceText, + sourceJson = sourceJson, targetLanguage = targetLanguage, prompt = prompt, ) - if (aiTranslation != null) { - val translated = toUiRichText(aiTranslation) + if (translatedContent != null) { + val translated = toUiRichText(translatedContent) cacheTranslation(translated) return@tryRun translated } - toUiRichText(legacyGoogleTranslate()) + error("Translation returned empty response") }.fold( onSuccess = { UiState.Success(it) }, onFailure = { UiState.Error(it) }, @@ -88,33 +78,6 @@ public class TranslatePresenter( }.value } - private suspend fun legacyGoogleTranslate(): String { - val baseUrl = "https://translate.google.com/translate_a/single" - val response = - ktorClient() - .get { - url(baseUrl) - parameter("client", "gtx") - parameter("sl", "auto") - parameter("tl", targetLanguage) - parameter("dt", "t") - parameter("q", sourceText) - parameter("ie", "UTF-8") - parameter("oe", "UTF-8") - }.body() - return buildString { - response.firstOrNull()?.jsonArray?.forEach { - it.jsonArray.firstOrNull()?.let { - val content = it.jsonPrimitive.content - if (content.isNotEmpty()) { - append(content) - append("\n") - } - } - } - } - } - private fun buildTranslatePrompt( template: String, targetLanguage: String, diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt index 6f056d538..4f7bcc9b8 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt @@ -385,10 +385,9 @@ class MixedRemoteMediatorTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", + translateConfig = AppSettings.TranslateConfig(preTranslate = true), aiConfig = AppSettings.AiConfig( - translation = true, - preTranslation = true, type = AppSettings.AiConfig.Type.OnDevice, ), ) @@ -488,10 +487,9 @@ class MixedRemoteMediatorTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", + translateConfig = AppSettings.TranslateConfig(preTranslate = true), aiConfig = AppSettings.AiConfig( - translation = true, - preTranslation = true, type = AppSettings.AiConfig.Type.OnDevice, ), ) @@ -565,10 +563,9 @@ class MixedRemoteMediatorTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", + translateConfig = AppSettings.TranslateConfig(preTranslate = true), aiConfig = AppSettings.AiConfig( - translation = true, - preTranslation = true, type = AppSettings.AiConfig.Type.OnDevice, ), ) @@ -645,10 +642,9 @@ class MixedRemoteMediatorTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", + translateConfig = AppSettings.TranslateConfig(preTranslate = true), aiConfig = AppSettings.AiConfig( - translation = true, - preTranslation = true, type = AppSettings.AiConfig.Type.OnDevice, ), ) @@ -725,10 +721,9 @@ class MixedRemoteMediatorTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", + translateConfig = AppSettings.TranslateConfig(preTranslate = true), aiConfig = AppSettings.AiConfig( - translation = true, - preTranslation = true, type = AppSettings.AiConfig.Type.OnDevice, ), ) @@ -827,10 +822,9 @@ class MixedRemoteMediatorTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", + translateConfig = AppSettings.TranslateConfig(preTranslate = true), aiConfig = AppSettings.AiConfig( - translation = true, - preTranslation = true, type = AppSettings.AiConfig.Type.OnDevice, ), ) @@ -867,10 +861,9 @@ class MixedRemoteMediatorTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", + translateConfig = AppSettings.TranslateConfig(preTranslate = true), aiConfig = AppSettings.AiConfig( - translation = true, - preTranslation = true, type = AppSettings.AiConfig.Type.OnDevice, ), ) diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt index c03f17021..1248eb2b4 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt @@ -391,10 +391,9 @@ class PostHandlerTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", + translateConfig = AppSettings.TranslateConfig(preTranslate = true), aiConfig = AppSettings.AiConfig( - translation = true, - preTranslation = true, type = AppSettings.AiConfig.Type.OnDevice, ), ) diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt index ad50062ea..68da2f34f 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt @@ -248,10 +248,9 @@ class UserHandlerTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", + translateConfig = AppSettings.TranslateConfig(preTranslate = true), aiConfig = AppSettings.AiConfig( - translation = true, - preTranslation = true, ), ) } @@ -289,10 +288,9 @@ class UserHandlerTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", + translateConfig = AppSettings.TranslateConfig(preTranslate = true), aiConfig = AppSettings.AiConfig( - translation = true, - preTranslation = true, type = AppSettings.AiConfig.Type.OnDevice, ), ) @@ -326,10 +324,9 @@ class UserHandlerTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", + translateConfig = AppSettings.TranslateConfig(preTranslate = true), aiConfig = AppSettings.AiConfig( - translation = true, - preTranslation = true, type = AppSettings.AiConfig.Type.OnDevice, ), ) @@ -384,10 +381,9 @@ class UserHandlerTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", + translateConfig = AppSettings.TranslateConfig(preTranslate = true), aiConfig = AppSettings.AiConfig( - translation = true, - preTranslation = true, type = AppSettings.AiConfig.Type.OnDevice, ), ) From 44853df41743567f908f0f6ced861e39fbfb5416 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 29 Mar 2026 23:48:51 +0900 Subject: [PATCH 10/14] refactor pre translate --- .../dev/dimension/flare/ui/AppContainer.kt | 8 +- .../ui/screen/settings/SettingsScreen.kt | 4 +- .../microblog/handler/PostHandler.kt | 11 +- .../microblog/handler/UserHandler.kt | 11 +- .../translation/PreTranslationContentRules.kt | 152 ++++ .../data/translation/PreTranslationModels.kt | 53 ++ .../PreTranslationPayloadSupport.kt | 64 ++ .../data/translation/PreTranslationService.kt | 757 +++++------------- .../translation/PreTranslationStoreSupport.kt | 158 ++++ .../data/translation/TranslationProvider.kt | 142 ++-- .../data/translation/TranslationSupport.kt | 53 ++ .../dev/dimension/flare/di/CommonModule.kt | 4 +- .../ui/presenter/home/TimelinePresenter.kt | 10 +- .../presenter/settings/AiConfigPresenter.kt | 3 +- .../ui/presenter/status/TranslatePresenter.kt | 43 +- .../microblog/MixedRemoteMediatorTest.kt | 126 +-- .../microblog/handler/PostHandlerTest.kt | 7 +- .../microblog/handler/UserHandlerTest.kt | 16 +- .../data/translation/TranslationTestConfig.kt | 9 + 19 files changed, 864 insertions(+), 767 deletions(-) create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationContentRules.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationModels.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationPayloadSupport.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationStoreSupport.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationSupport.kt create mode 100644 shared/src/commonTest/kotlin/dev/dimension/flare/data/translation/TranslationTestConfig.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt b/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt index 45f2ecbf3..6ac277e53 100644 --- a/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt +++ b/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt @@ -84,11 +84,11 @@ fun FlareApp(content: @Composable () -> Unit) { }, expandMediaSize = appearanceSettings.expandMediaSize, compatLinkPreview = appearanceSettings.compatLinkPreview, - aiConfig = - ComponentAppearance.AiConfig( + aiConfig = + ComponentAppearance.AiConfig( translation = true, - tldr = appSettings.aiConfig.tldr, - ), + tldr = appSettings.aiConfig.tldr, + ), fullWidthPost = appearanceSettings.fullWidthPost, postActionStyle = appearanceSettings.postActionStyle, absoluteTimestamp = appearanceSettings.absoluteTimestamp, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index 42bf93ccd..70e26acfb 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -88,7 +88,6 @@ import dev.dimension.flare.settings_ai_config_api_key_hint import dev.dimension.flare.settings_ai_config_description import dev.dimension.flare.settings_ai_config_enable_pre_translation import dev.dimension.flare.settings_ai_config_enable_tldr -import dev.dimension.flare.settings_ai_config_entable_translation import dev.dimension.flare.settings_ai_config_model import dev.dimension.flare.settings_ai_config_model_description import dev.dimension.flare.settings_ai_config_model_error @@ -103,7 +102,6 @@ import dev.dimension.flare.settings_ai_config_title import dev.dimension.flare.settings_ai_config_tldr_description import dev.dimension.flare.settings_ai_config_tldr_prompt import dev.dimension.flare.settings_ai_config_translate_prompt -import dev.dimension.flare.settings_ai_config_translation_description import dev.dimension.flare.settings_ai_config_type import dev.dimension.flare.settings_ai_config_type_description import dev.dimension.flare.settings_ai_config_type_on_device @@ -190,9 +188,9 @@ import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.settings.AiConfigPresenter import dev.dimension.flare.ui.presenter.settings.AiTypeOption -import dev.dimension.flare.ui.presenter.settings.TranslateProviderOption import dev.dimension.flare.ui.presenter.settings.StoragePresenter import dev.dimension.flare.ui.presenter.settings.StorageState +import dev.dimension.flare.ui.presenter.settings.TranslateProviderOption import dev.dimension.flare.ui.theme.LocalComposeWindow import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.composefluent.ExperimentalFluentApi diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt index 1cc008a2d..5d4bdabb5 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt @@ -6,12 +6,12 @@ import dev.dimension.flare.data.database.cache.connect import dev.dimension.flare.data.database.cache.mapper.saveToDatabase import dev.dimension.flare.data.database.cache.model.DbPagingTimeline import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.database.cache.model.TranslationDisplayOptions import dev.dimension.flare.data.datasource.microblog.loader.PostLoader import dev.dimension.flare.data.datasource.microblog.paging.TimelinePagingMapper import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.data.translation.PreTranslationService +import dev.dimension.flare.data.translation.TranslationSettingsSupport import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.DbAccountType import dev.dimension.flare.model.MicroBlogKey @@ -19,7 +19,6 @@ import dev.dimension.flare.ui.model.UiTimelineV2 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -35,13 +34,7 @@ internal class PostHandler( private val preTranslationService: PreTranslationService by inject() private val translationDisplayFlow by lazy { - appDataStore.appSettingsStore.data - .map { settings -> - TranslationDisplayOptions( - translationEnabled = true, - autoDisplayEnabled = settings.translateConfig.preTranslate, - ) - }.distinctUntilChanged() + TranslationSettingsSupport.displayOptionsFlow(appDataStore) } fun post(postKey: MicroBlogKey): Cacheable { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt index dbdee7daf..709b1623e 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt @@ -5,13 +5,13 @@ import dev.dimension.flare.common.Locale import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.toDbUser import dev.dimension.flare.data.database.cache.mapper.upsertUser -import dev.dimension.flare.data.database.cache.model.TranslationDisplayOptions import dev.dimension.flare.data.database.cache.model.TranslationEntityType import dev.dimension.flare.data.database.cache.model.applyTranslation import dev.dimension.flare.data.database.cache.model.translationEntityKey import dev.dimension.flare.data.datasource.microblog.loader.UserLoader import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.translation.PreTranslationService +import dev.dimension.flare.data.translation.TranslationSettingsSupport import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiHandle import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -19,7 +19,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -33,13 +32,7 @@ internal class UserHandler( private val preTranslationService: PreTranslationService by inject() private val translationDisplayFlow by lazy { - appDataStore.appSettingsStore.data - .map { settings -> - TranslationDisplayOptions( - translationEnabled = true, - autoDisplayEnabled = settings.translateConfig.preTranslate, - ) - }.distinctUntilChanged() + TranslationSettingsSupport.displayOptionsFlow(appDataStore) } fun userByHandleAndHost(uiHandle: UiHandle) = diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationContentRules.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationContentRules.kt new file mode 100644 index 000000000..15bacb1da --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationContentRules.kt @@ -0,0 +1,152 @@ +package dev.dimension.flare.data.translation + +import dev.dimension.flare.data.database.cache.model.TranslationPayload +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.render.RenderContent +import dev.dimension.flare.ui.render.RenderRun +import dev.dimension.flare.ui.render.RenderTextStyle +import dev.dimension.flare.ui.render.UiRichText + +internal object PreTranslationContentRules { + private val protectedTranslationPattern = + Regex("""https?://\S+|@[A-Za-z0-9._-]+(?:@[A-Za-z0-9.-]+)?|#[\p{L}\p{N}_]+""") + + fun sourceLanguages(timeline: UiTimelineV2): List = + when (timeline) { + is UiTimelineV2.Feed -> timeline.sourceLanguages + is UiTimelineV2.Post -> timeline.sourceLanguages + is UiTimelineV2.Message -> emptyList() + is UiTimelineV2.User -> emptyList() + is UiTimelineV2.UserList -> emptyList() + } + + fun shouldSkipForMatchingSourceLanguage( + sourceLanguages: List, + targetLanguage: String, + ): Boolean { + val canonicalTargetLanguage = canonicalTranslationLanguage(targetLanguage) ?: return false + return sourceLanguages + .asSequence() + .mapNotNull(::canonicalTranslationLanguage) + .any { it == canonicalTargetLanguage } + } + + fun isNonTranslatableOnly(payload: TranslationPayload): Boolean { + val fields = listOfNotNull(payload.content, payload.contentWarning, payload.title, payload.description) + return fields.isNotEmpty() && fields.all(::isNonTranslatableOnly) + } + + private fun canonicalTranslationLanguage(language: String): String? { + val normalized = language.trim().lowercase().replace('_', '-') + if (normalized.isBlank()) { + return null + } + val parts = normalized.split('-').filter { it.isNotBlank() } + if (parts.isEmpty()) { + return null + } + val primary = parts.first() + if (primary != "zh") { + return primary + } + val regionOrScript = parts.drop(1) + return when { + regionOrScript.any { it == "hant" } || regionOrScript.any { it in setOf("tw", "hk", "mo") } -> "zh-hant" + regionOrScript.any { it == "hans" } || regionOrScript.any { it in setOf("cn", "sg") } -> "zh-hans" + else -> "zh" + } + } + + private fun isNonTranslatableOnly(richText: UiRichText): Boolean { + var hasVisibleContent = false + richText.renderRuns.forEach { content -> + when (content) { + is RenderContent.BlockImage -> hasVisibleContent = true + is RenderContent.Text -> + content.runs.forEach { run -> + when (run) { + is RenderRun.Image -> hasVisibleContent = true + is RenderRun.Text -> { + if (run.text.isBlank()) { + return@forEach + } + hasVisibleContent = true + if (!isNonTranslatableOnlyText(run.text, run.style)) { + return false + } + } + } + } + } + } + return hasVisibleContent + } + + private fun isNonTranslatableOnlyText( + text: String, + style: RenderTextStyle, + ): Boolean { + if (text.isBlank()) { + return false + } + if (style.code || style.monospace) { + return true + } + var hasVisibleContent = false + var cursor = 0 + protectedTranslationPattern.findAll(text).forEach { match -> + if (match.range.first > cursor) { + val segment = text.substring(cursor, match.range.first) + if (!segment.isBlank()) { + hasVisibleContent = true + if (!isEmojiOnlyText(segment)) { + return false + } + } + } + if (match.value.isNotBlank()) { + hasVisibleContent = true + } + cursor = match.range.last + 1 + } + if (cursor < text.length) { + val trailing = text.substring(cursor) + if (!trailing.isBlank()) { + hasVisibleContent = true + if (!isEmojiOnlyText(trailing)) { + return false + } + } + } + return hasVisibleContent + } + + private fun isEmojiOnlyText(text: String): Boolean { + if (text.isBlank()) { + return false + } + var hasEmoji = false + var index = 0 + while (index < text.length) { + val current = text[index] + when { + current.isWhitespace() -> index += 1 + current in '\uD83C'..'\uD83E' && index + 1 < text.length && text[index + 1].isLowSurrogate() -> { + hasEmoji = true + index += 2 + } + + current.code == 0x200D || + current.code == 0x20E3 || + current.code in 0xFE00..0xFE0F || + current.code in 0x2600..0x27BF -> { + hasEmoji = true + index += 1 + } + + else -> return false + } + } + return hasEmoji + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationModels.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationModels.kt new file mode 100644 index 000000000..65ad0da35 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationModels.kt @@ -0,0 +1,53 @@ +package dev.dimension.flare.data.translation + +import dev.dimension.flare.data.database.cache.model.TranslationDisplayMode +import dev.dimension.flare.data.database.cache.model.TranslationEntityType +import dev.dimension.flare.data.database.cache.model.TranslationPayload +import dev.dimension.flare.data.datastore.model.AppSettings +import dev.dimension.flare.ui.render.TranslationDocument +import kotlinx.serialization.Serializable + +@Serializable +internal data class PreTranslationBatchDocument( + val version: Int = 1, + val targetLanguage: String = "", + val items: List, +) + +@Serializable +internal data class PreTranslationBatchItem( + val entityKey: String, + val status: PreTranslationBatchItemStatus = PreTranslationBatchItemStatus.Completed, + val payload: PreTranslationBatchPayload? = null, + val reason: String? = null, +) + +@Serializable +internal enum class PreTranslationBatchItemStatus { + Completed, + Skipped, +} + +@Serializable +internal data class PreTranslationBatchPayload( + val content: TranslationDocument? = null, + val contentWarning: TranslationDocument? = null, + val title: TranslationDocument? = null, + val description: TranslationDocument? = null, +) + +internal data class ActivePreTranslationSettings( + val targetLanguage: String, + val appSettings: AppSettings, +) + +internal data class PreparedTranslationCandidate( + val entityType: TranslationEntityType, + val entityKey: String, + val targetLanguage: String, + val sourceHash: String, + val sourcePayload: TranslationPayload, + val sourceDocument: PreTranslationBatchPayload, + val attemptCount: Int, + val displayMode: TranslationDisplayMode, +) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationPayloadSupport.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationPayloadSupport.kt new file mode 100644 index 000000000..b3723697a --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationPayloadSupport.kt @@ -0,0 +1,64 @@ +package dev.dimension.flare.data.translation + +import dev.dimension.flare.common.encodeJson +import dev.dimension.flare.data.database.cache.model.TranslationPayload +import dev.dimension.flare.ui.render.TranslationDocument +import dev.dimension.flare.ui.render.UiRichText +import dev.dimension.flare.ui.render.applyTranslationDocument +import dev.dimension.flare.ui.render.toTranslationDocument + +internal object PreTranslationPayloadSupport { + fun toBatchPayload( + payload: TranslationPayload, + targetLanguage: String, + ): PreTranslationBatchPayload = + PreTranslationBatchPayload( + content = toTranslationDocumentOrNull(payload.content, targetLanguage), + contentWarning = toTranslationDocumentOrNull(payload.contentWarning, targetLanguage), + title = toTranslationDocumentOrNull(payload.title, targetLanguage), + description = toTranslationDocumentOrNull(payload.description, targetLanguage), + ) + + fun applyBatchPayload( + sourcePayload: TranslationPayload, + sourceDocument: PreTranslationBatchPayload, + translatedDocument: PreTranslationBatchPayload, + ): TranslationPayload = + TranslationPayload( + content = applyTranslatedField(sourcePayload.content, sourceDocument.content, translatedDocument.content), + contentWarning = + applyTranslatedField( + sourcePayload.contentWarning, + sourceDocument.contentWarning, + translatedDocument.contentWarning, + ), + title = applyTranslatedField(sourcePayload.title, sourceDocument.title, translatedDocument.title), + description = applyTranslatedField(sourcePayload.description, sourceDocument.description, translatedDocument.description), + ) + + fun estimatedTokens(payload: PreTranslationBatchPayload): Int = + payload.encodeJson(PreTranslationBatchPayload.serializer()).length / 4 + 1 + + fun isEmpty(payload: PreTranslationBatchPayload): Boolean = + payload.content == null && + payload.contentWarning == null && + payload.title == null && + payload.description == null + + private fun toTranslationDocumentOrNull( + richText: UiRichText?, + targetLanguage: String, + ): TranslationDocument? = richText?.toTranslationDocument(targetLanguage)?.takeUnless { it.blocks.isEmpty() } + + private fun applyTranslatedField( + original: UiRichText?, + sourceDocument: TranslationDocument?, + translatedDocument: TranslationDocument?, + ): UiRichText? = + when { + original == null -> null + sourceDocument == null -> original + translatedDocument == null -> throw IllegalArgumentException("Missing translated field") + else -> original.applyTranslationDocument(translatedDocument) + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt index 1b7067205..fe0dbb058 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt @@ -16,27 +16,15 @@ import dev.dimension.flare.data.database.cache.model.statusTranslationEntityKey import dev.dimension.flare.data.database.cache.model.translationEntityKey import dev.dimension.flare.data.database.cache.model.translationPayload import dev.dimension.flare.data.datastore.AppDataStore -import dev.dimension.flare.data.datastore.model.AiPromptDefaults -import dev.dimension.flare.data.datastore.model.AppSettings import dev.dimension.flare.data.network.ai.AiCompletionService import dev.dimension.flare.data.repository.tryRun -import dev.dimension.flare.ui.render.RenderContent -import dev.dimension.flare.ui.render.RenderRun -import dev.dimension.flare.ui.render.RenderTextStyle -import dev.dimension.flare.ui.render.TranslationDocument -import dev.dimension.flare.ui.render.UiRichText -import dev.dimension.flare.ui.render.applyTranslationDocument -import dev.dimension.flare.ui.render.toTranslationDocument import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit -import kotlinx.serialization.Serializable import kotlin.time.Clock -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.minutes internal interface PreTranslationService { fun enqueueStatuses( @@ -78,7 +66,7 @@ internal data object NoopPreTranslationService : PreTranslationService { ) = Unit } -internal class AiPreTranslationService( +internal class OnlinePreTranslationService( private val database: CacheDatabase, private val appDataStore: AppDataStore, private val aiCompletionService: AiCompletionService, @@ -101,41 +89,24 @@ internal class AiPreTranslationService( return } coroutineScope.launch { - val settings = activeTranslationSettings(requirePreTranslation = true) ?: return@launch - val candidates = + enqueuePreparedCandidates(requirePreTranslation = true) { settings -> prepareStatusCandidates( statuses = snapshot, targetLanguage = settings.targetLanguage, allowLongText = allowLongText, ) - if (candidates.isEmpty()) { - return@launch - } - markPending(candidates) - semaphore.withPermit { - translatePreparedCandidates( - settings = settings.appSettings, - targetLanguage = settings.targetLanguage, - candidates = candidates, - ) } } } override fun enqueueProfile(user: DbUser) { coroutineScope.launch { - val settings = activeTranslationSettings(requirePreTranslation = true) ?: return@launch - val candidate = - prepareProfileCandidate( - user = user, - targetLanguage = settings.targetLanguage, - ) ?: return@launch - markPending(listOf(candidate)) - semaphore.withPermit { - translatePreparedCandidates( - settings = settings.appSettings, - targetLanguage = settings.targetLanguage, - candidates = listOf(candidate), + enqueuePreparedCandidates(requirePreTranslation = true) { settings -> + listOfNotNull( + prepareProfileCandidate( + user = user, + targetLanguage = settings.targetLanguage, + ), ) } } @@ -152,32 +123,16 @@ internal class AiPreTranslationService( statusKey = statusKey, mode = TranslationDisplayMode.Translated, ) - val dbAccountType = accountType as? dev.dimension.flare.model.DbAccountType ?: return@launch - val status = - database - .statusDao() - .getWithReferencesSync( - statusKey = statusKey, - accountType = dbAccountType, - ) ?: return@launch val candidates = - prepareStatusCandidates( - statuses = listOfNotNull(status.status.data) + status.references.mapNotNull { it.status?.data }, - targetLanguage = settings.targetLanguage, - allowLongText = true, - preferredDisplayMode = TranslationDisplayMode.Translated, - ) - if (candidates.isEmpty()) { - return@launch - } - markPending(candidates) - semaphore.withPermit { - translatePreparedCandidates( - settings = settings.appSettings, + prepareRetryCandidates( + accountType = accountType, + statusKey = statusKey, targetLanguage = settings.targetLanguage, - candidates = candidates, ) - } + processPreparedCandidates( + settings = settings, + candidates = candidates, + ) } } @@ -187,26 +142,50 @@ internal class AiPreTranslationService( mode: TranslationDisplayMode, ) { coroutineScope.launch { - val targetLanguage = currentTargetLanguage() - database - .translationDao() - .updateDisplayMode( - entityType = TranslationEntityType.Status, - entityKey = statusTranslationEntityKey(accountType, statusKey), - targetLanguage = targetLanguage, - displayMode = mode, - updatedAt = Clock.System.now().toEpochMilliseconds(), - ) + database.translationDao().updateDisplayMode( + entityType = TranslationEntityType.Status, + entityKey = statusTranslationEntityKey(accountType, statusKey), + targetLanguage = currentTargetLanguage(), + displayMode = mode, + updatedAt = Clock.System.now().toEpochMilliseconds(), + ) + } + } + + private suspend fun enqueuePreparedCandidates( + requirePreTranslation: Boolean, + prepareCandidates: suspend (ActivePreTranslationSettings) -> List, + ) { + val settings = activeTranslationSettings(requirePreTranslation = requirePreTranslation) ?: return + val candidates = prepareCandidates(settings) + processPreparedCandidates( + settings = settings, + candidates = candidates, + ) + } + + private suspend fun processPreparedCandidates( + settings: ActivePreTranslationSettings, + candidates: List, + ) { + if (candidates.isEmpty()) { + return + } + markPending(candidates) + semaphore.withPermit { + translatePreparedCandidates( + settings = settings, + candidates = candidates, + ) } } private suspend fun activeTranslationSettings(requirePreTranslation: Boolean): ActivePreTranslationSettings? { val appSettings = appDataStore.appSettingsStore.data.first() val targetLanguage = currentTargetLanguage() - val translateConfig = appSettings.translateConfig val canTranslate = if (requirePreTranslation) { - translateConfig.preTranslate + appSettings.translateConfig.preTranslate } else { true } @@ -224,12 +203,33 @@ internal class AiPreTranslationService( private suspend fun cleanupStaleInFlightTranslations() { val updatedAt = Clock.System.now().toEpochMilliseconds() database.translationDao().markStaleInFlightAsFailed( - staleBefore = updatedAt - STALE_TRANSLATION_TIMEOUT.inWholeMilliseconds, - statusReason = FAILED_STALE_IN_FLIGHT_REASON, + staleBefore = updatedAt - PreTranslationStoreSupport.STALE_TRANSLATION_TIMEOUT.inWholeMilliseconds, + statusReason = PreTranslationStoreSupport.FAILED_STALE_IN_FLIGHT_REASON, updatedAt = updatedAt, ) } + private suspend fun prepareRetryCandidates( + accountType: dev.dimension.flare.model.AccountType, + statusKey: dev.dimension.flare.model.MicroBlogKey, + targetLanguage: String, + ): List { + val dbAccountType = accountType as? dev.dimension.flare.model.DbAccountType ?: return emptyList() + val status = + database + .statusDao() + .getWithReferencesSync( + statusKey = statusKey, + accountType = dbAccountType, + ) ?: return emptyList() + return prepareStatusCandidates( + statuses = listOfNotNull(status.status.data) + status.references.mapNotNull { it.status?.data }, + targetLanguage = targetLanguage, + allowLongText = true, + preferredDisplayMode = TranslationDisplayMode.Translated, + ) + } + private suspend fun prepareStatusCandidates( statuses: List, targetLanguage: String, @@ -251,12 +251,13 @@ internal class AiPreTranslationService( val now = Clock.System.now().toEpochMilliseconds() return buildList { deduplicated.forEach { status -> + val entityKey = status.translationEntityKey() prepareCandidate( entityType = TranslationEntityType.Status, - entityKey = status.translationEntityKey(), + entityKey = entityKey, payload = status.content.translationPayload(), - sourceLanguages = status.content.translationSourceLanguages(), - existing = existingByKey[status.translationEntityKey()], + sourceLanguages = PreTranslationContentRules.sourceLanguages(status.content), + existing = existingByKey[entityKey], targetLanguage = targetLanguage, now = now, allowLongText = allowLongText, @@ -307,80 +308,37 @@ internal class AiPreTranslationService( } val sourceHash = payload.sourceHash() val displayMode = preferredDisplayMode ?: existing?.displayMode ?: TranslationDisplayMode.Auto - if (payload.isNonTranslatableOnly()) { - if ( - existing == null || - existing.sourceHash != sourceHash || - existing.status != TranslationStatus.Skipped || - existing.statusReason != SKIPPED_NON_TRANSLATABLE_ONLY_REASON - ) { - database.translationDao().insert( - DbTranslation( - entityType = entityType, - entityKey = entityKey, - targetLanguage = targetLanguage, - sourceHash = sourceHash, - status = TranslationStatus.Skipped, - displayMode = displayMode, - payload = null, - statusReason = SKIPPED_NON_TRANSLATABLE_ONLY_REASON, - attemptCount = existing?.attemptCount ?: 0, - updatedAt = now, - ), - ) - } - return null - } - val sourceDocument = payload.toBatchPayload(targetLanguage) - if (shouldSkipForMatchingSourceLanguage(sourceLanguages = sourceLanguages, targetLanguage = targetLanguage)) { - if ( - existing == null || - existing.sourceHash != sourceHash || - existing.status != TranslationStatus.Skipped || - existing.statusReason != SKIPPED_SAME_LANGUAGE_REASON - ) { - database.translationDao().insert( - DbTranslation( - entityType = entityType, - entityKey = entityKey, - targetLanguage = targetLanguage, - sourceHash = sourceHash, - status = TranslationStatus.Skipped, - displayMode = displayMode, - payload = null, - statusReason = SKIPPED_SAME_LANGUAGE_REASON, - attemptCount = existing?.attemptCount ?: 0, - updatedAt = now, - ), - ) - } - return null - } - if (sourceDocument.isEmpty()) { - if ( - existing == null || - existing.sourceHash != sourceHash || - existing.status != TranslationStatus.Skipped || - existing.statusReason != SKIPPED_EMPTY_REASON - ) { - database.translationDao().insert( - DbTranslation( - entityType = entityType, - entityKey = entityKey, - targetLanguage = targetLanguage, - sourceHash = sourceHash, - status = TranslationStatus.Skipped, - displayMode = displayMode, - payload = null, - statusReason = SKIPPED_EMPTY_REASON, - attemptCount = existing?.attemptCount ?: 0, - updatedAt = now, - ), - ) + val sourceDocument = PreTranslationPayloadSupport.toBatchPayload(payload, targetLanguage) + val skipReason = + when { + PreTranslationContentRules.isNonTranslatableOnly(payload) -> + PreTranslationStoreSupport.SKIPPED_NON_TRANSLATABLE_ONLY_REASON + + PreTranslationContentRules.shouldSkipForMatchingSourceLanguage( + sourceLanguages = sourceLanguages, + targetLanguage = targetLanguage, + ) -> PreTranslationStoreSupport.SKIPPED_SAME_LANGUAGE_REASON + + PreTranslationPayloadSupport.isEmpty(sourceDocument) -> + PreTranslationStoreSupport.SKIPPED_EMPTY_REASON + + else -> null } + if (skipReason != null) { + PreTranslationStoreSupport.persistSkippedTranslationIfNeeded( + database = database, + entityType = entityType, + entityKey = entityKey, + targetLanguage = targetLanguage, + sourceHash = sourceHash, + displayMode = displayMode, + existing = existing, + statusReason = skipReason, + updatedAt = now, + ) return null } - if (!shouldTranslate(existing = existing, sourceHash = sourceHash, now = now)) { + if (!PreTranslationStoreSupport.shouldTranslate(existing = existing, sourceHash = sourceHash)) { return null } return PreparedTranslationCandidate( @@ -402,16 +360,9 @@ internal class AiPreTranslationService( val updatedAt = Clock.System.now().toEpochMilliseconds() database.translationDao().insertAll( candidates.map { candidate -> - DbTranslation( - entityType = candidate.entityType, - entityKey = candidate.entityKey, - targetLanguage = candidate.targetLanguage, - sourceHash = candidate.sourceHash, + PreTranslationStoreSupport.toDbTranslation( + candidate = candidate, status = TranslationStatus.Pending, - displayMode = candidate.displayMode, - payload = null, - statusReason = null, - attemptCount = candidate.attemptCount, updatedAt = updatedAt, ) }, @@ -419,22 +370,21 @@ internal class AiPreTranslationService( } private suspend fun translatePreparedCandidates( - settings: AppSettings, - targetLanguage: String, + settings: ActivePreTranslationSettings, candidates: List, ) { - candidates.chunkedForBatching().forEach { batch -> - translateBatch( - settings = settings, - targetLanguage = targetLanguage, - candidates = batch, - ) - } + PreTranslationStoreSupport + .chunkCandidatesForBatching(candidates) + .forEach { batch -> + translateBatch( + settings = settings, + candidates = batch, + ) + } } private suspend fun translateBatch( - settings: AppSettings, - targetLanguage: String, + settings: ActivePreTranslationSettings, candidates: List, ) { if (candidates.isEmpty()) { @@ -443,16 +393,9 @@ internal class AiPreTranslationService( val startedAt = Clock.System.now().toEpochMilliseconds() database.translationDao().insertAll( candidates.map { candidate -> - DbTranslation( - entityType = candidate.entityType, - entityKey = candidate.entityKey, - targetLanguage = candidate.targetLanguage, - sourceHash = candidate.sourceHash, + PreTranslationStoreSupport.toDbTranslation( + candidate = candidate, status = TranslationStatus.Translating, - displayMode = candidate.displayMode, - payload = null, - statusReason = null, - attemptCount = candidate.attemptCount, updatedAt = startedAt, ) }, @@ -460,23 +403,16 @@ internal class AiPreTranslationService( runBatchTranslationWithRetry( settings = settings, - targetLanguage = targetLanguage, candidates = candidates, ).getOrElse { throwable -> val failedAt = Clock.System.now().toEpochMilliseconds() database.translationDao().insertAll( candidates.map { candidate -> - DbTranslation( - entityType = candidate.entityType, - entityKey = candidate.entityKey, - targetLanguage = candidate.targetLanguage, - sourceHash = candidate.sourceHash, + PreTranslationStoreSupport.toDbTranslation( + candidate = candidate, status = TranslationStatus.Failed, - displayMode = candidate.displayMode, - payload = null, - statusReason = throwable.message, - attemptCount = candidate.attemptCount, updatedAt = failedAt, + statusReason = throwable.message, ) }, ) @@ -484,33 +420,36 @@ internal class AiPreTranslationService( } private suspend fun runBatchTranslationWithRetry( - settings: AppSettings, - targetLanguage: String, + settings: ActivePreTranslationSettings, candidates: List, ): Result { var lastFailure: Throwable? = null - repeat(PRE_TRANSLATION_BATCH_MAX_ATTEMPTS) { attempt -> + repeat(PreTranslationStoreSupport.PRE_TRANSLATION_BATCH_MAX_ATTEMPTS) { attempt -> val result = tryRun { val sourceDocument = PreTranslationBatchDocument( - targetLanguage = targetLanguage, - items = - candidates.map { candidate -> - PreTranslationBatchItem( - entityKey = candidate.entityKey, - payload = candidate.sourceDocument, - ) - }, + targetLanguage = settings.targetLanguage, + items = candidates.map(PreTranslationStoreSupport::toBatchItem), + ) + val sourceJson = + sourceDocument.encodeJson( + PreTranslationBatchDocument.serializer(), + ) + val prompt = + TranslationPromptFormatter.buildTranslatePrompt( + settings = settings.appSettings, + targetLanguage = settings.targetLanguage, + sourceText = sourceJson, + sourceJson = sourceJson, ) - val sourceJson = sourceDocument.encodeJson(PreTranslationBatchDocument.serializer()) - val prompt = buildTranslatePrompt(settings.aiConfig.translatePrompt, targetLanguage, sourceJson) val translatedJson = - settings.translateBatchDocumentJson( + TranslationProvider.translateBatchDocumentJson( + settings = settings.appSettings, aiCompletionService = aiCompletionService, sourceJson = sourceJson, sourceDocument = sourceDocument, - targetLanguage = targetLanguage, + targetLanguage = settings.targetLanguage, prompt = prompt, ) ?: error("Pre-translation returned empty response") @@ -524,12 +463,11 @@ internal class AiPreTranslationService( return Result.success(Unit) }.onFailure { throwable -> lastFailure = throwable - if (attempt < PRE_TRANSLATION_BATCH_MAX_ATTEMPTS - 1) { - delay(PRE_TRANSLATION_BATCH_RETRY_DELAY) + if (attempt < PreTranslationStoreSupport.PRE_TRANSLATION_BATCH_MAX_ATTEMPTS - 1) { + delay(PreTranslationStoreSupport.PRE_TRANSLATION_BATCH_RETRY_DELAY) } } } - return Result.failure(requireNotNull(lastFailure)) } @@ -538,7 +476,9 @@ internal class AiPreTranslationService( candidates: List, ) { val translatedDocument = - cleanJsonResponse(translatedJson).decodeJson(PreTranslationBatchDocument.serializer()) + TranslationResponseSanitizer + .clean(translatedJson) + .decodeJson(PreTranslationBatchDocument.serializer()) val translatedItems = translatedDocument.items.associateBy { it.entityKey } val updatedAt = Clock.System.now().toEpochMilliseconds() database.translationDao().insertAll( @@ -546,378 +486,61 @@ internal class AiPreTranslationService( tryRun { val translatedItem = translatedItems[candidate.entityKey] - ?: throw IllegalArgumentException("Missing translated item for ${candidate.entityKey}") - when (translatedItem.status) { - PreTranslationBatchItemStatus.Completed -> { - val translatedPayload = - candidate.sourcePayload.applyBatchPayload( - sourceDocument = candidate.sourceDocument, - translatedDocument = - translatedItem.payload ?: throw IllegalArgumentException("Missing translated payload"), - ) - if (translatedPayload == candidate.sourcePayload) { - DbTranslation( - entityType = candidate.entityType, - entityKey = candidate.entityKey, - targetLanguage = candidate.targetLanguage, - sourceHash = candidate.sourceHash, - status = TranslationStatus.Skipped, - displayMode = candidate.displayMode, - payload = null, - statusReason = SKIPPED_UNCHANGED_REASON, - attemptCount = candidate.attemptCount, - updatedAt = updatedAt, - ) - } else { - DbTranslation( - entityType = candidate.entityType, - entityKey = candidate.entityKey, - targetLanguage = candidate.targetLanguage, - sourceHash = candidate.sourceHash, - status = TranslationStatus.Completed, - displayMode = candidate.displayMode, - payload = translatedPayload, - statusReason = null, - attemptCount = candidate.attemptCount, - updatedAt = updatedAt, - ) - } - } - - PreTranslationBatchItemStatus.Skipped -> - DbTranslation( - entityType = candidate.entityType, - entityKey = candidate.entityKey, - targetLanguage = candidate.targetLanguage, - sourceHash = candidate.sourceHash, - status = TranslationStatus.Skipped, - displayMode = candidate.displayMode, - payload = null, - statusReason = translatedItem.reason ?: SKIPPED_AI_SAME_LANGUAGE_REASON, - attemptCount = candidate.attemptCount, - updatedAt = updatedAt, - ) - } + ?: error("Missing translated item for ${candidate.entityKey}") + translatedDbTranslation( + candidate = candidate, + translatedItem = translatedItem, + updatedAt = updatedAt, + ) }.getOrElse { throwable -> - DbTranslation( - entityType = candidate.entityType, - entityKey = candidate.entityKey, - targetLanguage = candidate.targetLanguage, - sourceHash = candidate.sourceHash, + PreTranslationStoreSupport.toDbTranslation( + candidate = candidate, status = TranslationStatus.Failed, - displayMode = candidate.displayMode, - payload = null, - statusReason = throwable.message, - attemptCount = candidate.attemptCount, updatedAt = updatedAt, + statusReason = throwable.message, ) } }, ) } - private fun buildTranslatePrompt( - configuredPrompt: String, - targetLanguage: String, - sourceJson: String, - ): String { - val template = - configuredPrompt.ifBlank { - AiPromptDefaults.TRANSLATE_PROMPT - } - return template - .replace("{target_language}", targetLanguage) - .replace("{source_text}", sourceJson) - .replace("{source_json}", sourceJson) - .replace("{source_html}", sourceJson) - .replace("{source_xml}", sourceJson) - .replace("{source_markup}", sourceJson) - } - - private fun cleanJsonResponse(content: String): String = - content - .removePrefix("```json") - .removePrefix("```html") - .removePrefix("```xml") - .removePrefix("```markup") - .removePrefix("```text") - .removePrefix("```") - .removeSuffix("```") - .trim() - - private fun shouldTranslate( - existing: DbTranslation?, - sourceHash: String, - now: Long, - ): Boolean { - if (existing == null || existing.sourceHash != sourceHash) { - return true - } - return when (existing.status) { - TranslationStatus.Completed, - TranslationStatus.Skipped, - -> false - - TranslationStatus.Failed -> true - TranslationStatus.Pending, - TranslationStatus.Translating, - -> false - } - } -} - -@Serializable -internal data class PreTranslationBatchDocument( - val version: Int = 1, - val targetLanguage: String = "", - val items: List, -) - -@Serializable -internal data class PreTranslationBatchItem( - val entityKey: String, - val status: PreTranslationBatchItemStatus = PreTranslationBatchItemStatus.Completed, - val payload: PreTranslationBatchPayload? = null, - val reason: String? = null, -) - -@Serializable -internal enum class PreTranslationBatchItemStatus { - Completed, - Skipped, -} - -@Serializable -internal data class PreTranslationBatchPayload( - val content: TranslationDocument? = null, - val contentWarning: TranslationDocument? = null, - val title: TranslationDocument? = null, - val description: TranslationDocument? = null, -) - -private data class ActivePreTranslationSettings( - val targetLanguage: String, - val appSettings: AppSettings, -) - -private data class PreparedTranslationCandidate( - val entityType: TranslationEntityType, - val entityKey: String, - val targetLanguage: String, - val sourceHash: String, - val sourcePayload: TranslationPayload, - val sourceDocument: PreTranslationBatchPayload, - val attemptCount: Int, - val displayMode: TranslationDisplayMode, -) - -private const val DEFAULT_PRE_TRANSLATION_MAX_ITEMS = 8 -private const val DEFAULT_PRE_TRANSLATION_MAX_INPUT_TOKENS = 6000 -private const val PRE_TRANSLATION_BATCH_MAX_ATTEMPTS = 2 -private const val FAILED_STALE_IN_FLIGHT_REASON = "stale_in_flight" -private const val SKIPPED_AI_SAME_LANGUAGE_REASON = "ai_same_language" -private const val SKIPPED_EMPTY_REASON = "empty" -private const val SKIPPED_NON_TRANSLATABLE_ONLY_REASON = "non_translatable_only" -private const val SKIPPED_SAME_LANGUAGE_REASON = "source_language_matches_target" -private const val SKIPPED_UNCHANGED_REASON = "unchanged" -private val PRE_TRANSLATION_BATCH_RETRY_DELAY = 500.milliseconds -private val STALE_TRANSLATION_TIMEOUT = 10.minutes -private val protectedTranslationPattern = - Regex("""https?://\S+|@[A-Za-z0-9._-]+(?:@[A-Za-z0-9.-]+)?|#[\p{L}\p{N}_]+""") - -private fun List.chunkedForBatching(): List> { - val result = mutableListOf>() - val current = mutableListOf() - var currentTokenEstimate = 0 - forEach { candidate -> - val itemTokens = candidate.sourceDocument.estimatedTokens() - val wouldExceedCount = current.size >= DEFAULT_PRE_TRANSLATION_MAX_ITEMS - val wouldExceedTokens = - current.isNotEmpty() && - currentTokenEstimate + itemTokens > DEFAULT_PRE_TRANSLATION_MAX_INPUT_TOKENS - if (wouldExceedCount || wouldExceedTokens) { - result += current.toList() - current.clear() - currentTokenEstimate = 0 - } - current += candidate - currentTokenEstimate += itemTokens - } - if (current.isNotEmpty()) { - result += current.toList() - } - return result -} - -private fun PreTranslationBatchPayload.estimatedTokens(): Int = this.encodeJson(PreTranslationBatchPayload.serializer()).length / 4 + 1 - -private fun PreTranslationBatchPayload.isEmpty(): Boolean = - content == null && contentWarning == null && title == null && description == null - -private fun TranslationPayload.toBatchPayload(targetLanguage: String): PreTranslationBatchPayload = - PreTranslationBatchPayload( - content = content?.toTranslationDocumentOrNull(targetLanguage), - contentWarning = contentWarning?.toTranslationDocumentOrNull(targetLanguage), - title = title?.toTranslationDocumentOrNull(targetLanguage), - description = description?.toTranslationDocumentOrNull(targetLanguage), - ) - -private fun TranslationPayload.applyBatchPayload( - sourceDocument: PreTranslationBatchPayload, - translatedDocument: PreTranslationBatchPayload, -): TranslationPayload = - TranslationPayload( - content = content.applyTranslatedField(sourceDocument.content, translatedDocument.content), - contentWarning = contentWarning.applyTranslatedField(sourceDocument.contentWarning, translatedDocument.contentWarning), - title = title.applyTranslatedField(sourceDocument.title, translatedDocument.title), - description = description.applyTranslatedField(sourceDocument.description, translatedDocument.description), - ) - -private fun dev.dimension.flare.ui.render.UiRichText?.toTranslationDocumentOrNull(targetLanguage: String): TranslationDocument? = - this?.toTranslationDocument(targetLanguage)?.takeUnless { it.blocks.isEmpty() } - -private fun dev.dimension.flare.ui.render.UiRichText?.applyTranslatedField( - sourceDocument: TranslationDocument?, - translatedDocument: TranslationDocument?, -): dev.dimension.flare.ui.render.UiRichText? = - when { - this == null -> null - sourceDocument == null -> this - translatedDocument == null -> throw IllegalArgumentException("Missing translated field") - else -> applyTranslationDocument(translatedDocument) - } - -private fun dev.dimension.flare.ui.model.UiTimelineV2.translationSourceLanguages(): List = - when (this) { - is dev.dimension.flare.ui.model.UiTimelineV2.Feed -> sourceLanguages - is dev.dimension.flare.ui.model.UiTimelineV2.Post -> sourceLanguages - is dev.dimension.flare.ui.model.UiTimelineV2.Message -> emptyList() - is dev.dimension.flare.ui.model.UiTimelineV2.User -> emptyList() - is dev.dimension.flare.ui.model.UiTimelineV2.UserList -> emptyList() - } - -private fun shouldSkipForMatchingSourceLanguage( - sourceLanguages: List, - targetLanguage: String, -): Boolean { - val canonicalTargetLanguage = canonicalTranslationLanguage(targetLanguage) ?: return false - return sourceLanguages - .asSequence() - .mapNotNull(::canonicalTranslationLanguage) - .any { it == canonicalTargetLanguage } -} - -private fun canonicalTranslationLanguage(language: String): String? { - val normalized = language.trim().lowercase().replace('_', '-') - if (normalized.isBlank()) { - return null - } - val parts = normalized.split('-').filter { it.isNotBlank() } - if (parts.isEmpty()) { - return null - } - val primary = parts.first() - if (primary != "zh") { - return primary - } - val regionOrScript = parts.drop(1) - return when { - regionOrScript.any { it == "hant" } || regionOrScript.any { it in setOf("tw", "hk", "mo") } -> "zh-hant" - regionOrScript.any { it == "hans" } || regionOrScript.any { it in setOf("cn", "sg") } -> "zh-hans" - else -> "zh" - } -} - -private fun TranslationPayload.isNonTranslatableOnly(): Boolean { - val fields = listOfNotNull(content, contentWarning, title, description) - return fields.isNotEmpty() && fields.all { it.isNonTranslatableOnly() } -} - -private fun UiRichText.isNonTranslatableOnly(): Boolean { - var hasVisibleContent = false - renderRuns.forEach { content -> - when (content) { - is RenderContent.BlockImage -> hasVisibleContent = true - is RenderContent.Text -> - content.runs.forEach { run -> - when (run) { - is RenderRun.Image -> hasVisibleContent = true - is RenderRun.Text -> { - if (run.text.isBlank()) { - return@forEach - } - hasVisibleContent = true - if (!run.text.isNonTranslatableOnlyText(run.style)) { - return false - } - } - } - } - } - } - return hasVisibleContent -} - -private fun String.isNonTranslatableOnlyText(style: RenderTextStyle): Boolean { - if (isBlank()) { - return false - } - if (style.code || style.monospace) { - return true - } - var hasVisibleContent = false - var cursor = 0 - protectedTranslationPattern.findAll(this).forEach { match -> - if (match.range.first > cursor) { - val segment = substring(cursor, match.range.first) - if (!segment.isBlank()) { - hasVisibleContent = true - if (!segment.isEmojiOnlyText()) { - return false + private fun translatedDbTranslation( + candidate: PreparedTranslationCandidate, + translatedItem: PreTranslationBatchItem, + updatedAt: Long, + ): DbTranslation = + when (translatedItem.status) { + PreTranslationBatchItemStatus.Completed -> { + val translatedPayload = + PreTranslationPayloadSupport.applyBatchPayload( + sourcePayload = candidate.sourcePayload, + sourceDocument = candidate.sourceDocument, + translatedDocument = translatedItem.payload ?: error("Missing translated payload"), + ) + if (translatedPayload == candidate.sourcePayload) { + PreTranslationStoreSupport.toDbTranslation( + candidate = candidate, + status = TranslationStatus.Skipped, + updatedAt = updatedAt, + statusReason = PreTranslationStoreSupport.SKIPPED_UNCHANGED_REASON, + ) + } else { + PreTranslationStoreSupport.toDbTranslation( + candidate = candidate, + status = TranslationStatus.Completed, + updatedAt = updatedAt, + payload = translatedPayload, + ) } } - } - if (match.value.isNotBlank()) { - hasVisibleContent = true - } - cursor = match.range.last + 1 - } - if (cursor < length) { - val trailing = substring(cursor) - if (!trailing.isBlank()) { - hasVisibleContent = true - if (!trailing.isEmojiOnlyText()) { - return false - } - } - } - return hasVisibleContent -} -private fun String.isEmojiOnlyText(): Boolean { - if (isBlank()) { - return false - } - var hasEmoji = false - var index = 0 - while (index < length) { - val current = this[index] - when { - current.isWhitespace() -> index += 1 - current in '\uD83C'..'\uD83E' && index + 1 < length && this[index + 1].isLowSurrogate() -> { - hasEmoji = true - index += 2 - } - current.code == 0x200D || - current.code == 0x20E3 || - current.code in 0xFE00..0xFE0F || - current.code in 0x2600..0x27BF -> { - hasEmoji = true - index += 1 - } - - else -> return false + PreTranslationBatchItemStatus.Skipped -> + PreTranslationStoreSupport.toDbTranslation( + candidate = candidate, + status = TranslationStatus.Skipped, + updatedAt = updatedAt, + statusReason = + translatedItem.reason ?: PreTranslationStoreSupport.SKIPPED_AI_SAME_LANGUAGE_REASON, + ) } - } - return hasEmoji } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationStoreSupport.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationStoreSupport.kt new file mode 100644 index 000000000..b9a0cee03 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationStoreSupport.kt @@ -0,0 +1,158 @@ +package dev.dimension.flare.data.translation + +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.model.DbTranslation +import dev.dimension.flare.data.database.cache.model.TranslationDisplayMode +import dev.dimension.flare.data.database.cache.model.TranslationEntityType +import dev.dimension.flare.data.database.cache.model.TranslationPayload +import dev.dimension.flare.data.database.cache.model.TranslationStatus +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes + +internal object PreTranslationStoreSupport { + const val DEFAULT_PRE_TRANSLATION_MAX_ITEMS: Int = 8 + const val DEFAULT_PRE_TRANSLATION_MAX_INPUT_TOKENS: Int = 6000 + const val PRE_TRANSLATION_BATCH_MAX_ATTEMPTS: Int = 2 + const val FAILED_STALE_IN_FLIGHT_REASON: String = "stale_in_flight" + const val SKIPPED_AI_SAME_LANGUAGE_REASON: String = "ai_same_language" + const val SKIPPED_EMPTY_REASON: String = "empty" + const val SKIPPED_NON_TRANSLATABLE_ONLY_REASON: String = "non_translatable_only" + const val SKIPPED_SAME_LANGUAGE_REASON: String = "source_language_matches_target" + const val SKIPPED_UNCHANGED_REASON: String = "unchanged" + val PRE_TRANSLATION_BATCH_RETRY_DELAY: Duration = 500.milliseconds + val STALE_TRANSLATION_TIMEOUT: Duration = 10.minutes + + fun toDbTranslation( + candidate: PreparedTranslationCandidate, + status: TranslationStatus, + updatedAt: Long, + payload: TranslationPayload? = null, + statusReason: String? = null, + ): DbTranslation = + createTranslationRecord( + entityType = candidate.entityType, + entityKey = candidate.entityKey, + targetLanguage = candidate.targetLanguage, + sourceHash = candidate.sourceHash, + status = status, + displayMode = candidate.displayMode, + payload = payload, + statusReason = statusReason, + attemptCount = candidate.attemptCount, + updatedAt = updatedAt, + ) + + fun toBatchItem(candidate: PreparedTranslationCandidate): PreTranslationBatchItem = + PreTranslationBatchItem( + entityKey = candidate.entityKey, + payload = candidate.sourceDocument, + ) + + fun chunkCandidatesForBatching(candidates: List): List> { + val result = mutableListOf>() + val current = mutableListOf() + var currentTokenEstimate = 0 + candidates.forEach { candidate -> + val itemTokens = PreTranslationPayloadSupport.estimatedTokens(candidate.sourceDocument) + val wouldExceedCount = current.size >= DEFAULT_PRE_TRANSLATION_MAX_ITEMS + val wouldExceedTokens = + current.isNotEmpty() && + currentTokenEstimate + itemTokens > DEFAULT_PRE_TRANSLATION_MAX_INPUT_TOKENS + if (wouldExceedCount || wouldExceedTokens) { + result += current.toList() + current.clear() + currentTokenEstimate = 0 + } + current += candidate + currentTokenEstimate += itemTokens + } + if (current.isNotEmpty()) { + result += current.toList() + } + return result + } + + suspend fun persistSkippedTranslationIfNeeded( + database: CacheDatabase, + entityType: TranslationEntityType, + entityKey: String, + targetLanguage: String, + sourceHash: String, + displayMode: TranslationDisplayMode, + existing: DbTranslation?, + statusReason: String, + updatedAt: Long, + ) { + if (matchesSkipped(existing, sourceHash, statusReason)) { + return + } + database.translationDao().insert( + createTranslationRecord( + entityType = entityType, + entityKey = entityKey, + targetLanguage = targetLanguage, + sourceHash = sourceHash, + status = TranslationStatus.Skipped, + displayMode = displayMode, + payload = null, + statusReason = statusReason, + attemptCount = existing?.attemptCount ?: 0, + updatedAt = updatedAt, + ), + ) + } + + fun shouldTranslate( + existing: DbTranslation?, + sourceHash: String, + ): Boolean { + if (existing == null || existing.sourceHash != sourceHash) { + return true + } + return when (existing.status) { + TranslationStatus.Completed, + TranslationStatus.Skipped, + -> false + + TranslationStatus.Failed -> true + TranslationStatus.Pending, + TranslationStatus.Translating, + -> false + } + } + + private fun matchesSkipped( + existing: DbTranslation?, + sourceHash: String, + statusReason: String, + ): Boolean = + existing?.sourceHash == sourceHash && + existing.status == TranslationStatus.Skipped && + existing.statusReason == statusReason + + private fun createTranslationRecord( + entityType: TranslationEntityType, + entityKey: String, + targetLanguage: String, + sourceHash: String, + status: TranslationStatus, + displayMode: TranslationDisplayMode, + payload: TranslationPayload?, + statusReason: String?, + attemptCount: Int, + updatedAt: Long, + ): DbTranslation = + DbTranslation( + entityType = entityType, + entityKey = entityKey, + targetLanguage = targetLanguage, + sourceHash = sourceHash, + status = status, + displayMode = displayMode, + payload = payload, + statusReason = statusReason, + attemptCount = attemptCount, + updatedAt = updatedAt, + ) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProvider.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProvider.kt index 1371c7761..4b8613094 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProvider.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProvider.kt @@ -15,94 +15,114 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive -internal suspend fun AppSettings.translateDocumentJson( - aiCompletionService: AiCompletionService, - sourceText: String, - sourceJson: String, - targetLanguage: String, - prompt: String, -): String? = - when (translateConfig.provider) { - AppSettings.TranslateConfig.Provider.AI -> - aiCompletionService.translate( - config = aiConfig, - source = sourceText, - targetLanguage = targetLanguage, - prompt = prompt, - ) +internal object TranslationProvider { + suspend fun translateDocumentJson( + settings: AppSettings, + aiCompletionService: AiCompletionService, + sourceText: String, + sourceJson: String, + targetLanguage: String, + prompt: String, + ): String? = + when (settings.translateConfig.provider) { + AppSettings.TranslateConfig.Provider.AI -> + aiCompletionService.translate( + config = settings.aiConfig, + source = sourceText, + targetLanguage = targetLanguage, + prompt = prompt, + ) - AppSettings.TranslateConfig.Provider.Google -> - GoogleWebTranslationProvider.translateDocumentJson( - sourceJson = sourceJson, - targetLanguage = targetLanguage, - ) - } + AppSettings.TranslateConfig.Provider.Google -> + GoogleWebTranslationProvider.translateDocumentJson( + sourceJson = sourceJson, + targetLanguage = targetLanguage, + ) + } -internal suspend fun AppSettings.translateBatchDocumentJson( - aiCompletionService: AiCompletionService, - sourceJson: String, - sourceDocument: PreTranslationBatchDocument, - targetLanguage: String, - prompt: String, -): String? = - when (translateConfig.provider) { - AppSettings.TranslateConfig.Provider.AI -> - aiCompletionService.translate( - config = aiConfig, - source = sourceJson, - targetLanguage = targetLanguage, - prompt = prompt, - ) + suspend fun translateBatchDocumentJson( + settings: AppSettings, + aiCompletionService: AiCompletionService, + sourceJson: String, + sourceDocument: PreTranslationBatchDocument, + targetLanguage: String, + prompt: String, + ): String? = + when (settings.translateConfig.provider) { + AppSettings.TranslateConfig.Provider.AI -> + aiCompletionService.translate( + config = settings.aiConfig, + source = sourceJson, + targetLanguage = targetLanguage, + prompt = prompt, + ) - AppSettings.TranslateConfig.Provider.Google -> - GoogleWebTranslationProvider.translateBatchDocumentJson( - sourceDocument = sourceDocument, - targetLanguage = targetLanguage, - ) - } + AppSettings.TranslateConfig.Provider.Google -> + GoogleWebTranslationProvider.translateBatchDocumentJson( + sourceDocument = sourceDocument, + targetLanguage = targetLanguage, + ) + } +} private object GoogleWebTranslationProvider { suspend fun translateDocumentJson( sourceJson: String, targetLanguage: String, ): String = - sourceJson - .decodeJson(TranslationDocument.serializer()) - .translate(targetLanguage) - .encodeJson(TranslationDocument.serializer()) + translateDocument( + document = sourceJson.decodeJson(TranslationDocument.serializer()), + targetLanguage = targetLanguage, + ).encodeJson(TranslationDocument.serializer()) suspend fun translateBatchDocumentJson( sourceDocument: PreTranslationBatchDocument, targetLanguage: String, ): String = - sourceDocument - .translate(targetLanguage) - .encodeJson(PreTranslationBatchDocument.serializer()) + translateBatchDocument( + sourceDocument = sourceDocument, + targetLanguage = targetLanguage, + ).encodeJson(PreTranslationBatchDocument.serializer()) - private suspend fun PreTranslationBatchDocument.translate(targetLanguage: String): PreTranslationBatchDocument = - copy( + private suspend fun translateBatchDocument( + sourceDocument: PreTranslationBatchDocument, + targetLanguage: String, + ): PreTranslationBatchDocument = + sourceDocument.copy( targetLanguage = targetLanguage, items = - items.map { item -> + sourceDocument.items.map { item -> item.copy( - payload = item.payload?.translate(targetLanguage), + payload = + item.payload?.let { payload -> + translateBatchPayload( + payload = payload, + targetLanguage = targetLanguage, + ) + }, ) }, ) - private suspend fun PreTranslationBatchPayload.translate(targetLanguage: String): PreTranslationBatchPayload = + private suspend fun translateBatchPayload( + payload: PreTranslationBatchPayload, + targetLanguage: String, + ): PreTranslationBatchPayload = PreTranslationBatchPayload( - content = content?.translate(targetLanguage), - contentWarning = contentWarning?.translate(targetLanguage), - title = title?.translate(targetLanguage), - description = description?.translate(targetLanguage), + content = payload.content?.let { translateDocument(it, targetLanguage) }, + contentWarning = payload.contentWarning?.let { translateDocument(it, targetLanguage) }, + title = payload.title?.let { translateDocument(it, targetLanguage) }, + description = payload.description?.let { translateDocument(it, targetLanguage) }, ) - private suspend fun TranslationDocument.translate(targetLanguage: String): TranslationDocument = - copy( + private suspend fun translateDocument( + document: TranslationDocument, + targetLanguage: String, + ): TranslationDocument = + document.copy( targetLanguage = targetLanguage, blocks = - blocks.map { block -> + document.blocks.map { block -> block.copy( tokens = block.tokens.map { token -> diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationSupport.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationSupport.kt new file mode 100644 index 000000000..2a5a76780 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationSupport.kt @@ -0,0 +1,53 @@ +package dev.dimension.flare.data.translation + +import dev.dimension.flare.data.database.cache.model.TranslationDisplayOptions +import dev.dimension.flare.data.datastore.AppDataStore +import dev.dimension.flare.data.datastore.model.AiPromptDefaults +import dev.dimension.flare.data.datastore.model.AppSettings +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +internal object TranslationSettingsSupport { + fun displayOptionsFlow(appDataStore: AppDataStore): Flow = + appDataStore.appSettingsStore.data + .map(::displayOptions) + .distinctUntilChanged() + + fun displayOptions(settings: AppSettings): TranslationDisplayOptions = + TranslationDisplayOptions( + translationEnabled = true, + autoDisplayEnabled = settings.translateConfig.preTranslate, + ) +} + +internal object TranslationPromptFormatter { + fun buildTranslatePrompt( + settings: AppSettings, + targetLanguage: String, + sourceText: String, + sourceJson: String, + ): String = + settings.aiConfig.translatePrompt + .ifBlank { + AiPromptDefaults.TRANSLATE_PROMPT + }.replace("{target_language}", targetLanguage) + .replace("{source_text}", sourceText) + .replace("{source_json}", sourceJson) + .replace("{source_html}", sourceJson) + .replace("{source_xml}", sourceJson) + .replace("{source_markup}", sourceJson) +} + +internal object TranslationResponseSanitizer { + fun clean(content: String): String = + content + .removePrefix("```json") + .removePrefix("```html") + .removePrefix("```xml") + .removePrefix("```markup") + .removePrefix("```text") + .removePrefix("```") + .removeSuffix("```") + .trim() +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt index e55aaa1ff..1020f0407 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt @@ -16,7 +16,7 @@ import dev.dimension.flare.data.repository.DraftSendingRecoveryCoordinator import dev.dimension.flare.data.repository.LocalFilterRepository import dev.dimension.flare.data.repository.SearchHistoryRepository import dev.dimension.flare.data.repository.SettingsRepository -import dev.dimension.flare.data.translation.AiPreTranslationService +import dev.dimension.flare.data.translation.OnlinePreTranslationService import dev.dimension.flare.data.translation.PreTranslationService import dev.dimension.flare.ui.presenter.compose.ComposeUseCase import dev.dimension.flare.ui.presenter.compose.RestoreDraftUseCase @@ -63,5 +63,5 @@ internal val commonModule = singleOf(::Readability) singleOf(::OpenAIService) singleOf(::AiCompletionService) - single { AiPreTranslationService(get(), get(), get(), get()) } + single { OnlinePreTranslationService(get(), get(), get(), get()) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt index cc17e1650..a5464d2ff 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenter.kt @@ -32,6 +32,7 @@ import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.repository.LocalFilterRepository import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.data.translation.PreTranslationService +import dev.dimension.flare.data.translation.TranslationSettingsSupport import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.presenter.PresenterBase import kotlinx.coroutines.CoroutineScope @@ -40,7 +41,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -64,13 +64,7 @@ public abstract class TimelinePresenter : } private val translationSettingsFlow: Flow by lazy { - appDataStore.appSettingsStore.data - .map { settings -> - TranslationDisplayOptions( - translationEnabled = true, - autoDisplayEnabled = settings.translateConfig.preTranslate, - ) - }.distinctUntilChanged() + TranslationSettingsSupport.displayOptionsFlow(appDataStore) } internal open fun allowLongTextTranslationDisplay(loader: RemoteLoader): Boolean = false diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt index 65743eeb7..6897f3d3f 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt @@ -197,8 +197,7 @@ public class AiConfigPresenter : private fun AppSettings.AiConfig.normalized(): AppSettings.AiConfig = this -private fun AppSettings.TranslateConfig.normalized(): AppSettings.TranslateConfig = - this +private fun AppSettings.TranslateConfig.normalized(): AppSettings.TranslateConfig = this private val SERVER_SUGGESTIONS = persistentListOf( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt index ae3279b41..a79e06e7c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt @@ -11,10 +11,11 @@ import dev.dimension.flare.data.database.cache.model.TranslationPayload import dev.dimension.flare.data.database.cache.model.TranslationStatus import dev.dimension.flare.data.database.cache.model.sourceHash import dev.dimension.flare.data.datastore.AppDataStore -import dev.dimension.flare.data.datastore.model.AiPromptDefaults import dev.dimension.flare.data.network.ai.AiCompletionService import dev.dimension.flare.data.repository.tryRun -import dev.dimension.flare.data.translation.translateDocumentJson +import dev.dimension.flare.data.translation.TranslationPromptFormatter +import dev.dimension.flare.data.translation.TranslationProvider +import dev.dimension.flare.data.translation.TranslationResponseSanitizer import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiState @@ -52,13 +53,16 @@ public class TranslatePresenter( cachedTranslation()?.let { return@tryRun it } - val promptTemplate = - settings.aiConfig.translatePrompt.ifBlank { - AiPromptDefaults.TRANSLATE_PROMPT - } - val prompt = buildTranslatePrompt(promptTemplate, targetLanguage) + val prompt = + TranslationPromptFormatter.buildTranslatePrompt( + settings = settings, + targetLanguage = targetLanguage, + sourceText = sourceText, + sourceJson = sourceJson, + ) val translatedContent = - settings.translateDocumentJson( + TranslationProvider.translateDocumentJson( + settings = settings, aiCompletionService = aiCompletionService, sourceText = sourceText, sourceJson = sourceJson, @@ -78,28 +82,9 @@ public class TranslatePresenter( }.value } - private fun buildTranslatePrompt( - template: String, - targetLanguage: String, - ): String = - template - .replace("{target_language}", targetLanguage) - .replace("{source_text}", sourceText) - .replace("{source_json}", sourceJson) - .replace("{source_html}", sourceJson) - .replace("{source_xml}", sourceJson) - .replace("{source_markup}", sourceJson) - private fun toUiRichText(translatedContent: String): UiRichText = - translatedContent - .removePrefix("```json") - .removePrefix("```html") - .removePrefix("```xml") - .removePrefix("```markup") - .removePrefix("```text") - .removePrefix("```") - .removeSuffix("```") - .trim() + TranslationResponseSanitizer + .clean(translatedContent) .let { cleaned -> tryRun { source.applyTranslationJson(cleaned) diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt index 4f7bcc9b8..d4bacf0a2 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt @@ -15,7 +15,6 @@ import dev.dimension.flare.common.decodeJson import dev.dimension.flare.common.encodeJson import dev.dimension.flare.createTestRootPath import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.model.DbTranslation import dev.dimension.flare.data.database.cache.model.TranslationEntityType import dev.dimension.flare.data.database.cache.model.TranslationStatus import dev.dimension.flare.data.datasource.microblog.paging.CacheableRemoteLoader @@ -28,8 +27,9 @@ import dev.dimension.flare.data.datastore.model.AppSettings import dev.dimension.flare.data.io.PlatformPathProducer import dev.dimension.flare.data.network.ai.AiCompletionService import dev.dimension.flare.data.network.ai.OpenAIService -import dev.dimension.flare.data.translation.AiPreTranslationService +import dev.dimension.flare.data.translation.OnlinePreTranslationService import dev.dimension.flare.data.translation.PreTranslationService +import dev.dimension.flare.data.translation.aiPreTranslateConfig import dev.dimension.flare.deleteTestRootPath import dev.dimension.flare.memoryDatabaseBuilder import dev.dimension.flare.model.AccountType @@ -52,7 +52,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -385,7 +385,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", - translateConfig = AppSettings.TranslateConfig(preTranslate = true), + translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( type = AppSettings.AiConfig.Type.OnDevice, @@ -393,7 +393,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { ) } val preTranslationService: PreTranslationService = - AiPreTranslationService( + OnlinePreTranslationService( database = db, appDataStore = appDataStore, aiCompletionService = AiCompletionService(OpenAIService(), TestOnDeviceAI()), @@ -487,7 +487,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", - translateConfig = AppSettings.TranslateConfig(preTranslate = true), + translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( type = AppSettings.AiConfig.Type.OnDevice, @@ -495,7 +495,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { ) } val preTranslationService: PreTranslationService = - AiPreTranslationService( + OnlinePreTranslationService( database = db, appDataStore = appDataStore, aiCompletionService = AiCompletionService(OpenAIService(), TestOnDeviceAI()), @@ -563,7 +563,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", - translateConfig = AppSettings.TranslateConfig(preTranslate = true), + translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( type = AppSettings.AiConfig.Type.OnDevice, @@ -571,7 +571,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { ) } val preTranslationService: PreTranslationService = - AiPreTranslationService( + OnlinePreTranslationService( database = db, appDataStore = appDataStore, aiCompletionService = AiCompletionService(OpenAIService(), TestOnDeviceAI()), @@ -642,7 +642,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", - translateConfig = AppSettings.TranslateConfig(preTranslate = true), + translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( type = AppSettings.AiConfig.Type.OnDevice, @@ -650,7 +650,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { ) } val preTranslationService: PreTranslationService = - AiPreTranslationService( + OnlinePreTranslationService( database = db, appDataStore = appDataStore, aiCompletionService = AiCompletionService(OpenAIService(), SkippingOnDeviceAI()), @@ -721,7 +721,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", - translateConfig = AppSettings.TranslateConfig(preTranslate = true), + translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( type = AppSettings.AiConfig.Type.OnDevice, @@ -729,7 +729,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { ) } val preTranslationService: PreTranslationService = - AiPreTranslationService( + OnlinePreTranslationService( database = db, appDataStore = appDataStore, aiCompletionService = AiCompletionService(OpenAIService(), TestOnDeviceAI()), @@ -822,7 +822,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", - translateConfig = AppSettings.TranslateConfig(preTranslate = true), + translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( type = AppSettings.AiConfig.Type.OnDevice, @@ -831,7 +831,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { } val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) - AiPreTranslationService( + OnlinePreTranslationService( database = db, appDataStore = appDataStore, aiCompletionService = AiCompletionService(OpenAIService(), TestOnDeviceAI()), @@ -861,7 +861,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", - translateConfig = AppSettings.TranslateConfig(preTranslate = true), + translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( type = AppSettings.AiConfig.Type.OnDevice, @@ -872,59 +872,61 @@ class MixedRemoteMediatorTest : RobolectricTest() { val release = CompletableDeferred() val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) val preTranslationService: PreTranslationService = - AiPreTranslationService( + OnlinePreTranslationService( database = db, appDataStore = appDataStore, aiCompletionService = AiCompletionService(OpenAIService(), BlockingOnDeviceAI(started, release)), coroutineScope = scope, ) - val accountKey = MicroBlogKey(id = "account-pending-queue", host = "test.social") - val accountType = AccountType.Specific(accountKey) - val firstStatus = - TimelinePagingMapper - .toDb( - createPost( - accountType = accountType, - user = profile(MicroBlogKey("user-pending-1", "test.social"), "User"), - statusKey = MicroBlogKey("status-pending-1", "test.social"), - text = "first source", - ), - pagingKey = "home", - ).status.status.data - val secondStatus = - TimelinePagingMapper - .toDb( - createPost( - accountType = accountType, - user = profile(MicroBlogKey("user-pending-2", "test.social"), "User"), - statusKey = MicroBlogKey("status-pending-2", "test.social"), - text = "second source", - ), - pagingKey = "home", - ).status.status.data - - preTranslationService.enqueueStatuses(listOf(firstStatus), allowLongText = false) - started.await() - - preTranslationService.enqueueStatuses(listOf(secondStatus), allowLongText = false) - - var pendingTranslation: DbTranslation? = null - withTimeout(5_000) { - while (pendingTranslation == null) { - delay(50) - pendingTranslation = - db.translationDao().get( - entityType = TranslationEntityType.Status, - entityKey = secondStatus.id, - targetLanguage = Locale.language, - ) + try { + val accountKey = MicroBlogKey(id = "account-pending-queue", host = "test.social") + val accountType = AccountType.Specific(accountKey) + val firstStatus = + TimelinePagingMapper + .toDb( + createPost( + accountType = accountType, + user = profile(MicroBlogKey("user-pending-1", "test.social"), "User"), + statusKey = MicroBlogKey("status-pending-1", "test.social"), + text = "first source", + ), + pagingKey = "home", + ).status.status.data + val secondStatus = + TimelinePagingMapper + .toDb( + createPost( + accountType = accountType, + user = profile(MicroBlogKey("user-pending-2", "test.social"), "User"), + statusKey = MicroBlogKey("status-pending-2", "test.social"), + text = "second source", + ), + pagingKey = "home", + ).status.status.data + + preTranslationService.enqueueStatuses(listOf(firstStatus), allowLongText = false) + withTimeout(5_000) { + started.await() } - } - assertNotNull(pendingTranslation) - assertEquals(TranslationStatus.Pending, pendingTranslation.status) - release.complete(Unit) - scope.coroutineContext[Job]?.cancel() + preTranslationService.enqueueStatuses(listOf(secondStatus), allowLongText = false) + + val pendingTranslation = + withTimeout(5_000) { + db + .translationDao() + .find( + entityType = TranslationEntityType.Status, + entityKey = secondStatus.id, + targetLanguage = Locale.language, + ).filterNotNull() + .first { it.status == TranslationStatus.Pending } + } + assertEquals(TranslationStatus.Pending, pendingTranslation.status) + } finally { + release.complete(Unit) + scope.coroutineContext[Job]?.cancelAndJoin() + } } } diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt index 1248eb2b4..f46d08232 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt @@ -24,8 +24,9 @@ import dev.dimension.flare.data.datastore.model.AppSettings import dev.dimension.flare.data.io.PlatformPathProducer import dev.dimension.flare.data.network.ai.AiCompletionService import dev.dimension.flare.data.network.ai.OpenAIService -import dev.dimension.flare.data.translation.AiPreTranslationService +import dev.dimension.flare.data.translation.OnlinePreTranslationService import dev.dimension.flare.data.translation.PreTranslationService +import dev.dimension.flare.data.translation.aiPreTranslateConfig import dev.dimension.flare.deleteTestRootPath import dev.dimension.flare.memoryDatabaseBuilder import dev.dimension.flare.model.AccountType @@ -117,7 +118,7 @@ class PostHandlerTest : RobolectricTest() { single { onDeviceAI } single { OpenAIService() } single { AiCompletionService(get(), get()) } - single { AiPreTranslationService(get(), get(), get(), get()) } + single { OnlinePreTranslationService(get(), get(), get(), get()) } single { TestFormatter() } }, ) @@ -391,7 +392,7 @@ class PostHandlerTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", - translateConfig = AppSettings.TranslateConfig(preTranslate = true), + translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( type = AppSettings.AiConfig.Type.OnDevice, diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt index 68da2f34f..cb323a7b1 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt @@ -26,10 +26,11 @@ import dev.dimension.flare.data.datastore.model.AppSettings import dev.dimension.flare.data.io.PlatformPathProducer import dev.dimension.flare.data.network.ai.AiCompletionService import dev.dimension.flare.data.network.ai.OpenAIService -import dev.dimension.flare.data.translation.AiPreTranslationService +import dev.dimension.flare.data.translation.OnlinePreTranslationService import dev.dimension.flare.data.translation.PreTranslationBatchDocument import dev.dimension.flare.data.translation.PreTranslationBatchPayload import dev.dimension.flare.data.translation.PreTranslationService +import dev.dimension.flare.data.translation.aiPreTranslateConfig import dev.dimension.flare.deleteTestRootPath import dev.dimension.flare.memoryDatabaseBuilder import dev.dimension.flare.model.MicroBlogKey @@ -105,7 +106,7 @@ class UserHandlerTest : RobolectricTest() { single { onDeviceAI } single { OpenAIService() } single { AiCompletionService(get(), get()) } - single { AiPreTranslationService(get(), get(), get(), get()) } + single { OnlinePreTranslationService(get(), get(), get(), get()) } single { TestFormatter() } }, ) @@ -248,10 +249,9 @@ class UserHandlerTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", - translateConfig = AppSettings.TranslateConfig(preTranslate = true), + translateConfig = aiPreTranslateConfig(), aiConfig = - AppSettings.AiConfig( - ), + AppSettings.AiConfig(), ) } db.translationDao().insert( @@ -288,7 +288,7 @@ class UserHandlerTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", - translateConfig = AppSettings.TranslateConfig(preTranslate = true), + translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( type = AppSettings.AiConfig.Type.OnDevice, @@ -324,7 +324,7 @@ class UserHandlerTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", - translateConfig = AppSettings.TranslateConfig(preTranslate = true), + translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( type = AppSettings.AiConfig.Type.OnDevice, @@ -381,7 +381,7 @@ class UserHandlerTest : RobolectricTest() { appDataStore.appSettingsStore.updateData { it.copy( language = "zh-CN", - translateConfig = AppSettings.TranslateConfig(preTranslate = true), + translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( type = AppSettings.AiConfig.Type.OnDevice, diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/translation/TranslationTestConfig.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/translation/TranslationTestConfig.kt new file mode 100644 index 000000000..bb20bfaaa --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/translation/TranslationTestConfig.kt @@ -0,0 +1,9 @@ +package dev.dimension.flare.data.translation + +import dev.dimension.flare.data.datastore.model.AppSettings + +internal fun aiPreTranslateConfig(preTranslate: Boolean = true): AppSettings.TranslateConfig = + AppSettings.TranslateConfig( + preTranslate = preTranslate, + provider = AppSettings.TranslateConfig.Provider.AI, + ) From 8c9af711656bd408b025b4a383344245a69d79ff Mon Sep 17 00:00:00 2001 From: Tlaster Date: Mon, 30 Mar 2026 01:47:25 +0900 Subject: [PATCH 11/14] enhance google translate --- .../data/translation/TranslationProvider.kt | 248 +++++++++++++----- ...GoogleWebTranslationDocumentSupportTest.kt | 144 ++++++++++ ...ogleWebTranslationWhitespaceSupportTest.kt | 36 +++ 3 files changed, 365 insertions(+), 63 deletions(-) create mode 100644 shared/src/commonTest/kotlin/dev/dimension/flare/data/translation/GoogleWebTranslationDocumentSupportTest.kt create mode 100644 shared/src/commonTest/kotlin/dev/dimension/flare/data/translation/GoogleWebTranslationWhitespaceSupportTest.kt diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProvider.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProvider.kt index 4b8613094..fb47cc454 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProvider.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProvider.kt @@ -7,10 +7,16 @@ import dev.dimension.flare.data.network.ai.AiCompletionService import dev.dimension.flare.data.network.ktorClient import dev.dimension.flare.ui.render.TranslationDocument import dev.dimension.flare.ui.render.TranslationTokenKind +import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.request.url +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive @@ -66,104 +72,220 @@ internal object TranslationProvider { } private object GoogleWebTranslationProvider { + private const val MAX_CONCURRENT_REQUESTS = 4 + + private val httpClient: HttpClient by lazy { + ktorClient() + } + suspend fun translateDocumentJson( sourceJson: String, targetLanguage: String, ): String = - translateDocument( - document = sourceJson.decodeJson(TranslationDocument.serializer()), - targetLanguage = targetLanguage, - ).encodeJson(TranslationDocument.serializer()) + sourceJson + .decodeJson(TranslationDocument.serializer()) + .let { document -> + val translatedTexts = + translateTexts( + sourceTexts = GoogleWebTranslationDocumentSupport.collectUniqueTranslatableTexts(document), + targetLanguage = targetLanguage, + ) + GoogleWebTranslationDocumentSupport + .applyTranslations( + document = document, + targetLanguage = targetLanguage, + translatedTexts = translatedTexts, + ).encodeJson(TranslationDocument.serializer()) + } suspend fun translateBatchDocumentJson( sourceDocument: PreTranslationBatchDocument, targetLanguage: String, ): String = - translateBatchDocument( - sourceDocument = sourceDocument, + sourceDocument + .let { document -> + val translatedTexts = + translateTexts( + sourceTexts = GoogleWebTranslationDocumentSupport.collectUniqueTranslatableTexts(document), + targetLanguage = targetLanguage, + ) + GoogleWebTranslationDocumentSupport + .applyTranslations( + document = document, + targetLanguage = targetLanguage, + translatedTexts = translatedTexts, + ).encodeJson(PreTranslationBatchDocument.serializer()) + } + + private suspend fun translateTexts( + sourceTexts: List, + targetLanguage: String, + ): Map { + if (sourceTexts.isEmpty()) { + return emptyMap() + } + val semaphore = Semaphore(MAX_CONCURRENT_REQUESTS) + return coroutineScope { + sourceTexts + .map { sourceText -> + async { + semaphore.withPermit { + sourceText to translateText(sourceText = sourceText, targetLanguage = targetLanguage) + } + } + }.awaitAll() + .toMap() + } + } + + private suspend fun translateText( + sourceText: String, + targetLanguage: String, + ): String { + val requestText = GoogleWebTranslationWhitespaceSupport.trimBoundaryWhitespace(sourceText) + if (requestText.isEmpty()) { + return sourceText + } + val response = + httpClient + .get { + url("https://translate.google.com/translate_a/single") + parameter("client", "gtx") + parameter("sl", "auto") + parameter("tl", targetLanguage) + parameter("dt", "t") + parameter("q", requestText) + parameter("ie", "UTF-8") + parameter("oe", "UTF-8") + }.body() + val translatedText = + buildString { + response.firstOrNull()?.jsonArray?.forEach { item -> + item.jsonArray.firstOrNull()?.let { content -> + append(content.jsonPrimitive.content) + } + } + } + return GoogleWebTranslationWhitespaceSupport.preserveSourceBoundaryWhitespace( + sourceText = sourceText, + translatedText = translatedText, + ) + } +} + +internal object GoogleWebTranslationDocumentSupport { + fun collectUniqueTranslatableTexts(document: TranslationDocument): List = + LinkedHashSet() + .apply { + document.blocks.forEach { block -> + block.tokens.forEach { token -> + if (token.kind == TranslationTokenKind.Translatable && token.text.isNotBlank()) { + add(token.text) + } + } + } + }.toList() + + fun collectUniqueTranslatableTexts(document: PreTranslationBatchDocument): List = + LinkedHashSet() + .apply { + document.items.forEach { item -> + collectTranslatableTexts(item.payload) + } + }.toList() + + fun applyTranslations( + document: TranslationDocument, + targetLanguage: String, + translatedTexts: Map, + ): TranslationDocument = + document.copy( targetLanguage = targetLanguage, - ).encodeJson(PreTranslationBatchDocument.serializer()) + blocks = + document.blocks.map { block -> + block.copy( + tokens = + block.tokens.map { token -> + when { + token.kind != TranslationTokenKind.Translatable || token.text.isBlank() -> token + else -> + token.copy( + text = + translatedTexts[token.text] + ?: error("Missing translated text for token '${token.text.take(50)}'"), + ) + } + }, + ) + }, + ) - private suspend fun translateBatchDocument( - sourceDocument: PreTranslationBatchDocument, + fun applyTranslations( + document: PreTranslationBatchDocument, targetLanguage: String, + translatedTexts: Map, ): PreTranslationBatchDocument = - sourceDocument.copy( + document.copy( targetLanguage = targetLanguage, items = - sourceDocument.items.map { item -> + document.items.map { item -> item.copy( payload = item.payload?.let { payload -> - translateBatchPayload( + applyTranslations( payload = payload, targetLanguage = targetLanguage, + translatedTexts = translatedTexts, ) }, ) }, ) - private suspend fun translateBatchPayload( + private fun MutableSet.collectTranslatableTexts(payload: PreTranslationBatchPayload?) { + if (payload == null) { + return + } + collectTranslatableTexts(payload.content) + collectTranslatableTexts(payload.contentWarning) + collectTranslatableTexts(payload.title) + collectTranslatableTexts(payload.description) + } + + private fun MutableSet.collectTranslatableTexts(document: TranslationDocument?) { + if (document == null) { + return + } + addAll(collectUniqueTranslatableTexts(document)) + } + + private fun applyTranslations( payload: PreTranslationBatchPayload, targetLanguage: String, + translatedTexts: Map, ): PreTranslationBatchPayload = PreTranslationBatchPayload( - content = payload.content?.let { translateDocument(it, targetLanguage) }, - contentWarning = payload.contentWarning?.let { translateDocument(it, targetLanguage) }, - title = payload.title?.let { translateDocument(it, targetLanguage) }, - description = payload.description?.let { translateDocument(it, targetLanguage) }, + content = payload.content?.let { applyTranslations(it, targetLanguage, translatedTexts) }, + contentWarning = payload.contentWarning?.let { applyTranslations(it, targetLanguage, translatedTexts) }, + title = payload.title?.let { applyTranslations(it, targetLanguage, translatedTexts) }, + description = payload.description?.let { applyTranslations(it, targetLanguage, translatedTexts) }, ) +} - private suspend fun translateDocument( - document: TranslationDocument, - targetLanguage: String, - ): TranslationDocument = - document.copy( - targetLanguage = targetLanguage, - blocks = - document.blocks.map { block -> - block.copy( - tokens = - block.tokens.map { token -> - if (token.kind != TranslationTokenKind.Translatable || token.text.isBlank()) { - token - } else { - token.copy( - text = - translateText( - sourceText = token.text, - targetLanguage = targetLanguage, - ), - ) - } - }, - ) - }, - ) +internal object GoogleWebTranslationWhitespaceSupport { + fun trimBoundaryWhitespace(text: String): String = text.trim { it.isWhitespace() } - private suspend fun translateText( + fun preserveSourceBoundaryWhitespace( sourceText: String, - targetLanguage: String, + translatedText: String, ): String { - val response = - ktorClient() - .get { - url("https://translate.google.com/translate_a/single") - parameter("client", "gtx") - parameter("sl", "auto") - parameter("tl", targetLanguage) - parameter("dt", "t") - parameter("q", sourceText) - parameter("ie", "UTF-8") - parameter("oe", "UTF-8") - }.body() - return buildString { - response.firstOrNull()?.jsonArray?.forEach { item -> - item.jsonArray.firstOrNull()?.let { content -> - append(content.jsonPrimitive.content) - } - } + val leadingWhitespace = sourceText.takeWhile { it.isWhitespace() } + val trailingWhitespace = sourceText.reversed().takeWhile { it.isWhitespace() }.reversed() + val translatedCore = trimBoundaryWhitespace(translatedText) + return buildString(sourceText.length + translatedCore.length) { + append(leadingWhitespace) + append(translatedCore) + append(trailingWhitespace) } } } diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/translation/GoogleWebTranslationDocumentSupportTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/translation/GoogleWebTranslationDocumentSupportTest.kt new file mode 100644 index 000000000..22b769a8e --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/translation/GoogleWebTranslationDocumentSupportTest.kt @@ -0,0 +1,144 @@ +package dev.dimension.flare.data.translation + +import dev.dimension.flare.ui.render.TranslationBlock +import dev.dimension.flare.ui.render.TranslationDocument +import dev.dimension.flare.ui.render.TranslationToken +import dev.dimension.flare.ui.render.TranslationTokenKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class GoogleWebTranslationDocumentSupportTest { + @Test + fun collectTranslatableTexts_deduplicatesAcrossBatchPayloads() { + val document = + TranslationDocument( + blocks = + listOf( + TranslationBlock( + id = 0, + tokens = + listOf( + TranslationToken(0, TranslationTokenKind.Translatable, "Hello"), + TranslationToken(1, TranslationTokenKind.Locked, " @alice "), + TranslationToken(2, TranslationTokenKind.Translatable, "world"), + ), + ), + ), + ) + val batch = + PreTranslationBatchDocument( + items = + listOf( + PreTranslationBatchItem( + entityKey = "status:1", + payload = + PreTranslationBatchPayload( + content = document, + title = + TranslationDocument( + blocks = + listOf( + TranslationBlock( + id = 0, + tokens = + listOf( + TranslationToken(0, TranslationTokenKind.Translatable, "Hello"), + ), + ), + ), + ), + ), + ), + PreTranslationBatchItem( + entityKey = "profile:1", + payload = + PreTranslationBatchPayload( + description = + TranslationDocument( + blocks = + listOf( + TranslationBlock( + id = 0, + tokens = + listOf( + TranslationToken(0, TranslationTokenKind.Translatable, "Bio"), + ), + ), + ), + ), + ), + ), + ), + ) + + assertEquals( + listOf("Hello", "world", "Bio"), + GoogleWebTranslationDocumentSupport.collectUniqueTranslatableTexts(batch), + ) + } + + @Test + fun applyTranslations_updatesOnlyTranslatableTokens() { + val document = + TranslationDocument( + blocks = + listOf( + TranslationBlock( + id = 0, + tokens = + listOf( + TranslationToken(0, TranslationTokenKind.Translatable, "Hello"), + TranslationToken(1, TranslationTokenKind.Locked, " @alice "), + TranslationToken(2, TranslationTokenKind.Translatable, "world"), + ), + ), + ), + ) + + val translated = + GoogleWebTranslationDocumentSupport.applyTranslations( + document = document, + targetLanguage = "zh-CN", + translatedTexts = + mapOf( + "Hello" to "你好", + "world" to "世界", + ), + ) + + assertEquals("zh-CN", translated.targetLanguage) + assertEquals( + listOf("你好", " @alice ", "世界"), + translated.blocks + .single() + .tokens + .map { it.text }, + ) + } + + @Test + fun applyTranslations_requiresTranslationsForEveryTranslatableToken() { + val document = + TranslationDocument( + blocks = + listOf( + TranslationBlock( + id = 0, + tokens = + listOf( + TranslationToken(0, TranslationTokenKind.Translatable, "Hello"), + ), + ), + ), + ) + + assertFailsWith { + GoogleWebTranslationDocumentSupport.applyTranslations( + document = document, + targetLanguage = "zh-CN", + translatedTexts = emptyMap(), + ) + } + } +} diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/translation/GoogleWebTranslationWhitespaceSupportTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/translation/GoogleWebTranslationWhitespaceSupportTest.kt new file mode 100644 index 000000000..ec4ed42d7 --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/translation/GoogleWebTranslationWhitespaceSupportTest.kt @@ -0,0 +1,36 @@ +package dev.dimension.flare.data.translation + +import kotlin.test.Test +import kotlin.test.assertEquals + +class GoogleWebTranslationWhitespaceSupportTest { + @Test + fun preserveSourceBoundaryWhitespace_restoresTrailingSpace() { + assertEquals( + "你好 ", + GoogleWebTranslationWhitespaceSupport.preserveSourceBoundaryWhitespace( + sourceText = "Hello ", + translatedText = "你好", + ), + ) + } + + @Test + fun preserveSourceBoundaryWhitespace_restoresLeadingAndTrailingNewlines() { + assertEquals( + "\n你好\n\n", + GoogleWebTranslationWhitespaceSupport.preserveSourceBoundaryWhitespace( + sourceText = "\nHello\n\n", + translatedText = "你好", + ), + ) + } + + @Test + fun trimBoundaryWhitespace_keepsInnerWhitespace() { + assertEquals( + "Hello world", + GoogleWebTranslationWhitespaceSupport.trimBoundaryWhitespace(" Hello world "), + ) + } +} From 912c41d242d0f5ac14b067f5800f00e0751e3b91 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Mon, 30 Mar 2026 11:09:57 +0900 Subject: [PATCH 12/14] add provider as cache key --- .../cache/model/TranslationDisplay.kt | 12 +- .../microblog/paging/TimelinePagingMapper.kt | 6 +- .../data/translation/PreTranslationModels.kt | 1 + .../data/translation/PreTranslationService.kt | 13 +- .../TranslationProviderCacheKey.kt | 11 + .../data/translation/TranslationSupport.kt | 1 + .../ui/presenter/status/TranslatePresenter.kt | 19 +- .../database/cache/mapper/MicroblogTest.kt | 431 +++++++++++++----- .../microblog/MixedRemoteMediatorTest.kt | 23 +- .../microblog/handler/UserHandlerTest.kt | 6 +- 10 files changed, 389 insertions(+), 134 deletions(-) create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProviderCacheKey.kt diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/TranslationDisplay.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/TranslationDisplay.kt index c7413c6be..9fead652c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/TranslationDisplay.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/TranslationDisplay.kt @@ -15,6 +15,7 @@ import kotlinx.collections.immutable.toPersistentList internal data class TranslationDisplayOptions( val translationEnabled: Boolean, val autoDisplayEnabled: Boolean, + val providerCacheKey: String, ) internal fun UiTimelineV2.applyTranslation( @@ -28,7 +29,7 @@ internal fun UiTimelineV2.applyTranslation( val translation = translations.firstOrNull { it.targetLanguage == Locale.language && - it.sourceHash == payload.sourceHash() + it.sourceHash == payload.sourceHash(options.providerCacheKey) } return when (this) { @@ -99,7 +100,7 @@ internal fun UiProfile.applyTranslation( val matchedTranslation = translation?.takeIf { it.targetLanguage == Locale.language && - it.sourceHash == payload.sourceHash() + it.sourceHash == payload.sourceHash(options.providerCacheKey) } val displayState = matchedTranslation.toDisplayState() return copy( @@ -132,7 +133,12 @@ internal fun UiProfile.translationPayload(): TranslationPayload = description = description, ) -internal fun TranslationPayload.sourceHash(): String = encodeJson(TranslationPayload.serializer()).stableTranslationHash() +internal fun TranslationPayload.sourceHash(providerCacheKey: String): String = + buildString { + append(providerCacheKey) + append('\u0000') + append(encodeJson(TranslationPayload.serializer())) + }.stableTranslationHash() private fun DbTranslation?.toDisplayState(): TranslationDisplayState = when (this?.status) { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelinePagingMapper.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelinePagingMapper.kt index ea44457a3..4704d823e 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelinePagingMapper.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelinePagingMapper.kt @@ -59,11 +59,7 @@ internal object TimelinePagingMapper { item: DbPagingTimelineWithStatus, pagingKey: String, useDbKeyInItemKey: Boolean, - translationDisplayOptions: TranslationDisplayOptions = - TranslationDisplayOptions( - translationEnabled = false, - autoDisplayEnabled = false, - ), + translationDisplayOptions: TranslationDisplayOptions, ): UiTimelineV2 { val root = dbStatusWithUserToUiTimeline( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationModels.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationModels.kt index 65ad0da35..5661cffd3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationModels.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationModels.kt @@ -39,6 +39,7 @@ internal data class PreTranslationBatchPayload( internal data class ActivePreTranslationSettings( val targetLanguage: String, val appSettings: AppSettings, + val providerCacheKey: String, ) internal data class PreparedTranslationCandidate( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt index fe0dbb058..8dfc0d37e 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt @@ -93,6 +93,7 @@ internal class OnlinePreTranslationService( prepareStatusCandidates( statuses = snapshot, targetLanguage = settings.targetLanguage, + providerCacheKey = settings.providerCacheKey, allowLongText = allowLongText, ) } @@ -106,6 +107,7 @@ internal class OnlinePreTranslationService( prepareProfileCandidate( user = user, targetLanguage = settings.targetLanguage, + providerCacheKey = settings.providerCacheKey, ), ) } @@ -128,6 +130,7 @@ internal class OnlinePreTranslationService( accountType = accountType, statusKey = statusKey, targetLanguage = settings.targetLanguage, + providerCacheKey = settings.providerCacheKey, ) processPreparedCandidates( settings = settings, @@ -195,6 +198,7 @@ internal class OnlinePreTranslationService( return ActivePreTranslationSettings( targetLanguage = targetLanguage, appSettings = appSettings, + providerCacheKey = appSettings.translationProviderCacheKey(), ) } @@ -213,6 +217,7 @@ internal class OnlinePreTranslationService( accountType: dev.dimension.flare.model.AccountType, statusKey: dev.dimension.flare.model.MicroBlogKey, targetLanguage: String, + providerCacheKey: String, ): List { val dbAccountType = accountType as? dev.dimension.flare.model.DbAccountType ?: return emptyList() val status = @@ -225,6 +230,7 @@ internal class OnlinePreTranslationService( return prepareStatusCandidates( statuses = listOfNotNull(status.status.data) + status.references.mapNotNull { it.status?.data }, targetLanguage = targetLanguage, + providerCacheKey = providerCacheKey, allowLongText = true, preferredDisplayMode = TranslationDisplayMode.Translated, ) @@ -233,6 +239,7 @@ internal class OnlinePreTranslationService( private suspend fun prepareStatusCandidates( statuses: List, targetLanguage: String, + providerCacheKey: String, allowLongText: Boolean, preferredDisplayMode: TranslationDisplayMode? = null, ): List { @@ -259,6 +266,7 @@ internal class OnlinePreTranslationService( sourceLanguages = PreTranslationContentRules.sourceLanguages(status.content), existing = existingByKey[entityKey], targetLanguage = targetLanguage, + providerCacheKey = providerCacheKey, now = now, allowLongText = allowLongText, preferredDisplayMode = preferredDisplayMode, @@ -270,6 +278,7 @@ internal class OnlinePreTranslationService( private suspend fun prepareProfileCandidate( user: DbUser, targetLanguage: String, + providerCacheKey: String, ): PreparedTranslationCandidate? = prepareCandidate( entityType = TranslationEntityType.Profile, @@ -285,6 +294,7 @@ internal class OnlinePreTranslationService( targetLanguage = targetLanguage, ), targetLanguage = targetLanguage, + providerCacheKey = providerCacheKey, now = Clock.System.now().toEpochMilliseconds(), allowLongText = true, ) @@ -296,6 +306,7 @@ internal class OnlinePreTranslationService( sourceLanguages: List, existing: DbTranslation?, targetLanguage: String, + providerCacheKey: String, now: Long, allowLongText: Boolean, preferredDisplayMode: TranslationDisplayMode? = null, @@ -306,7 +317,7 @@ internal class OnlinePreTranslationService( if (!allowLongText && payload.content?.isLongText == true) { return null } - val sourceHash = payload.sourceHash() + val sourceHash = payload.sourceHash(providerCacheKey) val displayMode = preferredDisplayMode ?: existing?.displayMode ?: TranslationDisplayMode.Auto val sourceDocument = PreTranslationPayloadSupport.toBatchPayload(payload, targetLanguage) val skipReason = diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProviderCacheKey.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProviderCacheKey.kt new file mode 100644 index 000000000..6277b6c44 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProviderCacheKey.kt @@ -0,0 +1,11 @@ +package dev.dimension.flare.data.translation + +import dev.dimension.flare.data.datastore.model.AppSettings + +internal fun AppSettings.translationProviderCacheKey(): String = translateConfig.provider.cacheKey() + +internal fun AppSettings.TranslateConfig.Provider.cacheKey(): String = + when (this) { + AppSettings.TranslateConfig.Provider.AI -> "ai" + AppSettings.TranslateConfig.Provider.Google -> "google" + } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationSupport.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationSupport.kt index 2a5a76780..0db4ba718 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationSupport.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationSupport.kt @@ -18,6 +18,7 @@ internal object TranslationSettingsSupport { TranslationDisplayOptions( translationEnabled = true, autoDisplayEnabled = settings.translateConfig.preTranslate, + providerCacheKey = settings.translationProviderCacheKey(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt index a79e06e7c..1eed39371 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/TranslatePresenter.kt @@ -16,6 +16,7 @@ import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.data.translation.TranslationPromptFormatter import dev.dimension.flare.data.translation.TranslationProvider import dev.dimension.flare.data.translation.TranslationResponseSanitizer +import dev.dimension.flare.data.translation.translationProviderCacheKey import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiState @@ -50,7 +51,8 @@ public class TranslatePresenter( val settings = appDataStore.appSettingsStore.data .first() - cachedTranslation()?.let { + val providerCacheKey = settings.translationProviderCacheKey() + cachedTranslation(providerCacheKey)?.let { return@tryRun it } val prompt = @@ -71,7 +73,7 @@ public class TranslatePresenter( ) if (translatedContent != null) { val translated = toUiRichText(translatedContent) - cacheTranslation(translated) + cacheTranslation(translated, providerCacheKey) return@tryRun translated } error("Translation returned empty response") @@ -93,7 +95,7 @@ public class TranslatePresenter( } } - private suspend fun cachedTranslation(): UiRichText? { + private suspend fun cachedTranslation(providerCacheKey: String): UiRichText? { val target = cacheTarget ?: return null val translation = database @@ -103,7 +105,7 @@ public class TranslatePresenter( entityKey = target.entityKey(), targetLanguage = targetLanguage, ) ?: return null - if (translation.sourceHash != target.sourcePayload().sourceHash()) { + if (translation.sourceHash != target.sourcePayload().sourceHash(providerCacheKey)) { return null } return when (translation.status) { @@ -116,7 +118,10 @@ public class TranslatePresenter( } } - private suspend fun cacheTranslation(translated: UiRichText) { + private suspend fun cacheTranslation( + translated: UiRichText, + providerCacheKey: String, + ) { val target = cacheTarget ?: return val sourcePayload = target.sourcePayload() val existing = @@ -129,7 +134,7 @@ public class TranslatePresenter( ) val mergedPayload = target.mergePayload( - existing = existing?.takeIf { it.sourceHash == sourcePayload.sourceHash() }?.payload, + existing = existing?.takeIf { it.sourceHash == sourcePayload.sourceHash(providerCacheKey) }?.payload, translated = translated, ) database.translationDao().insert( @@ -137,7 +142,7 @@ public class TranslatePresenter( entityType = TranslationEntityType.Status, entityKey = target.entityKey(), targetLanguage = targetLanguage, - sourceHash = sourcePayload.sourceHash(), + sourceHash = sourcePayload.sourceHash(providerCacheKey), status = TranslationStatus.Completed, displayMode = TranslationDisplayMode.Translated, payload = mergedPayload, diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt index 3d5cecabf..41fe9d0b7 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt @@ -21,6 +21,8 @@ import dev.dimension.flare.data.database.cache.model.translationEntityKey import dev.dimension.flare.data.database.cache.model.translationPayload import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.data.datasource.microblog.paging.TimelinePagingMapper +import dev.dimension.flare.data.datastore.model.AppSettings +import dev.dimension.flare.data.translation.cacheKey import dev.dimension.flare.memoryDatabaseBuilder import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey @@ -55,6 +57,12 @@ import kotlin.time.Clock @OptIn(ExperimentalCoroutinesApi::class) class MicroblogTest : RobolectricTest() { private lateinit var db: CacheDatabase + private val googleTranslationProviderCacheKey = + AppSettings.TranslateConfig.Provider.Google + .cacheKey() + private val aiTranslationProviderCacheKey = + AppSettings.TranslateConfig.Provider.AI + .cacheKey() @BeforeTest fun setup() { @@ -81,6 +89,16 @@ class MicroblogTest : RobolectricTest() { stopKoin() } + private fun translationDisplayOptions( + translationEnabled: Boolean = true, + autoDisplayEnabled: Boolean = true, + providerCacheKey: String = googleTranslationProviderCacheKey, + ) = TranslationDisplayOptions( + translationEnabled = translationEnabled, + autoDisplayEnabled = autoDisplayEnabled, + providerCacheKey = providerCacheKey, + ) + @Test fun saveToDatabasePersistsUserAndStatus() = runTest { @@ -102,7 +120,8 @@ class MicroblogTest : RobolectricTest() { assertEquals(user.key, savedUser.userKey) assertEquals("User One", savedUser.content.name.raw) - val savedStatus = db.statusDao().get(post.statusKey, AccountType.Specific(accountKey)).first() + val savedStatus = + db.statusDao().get(post.statusKey, AccountType.Specific(accountKey)).first() assertNotNull(savedStatus) assertEquals(post.statusKey, savedStatus.content.statusKey) requireNotNull(savedStatus.text) @@ -158,7 +177,11 @@ class MicroblogTest : RobolectricTest() { runTest { val accountKey = MicroBlogKey(id = "account", host = "test.com") val statusKey = MicroBlogKey(id = "status-update", host = "test.com") - val user = createUser(MicroBlogKey(id = "status-update-user", host = "test.com"), "Status User") + val user = + createUser( + MicroBlogKey(id = "status-update-user", host = "test.com"), + "Status User", + ) saveToDatabase( db, @@ -190,7 +213,8 @@ class MicroblogTest : RobolectricTest() { ), ) - val savedStatus = db.statusDao().get(statusKey, AccountType.Specific(accountKey)).first() + val savedStatus = + db.statusDao().get(statusKey, AccountType.Specific(accountKey)).first() assertNotNull(savedStatus) requireNotNull(savedStatus.text) assertTrue(savedStatus.text.contains("new status text")) @@ -210,7 +234,8 @@ class MicroblogTest : RobolectricTest() { text = "ref status", ) - val mainUser = createUser(MicroBlogKey(id = "main-user", host = "test.com"), "Main User") + val mainUser = + createUser(MicroBlogKey(id = "main-user", host = "test.com"), "Main User") val mainPost = createPost( accountKey = accountKey, @@ -223,14 +248,16 @@ class MicroblogTest : RobolectricTest() { val timelineItem = TimelinePagingMapper.toDb(mainPost, pagingKey = "home") saveToDatabase(db, listOf(timelineItem)) - val savedMainStatus = db.statusDao().get(mainPost.statusKey, AccountType.Specific(accountKey)).first() + val savedMainStatus = + db.statusDao().get(mainPost.statusKey, AccountType.Specific(accountKey)).first() assertNotNull(savedMainStatus) val savedMainPost = assertIs(savedMainStatus.content) assertTrue(savedMainPost.parents.isEmpty()) assertEquals(1, savedMainPost.references.size) assertEquals(ReferenceType.Reply, savedMainPost.references.first().type) assertEquals(refPost.statusKey, savedMainPost.references.first().statusKey) - val savedRefStatus = db.statusDao().get(refPost.statusKey, AccountType.Specific(accountKey)).first() + val savedRefStatus = + db.statusDao().get(refPost.statusKey, AccountType.Specific(accountKey)).first() assertNotNull(savedRefStatus) val savedReferences = db.statusReferenceDao().getByStatusKey(mainPost.statusKey) @@ -243,7 +270,8 @@ class MicroblogTest : RobolectricTest() { runTest { val accountKey = MicroBlogKey(id = "account", host = "test.com") val firstUser = createUser(MicroBlogKey(id = "user-1", host = "test.com"), "First User") - val secondUser = createUser(MicroBlogKey(id = "user-2", host = "test.com"), "Second User") + val secondUser = + createUser(MicroBlogKey(id = "user-2", host = "test.com"), "Second User") val postUser = createUser(MicroBlogKey(id = "user-3", host = "test.com"), "Post User") val userTimeline = @@ -305,7 +333,8 @@ class MicroblogTest : RobolectricTest() { text = "ref status", ) - val mainUser = createUser(MicroBlogKey(id = "main-user", host = "test.com"), "Main User") + val mainUser = + createUser(MicroBlogKey(id = "main-user", host = "test.com"), "Main User") val withRef = createPost( accountKey = accountKey, @@ -335,7 +364,8 @@ class MicroblogTest : RobolectricTest() { runTest { val accountKey = MicroBlogKey(id = "account", host = "test.com") - val quoteUser = createUser(MicroBlogKey(id = "quote-user", host = "test.com"), "Quote User") + val quoteUser = + createUser(MicroBlogKey(id = "quote-user", host = "test.com"), "Quote User") val quotePost = createPost( accountKey = accountKey, @@ -344,7 +374,8 @@ class MicroblogTest : RobolectricTest() { text = "quote status", ) - val mainUser = createUser(MicroBlogKey(id = "main-user-quote", host = "test.com"), "Main User") + val mainUser = + createUser(MicroBlogKey(id = "main-user-quote", host = "test.com"), "Main User") val withQuote = createPost( accountKey = accountKey, @@ -378,7 +409,8 @@ class MicroblogTest : RobolectricTest() { text = "ref status", ) - val mainUser = createUser(MicroBlogKey(id = "main-user", host = "test.com"), "Main User") + val mainUser = + createUser(MicroBlogKey(id = "main-user", host = "test.com"), "Main User") val withParents = createPost( accountKey = accountKey, @@ -390,9 +422,18 @@ class MicroblogTest : RobolectricTest() { saveToDatabase(db, listOf(TimelinePagingMapper.toDb(withParents, pagingKey = "home"))) val withoutParents = withParents.copy(parents = persistentListOf()) - saveToDatabase(db, listOf(TimelinePagingMapper.toDb(withoutParents, pagingKey = "post_only_${withParents.statusKey}"))) + saveToDatabase( + db, + listOf( + TimelinePagingMapper.toDb( + withoutParents, + pagingKey = "post_only_${withParents.statusKey}", + ), + ), + ) - val saved = db.statusDao().get(withParents.statusKey, AccountType.Specific(accountKey)).first() + val saved = + db.statusDao().get(withParents.statusKey, AccountType.Specific(accountKey)).first() val savedPost = assertIs(assertNotNull(saved).content) assertTrue(savedPost.parents.isEmpty()) assertEquals(1, savedPost.references.size) @@ -403,8 +444,10 @@ class MicroblogTest : RobolectricTest() { fun toDbMapsReplyReference() = runTest { val accountKey = MicroBlogKey(id = "account", host = "test.com") - val rootUser = createUser(MicroBlogKey(id = "root-user", host = "test.com"), "Root User") - val parentUser = createUser(MicroBlogKey(id = "parent-user", host = "test.com"), "Parent User") + val rootUser = + createUser(MicroBlogKey(id = "root-user", host = "test.com"), "Root User") + val parentUser = + createUser(MicroBlogKey(id = "parent-user", host = "test.com"), "Parent User") val parentPost = createPost( accountKey = accountKey, @@ -437,8 +480,10 @@ class MicroblogTest : RobolectricTest() { fun toDbMapsRetweetReferenceFromInternalRepost() = runTest { val accountKey = MicroBlogKey(id = "account", host = "test.com") - val wrapperUser = createUser(MicroBlogKey(id = "wrapper-user", host = "test.com"), "Wrapper User") - val repostUser = createUser(MicroBlogKey(id = "repost-user", host = "test.com"), "Repost User") + val wrapperUser = + createUser(MicroBlogKey(id = "wrapper-user", host = "test.com"), "Wrapper User") + val repostUser = + createUser(MicroBlogKey(id = "repost-user", host = "test.com"), "Repost User") val repostPost = createPost( accountKey = accountKey, @@ -468,8 +513,10 @@ class MicroblogTest : RobolectricTest() { fun toUiSetsExtraKeyForRootAndReferences() = runTest { val accountKey = MicroBlogKey(id = "account", host = "test.com") - val rootUser = createUser(MicroBlogKey(id = "root-user", host = "test.com"), "Root User") - val parentUser = createUser(MicroBlogKey(id = "parent-user", host = "test.com"), "Parent User") + val rootUser = + createUser(MicroBlogKey(id = "root-user", host = "test.com"), "Root User") + val parentUser = + createUser(MicroBlogKey(id = "parent-user", host = "test.com"), "Parent User") val parentPost = createPost( accountKey = accountKey, @@ -487,7 +534,13 @@ class MicroblogTest : RobolectricTest() { ) val mapped = TimelinePagingMapper.toDb(rootPost, pagingKey = "home") - val ui = TimelinePagingMapper.toUi(mapped, pagingKey = "home", useDbKeyInItemKey = true) + val ui = + TimelinePagingMapper.toUi( + mapped, + pagingKey = "home", + useDbKeyInItemKey = true, + translationDisplayOptions = translationDisplayOptions(), + ) val post = assertIs(ui) assertEquals("home", post.extraKey) assertEquals(1, post.parents.size) @@ -499,8 +552,16 @@ class MicroblogTest : RobolectricTest() { fun toUiUsesCompletedTranslationForRootAndReplyReference() = runTest { val accountKey = MicroBlogKey(id = "account", host = "test.com") - val rootUser = createUser(MicroBlogKey(id = "root-user-translated", host = "test.com"), "Root User") - val parentUser = createUser(MicroBlogKey(id = "parent-user-translated", host = "test.com"), "Parent User") + val rootUser = + createUser( + MicroBlogKey(id = "root-user-translated", host = "test.com"), + "Root User", + ) + val parentUser = + createUser( + MicroBlogKey(id = "parent-user-translated", host = "test.com"), + "Parent User", + ) val parentPost = createPost( accountKey = accountKey, @@ -519,7 +580,10 @@ class MicroblogTest : RobolectricTest() { val mapped = TimelinePagingMapper.toDb(rootPost, pagingKey = "home") saveToDatabase(db, listOf(mapped)) - val savedParentStatus = assertNotNull(db.statusDao().get(parentPost.statusKey, AccountType.Specific(accountKey)).first()) + val savedParentStatus = + assertNotNull( + db.statusDao().get(parentPost.statusKey, AccountType.Specific(accountKey)).first(), + ) db.translationDao().insertAll( listOf( DbTranslation( @@ -528,7 +592,10 @@ class MicroblogTest : RobolectricTest() { mapped.status.status.data .translationEntityKey(), targetLanguage = Locale.language, - sourceHash = rootPost.translationPayload()!!.sourceHash(), + sourceHash = + rootPost + .translationPayload()!! + .sourceHash(googleTranslationProviderCacheKey), status = TranslationStatus.Completed, payload = TranslationPayload(content = "根帖子".toUiPlainText()), updatedAt = 1L, @@ -537,7 +604,10 @@ class MicroblogTest : RobolectricTest() { entityType = TranslationEntityType.Status, entityKey = savedParentStatus.translationEntityKey(), targetLanguage = Locale.language, - sourceHash = parentPost.translationPayload()!!.sourceHash(), + sourceHash = + parentPost + .translationPayload()!! + .sourceHash(googleTranslationProviderCacheKey), status = TranslationStatus.Completed, payload = TranslationPayload(content = "父帖子".toUiPlainText()), updatedAt = 1L, @@ -548,7 +618,10 @@ class MicroblogTest : RobolectricTest() { val paging = db.pagingTimelineDao().getPagingSource("home") val pager = TestPager(config = PagingConfig(pageSize = 20), paging) val refreshResult = pager.refresh() - val page = assertIs>(refreshResult) + val page = + assertIs>( + refreshResult, + ) val dbItem = assertNotNull(page.data.firstOrNull()) val ui = @@ -556,11 +629,7 @@ class MicroblogTest : RobolectricTest() { item = dbItem, pagingKey = "home", useDbKeyInItemKey = false, - translationDisplayOptions = - TranslationDisplayOptions( - translationEnabled = true, - autoDisplayEnabled = true, - ), + translationDisplayOptions = translationDisplayOptions(), ) val post = assertIs(ui) @@ -578,7 +647,8 @@ class MicroblogTest : RobolectricTest() { fun timelinePagingMapperKeepsPostMessageAfterRoundTrip() = runTest { val accountKey = MicroBlogKey(id = "account", host = "test.com") - val postUser = createUser(MicroBlogKey(id = "post-user", host = "test.com"), "Post User") + val postUser = + createUser(MicroBlogKey(id = "post-user", host = "test.com"), "Post User") val post = createPost( accountKey = accountKey, @@ -602,7 +672,8 @@ class MicroblogTest : RobolectricTest() { ) val mapped = TimelinePagingMapper.toDb(post, pagingKey = "home") - val roundTrip = TimelinePagingMapper.toUi(mapped, pagingKey = "home", useDbKeyInItemKey = false) + val roundTrip = + TimelinePagingMapper.toUi(mapped, pagingKey = "home", useDbKeyInItemKey = false, translationDisplayOptions()) val rendered = assertIs(roundTrip) val message = assertNotNull(rendered.message) val type = assertIs(message.type) @@ -614,7 +685,8 @@ class MicroblogTest : RobolectricTest() { fun toUiUsesEmbeddedUserDataWithoutReadingUserJoin() = runTest { val accountKey = MicroBlogKey(id = "account", host = "test.com") - val user = createUser(MicroBlogKey(id = "user-join", host = "test.com"), "Embedded User") + val user = + createUser(MicroBlogKey(id = "user-join", host = "test.com"), "Embedded User") val post = createPost( accountKey = accountKey, @@ -635,7 +707,10 @@ class MicroblogTest : RobolectricTest() { val paging = db.pagingTimelineDao().getPagingSource("home") val pager = TestPager(config = PagingConfig(pageSize = 20), paging) val refreshResult = pager.refresh() - val page = assertIs>(refreshResult) + val page = + assertIs>( + refreshResult, + ) val dbItem = assertNotNull( page.data.firstOrNull { @@ -649,6 +724,7 @@ class MicroblogTest : RobolectricTest() { dbItem, pagingKey = "home", useDbKeyInItemKey = false, + translationDisplayOptions(), ), ) assertEquals("Embedded User", rendered.user?.name?.raw) @@ -658,8 +734,10 @@ class MicroblogTest : RobolectricTest() { fun toUiFlattensInternalRepostButKeepsReferencePayload() = runTest { val accountKey = MicroBlogKey(id = "account", host = "test.com") - val wrapperUser = createUser(MicroBlogKey(id = "wrapper-user", host = "test.com"), "Wrapper User") - val repostUser = createUser(MicroBlogKey(id = "repost-user", host = "test.com"), "Repost User") + val wrapperUser = + createUser(MicroBlogKey(id = "wrapper-user", host = "test.com"), "Wrapper User") + val repostUser = + createUser(MicroBlogKey(id = "repost-user", host = "test.com"), "Repost User") val repostPost = createPost( accountKey = accountKey, @@ -694,8 +772,10 @@ class MicroblogTest : RobolectricTest() { val mapped = TimelinePagingMapper.toDb(wrapperPost, pagingKey = "home") saveToDatabase(db, listOf(mapped)) - val savedWrapper = db.statusDao().get(wrapperPost.statusKey, AccountType.Specific(accountKey)).first() - val savedRepost = db.statusDao().get(repostPost.statusKey, AccountType.Specific(accountKey)).first() + val savedWrapper = + db.statusDao().get(wrapperPost.statusKey, AccountType.Specific(accountKey)).first() + val savedRepost = + db.statusDao().get(repostPost.statusKey, AccountType.Specific(accountKey)).first() assertNotNull(savedWrapper) assertNotNull(savedRepost) val savedWrapperPost = assertIs(savedWrapper.content) @@ -705,7 +785,8 @@ class MicroblogTest : RobolectricTest() { assertEquals(1, savedWrapperPost.references.size) assertEquals(ReferenceType.Retweet, savedWrapperPost.references.first().type) - val roundTrip = TimelinePagingMapper.toUi(mapped, pagingKey = "home", useDbKeyInItemKey = false) + val roundTrip = + TimelinePagingMapper.toUi(mapped, pagingKey = "home", useDbKeyInItemKey = false, translationDisplayOptions()) val rendered = assertIs(roundTrip) val internalRepost = assertNotNull(rendered.internalRepost) @@ -767,20 +848,25 @@ class MicroblogTest : RobolectricTest() { ) val mapped = TimelinePagingMapper.toDb(postA, pagingKey = "home") - val retweetRefs = mapped.status.references.filter { it.reference.referenceType == ReferenceType.Retweet } - val quoteRefs = mapped.status.references.filter { it.reference.referenceType == ReferenceType.Quote } + val retweetRefs = + mapped.status.references.filter { it.reference.referenceType == ReferenceType.Retweet } + val quoteRefs = + mapped.status.references.filter { it.reference.referenceType == ReferenceType.Quote } assertEquals(1, retweetRefs.size) assertEquals(postB.statusKey, retweetRefs.first().reference.referenceStatusKey) assertEquals(1, quoteRefs.size) assertEquals(postC.statusKey, quoteRefs.first().reference.referenceStatusKey) saveToDatabase(db, listOf(mapped)) - val savedA = db.statusDao().get(postA.statusKey, AccountType.Specific(accountKey)).first() - val savedB = db.statusDao().get(postB.statusKey, AccountType.Specific(accountKey)).first() + val savedA = + db.statusDao().get(postA.statusKey, AccountType.Specific(accountKey)).first() + val savedB = + db.statusDao().get(postB.statusKey, AccountType.Specific(accountKey)).first() assertNotNull(savedA) assertNotNull(savedB) - val ui = TimelinePagingMapper.toUi(mapped, pagingKey = "home", useDbKeyInItemKey = false) + val ui = + TimelinePagingMapper.toUi(mapped, pagingKey = "home", useDbKeyInItemKey = false, translationDisplayOptions()) val rendered = assertIs(ui) val repost = assertNotNull(rendered.internalRepost) @@ -809,9 +895,15 @@ class MicroblogTest : RobolectricTest() { fun databaseRoundTripKeepsQuoteOnInternalRepostForRetweetWrapper() = runTest { val accountKey = MicroBlogKey(id = "account", host = "test.com") - val wrapperUser = createUser(MicroBlogKey(id = "wrapper-user-quote", host = "test.com"), "Wrapper User") - val repostUser = createUser(MicroBlogKey(id = "repost-user-quote", host = "test.com"), "Repost User") - val quoteUser = createUser(MicroBlogKey(id = "quote-user", host = "test.com"), "Quote User") + val wrapperUser = + createUser( + MicroBlogKey(id = "wrapper-user-quote", host = "test.com"), + "Wrapper User", + ) + val repostUser = + createUser(MicroBlogKey(id = "repost-user-quote", host = "test.com"), "Repost User") + val quoteUser = + createUser(MicroBlogKey(id = "quote-user", host = "test.com"), "Quote User") val quotePost = createPost( @@ -858,7 +950,10 @@ class MicroblogTest : RobolectricTest() { val paging = db.pagingTimelineDao().getPagingSource("home") val pager = TestPager(config = PagingConfig(pageSize = 20), paging) val refreshResult = pager.refresh() - val page = assertIs>(refreshResult) + val page = + assertIs>( + refreshResult, + ) val dbItem = assertNotNull( page.data.firstOrNull { @@ -871,6 +966,7 @@ class MicroblogTest : RobolectricTest() { dbItem, pagingKey = "home", useDbKeyInItemKey = false, + translationDisplayOptions(), ), ) val internalRepost = assertNotNull(rendered.internalRepost) @@ -892,7 +988,8 @@ class MicroblogTest : RobolectricTest() { fun quoteAndRetweetTogetherKeepsRetweetMessageOnSharedStatus() = runTest { val accountKey = MicroBlogKey(id = "account", host = "x.com") - val originalUser = createUser(MicroBlogKey(id = "u-original", host = "x.com"), "Original") + val originalUser = + createUser(MicroBlogKey(id = "u-original", host = "x.com"), "Original") val wrapperUser = createUser(MicroBlogKey(id = "u-wrapper", host = "x.com"), "Wrapper") val original = createPost( @@ -953,7 +1050,8 @@ class MicroblogTest : RobolectricTest() { fun detailRefreshDoesNotRemoveExistingRetweetMessage() = runTest { val accountKey = MicroBlogKey(id = "account", host = "x.com") - val originalUser = createUser(MicroBlogKey(id = "u-original", host = "x.com"), "Original") + val originalUser = + createUser(MicroBlogKey(id = "u-original", host = "x.com"), "Original") val wrapperUser = createUser(MicroBlogKey(id = "u-wrapper", host = "x.com"), "Wrapper") val statusKey = MicroBlogKey(id = "fake-original-detail", host = "x.com") val original = @@ -983,11 +1081,20 @@ class MicroblogTest : RobolectricTest() { ) val detailView = original.copy(message = null) - saveToDatabase(db, listOf(TimelinePagingMapper.toDb(homeRetweetView, pagingKey = "home"))) - saveToDatabase(db, listOf(TimelinePagingMapper.toDb(detailView, pagingKey = "post_only_$statusKey"))) + saveToDatabase( + db, + listOf(TimelinePagingMapper.toDb(homeRetweetView, pagingKey = "home")), + ) + saveToDatabase( + db, + listOf(TimelinePagingMapper.toDb(detailView, pagingKey = "post_only_$statusKey")), + ) val saved = - db.statusDao().get(retweetMessage.statusKey, AccountType.Specific(accountKey)).first() + db + .statusDao() + .get(retweetMessage.statusKey, AccountType.Specific(accountKey)) + .first() val savedPost = assertIs(assertNotNull(saved).content) val savedMessage = assertNotNull(savedPost.message) val savedType = assertIs(savedMessage.type) @@ -1004,14 +1111,24 @@ class MicroblogTest : RobolectricTest() { platformType = dev.dimension.flare.model.PlatformType.Bluesky, banner = "https://bsky.social/banner.png", description = "full profile".toUiPlainText(), - matrices = UiProfile.Matrices(fansCount = 12, followsCount = 34, statusesCount = 56), + matrices = + UiProfile.Matrices( + fansCount = 12, + followsCount = 34, + statusesCount = 56, + ), ) val partialUser = createUser(userKey, "Partial").copy( platformType = dev.dimension.flare.model.PlatformType.Bluesky, banner = null, description = null, - matrices = UiProfile.Matrices(fansCount = 0, followsCount = 0, statusesCount = 0), + matrices = + UiProfile.Matrices( + fansCount = 0, + followsCount = 0, + statusesCount = 0, + ), ) saveToDatabase( @@ -1057,7 +1174,8 @@ class MicroblogTest : RobolectricTest() { runTest { val accountKey = MicroBlogKey(id = "account-longtext", host = "test.com") val longText = buildString { repeat(520) { append('长') } } - val postUser = createUser(MicroBlogKey(id = "post-user-longtext", host = "test.com"), "Post User") + val postUser = + createUser(MicroBlogKey(id = "post-user-longtext", host = "test.com"), "Post User") val post = createPost( accountKey = accountKey, @@ -1075,7 +1193,10 @@ class MicroblogTest : RobolectricTest() { mapped.status.status.data .translationEntityKey(), targetLanguage = Locale.language, - sourceHash = post.translationPayload()!!.sourceHash(), + sourceHash = + post + .translationPayload()!! + .sourceHash(googleTranslationProviderCacheKey), status = TranslationStatus.Completed, payload = TranslationPayload(content = "长文译文".toUiPlainText()), updatedAt = 1L, @@ -1085,7 +1206,10 @@ class MicroblogTest : RobolectricTest() { val paging = db.pagingTimelineDao().getPagingSource("home") val pager = TestPager(config = PagingConfig(pageSize = 20), paging) val refreshResult = pager.refresh() - val page = assertIs>(refreshResult) + val page = + assertIs>( + refreshResult, + ) val dbItem = assertNotNull(page.data.firstOrNull()) val timelineUi = @@ -1093,33 +1217,88 @@ class MicroblogTest : RobolectricTest() { item = dbItem, pagingKey = "home", useDbKeyInItemKey = false, - translationDisplayOptions = - TranslationDisplayOptions( - translationEnabled = true, - autoDisplayEnabled = true, - ), + translationDisplayOptions = translationDisplayOptions(), ) val detailUi = TimelinePagingMapper.toUi( item = dbItem, pagingKey = "post_only_${post.statusKey}", useDbKeyInItemKey = false, - translationDisplayOptions = - TranslationDisplayOptions( - translationEnabled = true, - autoDisplayEnabled = true, - ), + translationDisplayOptions = translationDisplayOptions(), ) assertEquals("长文译文", assertIs(timelineUi).content.raw) assertEquals("长文译文", assertIs(detailUi).content.raw) } + @Test + fun toUiIgnoresCachedTranslationWhenProviderChanges() = + runTest { + val accountKey = MicroBlogKey(id = "account-provider-switch", host = "test.com") + val postUser = + createUser( + MicroBlogKey(id = "post-user-provider-switch", host = "test.com"), + "Post User", + ) + val post = + createPost( + accountKey = accountKey, + user = postUser, + statusKey = MicroBlogKey(id = "post-status-provider-switch", host = "test.com"), + text = "source content", + ) + + val mapped = TimelinePagingMapper.toDb(post, pagingKey = "home") + saveToDatabase(db, listOf(mapped)) + db.translationDao().insert( + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = + mapped.status.status.data + .translationEntityKey(), + targetLanguage = Locale.language, + sourceHash = + post + .translationPayload()!! + .sourceHash(googleTranslationProviderCacheKey), + status = TranslationStatus.Completed, + payload = TranslationPayload(content = "translated content".toUiPlainText()), + updatedAt = 1L, + ), + ) + + val dbItem = + assertNotNull( + ( + assertIs>( + TestPager( + config = PagingConfig(pageSize = 20), + db.pagingTimelineDao().getPagingSource("home"), + ).refresh(), + ) + ).data.firstOrNull(), + ) + + val timelineUi = + assertIs( + TimelinePagingMapper.toUi( + item = dbItem, + pagingKey = "home", + useDbKeyInItemKey = false, + translationDisplayOptions = translationDisplayOptions(providerCacheKey = aiTranslationProviderCacheKey), + ), + ) + + assertEquals("source content", timelineUi.content.raw) + assertEquals(TranslationDisplayState.Hidden, timelineUi.translationDisplayState) + } + @Test fun toUiMarksPendingTranslationAsTranslating() = runTest { val accountKey = MicroBlogKey(id = "account-pending", host = "test.com") - val postUser = createUser(MicroBlogKey(id = "post-user-pending", host = "test.com"), "Post User") + val postUser = + createUser(MicroBlogKey(id = "post-user-pending", host = "test.com"), "Post User") val post = createPost( accountKey = accountKey, @@ -1137,7 +1316,10 @@ class MicroblogTest : RobolectricTest() { mapped.status.status.data .translationEntityKey(), targetLanguage = Locale.language, - sourceHash = post.translationPayload()!!.sourceHash(), + sourceHash = + post + .translationPayload()!! + .sourceHash(googleTranslationProviderCacheKey), status = TranslationStatus.Pending, payload = null, updatedAt = Clock.System.now().toEpochMilliseconds(), @@ -1147,7 +1329,10 @@ class MicroblogTest : RobolectricTest() { val paging = db.pagingTimelineDao().getPagingSource("home") val pager = TestPager(config = PagingConfig(pageSize = 20), paging) val refreshResult = pager.refresh() - val page = assertIs>(refreshResult) + val page = + assertIs>( + refreshResult, + ) val dbItem = assertNotNull(page.data.firstOrNull()) val timelineUi = @@ -1156,11 +1341,7 @@ class MicroblogTest : RobolectricTest() { item = dbItem, pagingKey = "home", useDbKeyInItemKey = false, - translationDisplayOptions = - TranslationDisplayOptions( - translationEnabled = true, - autoDisplayEnabled = true, - ), + translationDisplayOptions = translationDisplayOptions(), ), ) @@ -1172,7 +1353,8 @@ class MicroblogTest : RobolectricTest() { fun toUiPrependsRetryTranslationToMoreMenuWhenTranslationFailed() = runTest { val accountKey = MicroBlogKey(id = "account-failed", host = "test.com") - val postUser = createUser(MicroBlogKey(id = "post-user-failed", host = "test.com"), "Post User") + val postUser = + createUser(MicroBlogKey(id = "post-user-failed", host = "test.com"), "Post User") val post = createPost( accountKey = accountKey, @@ -1208,7 +1390,10 @@ class MicroblogTest : RobolectricTest() { mapped.status.status.data .translationEntityKey(), targetLanguage = Locale.language, - sourceHash = post.translationPayload()!!.sourceHash(), + sourceHash = + post + .translationPayload()!! + .sourceHash(googleTranslationProviderCacheKey), status = TranslationStatus.Failed, payload = null, updatedAt = 1L, @@ -1218,7 +1403,10 @@ class MicroblogTest : RobolectricTest() { val paging = db.pagingTimelineDao().getPagingSource("home") val pager = TestPager(config = PagingConfig(pageSize = 20), paging) val refreshResult = pager.refresh() - val page = assertIs>(refreshResult) + val page = + assertIs>( + refreshResult, + ) val dbItem = assertNotNull(page.data.firstOrNull()) val timelineUi = @@ -1227,11 +1415,7 @@ class MicroblogTest : RobolectricTest() { item = dbItem, pagingKey = "home", useDbKeyInItemKey = false, - translationDisplayOptions = - TranslationDisplayOptions( - translationEnabled = true, - autoDisplayEnabled = true, - ), + translationDisplayOptions = translationDisplayOptions(), ), ) @@ -1247,7 +1431,11 @@ class MicroblogTest : RobolectricTest() { fun toUiPrependsShowOriginalWhenTranslatedContentIsDisplayed() = runTest { val accountKey = MicroBlogKey(id = "account-translated", host = "test.com") - val postUser = createUser(MicroBlogKey(id = "post-user-translated", host = "test.com"), "Post User") + val postUser = + createUser( + MicroBlogKey(id = "post-user-translated", host = "test.com"), + "Post User", + ) val post = createPost( accountKey = accountKey, @@ -1277,7 +1465,10 @@ class MicroblogTest : RobolectricTest() { mapped.status.status.data .translationEntityKey(), targetLanguage = Locale.language, - sourceHash = post.translationPayload()!!.sourceHash(), + sourceHash = + post + .translationPayload()!! + .sourceHash(googleTranslationProviderCacheKey), status = TranslationStatus.Completed, payload = TranslationPayload(content = "translated content".toUiPlainText()), updatedAt = 1L, @@ -1288,7 +1479,10 @@ class MicroblogTest : RobolectricTest() { assertNotNull( ( assertIs>( - TestPager(config = PagingConfig(pageSize = 20), db.pagingTimelineDao().getPagingSource("home")).refresh(), + TestPager( + config = PagingConfig(pageSize = 20), + db.pagingTimelineDao().getPagingSource("home"), + ).refresh(), ) ).data.firstOrNull(), ) @@ -1299,11 +1493,7 @@ class MicroblogTest : RobolectricTest() { item = dbItem, pagingKey = "home", useDbKeyInItemKey = false, - translationDisplayOptions = - TranslationDisplayOptions( - translationEnabled = true, - autoDisplayEnabled = true, - ), + translationDisplayOptions = translationDisplayOptions(), ), ) @@ -1320,7 +1510,8 @@ class MicroblogTest : RobolectricTest() { fun toUiPrependsTranslateWhenOriginalModeIsForced() = runTest { val accountKey = MicroBlogKey(id = "account-original", host = "test.com") - val postUser = createUser(MicroBlogKey(id = "post-user-original", host = "test.com"), "Post User") + val postUser = + createUser(MicroBlogKey(id = "post-user-original", host = "test.com"), "Post User") val statusKey = MicroBlogKey(id = "post-status-original", host = "test.com") val post = createPost( @@ -1351,7 +1542,10 @@ class MicroblogTest : RobolectricTest() { mapped.status.status.data .translationEntityKey(), targetLanguage = Locale.language, - sourceHash = post.translationPayload()!!.sourceHash(), + sourceHash = + post + .translationPayload()!! + .sourceHash(googleTranslationProviderCacheKey), status = TranslationStatus.Completed, displayMode = TranslationDisplayMode.Original, payload = TranslationPayload(content = "translated content".toUiPlainText()), @@ -1363,7 +1557,10 @@ class MicroblogTest : RobolectricTest() { assertNotNull( ( assertIs>( - TestPager(config = PagingConfig(pageSize = 20), db.pagingTimelineDao().getPagingSource("home")).refresh(), + TestPager( + config = PagingConfig(pageSize = 20), + db.pagingTimelineDao().getPagingSource("home"), + ).refresh(), ) ).data.firstOrNull(), ) @@ -1374,11 +1571,7 @@ class MicroblogTest : RobolectricTest() { item = dbItem, pagingKey = "home", useDbKeyInItemKey = false, - translationDisplayOptions = - TranslationDisplayOptions( - translationEnabled = true, - autoDisplayEnabled = true, - ), + translationDisplayOptions = translationDisplayOptions(), ), ) @@ -1395,12 +1588,20 @@ class MicroblogTest : RobolectricTest() { fun toUiStillPrependsTranslateWhenPreTranslationDisplayIsDisabled() = runTest { val accountKey = MicroBlogKey(id = "account-pretranslation-off", host = "test.com") - val postUser = createUser(MicroBlogKey(id = "post-user-pretranslation-off", host = "test.com"), "Post User") + val postUser = + createUser( + MicroBlogKey(id = "post-user-pretranslation-off", host = "test.com"), + "Post User", + ) val post = createPost( accountKey = accountKey, user = postUser, - statusKey = MicroBlogKey(id = "post-status-pretranslation-off", host = "test.com"), + statusKey = + MicroBlogKey( + id = "post-status-pretranslation-off", + host = "test.com", + ), text = "source content", ).copy( actions = @@ -1425,7 +1626,10 @@ class MicroblogTest : RobolectricTest() { mapped.status.status.data .translationEntityKey(), targetLanguage = Locale.language, - sourceHash = post.translationPayload()!!.sourceHash(), + sourceHash = + post + .translationPayload()!! + .sourceHash(googleTranslationProviderCacheKey), status = TranslationStatus.Completed, payload = TranslationPayload(content = "translated content".toUiPlainText()), updatedAt = 1L, @@ -1436,7 +1640,10 @@ class MicroblogTest : RobolectricTest() { assertNotNull( ( assertIs>( - TestPager(config = PagingConfig(pageSize = 20), db.pagingTimelineDao().getPagingSource("home")).refresh(), + TestPager( + config = PagingConfig(pageSize = 20), + db.pagingTimelineDao().getPagingSource("home"), + ).refresh(), ) ).data.firstOrNull(), ) @@ -1447,11 +1654,7 @@ class MicroblogTest : RobolectricTest() { item = dbItem, pagingKey = "home", useDbKeyInItemKey = false, - translationDisplayOptions = - TranslationDisplayOptions( - translationEnabled = true, - autoDisplayEnabled = false, - ), + translationDisplayOptions = translationDisplayOptions(autoDisplayEnabled = false), ), ) @@ -1481,7 +1684,13 @@ class MicroblogTest : RobolectricTest() { clickEvent = ClickEvent.Noop, banner = null, description = null, - matrices = UiProfile.Matrices(fansCount = 0, followsCount = 0, statusesCount = 0, platformFansCount = "0"), + matrices = + UiProfile.Matrices( + fansCount = 0, + followsCount = 0, + statusesCount = 0, + platformFansCount = "0", + ), mark = persistentListOf(), bottomContent = null, ) diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt index d4bacf0a2..11a620eed 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt @@ -15,6 +15,7 @@ import dev.dimension.flare.common.decodeJson import dev.dimension.flare.common.encodeJson import dev.dimension.flare.createTestRootPath import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.model.TranslationDisplayOptions import dev.dimension.flare.data.database.cache.model.TranslationEntityType import dev.dimension.flare.data.database.cache.model.TranslationStatus import dev.dimension.flare.data.datasource.microblog.paging.CacheableRemoteLoader @@ -371,6 +372,12 @@ class MixedRemoteMediatorTest : RobolectricTest() { item = page.data.single(), pagingKey = mediator.pagingKey, useDbKeyInItemKey = false, + translationDisplayOptions = + TranslationDisplayOptions( + translationEnabled = false, + autoDisplayEnabled = false, + providerCacheKey = "", + ), ), ) assertEquals(postC.statusKey, post.statusKey) @@ -624,12 +631,16 @@ class MixedRemoteMediatorTest : RobolectricTest() { val savedStatus = db.statusDao().get(post.statusKey, AccountType.Specific(accountKey)).first() assertNotNull(savedStatus) val translation = - db.translationDao().get( - entityType = TranslationEntityType.Status, - entityKey = savedStatus.id, - targetLanguage = Locale.language, - ) - assertNotNull(translation) + withTimeout(5_000) { + db + .translationDao() + .find( + entityType = TranslationEntityType.Status, + entityKey = savedStatus.id, + targetLanguage = Locale.language, + ).filterNotNull() + .first() + } assertEquals(TranslationStatus.Skipped, translation.status) assertEquals("source_language_matches_target", translation.statusReason) } diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt index cb323a7b1..c820420b6 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandlerTest.kt @@ -31,6 +31,7 @@ import dev.dimension.flare.data.translation.PreTranslationBatchDocument import dev.dimension.flare.data.translation.PreTranslationBatchPayload import dev.dimension.flare.data.translation.PreTranslationService import dev.dimension.flare.data.translation.aiPreTranslateConfig +import dev.dimension.flare.data.translation.cacheKey import dev.dimension.flare.deleteTestRootPath import dev.dimension.flare.memoryDatabaseBuilder import dev.dimension.flare.model.MicroBlogKey @@ -83,6 +84,9 @@ class UserHandlerTest : RobolectricTest() { private lateinit var onDeviceAI: FakeOnDeviceAI private val accountKey = MicroBlogKey(id = "account-1", host = "test.social") + private val aiTranslationProviderCacheKey = + AppSettings.TranslateConfig.Provider.AI + .cacheKey() @BeforeTest fun setup() { @@ -259,7 +263,7 @@ class UserHandlerTest : RobolectricTest() { entityType = TranslationEntityType.Profile, entityKey = profile.translationEntityKey(), targetLanguage = Locale.language, - sourceHash = profile.translationPayload().sourceHash(), + sourceHash = profile.translationPayload().sourceHash(aiTranslationProviderCacheKey), status = TranslationStatus.Completed, payload = TranslationPayload(description = "翻译后的简介".toUiPlainText()), updatedAt = 1L, From 6622ec1b5a999d47fe200cb9deeb74a0d6c4e508 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Mon, 30 Mar 2026 12:27:36 +0900 Subject: [PATCH 13/14] update ai config presenter --- .../ui/screen/settings/AiConfigScreen.kt | 152 ++++--------- app/src/main/res/values-zh-rCN/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../values-zh-rCN/strings.xml | 4 + .../main/composeResources/values/strings.xml | 4 + .../ui/screen/settings/SettingsScreen.kt | 138 ++++------- iosApp/flare/Localizable.xcstrings | 16 ++ iosApp/flare/UI/Screen/AiConfigScreen.swift | 178 +++------------ .../presenter/settings/AiConfigPresenter.kt | 215 +++++++++++++++--- 9 files changed, 338 insertions(+), 377 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt index 491d38382..0138336ff 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import dev.dimension.flare.R -import dev.dimension.flare.data.datastore.model.AppSettings import dev.dimension.flare.ui.component.BackButton import dev.dimension.flare.ui.component.FlareDropdownMenu import dev.dimension.flare.ui.component.FlareLargeFlexibleTopAppBar @@ -92,23 +91,13 @@ internal fun AiConfigScreen(onBack: () -> Unit) { val apiKeyHint = stringResource(id = R.string.settings_ai_config_api_key_hint) val translatePromptTitle = stringResource(id = R.string.settings_ai_config_translate_prompt) val tldrPromptTitle = stringResource(id = R.string.settings_ai_config_tldr_prompt) - val selectedTranslateProvider = - when (state.translateConfig.provider) { - AppSettings.TranslateConfig.Provider.AI -> TranslateProviderOption.AI - AppSettings.TranslateConfig.Provider.Google -> TranslateProviderOption.Google - } - val selectedType = - when (state.aiConfig.type) { - is AppSettings.AiConfig.Type.OpenAI -> AiTypeOption.OpenAI - AppSettings.AiConfig.Type.OnDevice -> AiTypeOption.OnDevice - } SegmentedListItem( checked = state.showTypeDropdown, onCheckedChange = { state.setShowTypeDropdown(it) }, shapes = - if (selectedType == AiTypeOption.OpenAI) { + if (state.aiType == AiTypeOption.OpenAI) { ListItemDefaults.first() } else { ListItemDefaults.single() @@ -135,7 +124,7 @@ internal fun AiConfigScreen(onBack: () -> Unit) { text = stringResource( id = - when (selectedType) { + when (state.aiType) { AiTypeOption.OnDevice -> R.string.settings_ai_config_type_on_device AiTypeOption.OpenAI -> R.string.settings_ai_config_type_openai }, @@ -173,9 +162,7 @@ internal fun AiConfigScreen(onBack: () -> Unit) { } }, ) - val openAIType = state.aiConfig.type as? AppSettings.AiConfig.Type.OpenAI - val openAITypeForDisplay = openAIType ?: AppSettings.AiConfig.Type.OpenAI("", "", "") - AnimatedVisibility(visible = openAIType != null) { + AnimatedVisibility(visible = state.aiType == AiTypeOption.OpenAI) { SegmentedListItem( checked = state.textEditDialog?.field == AiConfigEditField.ServerUrl, onCheckedChange = { checked -> @@ -185,18 +172,11 @@ internal fun AiConfigScreen(onBack: () -> Unit) { field = AiConfigEditField.ServerUrl, title = serverTitle, placeholder = serverHint, - value = openAITypeForDisplay.serverUrl, + value = state.openAIServerUrl, suggestions = state.serverSuggestions, hint = serverRequirementHint, onConfirm = { newValue -> - state.update { - val currentType = type as? AppSettings.AiConfig.Type.OpenAI - copy( - type = - (currentType ?: AppSettings.AiConfig.Type.OpenAI("", "", "")) - .copy(serverUrl = newValue), - ) - } + state.setOpenAIServerUrl(newValue) }, ), ) @@ -211,7 +191,7 @@ internal fun AiConfigScreen(onBack: () -> Unit) { supportingContent = { Text( text = - openAITypeForDisplay.serverUrl.ifBlank { + state.openAIServerUrl.ifBlank { stringResource(id = R.string.settings_ai_config_value_empty_placeholder) }, style = MaterialTheme.typography.bodySmall, @@ -219,7 +199,7 @@ internal fun AiConfigScreen(onBack: () -> Unit) { }, ) } - AnimatedVisibility(visible = openAIType != null) { + AnimatedVisibility(visible = state.aiType == AiTypeOption.OpenAI) { SegmentedListItem( checked = state.textEditDialog?.field == AiConfigEditField.ApiKey, onCheckedChange = { checked -> @@ -229,16 +209,9 @@ internal fun AiConfigScreen(onBack: () -> Unit) { field = AiConfigEditField.ApiKey, title = apiKeyTitle, placeholder = apiKeyHint, - value = openAITypeForDisplay.apiKey, + value = state.openAIApiKey, onConfirm = { newValue -> - state.update { - val currentType = type as? AppSettings.AiConfig.Type.OpenAI - copy( - type = - (currentType ?: AppSettings.AiConfig.Type.OpenAI("", "", "")) - .copy(apiKey = newValue), - ) - } + state.setOpenAIApiKey(newValue) }, ), ) @@ -253,7 +226,7 @@ internal fun AiConfigScreen(onBack: () -> Unit) { supportingContent = { Text( text = - openAITypeForDisplay.apiKey.ifBlank { + state.openAIApiKey.ifBlank { stringResource(id = R.string.settings_ai_config_value_empty_placeholder) }, style = MaterialTheme.typography.bodySmall, @@ -261,7 +234,7 @@ internal fun AiConfigScreen(onBack: () -> Unit) { }, ) } - AnimatedVisibility(visible = openAIType != null) { + AnimatedVisibility(visible = state.aiType == AiTypeOption.OpenAI) { SegmentedListItem( checked = state.showModelDropdown, onCheckedChange = { checked -> @@ -286,7 +259,7 @@ internal fun AiConfigScreen(onBack: () -> Unit) { ) { Text( text = - openAITypeForDisplay.model.ifBlank { + state.openAIModel.ifBlank { stringResource(id = R.string.settings_ai_config_model_select) }, ) @@ -323,14 +296,7 @@ internal fun AiConfigScreen(onBack: () -> Unit) { text = { Text(model) }, onClick = { state.setShowModelDropdown(false) - state.update { - val currentType = type as? AppSettings.AiConfig.Type.OpenAI - copy( - type = - (currentType ?: AppSettings.AiConfig.Type.OpenAI("", "", "")) - .copy(model = model), - ) - } + state.setOpenAIModel(model) }, ) } @@ -350,11 +316,11 @@ internal fun AiConfigScreen(onBack: () -> Unit) { }, shapes = ListItemDefaults.first(), content = { - Text(text = "Translation Provider") + Text(text = stringResource(id = R.string.settings_ai_config_translate_provider)) }, supportingContent = { Text( - text = "Choose which service handles translation", + text = stringResource(id = R.string.settings_ai_config_translate_provider_description), style = MaterialTheme.typography.bodySmall, ) }, @@ -367,9 +333,15 @@ internal fun AiConfigScreen(onBack: () -> Unit) { ) { Text( text = - when (selectedTranslateProvider) { - TranslateProviderOption.AI -> "AI" - TranslateProviderOption.Google -> "Google Translate" + when (state.translateProvider) { + TranslateProviderOption.AI -> + stringResource( + id = R.string.settings_ai_config_translate_provider_ai, + ) + TranslateProviderOption.Google -> + stringResource( + id = R.string.settings_ai_config_translate_provider_google, + ) }, ) } @@ -385,8 +357,14 @@ internal fun AiConfigScreen(onBack: () -> Unit) { Text( text = when (provider) { - TranslateProviderOption.AI -> "AI" - TranslateProviderOption.Google -> "Google Translate" + TranslateProviderOption.AI -> + stringResource( + id = R.string.settings_ai_config_translate_provider_ai, + ) + TranslateProviderOption.Google -> + stringResource( + id = R.string.settings_ai_config_translate_provider_google, + ) }, ) }, @@ -404,9 +382,7 @@ internal fun AiConfigScreen(onBack: () -> Unit) { AnimatedVisibility(visible = true) { SegmentedListItem( onClick = { - state.updateTranslateConfig { - copy(preTranslate = !state.translateConfig.preTranslate) - } + state.setPreTranslate(!state.preTranslate) }, shapes = ListItemDefaults.item(), content = { @@ -421,11 +397,9 @@ internal fun AiConfigScreen(onBack: () -> Unit) { }, trailingContent = { Switch( - checked = state.translateConfig.preTranslate, + checked = state.preTranslate, onCheckedChange = { - state.updateTranslateConfig { - copy(preTranslate = it) - } + state.setPreTranslate(it) }, ) }, @@ -433,7 +407,7 @@ internal fun AiConfigScreen(onBack: () -> Unit) { } AnimatedVisibility( visible = - selectedTranslateProvider == TranslateProviderOption.AI, + state.translateProvider == TranslateProviderOption.AI, ) { SegmentedListItem( checked = state.textEditDialog?.field == AiConfigEditField.TranslatePrompt, @@ -444,11 +418,9 @@ internal fun AiConfigScreen(onBack: () -> Unit) { field = AiConfigEditField.TranslatePrompt, title = translatePromptTitle, placeholder = "", - value = state.aiConfig.translatePrompt, + value = state.translatePrompt, onConfirm = { newValue -> - state.update { - copy(translatePrompt = newValue) - } + state.setTranslatePrompt(newValue) }, ), ) @@ -463,7 +435,7 @@ internal fun AiConfigScreen(onBack: () -> Unit) { supportingContent = { Text( text = - state.aiConfig.translatePrompt.ifBlank { + state.translatePrompt.ifBlank { stringResource(id = R.string.settings_ai_config_value_empty_placeholder) }, style = MaterialTheme.typography.bodySmall, @@ -473,12 +445,10 @@ internal fun AiConfigScreen(onBack: () -> Unit) { } SegmentedListItem( onClick = { - state.update { - copy(tldr = !state.aiConfig.tldr) - } + state.setAITldr(!state.aiTldr) }, shapes = - if (state.aiConfig.tldr) { + if (state.aiTldr) { ListItemDefaults.item() } else { ListItemDefaults.last() @@ -495,16 +465,14 @@ internal fun AiConfigScreen(onBack: () -> Unit) { }, trailingContent = { Switch( - checked = state.aiConfig.tldr, + checked = state.aiTldr, onCheckedChange = { - state.update { - copy(tldr = it) - } + state.setAITldr(it) }, ) }, ) - AnimatedVisibility(visible = state.aiConfig.tldr) { + AnimatedVisibility(visible = state.aiTldr) { SegmentedListItem( checked = state.textEditDialog?.field == AiConfigEditField.TldrPrompt, onCheckedChange = { checked -> @@ -514,11 +482,9 @@ internal fun AiConfigScreen(onBack: () -> Unit) { field = AiConfigEditField.TldrPrompt, title = tldrPromptTitle, placeholder = "", - value = state.aiConfig.tldrPrompt, + value = state.tldrPrompt, onConfirm = { newValue -> - state.update { - copy(tldrPrompt = newValue) - } + state.setTldrPrompt(newValue) }, ), ) @@ -533,7 +499,7 @@ internal fun AiConfigScreen(onBack: () -> Unit) { supportingContent = { Text( text = - state.aiConfig.tldrPrompt.ifBlank { + state.tldrPrompt.ifBlank { stringResource(id = R.string.settings_ai_config_value_empty_placeholder) }, style = MaterialTheme.typography.bodySmall, @@ -570,34 +536,12 @@ private fun presenter() = var showProviderDropdown by remember { mutableStateOf(false) } var textEditDialog by remember { mutableStateOf(null) } - object { - val aiConfig = businessState.aiConfig - val translateConfig = businessState.translateConfig - val openAIModels = businessState.openAIModels - val supportedTypes = businessState.supportedTypes - val supportedTranslateProviders = businessState.supportedTranslateProviders - val serverSuggestions = businessState.serverSuggestions + object : AiConfigPresenter.State by businessState { val showTypeDropdown = showTypeDropdown val showModelDropdown = showModelDropdown val showProviderDropdown = showProviderDropdown val textEditDialog = textEditDialog - fun update(block: AppSettings.AiConfig.() -> AppSettings.AiConfig) { - businessState.update(block) - } - - fun updateTranslateConfig(block: AppSettings.TranslateConfig.() -> AppSettings.TranslateConfig) { - businessState.updateTranslateConfig(block) - } - - fun selectType(type: AiTypeOption) { - businessState.selectType(type) - } - - fun selectTranslateProvider(type: TranslateProviderOption) { - businessState.selectTranslateProvider(type) - } - fun setShowTypeDropdown(value: Boolean) { showTypeDropdown = value } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index fc3ebc74b..bc0846832 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -92,6 +92,10 @@ 对长篇文章启用 AI 摘要,仅在超过 500 个字符的帖子中可用 翻译提示 摘要提示 + 翻译服务提供商 + 选择处理翻译的服务提供商 + AI + 谷歌翻译 语言 更改应用程序的语言 RSS 管理 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f56edbf68..22a191f8c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -102,6 +102,10 @@ Translate and cache newly loaded timeline and profile content in the background. This can consume a large amount of tokens. Translation Prompt Summary Prompt + Translation Provider + Choose which service handles translation + AI + Google Translate Language Change the language of the app RSS Management diff --git a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml index 7d11df957..ec895bfad 100644 --- a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml +++ b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml @@ -175,6 +175,10 @@ 在后台翻译并缓存新加载的时间线和个人资料内容。这会消耗大量 token。 启用 AI 摘要 对长篇文章启用 AI 摘要,仅在超过 500 个字符的帖子中可用 + 翻译服务提供商 + 选择处理翻译的服务提供商 + AI + 谷歌翻译 通用 在首页时间轴顶部显示撰写框 在首页时间轴顶部显示撰写框 diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index a047f62c0..b11a3ac6d 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -195,6 +195,10 @@ Translate and cache newly loaded timeline and profile content in the background. This can consume a large amount of tokens. Enable AI Summarization Enable AI summarization for long posts, only available in post that longer than 500 characters + Translation Provider + Choose which service handles translation + AI + Google Translate Generic diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index 70e26acfb..01ed86710 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -53,7 +53,6 @@ import dev.dimension.flare.action_import import dev.dimension.flare.add_account import dev.dimension.flare.app_name import dev.dimension.flare.cancel -import dev.dimension.flare.data.datastore.model.AppSettings import dev.dimension.flare.data.model.AppearanceSettings import dev.dimension.flare.data.model.AvatarShape import dev.dimension.flare.data.model.LocalAppearanceSettings @@ -102,6 +101,10 @@ import dev.dimension.flare.settings_ai_config_title import dev.dimension.flare.settings_ai_config_tldr_description import dev.dimension.flare.settings_ai_config_tldr_prompt import dev.dimension.flare.settings_ai_config_translate_prompt +import dev.dimension.flare.settings_ai_config_translate_provider +import dev.dimension.flare.settings_ai_config_translate_provider_ai +import dev.dimension.flare.settings_ai_config_translate_provider_description +import dev.dimension.flare.settings_ai_config_translate_provider_google import dev.dimension.flare.settings_ai_config_type import dev.dimension.flare.settings_ai_config_type_description import dev.dimension.flare.settings_ai_config_type_on_device @@ -1205,16 +1208,6 @@ internal fun SettingsScreen( val apiKeyHint = stringResource(Res.string.settings_ai_config_api_key_hint) val translatePromptTitle = stringResource(Res.string.settings_ai_config_translate_prompt) val tldrPromptTitle = stringResource(Res.string.settings_ai_config_tldr_prompt) - val selectedTranslateProvider = - when (state.aiConfigState.translateConfig.provider) { - AppSettings.TranslateConfig.Provider.AI -> TranslateProviderOption.AI - AppSettings.TranslateConfig.Provider.Google -> TranslateProviderOption.Google - } - val selectedType = - when (state.aiConfigState.aiConfig.type) { - is AppSettings.AiConfig.Type.OpenAI -> AiTypeOption.OpenAI - AppSettings.AiConfig.Type.OnDevice -> AiTypeOption.OnDevice - } ExpanderItem( heading = { Text(stringResource(Res.string.settings_ai_config_type)) @@ -1230,7 +1223,7 @@ internal fun SettingsScreen( ) { Text( stringResource( - when (selectedType) { + when (state.aiConfigState.aiType) { AiTypeOption.OnDevice -> Res.string.settings_ai_config_type_on_device AiTypeOption.OpenAI -> Res.string.settings_ai_config_type_openai }, @@ -1265,15 +1258,13 @@ internal fun SettingsScreen( }, ) ExpanderItemSeparator() - val openAIType = state.aiConfigState.aiConfig.type as? AppSettings.AiConfig.Type.OpenAI - val openAITypeForDisplay = openAIType ?: AppSettings.AiConfig.Type.OpenAI("", "", "") - AnimatedVisibility(openAIType != null) { + AnimatedVisibility(state.aiConfigState.aiType == AiTypeOption.OpenAI) { Column { ExpanderItem( heading = { Text(stringResource(Res.string.settings_ai_config_server)) }, caption = { Text( - openAITypeForDisplay.serverUrl.ifBlank { + state.aiConfigState.openAIServerUrl.ifBlank { stringResource(Res.string.settings_ai_config_value_empty_placeholder) }, ) @@ -1285,18 +1276,11 @@ internal fun SettingsScreen( TextEditDialogState( title = serverTitle, placeholder = serverHint, - value = openAITypeForDisplay.serverUrl, + value = state.aiConfigState.openAIServerUrl, suggestions = state.aiConfigState.serverSuggestions, hint = serverRequirementHint, onConfirm = { newValue -> - state.aiConfigState.update { - val currentType = type as? AppSettings.AiConfig.Type.OpenAI - copy( - type = - (currentType ?: AppSettings.AiConfig.Type.OpenAI("", "", "")) - .copy(serverUrl = newValue), - ) - } + state.aiConfigState.setOpenAIServerUrl(newValue) }, ), ) @@ -1311,7 +1295,7 @@ internal fun SettingsScreen( heading = { Text(stringResource(Res.string.settings_ai_config_api_key)) }, caption = { Text( - openAITypeForDisplay.apiKey.ifBlank { + state.aiConfigState.openAIApiKey.ifBlank { stringResource(Res.string.settings_ai_config_value_empty_placeholder) }, ) @@ -1323,16 +1307,9 @@ internal fun SettingsScreen( TextEditDialogState( title = apiKeyTitle, placeholder = apiKeyHint, - value = openAITypeForDisplay.apiKey, + value = state.aiConfigState.openAIApiKey, onConfirm = { newValue -> - state.aiConfigState.update { - val currentType = type as? AppSettings.AiConfig.Type.OpenAI - copy( - type = - (currentType ?: AppSettings.AiConfig.Type.OpenAI("", "", "")) - .copy(apiKey = newValue), - ) - } + state.aiConfigState.setOpenAIApiKey(newValue) }, ), ) @@ -1353,7 +1330,7 @@ internal fun SettingsScreen( }, ) { Text( - openAITypeForDisplay.model.ifBlank { + state.aiConfigState.openAIModel.ifBlank { stringResource(Res.string.settings_ai_config_model_select) }, ) @@ -1386,16 +1363,7 @@ internal fun SettingsScreen( MenuFlyoutItem( text = { Text(model) }, onClick = { - state.aiConfigState.update { - val currentType = type as? AppSettings.AiConfig.Type.OpenAI - copy( - type = - ( - currentType - ?: AppSettings.AiConfig.Type.OpenAI("", "", "") - ).copy(model = model), - ) - } + state.aiConfigState.setOpenAIModel(model) state.aiConfigState.setShowModelDropdown(false) }, ) @@ -1412,10 +1380,10 @@ internal fun SettingsScreen( Column { ExpanderItem( heading = { - Text("Translation Provider") + Text(stringResource(Res.string.settings_ai_config_translate_provider)) }, caption = { - Text("Choose which service handles translation") + Text(stringResource(Res.string.settings_ai_config_translate_provider_description)) }, trailing = { DropDownButton( @@ -1424,9 +1392,15 @@ internal fun SettingsScreen( }, ) { Text( - when (selectedTranslateProvider) { - TranslateProviderOption.AI -> "AI" - TranslateProviderOption.Google -> "Google Translate" + when (state.aiConfigState.translateProvider) { + TranslateProviderOption.AI -> + stringResource( + Res.string.settings_ai_config_translate_provider_ai, + ) + TranslateProviderOption.Google -> + stringResource( + Res.string.settings_ai_config_translate_provider_google, + ) }, ) } @@ -1441,8 +1415,14 @@ internal fun SettingsScreen( text = { Text( when (provider) { - TranslateProviderOption.AI -> "AI" - TranslateProviderOption.Google -> "Google Translate" + TranslateProviderOption.AI -> + stringResource( + Res.string.settings_ai_config_translate_provider_ai, + ) + TranslateProviderOption.Google -> + stringResource( + Res.string.settings_ai_config_translate_provider_google, + ) }, ) }, @@ -1465,9 +1445,9 @@ internal fun SettingsScreen( }, trailing = { Switcher( - checked = state.aiConfigState.translateConfig.preTranslate, + checked = state.aiConfigState.preTranslate, { - state.aiConfigState.updateTranslateConfig { copy(preTranslate = it) } + state.aiConfigState.setPreTranslate(it) }, textBefore = true, ) @@ -1476,13 +1456,13 @@ internal fun SettingsScreen( ExpanderItemSeparator() } } - AnimatedVisibility(selectedTranslateProvider == TranslateProviderOption.AI) { + AnimatedVisibility(state.aiConfigState.translateProvider == TranslateProviderOption.AI) { Column { ExpanderItem( heading = { Text(stringResource(Res.string.settings_ai_config_translate_prompt)) }, caption = { Text( - state.aiConfigState.aiConfig.translatePrompt.ifBlank { + state.aiConfigState.translatePrompt.ifBlank { stringResource(Res.string.settings_ai_config_value_empty_placeholder) }, ) @@ -1494,11 +1474,9 @@ internal fun SettingsScreen( TextEditDialogState( title = translatePromptTitle, placeholder = "", - value = state.aiConfigState.aiConfig.translatePrompt, + value = state.aiConfigState.translatePrompt, onConfirm = { newValue -> - state.aiConfigState.update { - copy(translatePrompt = newValue) - } + state.aiConfigState.setTranslatePrompt(newValue) }, ), ) @@ -1520,22 +1498,22 @@ internal fun SettingsScreen( }, trailing = { Switcher( - checked = state.aiConfigState.aiConfig.tldr, + checked = state.aiConfigState.aiTldr, { - state.aiConfigState.update { copy(tldr = it) } + state.aiConfigState.setAITldr(it) }, textBefore = true, ) }, ) - AnimatedVisibility(state.aiConfigState.aiConfig.tldr) { + AnimatedVisibility(state.aiConfigState.aiTldr) { Column { ExpanderItemSeparator() ExpanderItem( heading = { Text(stringResource(Res.string.settings_ai_config_tldr_prompt)) }, caption = { Text( - state.aiConfigState.aiConfig.tldrPrompt.ifBlank { + state.aiConfigState.tldrPrompt.ifBlank { stringResource(Res.string.settings_ai_config_value_empty_placeholder) }, ) @@ -1547,11 +1525,9 @@ internal fun SettingsScreen( TextEditDialogState( title = tldrPromptTitle, placeholder = "", - value = state.aiConfigState.aiConfig.tldrPrompt, + value = state.aiConfigState.tldrPrompt, onConfirm = { newValue -> - state.aiConfigState.update { - copy(tldrPrompt = newValue) - } + state.aiConfigState.setTldrPrompt(newValue) }, ), ) @@ -1910,13 +1886,7 @@ private fun aiConfigPresenter() = var showModelDropdown by remember { mutableStateOf(false) } var showProviderDropdown by remember { mutableStateOf(false) } var textEditDialog by remember { mutableStateOf(null) } - object { - val aiConfig = state.aiConfig - val translateConfig = state.translateConfig - val openAIModels = state.openAIModels - val supportedTypes = state.supportedTypes - val supportedTranslateProviders = state.supportedTranslateProviders - val serverSuggestions = state.serverSuggestions + object : AiConfigPresenter.State by state { val expanded = expanded val showTypeDropdown = showTypeDropdown val showModelDropdown = showModelDropdown @@ -1927,22 +1897,6 @@ private fun aiConfigPresenter() = expanded = value } - fun update(block: AppSettings.AiConfig.() -> AppSettings.AiConfig) { - state.update(block) - } - - fun updateTranslateConfig(block: AppSettings.TranslateConfig.() -> AppSettings.TranslateConfig) { - state.updateTranslateConfig(block) - } - - fun selectType(type: AiTypeOption) { - state.selectType(type) - } - - fun selectTranslateProvider(type: TranslateProviderOption) { - state.selectTranslateProvider(type) - } - fun setShowTypeDropdown(value: Boolean) { showTypeDropdown = value } diff --git a/iosApp/flare/Localizable.xcstrings b/iosApp/flare/Localizable.xcstrings index cfaf91858..cabf7f488 100644 --- a/iosApp/flare/Localizable.xcstrings +++ b/iosApp/flare/Localizable.xcstrings @@ -2152,6 +2152,10 @@ } } }, + "AI" : { + "comment" : "The name of the AI translation service.", + "isCommentAutoGenerated" : true + }, "AI Type" : { "localizations" : { "ar" : { @@ -3416,6 +3420,7 @@ } }, "ai_config_translate" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -18312,6 +18317,9 @@ } } } + }, + "Choose which service handles translation" : { + }, "Close" : { "localizations" : { @@ -27034,6 +27042,10 @@ } } }, + "Google Translate" : { + "comment" : "Title of a translate provider option that uses Google Translate.", + "isCommentAutoGenerated" : true + }, "home_tab_bookmarks_title" : { "localizations" : { "af" : { @@ -97487,6 +97499,7 @@ } }, "Translate text with AI" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -97615,6 +97628,9 @@ } } } + }, + "Translation Provider" : { + }, "unblock" : { "localizations" : { diff --git a/iosApp/flare/UI/Screen/AiConfigScreen.swift b/iosApp/flare/UI/Screen/AiConfigScreen.swift index 3cab4c16b..6b5573759 100644 --- a/iosApp/flare/UI/Screen/AiConfigScreen.swift +++ b/iosApp/flare/UI/Screen/AiConfigScreen.swift @@ -20,7 +20,7 @@ struct AiConfigScreen: View { Section { Picker( selection: Binding( - get: { aiTypeOption(type: presenter.state.aiConfig.type) }, + get: { presenter.state.aiType }, set: { type in presenter.state.selectType(type: type) } @@ -34,16 +34,16 @@ struct AiConfigScreen: View { Text("Select AI provider") } - if isOpenAIType(presenter.state.aiConfig.type) { + if presenter.state.aiType == .openAi { Button { beginEditing( field: .serverUrl, - value: openAIValue(presenter.state.aiConfig.type).serverUrl + value: presenter.state.openAIServerUrl ) } label: { VStack(alignment: .leading, spacing: 2) { Text("Server URL") - Text(displayText(openAIValue(presenter.state.aiConfig.type).serverUrl)) + Text(displayText(presenter.state.openAIServerUrl)) .foregroundStyle(.secondary) .font(.subheadline) } @@ -52,16 +52,16 @@ struct AiConfigScreen: View { .transition(.opacity.combined(with: .move(edge: .top))) } - if isOpenAIType(presenter.state.aiConfig.type) { + if presenter.state.aiType == .openAi { Button { beginEditing( field: .apiKey, - value: openAIValue(presenter.state.aiConfig.type).apiKey + value: presenter.state.openAIApiKey ) } label: { VStack(alignment: .leading, spacing: 2) { Text("API Key") - Text(displayText(openAIValue(presenter.state.aiConfig.type).apiKey)) + Text(displayText(presenter.state.openAIApiKey)) .foregroundStyle(.secondary) .font(.subheadline) } @@ -70,8 +70,8 @@ struct AiConfigScreen: View { .transition(.opacity.combined(with: .move(edge: .top))) } - if isOpenAIType(presenter.state.aiConfig.type) { - let selectedModel = openAIValue(presenter.state.aiConfig.type).model + if presenter.state.aiType == .openAi { + let selectedModel = presenter.state.openAIModel Picker( selection: Binding( get: { selectedModel }, @@ -79,25 +79,7 @@ struct AiConfigScreen: View { if model.hasPrefix("__meta__") { return } - presenter.state.update { current in - switch onEnum(of: current.type) { - case .onDevice: - return current - case .openAI(let openAI): - return current.doCopy( - translation: current.translation, - tldr: current.tldr, - type: openAI.doCopy( - serverUrl: openAI.serverUrl, - apiKey: openAI.apiKey, - model: model - ), - translatePrompt: current.translatePrompt, - tldrPrompt: current.tldrPrompt, - preTranslation: current.preTranslation - ) - } - } + presenter.state.setOpenAIModel(value: model) } ) ) { @@ -130,7 +112,7 @@ struct AiConfigScreen: View { Section { Picker( selection: Binding( - get: { translateProviderOption(provider: presenter.state.translateConfig.provider) }, + get: { presenter.state.translateProvider }, set: { provider in presenter.state.selectTranslateProvider(type: provider) } @@ -146,14 +128,9 @@ struct AiConfigScreen: View { Toggle( isOn: Binding( - get: { presenter.state.translateConfig.preTranslate }, + get: { presenter.state.preTranslate }, set: { newValue in - presenter.state.updateTranslateConfig { current in - current.doCopy( - preTranslate: newValue, - provider: current.provider - ) - } + presenter.state.setPreTranslate(value: newValue) } ) ) { @@ -162,16 +139,16 @@ struct AiConfigScreen: View { } .transition(.opacity.combined(with: .move(edge: .top))) - if translateProviderOption(provider: presenter.state.translateConfig.provider) == .ai { + if presenter.state.translateProvider == .ai { Button { beginEditing( field: .translatePrompt, - value: String(presenter.state.aiConfig.translatePrompt) + value: presenter.state.translatePrompt ) } label: { VStack(alignment: .leading, spacing: 2) { Text("Translate Prompt") - Text(displayText(String(presenter.state.aiConfig.translatePrompt))) + Text(displayText(presenter.state.translatePrompt)) .foregroundStyle(.secondary) .font(.subheadline) } @@ -182,18 +159,9 @@ struct AiConfigScreen: View { Toggle( isOn: Binding( - get: { presenter.state.aiConfig.tldr }, + get: { presenter.state.aiTldr }, set: { newValue in - presenter.state.update { current in - current.doCopy( - translation: current.translation, - tldr: newValue, - type: current.type, - translatePrompt: current.translatePrompt, - tldrPrompt: current.tldrPrompt, - preTranslation: current.preTranslation, - ) - } + presenter.state.setAITldr(value: newValue) } ) ) { @@ -201,16 +169,16 @@ struct AiConfigScreen: View { Text("Summarize long text with AI") } - if presenter.state.aiConfig.tldr { + if presenter.state.aiTldr { Button { beginEditing( field: .tldrPrompt, - value: String(presenter.state.aiConfig.tldrPrompt) + value: presenter.state.tldrPrompt ) } label: { VStack(alignment: .leading, spacing: 2) { Text("Summary Prompt") - Text(displayText(String(presenter.state.aiConfig.tldrPrompt))) + Text(displayText(presenter.state.tldrPrompt)) .foregroundStyle(.secondary) .font(.subheadline) } @@ -220,9 +188,9 @@ struct AiConfigScreen: View { } } } - .animation(.easeInOut(duration: 0.2), value: isOpenAIType(presenter.state.aiConfig.type)) - .animation(.easeInOut(duration: 0.2), value: presenter.state.translateConfig.preTranslate) - .animation(.easeInOut(duration: 0.2), value: presenter.state.aiConfig.tldr) + .animation(.easeInOut(duration: 0.2), value: presenter.state.aiType == .openAi) + .animation(.easeInOut(duration: 0.2), value: presenter.state.preTranslate) + .animation(.easeInOut(duration: 0.2), value: presenter.state.aiTldr) .sheet(item: $editingField) { field in NavigationStack { Form { @@ -280,46 +248,6 @@ struct AiConfigScreen: View { .navigationTitle("ai_config_title") } - private func isOpenAIType(_ type: any AppSettingsAiConfigType) -> Bool { - switch onEnum(of: type) { - case .onDevice: - return false - case .openAI: - return true - } - } - - private func openAIValue(_ type: any AppSettingsAiConfigType) -> (serverUrl: String, apiKey: String, model: String) { - switch onEnum(of: type) { - case .onDevice: - return ("", "", "") - case .openAI(let openAI): - return (String(openAI.serverUrl), String(openAI.apiKey), String(openAI.model)) - } - } - - private func aiTypeTitle(type: any AppSettingsAiConfigType) -> LocalizedStringResource { - aiTypeOptionTitle(option: aiTypeOption(type: type)) - } - - private func aiTypeOption(type: any AppSettingsAiConfigType) -> AiTypeOption { - switch onEnum(of: type) { - case .onDevice: - return .onDevice - case .openAI: - return .openAi - } - } - - private func translateProviderOption(provider: any AppSettingsTranslateConfigProvider) -> TranslateProviderOption { - switch onEnum(of: provider) { - case .ai: - return .ai - case .google: - return .google - } - } - private func aiTypeOptionTitle(option: AiTypeOption) -> LocalizedStringResource { switch option { case .onDevice: @@ -397,55 +325,15 @@ struct AiConfigScreen: View { } private func applyEdit(field: EditableField, value: String) { - presenter.state.update { current in - switch field { - case .serverUrl: - switch onEnum(of: current.type) { - case .onDevice: - return current - case .openAI(let openAI): - return current.doCopy( - translation: current.translation, - tldr: current.tldr, - type: openAI.doCopy(serverUrl: value, apiKey: openAI.apiKey, model: openAI.model), - translatePrompt: current.translatePrompt, - tldrPrompt: current.tldrPrompt, - preTranslation: current.preTranslation - ) - } - case .apiKey: - switch onEnum(of: current.type) { - case .onDevice: - return current - case .openAI(let openAI): - return current.doCopy( - translation: current.translation, - tldr: current.tldr, - type: openAI.doCopy(serverUrl: openAI.serverUrl, apiKey: value, model: openAI.model), - translatePrompt: current.translatePrompt, - tldrPrompt: current.tldrPrompt, - preTranslation: current.preTranslation - ) - } - case .translatePrompt: - return current.doCopy( - translation: current.translation, - tldr: current.tldr, - type: current.type, - translatePrompt: value, - tldrPrompt: current.tldrPrompt, - preTranslation: current.preTranslation - ) - case .tldrPrompt: - return current.doCopy( - translation: current.translation, - tldr: current.tldr, - type: current.type, - translatePrompt: current.translatePrompt, - tldrPrompt: value, - preTranslation: current.preTranslation - ) - } + switch field { + case .serverUrl: + presenter.state.setOpenAIServerUrl(value: value) + case .apiKey: + presenter.state.setOpenAIApiKey(value: value) + case .translatePrompt: + presenter.state.setTranslatePrompt(value: value) + case .tldrPrompt: + presenter.state.setTldrPrompt(value: value) } } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt index 6897f3d3f..af86dbdff 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt @@ -49,20 +49,41 @@ public class AiConfigPresenter : @Immutable public interface State { - public val aiConfig: AppSettings.AiConfig - public val translateConfig: AppSettings.TranslateConfig + public val aiType: AiTypeOption + public val openAIServerUrl: String + public val openAIApiKey: String + public val openAIModel: String + public val translateProvider: TranslateProviderOption public val openAIModels: UiState> public val supportedTypes: ImmutableList public val supportedTranslateProviders: ImmutableList public val serverSuggestions: ImmutableList - - public fun update(block: AppSettings.AiConfig.() -> AppSettings.AiConfig) - - public fun updateTranslateConfig(block: AppSettings.TranslateConfig.() -> AppSettings.TranslateConfig) + public val aiTldr: Boolean + public val translatePrompt: String + public val tldrPrompt: String + public val preTranslate: Boolean public fun selectType(type: AiTypeOption) public fun selectTranslateProvider(type: TranslateProviderOption) + + public fun setAIType(value: AiTypeOption) + + public fun setTranslateProvider(value: TranslateProviderOption) + + public fun setOpenAIServerUrl(value: String) + + public fun setOpenAIApiKey(value: String) + + public fun setOpenAIModel(value: String) + + public fun setAITldr(value: Boolean) + + public fun setTranslatePrompt(value: String) + + public fun setTldrPrompt(value: String) + + public fun setPreTranslate(value: Boolean) } @OptIn(FlowPreview::class) @@ -71,8 +92,6 @@ public class AiConfigPresenter : val scope = rememberCoroutineScope() val appSettings by remember { appDataStore.appSettingsStore.data } .collectAsState(AppSettings(version = "")) - val aiConfig = appSettings.aiConfig - val translateConfig = appSettings.translateConfig var openAIModels by remember { mutableStateOf>>(UiState.Success(persistentListOf())) } @@ -98,7 +117,7 @@ public class AiConfigPresenter : } LaunchedEffect(Unit) { - snapshotFlow { aiConfig.type as? AppSettings.AiConfig.Type.OpenAI } + snapshotFlow { appSettings.aiConfig.type as? AppSettings.AiConfig.Type.OpenAI } .map { (it?.serverUrl ?: "") to (it?.apiKey ?: "") } .distinctUntilChanged() .debounce(666L) @@ -122,41 +141,165 @@ public class AiConfigPresenter : } } } + + fun update(block: AppSettings.AiConfig.() -> AppSettings.AiConfig) { + scope.launch { + withContext(Dispatchers.Main) { + appDataStore.appSettingsStore.updateData { current -> + current.copy( + aiConfig = + block + .invoke(current.aiConfig) + .normalized(), + ) + } + } + } + } + + fun updateTranslateConfig(block: AppSettings.TranslateConfig.() -> AppSettings.TranslateConfig) { + scope.launch { + withContext(Dispatchers.Main) { + appDataStore.appSettingsStore.updateData { current -> + current.copy( + translateConfig = + block + .invoke(current.translateConfig) + .normalized(), + ) + } + } + } + } + return object : State { - override val aiConfig: AppSettings.AiConfig = aiConfig - override val translateConfig: AppSettings.TranslateConfig = translateConfig override val openAIModels: UiState> = openAIModels override val supportedTypes: ImmutableList = supportedTypes - override val supportedTranslateProviders: ImmutableList = supportedTranslateProviders + override val supportedTranslateProviders: ImmutableList = + supportedTranslateProviders override val serverSuggestions: ImmutableList = SERVER_SUGGESTIONS + override val aiType: AiTypeOption = + when (appSettings.aiConfig.type) { + AppSettings.AiConfig.Type.OnDevice -> AiTypeOption.OnDevice + is AppSettings.AiConfig.Type.OpenAI -> AiTypeOption.OpenAI + } + override val translateProvider: TranslateProviderOption = + when (appSettings.translateConfig.provider) { + AppSettings.TranslateConfig.Provider.AI -> TranslateProviderOption.AI + AppSettings.TranslateConfig.Provider.Google -> TranslateProviderOption.Google + } - override fun update(block: AppSettings.AiConfig.() -> AppSettings.AiConfig) { - scope.launch { - withContext(Dispatchers.Main) { - appDataStore.appSettingsStore.updateData { current -> - current.copy( - aiConfig = - block - .invoke(current.aiConfig) - .normalized(), - ) - } - } + override val openAIServerUrl: String = + (appSettings.aiConfig.type as? AppSettings.AiConfig.Type.OpenAI)?.serverUrl ?: "" + + override val openAIApiKey: String = + (appSettings.aiConfig.type as? AppSettings.AiConfig.Type.OpenAI)?.apiKey ?: "" + + override val openAIModel: String = + (appSettings.aiConfig.type as? AppSettings.AiConfig.Type.OpenAI)?.model ?: "" + + override val aiTldr: Boolean = appSettings.aiConfig.tldr + override val translatePrompt: String = appSettings.aiConfig.translatePrompt + override val tldrPrompt: String = appSettings.aiConfig.tldrPrompt + override val preTranslate: Boolean = appSettings.translateConfig.preTranslate + + override fun setAITldr(value: Boolean) { + update { + copy( + tldr = value, + ) } } - override fun updateTranslateConfig(block: AppSettings.TranslateConfig.() -> AppSettings.TranslateConfig) { - scope.launch { - withContext(Dispatchers.Main) { - appDataStore.appSettingsStore.updateData { current -> - current.copy( - translateConfig = - block - .invoke(current.translateConfig) - .normalized(), - ) - } - } + override fun setTranslatePrompt(value: String) { + update { + copy( + translatePrompt = value, + ) + } + } + + override fun setTldrPrompt(value: String) { + update { + copy( + tldrPrompt = value, + ) + } + } + + override fun setPreTranslate(value: Boolean) { + updateTranslateConfig { + copy( + preTranslate = value, + ) + } + } + + override fun setAIType(value: AiTypeOption) { + update { + copy( + type = + when (value) { + AiTypeOption.OnDevice -> AppSettings.AiConfig.Type.OnDevice + AiTypeOption.OpenAI -> + AppSettings.AiConfig.Type.OpenAI( + serverUrl = "", + apiKey = "", + model = "", + ) + }, + ) + } + } + + override fun setOpenAIServerUrl(value: String) { + update { + copy( + type = + AppSettings.AiConfig.Type.OpenAI( + serverUrl = value, + apiKey = openAIApiKey, + model = openAIModel, + ), + ) + } + } + + override fun setOpenAIApiKey(value: String) { + update { + copy( + type = + AppSettings.AiConfig.Type.OpenAI( + serverUrl = openAIServerUrl, + apiKey = value, + model = openAIModel, + ), + ) + } + } + + override fun setOpenAIModel(value: String) { + update { + copy( + type = + AppSettings.AiConfig.Type.OpenAI( + serverUrl = openAIServerUrl, + apiKey = openAIApiKey, + model = value, + ), + ) + } + } + + override fun setTranslateProvider(value: TranslateProviderOption) { + updateTranslateConfig { + copy( + provider = + when (value) { + TranslateProviderOption.AI -> AppSettings.TranslateConfig.Provider.AI + TranslateProviderOption.Google -> AppSettings.TranslateConfig.Provider.Google + }, + ) } } From 66ce35eb899955a4dd339381d66cda38ff6174c3 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Mon, 30 Mar 2026 13:18:25 +0900 Subject: [PATCH 14/14] fix test --- .../microblog/MixedRemoteMediatorTest.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt index 11a620eed..1035e1c9b 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt @@ -391,7 +391,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { val appDataStore = AppDataStore(pathProducer) appDataStore.appSettingsStore.updateData { it.copy( - language = "zh-CN", + language = Locale.language, translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( @@ -493,7 +493,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { val appDataStore = AppDataStore(pathProducer) appDataStore.appSettingsStore.updateData { it.copy( - language = "zh-CN", + language = Locale.language, translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( @@ -569,7 +569,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { val appDataStore = AppDataStore(pathProducer) appDataStore.appSettingsStore.updateData { it.copy( - language = "zh-CN", + language = Locale.language, translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( @@ -652,7 +652,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { val appDataStore = AppDataStore(pathProducer) appDataStore.appSettingsStore.updateData { it.copy( - language = "zh-CN", + language = Locale.language, translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( @@ -731,7 +731,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { val appDataStore = AppDataStore(pathProducer) appDataStore.appSettingsStore.updateData { it.copy( - language = "zh-CN", + language = Locale.language, translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( @@ -832,7 +832,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { val appDataStore = AppDataStore(pathProducer) appDataStore.appSettingsStore.updateData { it.copy( - language = "zh-CN", + language = Locale.language, translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig( @@ -871,7 +871,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { val appDataStore = AppDataStore(pathProducer) appDataStore.appSettingsStore.updateData { it.copy( - language = "zh-CN", + language = Locale.language, translateConfig = aiPreTranslateConfig(), aiConfig = AppSettings.AiConfig(