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..6ac277e53 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 = @@ -86,7 +86,7 @@ fun FlareApp(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/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..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 @@ -46,6 +45,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,18 +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 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() @@ -129,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 }, @@ -167,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 -> @@ -179,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) }, ), ) @@ -205,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, @@ -213,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 -> @@ -223,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) }, ), ) @@ -247,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, @@ -255,7 +234,7 @@ internal fun AiConfigScreen(onBack: () -> Unit) { }, ) } - AnimatedVisibility(visible = openAIType != null) { + AnimatedVisibility(visible = state.aiType == AiTypeOption.OpenAI) { SegmentedListItem( checked = state.showModelDropdown, onCheckedChange = { checked -> @@ -280,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) }, ) @@ -317,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) }, ) } @@ -336,35 +308,107 @@ 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 = stringResource(id = R.string.settings_ai_config_translate_provider)) + }, + supportingContent = { + Text( + text = stringResource(id = R.string.settings_ai_config_translate_provider_description), + style = MaterialTheme.typography.bodySmall, + ) + }, + trailingContent = { + Box { + TextButton( + onClick = { + state.setShowProviderDropdown(true) + }, + ) { + Text( + text = + 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, + ) + }, + ) } - }, - ) - }, - ) - 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 -> + stringResource( + id = R.string.settings_ai_config_translate_provider_ai, + ) + TranslateProviderOption.Google -> + stringResource( + id = R.string.settings_ai_config_translate_provider_google, + ) + }, + ) + }, + onClick = { + state.setShowProviderDropdown(false) + state.selectTranslateProvider(provider) + }, + ) + } + } + } + }, + ) + } + AnimatedVisibility(visible = true) { + SegmentedListItem( + onClick = { + state.setPreTranslate(!state.preTranslate) + }, + 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.preTranslate, + onCheckedChange = { + state.setPreTranslate(it) + }, + ) + }, + ) + } + AnimatedVisibility( + visible = + state.translateProvider == TranslateProviderOption.AI, + ) { SegmentedListItem( checked = state.textEditDialog?.field == AiConfigEditField.TranslatePrompt, onCheckedChange = { checked -> @@ -374,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) }, ), ) @@ -393,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, @@ -403,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() @@ -425,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 -> @@ -444,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) }, ), ) @@ -463,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, @@ -497,25 +533,15 @@ 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 openAIModels = businessState.openAIModels - val supportedTypes = businessState.supportedTypes - 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 selectType(type: AiTypeOption) { - businessState.selectType(type) - } - fun setShowTypeDropdown(value: Boolean) { showTypeDropdown = value } @@ -524,6 +550,10 @@ private fun presenter() = showModelDropdown = value } + fun setShowProviderDropdown(value: Boolean) { + showProviderDropdown = value + } + fun setTextEditDialog(value: TextEditDialogState?) { textEditDialog = 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 c961c7984..22a191f8c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -98,8 +98,14 @@ 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 + Translation Provider + Choose which service handles translation + AI + Google Translate Language Change the language of the app RSS Management 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 b974fa9cc..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, @@ -309,6 +320,7 @@ public fun CommonStatusComponent( if (isDetail && !item.content.isEmpty && appearanceSettings.showTranslateButton) { TranslationComponent( + item = item, statusKey = item.itemKey, contentWarning = item.contentWarning, rawContent = item.content.innerText, @@ -625,8 +637,45 @@ 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, statusKey: String, contentWarning: UiRichText?, rawContent: String, @@ -700,6 +749,7 @@ private fun TranslationComponent( "translate_${contentWarning}_${rawContent}_${Locale.current.language}_${componentAppearance.aiConfig.translation}", ) { statusTranslatePresenter( + item = item, contentWarning = contentWarning, content = content, targetLanguage = Locale.current.language, @@ -813,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 -> @@ -834,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, @@ -931,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/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/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/composeResources/values-zh-rCN/strings.xml b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml index db933c475..ec895bfad 100644 --- a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml +++ b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml @@ -171,8 +171,14 @@ 点击编辑 启用 AI 翻译 将 Google 翻译替换为 AI 翻译,可能需要更长的时间 + 启用预翻译 + 在后台翻译并缓存新加载的时间线和个人资料内容。这会消耗大量 token。 启用 AI 摘要 对长篇文章启用 AI 摘要,仅在超过 500 个字符的帖子中可用 + 翻译服务提供商 + 选择处理翻译的服务提供商 + AI + 谷歌翻译 通用 在首页时间轴顶部显示撰写框 在首页时间轴顶部显示撰写框 diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index cce3dba88..b11a3ac6d 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -191,8 +191,14 @@ 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 + 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 e53c5b928..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 @@ -86,14 +85,15 @@ 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 import dev.dimension.flare.settings_ai_config_model_description 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 @@ -101,7 +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_translation_description +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 @@ -190,6 +193,7 @@ import dev.dimension.flare.ui.presenter.settings.AiConfigPresenter import dev.dimension.flare.ui.presenter.settings.AiTypeOption 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 @@ -1204,11 +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 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)) @@ -1224,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 }, @@ -1259,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) }, ) @@ -1279,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) }, ), ) @@ -1305,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) }, ) @@ -1317,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) }, ), ) @@ -1347,7 +1330,7 @@ internal fun SettingsScreen( }, ) { Text( - openAITypeForDisplay.model.ifBlank { + state.aiConfigState.openAIModel.ifBlank { stringResource(Res.string.settings_ai_config_model_select) }, ) @@ -1380,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) }, ) @@ -1402,31 +1376,93 @@ 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(stringResource(Res.string.settings_ai_config_translate_provider)) + }, + caption = { + Text(stringResource(Res.string.settings_ai_config_translate_provider_description)) + }, + trailing = { + DropDownButton( + onClick = { + state.aiConfigState.setShowProviderDropdown(!state.aiConfigState.showProviderDropdown) + }, + ) { + Text( + 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, + ) + }, + ) + } + 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 -> + stringResource( + Res.string.settings_ai_config_translate_provider_ai, + ) + TranslateProviderOption.Google -> + stringResource( + Res.string.settings_ai_config_translate_provider_google, + ) + }, + ) + }, + onClick = { + state.aiConfigState.selectTranslateProvider(provider) + state.aiConfigState.setShowProviderDropdown(false) + }, + ) + } + } }, - textBefore = true, ) - }, - ) - ExpanderItemSeparator() - AnimatedVisibility(state.aiConfigState.aiConfig.translation) { + ExpanderItemSeparator() + 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.preTranslate, + { + state.aiConfigState.setPreTranslate(it) + }, + textBefore = true, + ) + }, + ) + ExpanderItemSeparator() + } + } + 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) }, ) @@ -1438,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) }, ), ) @@ -1464,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) }, ) @@ -1491,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) }, ), ) @@ -1852,29 +1884,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 openAIModels = state.openAIModels - val supportedTypes = state.supportedTypes - val serverSuggestions = state.serverSuggestions + object : AiConfigPresenter.State by state { val expanded = expanded val showTypeDropdown = showTypeDropdown val showModelDropdown = showModelDropdown + val showProviderDropdown = showProviderDropdown val textEditDialog = textEditDialog fun setExpanded(value: Boolean) { expanded = value } - fun update(block: AppSettings.AiConfig.() -> AppSettings.AiConfig) { - state.update(block) - } - - fun selectType(type: AiTypeOption) { - state.selectType(type) - } - fun setShowTypeDropdown(value: Boolean) { showTypeDropdown = value } @@ -1883,6 +1905,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/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..cabf7f488 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" : { @@ -2152,6 +2152,10 @@ } } }, + "AI" : { + "comment" : "The name of the AI translation service.", + "isCommentAutoGenerated" : true + }, "AI Type" : { "localizations" : { "ar" : { @@ -2220,13 +2224,13 @@ "value" : "AI Type" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "AI Type" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "AI Type" @@ -2386,16 +2390,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 +2551,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 +2613,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 +2762,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 +2953,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 +3143,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 +3333,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" : { @@ -3372,6 +3420,7 @@ } }, "ai_config_translate" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -3475,16 +3524,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 +3714,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 +3904,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 +4064,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 +4230,16 @@ "value" : "목록" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Klantenlijst" + "value" : "Liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste" + "value" : "Klantenlijst" } }, "pl" : { @@ -4335,16 +4384,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 +4508,13 @@ "value" : "ALT" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "ALT" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "ALT" @@ -4625,13 +4674,13 @@ "value" : "안테나" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" @@ -4815,13 +4864,13 @@ "value" : "안테나" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" @@ -4969,16 +5018,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 +5178,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 +5362,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 +5516,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 +5652,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 +5824,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 +6014,16 @@ "value" : "둥글게" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ronde" + "value" : "Rund" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rund" + "value" : "Ronde" } }, "pl" : { @@ -6155,16 +6204,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 +6394,16 @@ "value" : "정사각형" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vierkant" + "value" : "Firkant" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Firkant" + "value" : "Vierkant" } }, "pl" : { @@ -6529,16 +6578,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 +6732,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 +6898,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 +7088,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 +7272,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 +7426,13 @@ "value" : "Font Size" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Font Size" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Font Size" @@ -7513,16 +7562,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 +7698,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 +7834,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 +7964,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 +8094,13 @@ "value" : "Hidden" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Hidden" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Hidden" @@ -8175,16 +8224,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 +8354,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 +8484,16 @@ "value" : "ストレッチ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Uitrekken" + "value" : "Strekk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Strekk" + "value" : "Uitrekken" } }, "pl" : { @@ -8601,16 +8650,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 +8804,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 +8970,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 +9124,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 +9290,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 +9444,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 +9574,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 +9704,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 +9870,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 +10054,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 +10244,16 @@ "value" : "테마" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Thema" + "value" : "Tema" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tema" + "value" : "Thema" } }, "pl" : { @@ -10385,16 +10434,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 +10624,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 +10814,16 @@ "value" : "밝은 색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Licht" + "value" : "Lys" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lys" + "value" : "Licht" } }, "pl" : { @@ -10949,16 +10998,16 @@ "value" : "시스템" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Systeem" + "value" : "Systemadministrasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Systemadministrasjon" + "value" : "Systeem" } }, "pl" : { @@ -11139,16 +11188,16 @@ "value" : "모양" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Uiterlijk" + "value" : "Utseende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utseende" + "value" : "Uiterlijk" } }, "pl" : { @@ -11293,16 +11342,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 +11472,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 +11638,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 +11828,16 @@ "value" : "항상" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "altijd" + "value" : "Alltid" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alltid" + "value" : "altijd" } }, "pl" : { @@ -11933,16 +11982,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 +12148,16 @@ "value" : "절대" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Nooit" + "value" : "Aldri" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aldri" + "value" : "Nooit" } }, "pl" : { @@ -12289,16 +12338,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 +12528,16 @@ "value" : "차단" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Blokkeren" + "value" : "Blokker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blokker" + "value" : "Blokkeren" } }, "pl" : { @@ -12633,16 +12682,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 +12812,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 +13108,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 +13299,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 +13490,16 @@ "value" : "고정됨" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vastgezet" + "value" : "Festet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Festet" + "value" : "Vastgezet" } }, "pl" : { @@ -13632,16 +13681,16 @@ "value" : "좋아요를 눌렀습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "favoriet" + "value" : "favorisert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "favorisert" + "value" : "favoriet" } }, "pl" : { @@ -13823,16 +13872,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 +14063,16 @@ "value" : "인용했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "geciteerd" + "value" : "sitert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "sitert" + "value" : "geciteerd" } }, "pl" : { @@ -14205,16 +14254,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 +14439,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 +14623,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 +14808,16 @@ "value" : "알 수 없음" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Onbekend" + "value" : "Ukjent" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ukjent" + "value" : "Onbekend" } }, "pl" : { @@ -14949,16 +14998,16 @@ "value" : "신고하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -15133,16 +15182,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 +15366,16 @@ "value" : "오해의 소지가 있는" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Misleidend" + "value" : "Villende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Villende" + "value" : "Misleidend" } }, "pl" : { @@ -15501,16 +15550,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 +15734,16 @@ "value" : "기타" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "anders" + "value" : "Annet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Annet" + "value" : "anders" } }, "pl" : { @@ -15869,16 +15918,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 +16102,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 +16286,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 +16470,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 +16654,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 +16844,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 +17028,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 +17212,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 +17396,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 +17586,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 +17776,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 +17966,16 @@ "value" : "취소" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "annuleren" + "value" : "Avbryt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avbryt" + "value" : "annuleren" } }, "pl" : { @@ -18071,16 +18120,16 @@ "value" : "チャンネル" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Kanaal" + "value" : "Kanal" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kanal" + "value" : "Kanaal" } }, "pl" : { @@ -18201,16 +18250,16 @@ "value" : "チャンネル" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Kanalen" + "value" : "Kanaler" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kanaler" + "value" : "Kanalen" } }, "pl" : { @@ -18268,6 +18317,9 @@ } } } + }, + "Choose which service handles translation" : { + }, "Close" : { "localizations" : { @@ -18373,16 +18425,16 @@ "value" : "닫기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afsluiten" + "value" : "Lukk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lukk" + "value" : "Afsluiten" } }, "pl" : { @@ -18563,16 +18615,16 @@ "value" : "댓글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opmerking" + "value" : "Kommentar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentar" + "value" : "Opmerking" } }, "pl" : { @@ -18723,13 +18775,13 @@ "value" : "Cnacel" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Cnacel" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Cnacel" @@ -18889,16 +18941,16 @@ "value" : "보내기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verzenden" + "value" : "Sende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sende" + "value" : "Verzenden" } }, "pl" : { @@ -19079,16 +19131,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 +19321,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 +19511,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 +19665,16 @@ "value" : "Option" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Optie" + "value" : "Alternativ" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alternativ" + "value" : "Optie" } }, "pl" : { @@ -19749,16 +19801,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 +19931,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 +20097,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 +20287,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 +20477,16 @@ "value" : "작성하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Samenstellen" + "value" : "Skriv" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv" + "value" : "Samenstellen" } }, "pl" : { @@ -20615,16 +20667,16 @@ "value" : "인용하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Offerte" + "value" : "Sitat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sitat" + "value" : "Offerte" } }, "pl" : { @@ -20805,16 +20857,16 @@ "value" : "답글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beantwoorden" + "value" : "Svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Svar" + "value" : "Beantwoorden" } }, "pl" : { @@ -20995,16 +21047,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 +21237,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 +21391,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 +21557,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -21695,16 +21747,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -21885,16 +21937,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -22039,16 +22091,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 +22257,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 +22441,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 +22595,16 @@ "value" : "説明" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beschrijving" + "value" : "Beskrivelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Beskrivelse" + "value" : "Beschrijving" } }, "pl" : { @@ -22679,16 +22731,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 +22867,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 +23039,13 @@ "value" : "트렌딩 해시태그" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Trending Hashtags" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Trending Hashtags" @@ -23178,16 +23230,16 @@ "value" : "발견하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontdek" + "value" : "Oppdag" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdag" + "value" : "Ontdek" } }, "pl" : { @@ -23368,16 +23420,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 +23580,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 +23716,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 +23882,16 @@ "value" : "완료" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voltooid" + "value" : "Ferdig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ferdig" + "value" : "Voltooid" } }, "pl" : { @@ -24020,16 +24072,16 @@ "value" : "완료" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voltooid" + "value" : "Ferdig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ferdig" + "value" : "Voltooid" } }, "pl" : { @@ -24168,16 +24220,16 @@ "value" : "ドラフト(下書き)" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Kladversie" + "value" : "Utkast" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utkast" + "value" : "Kladversie" } }, "pl" : { @@ -24292,16 +24344,16 @@ "value" : "Drafts" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Concepten" + "value" : "Utkast" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utkast" + "value" : "Concepten" } }, "pl" : { @@ -24458,16 +24510,16 @@ "value" : "편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bewerken" + "value" : "Rediger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger" + "value" : "Bewerken" } }, "pl" : { @@ -24642,16 +24694,16 @@ "value" : "편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bewerken" + "value" : "Rediger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger" + "value" : "Bewerken" } }, "pl" : { @@ -24796,16 +24848,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 +25014,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 +25198,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 +25388,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 +25542,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 +25678,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 +25814,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 +25944,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 +26110,16 @@ "value" : "오류" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Foutmelding" + "value" : "Feil" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Feil" + "value" : "Foutmelding" } }, "pl" : { @@ -26248,16 +26300,16 @@ "value" : "오류" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Foutmelding" + "value" : "Feil" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Feil" + "value" : "Foutmelding" } }, "pl" : { @@ -26402,16 +26454,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 +26578,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 +26714,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 +26844,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 +26980,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" : { @@ -26990,6 +27042,10 @@ } } }, + "Google Translate" : { + "comment" : "Title of a translate provider option that uses Google Translate.", + "isCommentAutoGenerated" : true + }, "home_tab_bookmarks_title" : { "localizations" : { "af" : { @@ -27094,16 +27150,16 @@ "value" : "북마크" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bladwijzers" + "value" : "Bokmerker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bokmerker" + "value" : "Bladwijzers" } }, "pl" : { @@ -27284,16 +27340,16 @@ "value" : "발견하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontdek" + "value" : "Oppdag" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdag" + "value" : "Ontdek" } }, "pl" : { @@ -27438,16 +27494,16 @@ "value" : "お気に入り" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Favoriete" + "value" : "Favoritt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Favoritt" + "value" : "Favoriete" } }, "pl" : { @@ -27610,16 +27666,16 @@ "value" : "추천" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aanbevolen" + "value" : "Anbefalt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Anbefalt" + "value" : "Aanbevolen" } }, "pl" : { @@ -27800,16 +27856,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 +28046,16 @@ "value" : "홈" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Startpagina" + "value" : "Hjem" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hjem" + "value" : "Startpagina" } }, "pl" : { @@ -28180,16 +28236,16 @@ "value" : "목록" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Klantenlijst" + "value" : "Liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste" + "value" : "Klantenlijst" } }, "pl" : { @@ -28370,16 +28426,16 @@ "value" : "내 정보" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "IK" + "value" : "Meg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Meg" + "value" : "IK" } }, "pl" : { @@ -28524,16 +28580,16 @@ "value" : "Notification" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notificatie" + "value" : "Varsling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Varsling" + "value" : "Notificatie" } }, "pl" : { @@ -28660,16 +28716,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 +28846,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 +28976,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 +29106,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 +29272,16 @@ "value" : "밝은 색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Licht" + "value" : "Lys" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lys" + "value" : "Licht" } }, "pl" : { @@ -29406,16 +29462,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 +29616,16 @@ "value" : "いいね!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Leukgevonden" + "value" : "Likte" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Likte" + "value" : "Leukgevonden" } }, "pl" : { @@ -29726,16 +29782,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 +29936,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 +30102,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 +30292,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 +30482,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 +30672,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 +30826,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 +30992,16 @@ "value" : "구성원" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "leden" + "value" : "Medlemmer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Medlemmer" + "value" : "leden" } }, "pl" : { @@ -31126,16 +31182,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 +31336,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 +31502,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 +31692,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 +31882,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 +32072,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 +32262,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 +32416,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 +32546,16 @@ "value" : "読み込み中..." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Laden..." + "value" : "Laster..." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Laster..." + "value" : "Laden..." } }, "pl" : { @@ -32662,16 +32718,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -32852,16 +32908,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 +33098,16 @@ "value" : "편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bewerken" + "value" : "Rediger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger" + "value" : "Bewerken" } }, "pl" : { @@ -33202,16 +33258,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 +33424,13 @@ "value" : "키워드" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Keyword" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Keyword" @@ -33522,16 +33578,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 +33714,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 +33844,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 +33974,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 +34104,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 +34270,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 +34461,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 +34603,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 +34769,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Statuses" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Statuses" } }, "pl" : { @@ -34903,16 +34959,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 +35149,16 @@ "value" : "사용자" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruikers" + "value" : "Brukere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Brukere" + "value" : "Gebruikers" } }, "pl" : { @@ -35283,16 +35339,16 @@ "value" : "로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aanmelden" + "value" : "Innlogging" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlogging" + "value" : "Aanmelden" } }, "pl" : { @@ -35473,16 +35529,16 @@ "value" : "로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aanmelden" + "value" : "Innlogging" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlogging" + "value" : "Aanmelden" } }, "pl" : { @@ -35627,16 +35683,16 @@ "value" : "ログアウト" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afmelden" + "value" : "Logg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg" + "value" : "Afmelden" } }, "pl" : { @@ -35757,16 +35813,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 +35979,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 +36169,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 +36359,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 +36549,16 @@ "value" : "좋아요를 눌렀습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "favoriet" + "value" : "favorisert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "favorisert" + "value" : "favoriet" } }, "pl" : { @@ -36683,16 +36739,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 +36923,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 +37113,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 +37297,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 +37487,16 @@ "value" : "리블로그했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "gedeeld" + "value" : "reblogget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "reblogget" + "value" : "gedeeld" } }, "pl" : { @@ -37615,16 +37671,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 +37855,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 +38039,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 +38229,16 @@ "value" : "신고하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -38333,16 +38389,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 +38531,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 +38697,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 +38887,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 +39072,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 +39257,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 +39442,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 +39627,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 +39812,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 +39997,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 +40182,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 +40367,13 @@ "value" : "🤯" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "🤯" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "🤯" @@ -40496,16 +40552,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 +40743,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 +40928,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 +41119,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 +41310,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 +41501,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 +41686,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 +41871,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 +42056,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 +42241,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 +42426,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 +42611,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 +42796,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 +42987,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 +43178,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 +43369,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 +43560,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 +43751,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 +43936,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 +44127,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 +44318,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 +44509,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 +44694,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 +44885,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 +45076,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 +45267,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 +45458,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 +45649,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 +45834,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 +46025,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 +46210,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 +46401,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 +46592,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 +46783,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 +46974,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 +47165,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 +47350,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 +47535,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 +47720,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 +47905,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 +48090,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 +48275,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 +48460,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 +48645,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 +48800,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 +48967,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 +49158,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 +49349,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 +49540,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 +49725,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 +49916,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 +50101,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 +50286,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 +50477,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 +50662,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 +50847,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 +51038,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 +51223,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 +51414,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 +51599,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 +51784,13 @@ "value" : "미스키스트 I" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Misskist I" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Misskist I" @@ -51913,16 +51969,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 +52154,13 @@ "value" : "미스키스트 II" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Misskist II" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Misskist II" @@ -52283,16 +52339,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 +52524,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 +52709,13 @@ "value" : "미스키스트 III" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Misskist III" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Misskist III" @@ -52838,16 +52894,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 +53079,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 +53264,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 +53449,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 +53634,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 +53819,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 +54004,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 +54189,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 +54374,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 +54559,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 +54744,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 +54929,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 +55114,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 +55299,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 +55484,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 +55669,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 +55854,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 +56039,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 +56230,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 +56415,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 +56600,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 +56785,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 +56976,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 +57161,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 +57346,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 +57531,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 +57716,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 +57901,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 +58086,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 +58271,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 +58456,13 @@ "value" : "별을 바라보는 사람" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Stargazer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Stargazer" @@ -58585,16 +58641,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 +58826,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 +59011,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 +59202,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 +59387,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 +59578,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 +59769,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 +59960,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 +60145,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 +60336,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 +60527,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 +60718,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 +60903,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 +61094,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 +61279,16 @@ "value" : "슈퍼노트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Supernotitie" + "value" : "Supernota" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Supernota" + "value" : "Supernotitie" } }, "pl" : { @@ -61414,16 +61470,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 +61661,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 +61852,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 +62043,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 +62234,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 +62425,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 +62616,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 +62807,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 +62998,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 +63189,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 +63380,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 +63571,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 +63762,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 +63953,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 +64144,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 +64335,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 +64526,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 +64711,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 +64902,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 +65087,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 +65272,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 +65457,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 +65648,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 +65833,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 +66018,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 +66203,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 +66388,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 +66573,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 +66758,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 +66943,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 +67128,13 @@ "value" : "클릭 클릭 클릭 쨍" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Click Click Click Claaang" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Click Click Click Claaang" @@ -67257,16 +67313,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 +67498,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 +67683,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 +67868,16 @@ "value" : "야행성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Nachtelijk" + "value" : "Nattlig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nattlig" + "value" : "Nachtelijk" } }, "pl" : { @@ -67997,16 +68053,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 +68238,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 +68423,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 +68608,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 +68793,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 +68978,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 +69169,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 +69360,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 +69545,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 +69730,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 +69915,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 +70100,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 +70285,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 +70470,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 +70655,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 +70840,16 @@ "value" : "분석가" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Analist" + "value" : "Analytiker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Analytiker" + "value" : "Analist" } }, "pl" : { @@ -70938,16 +70994,16 @@ "value" : "お気に入り" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Favoriete" + "value" : "Favoritt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Favoritt" + "value" : "Favoriete" } }, "pl" : { @@ -71110,16 +71166,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 +71320,16 @@ "value" : "お気に入り" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Favorieten" + "value" : "Favoritter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Favoritter" + "value" : "Favorieten" } }, "pl" : { @@ -71436,16 +71492,16 @@ "value" : "추천" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aanbevolen" + "value" : "Anbefalt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Anbefalt" + "value" : "Aanbevolen" } }, "pl" : { @@ -71626,16 +71682,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 +71836,16 @@ "value" : "所有しています" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Eigendom" + "value" : "Eid" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Eid" + "value" : "Eigendom" } }, "pl" : { @@ -71916,13 +71972,13 @@ "value" : "Unfavourite" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Unfavourite" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Unfavourite" @@ -72052,16 +72108,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 +72528,13 @@ "value" : "앱" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "App" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "App" @@ -72663,16 +72719,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 +72903,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 +73094,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 +73279,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 +73469,16 @@ "value" : "인용했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "geciteerd" + "value" : "sitert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "sitert" + "value" : "geciteerd" } }, "pl" : { @@ -73603,16 +73659,16 @@ "value" : "다시 게시했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "gepost" + "value" : "repostet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "repostet" + "value" : "gepost" } }, "pl" : { @@ -73788,16 +73844,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 +73999,13 @@ "value" : "renote" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "renote" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "renote" @@ -74103,16 +74159,16 @@ "value" : "답장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beantwoorden" + "value" : "svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "svar" + "value" : "Beantwoorden" } }, "pl" : { @@ -74258,16 +74314,16 @@ "value" : "Unknwn" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontbrekend" + "value" : "Uknuste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Uknuste" + "value" : "Ontbrekend" } }, "pl" : { @@ -74418,16 +74474,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 +74628,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 +74794,16 @@ "value" : "신고하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -74928,16 +74984,16 @@ "value" : "혼합" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gemengd" + "value" : "Blandet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blandet" + "value" : "Gemengd" } }, "pl" : { @@ -75082,16 +75138,16 @@ "value" : "モデル" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Model" + "value" : "Modell" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Modell" + "value" : "Model" } }, "pl" : { @@ -75248,16 +75304,16 @@ "value" : "더보기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meer" + "value" : "Mer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mer" + "value" : "Meer" } }, "pl" : { @@ -75439,16 +75495,16 @@ "value" : "더보기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meer" + "value" : "Mer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mer" + "value" : "Meer" } }, "pl" : { @@ -75629,16 +75685,16 @@ "value" : "음소거" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Dempen" + "value" : "Demp" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Demp" + "value" : "Dempen" } }, "pl" : { @@ -75783,16 +75839,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 +75969,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 +76217,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 +76335,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 +76465,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 +76589,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 +76713,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 +76831,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 +76973,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 +77109,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 +77275,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 +77435,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 +77571,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 +77737,16 @@ "value" : "모두" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Allemaal" + "value" : "Alle" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alle" + "value" : "Allemaal" } }, "pl" : { @@ -77871,16 +77927,16 @@ "value" : "댓글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opmerking" + "value" : "Kommentar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentar" + "value" : "Opmerking" } }, "pl" : { @@ -78061,16 +78117,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 +78271,16 @@ "value" : "メンション" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vermelding" + "value" : "Nevn" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nevn" + "value" : "Vermelding" } }, "pl" : { @@ -78345,16 +78401,16 @@ "value" : "Notification" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notificatie" + "value" : "Varsling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Varsling" + "value" : "Notificatie" } }, "pl" : { @@ -78517,13 +78573,13 @@ "value" : "확인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Ok" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Ok" @@ -78701,13 +78757,13 @@ "value" : "확인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Ok" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Ok" @@ -78849,16 +78905,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 +79035,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 +79159,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 +79289,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 +79419,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 +79555,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 +79691,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 +79821,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 +79981,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 +80135,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 +80301,16 @@ "value" : "투표" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Stemming" + "value" : "Stem" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Stem" + "value" : "Stemming" } }, "pl" : { @@ -80435,16 +80491,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Berichten" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Berichten" } }, "pl" : { @@ -80625,16 +80681,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 +80871,16 @@ "value" : "미디어" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Medium" + "value" : "Medier" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Medier" + "value" : "Medium" } }, "pl" : { @@ -81005,16 +81061,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 +81251,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Berichten" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Berichten" } }, "pl" : { @@ -81385,16 +81441,16 @@ "value" : "인용하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Offerte" + "value" : "Sitat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sitat" + "value" : "Offerte" } }, "pl" : { @@ -81575,16 +81631,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 +81821,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 +82005,16 @@ "value" : "거부" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afwijzen" + "value" : "Avvis" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avvis" + "value" : "Afwijzen" } }, "pl" : { @@ -82139,16 +82195,16 @@ "value" : "차단됨" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geblokkeerd" + "value" : "Blokkert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blokkert" + "value" : "Geblokkeerd" } }, "pl" : { @@ -82329,16 +82385,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 +82575,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 +82765,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 +82955,16 @@ "value" : "요청됨" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aangevraagd" + "value" : "Forespurt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Forespurt" + "value" : "Aangevraagd" } }, "pl" : { @@ -83089,16 +83145,16 @@ "value" : "답장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beantwoorden" + "value" : "Svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Svar" + "value" : "Beantwoorden" } }, "pl" : { @@ -83273,16 +83329,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 +83519,16 @@ "value" : "보고서" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -83647,16 +83703,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 +83788,9 @@ } } } + }, + "Retry translation" : { + }, "retweet" : { "localizations" : { @@ -83837,16 +83896,16 @@ "value" : "리트윗" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Retweet" + "value" : "Gjenkjenn" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gjenkjenn" + "value" : "Retweet" } }, "pl" : { @@ -84021,16 +84080,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 +84270,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 +84424,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 +84566,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 +84708,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 +84850,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 +84992,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 +85122,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 +85288,13 @@ "value" : "앱" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "App" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "App" @@ -85383,16 +85442,16 @@ "value" : "ブラウザー" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "browser" + "value" : "Nettleser" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nettleser" + "value" : "browser" } }, "pl" : { @@ -85531,16 +85590,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 +85732,13 @@ "value" : "RSS" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "RSS" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "RSS" @@ -85803,13 +85862,13 @@ "value" : "RSS URL" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "RSS URL" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "RSS URL" @@ -85939,16 +85998,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 +86122,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 +86246,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 +86376,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 +86506,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 +86636,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 +86808,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 +86998,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Statuses" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Statuses" } }, "pl" : { @@ -87129,16 +87188,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 +87378,16 @@ "value" : "사용자" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruikers" + "value" : "Brukere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Brukere" + "value" : "Gebruikers" } }, "pl" : { @@ -87473,16 +87532,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 +87662,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 +87786,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 +87952,16 @@ "value" : "보내기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verzenden" + "value" : "Sende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sende" + "value" : "Verzenden" } }, "pl" : { @@ -88083,16 +88142,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 +88303,16 @@ "value" : "表示" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Weergeven" + "value" : "Vis" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis" + "value" : "Weergeven" } }, "pl" : { @@ -88410,16 +88469,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 +88617,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 +88748,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 +88878,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 +89014,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 +89144,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 +89274,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 +89404,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 +89570,16 @@ "value" : "설정" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Instellingen" + "value" : "Innstillinger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innstillinger" + "value" : "Instellingen" } }, "pl" : { @@ -89701,16 +89760,16 @@ "value" : "공유" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Delen" + "value" : "Del" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del" + "value" : "Delen" } }, "pl" : { @@ -89855,16 +89914,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 +90050,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 +90186,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 +90322,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 +90383,9 @@ } } } + }, + "Show original" : { + }, "show_media_button" : { "comment" : "Button to show media attachments", @@ -90430,16 +90492,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 +90664,16 @@ "value" : "소셜" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Sociaal" + "value" : "Sosial" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sosial" + "value" : "Sociaal" } }, "pl" : { @@ -90744,16 +90806,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 +90972,16 @@ "value" : "공유" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Delen" + "value" : "Del" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del" + "value" : "Delen" } }, "pl" : { @@ -91070,16 +91132,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 +91268,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 +91434,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 +91588,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 +91754,16 @@ "value" : "홈" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Startpagina" + "value" : "Hjem" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hjem" + "value" : "Startpagina" } }, "pl" : { @@ -91846,16 +91908,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 +92074,16 @@ "value" : "공개" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Openbaar" + "value" : "Offentlig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Offentlig" + "value" : "Openbaar" } }, "pl" : { @@ -92166,16 +92228,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 +92394,16 @@ "value" : "명시된 사람만" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opgegeven" + "value" : "Spesifisert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Spesifisert" + "value" : "Opgegeven" } }, "pl" : { @@ -92486,16 +92548,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 +92678,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 +92808,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 +92974,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 +93128,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 +93294,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 +93484,16 @@ "value" : "저장소" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opslagruimte" + "value" : "Lagring" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lagring" + "value" : "Opslagruimte" } }, "pl" : { @@ -93606,16 +93668,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 +93816,16 @@ "value" : "候補" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Suggesties" + "value" : "Forslag" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Forslag" + "value" : "Suggesties" } }, "pl" : { @@ -93884,16 +93946,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 +94076,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 +94206,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 +94366,16 @@ "value" : "시스템" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Systeem" + "value" : "Systemadministrasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Systemadministrasjon" + "value" : "Systeem" } }, "pl" : { @@ -94458,16 +94520,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 +94650,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 +94780,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 +94910,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 +95076,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -95204,16 +95266,16 @@ "value" : "편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bewerken" + "value" : "Rediger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger" + "value" : "Bewerken" } }, "pl" : { @@ -95358,16 +95420,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 +95550,13 @@ "value" : "Tab icon" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Tab icon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Tab icon" @@ -95624,16 +95686,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 +95822,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 +95952,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 +96118,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 +96272,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 +96402,16 @@ "value" : "グループ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Groeperen" + "value" : "Gruppe" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gruppe" + "value" : "Groeperen" } }, "pl" : { @@ -96470,16 +96532,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 +96662,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 +96799,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 +96965,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 +97113,13 @@ "value" : "Tabs" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Tabs" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Tabs" @@ -97217,16 +97279,16 @@ "value" : "테마" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Thema" + "value" : "Tema" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tema" + "value" : "Thema" } }, "pl" : { @@ -97302,6 +97364,9 @@ } } } + }, + "Translate" : { + }, "Translate Prompt" : { "localizations" : { @@ -97371,16 +97436,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" : { @@ -97434,6 +97499,7 @@ } }, "Translate text with AI" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -97501,16 +97567,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" : { @@ -97562,6 +97628,9 @@ } } } + }, + "Translation Provider" : { + }, "unblock" : { "localizations" : { @@ -97667,16 +97736,16 @@ "value" : "차단 해제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Deblokkeer" + "value" : "Avblokker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avblokker" + "value" : "Deblokkeer" } }, "pl" : { @@ -97857,16 +97926,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 +98110,16 @@ "value" : "음소거 해제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Deblokkeer" + "value" : "Udemp" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Udemp" + "value" : "Deblokkeer" } }, "pl" : { @@ -98231,16 +98300,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 +98490,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 +98639,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 +98806,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 +98996,16 @@ "value" : "댓글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opmerkingen" + "value" : "Kommentarer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentarer" + "value" : "Opmerkingen" } }, "pl" : { @@ -99081,16 +99150,16 @@ "value" : "リポスト" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Herposten" + "value" : "Tilbakestilling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tilbakestilling" + "value" : "Herposten" } }, "pl" : { @@ -99247,16 +99316,16 @@ "value" : "상태" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "status" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "status" } }, "pl" : { @@ -99438,16 +99507,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 +99692,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..73c643f6b 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 "Retry translation" + case .translate: return "Translate" + case .showOriginal: return "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..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,33 +70,16 @@ 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: { String(selectedModel) }, + get: { selectedModel }, set: { model in 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 - ) - } - } + presenter.state.setOpenAIModel(value: model) } ) ) { @@ -127,36 +110,45 @@ struct AiConfigScreen: View { } Section { + Picker( + selection: Binding( + get: { presenter.state.translateProvider }, + 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.preTranslate }, set: { newValue in - presenter.state.update { current in - current.doCopy( - translation: newValue, - tldr: current.tldr, - type: current.type, - translatePrompt: current.translatePrompt, - tldrPrompt: current.tldrPrompt - ) - } + presenter.state.setPreTranslate(value: newValue) } ) ) { - 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 { + 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) } @@ -167,17 +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 - ) - } + presenter.state.setAITldr(value: newValue) } ) ) { @@ -185,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) } @@ -204,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.aiConfig.translation) - .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 { @@ -264,37 +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 aiTypeOptionTitle(option: AiTypeOption) -> LocalizedStringResource { switch option { case .onDevice: @@ -304,6 +257,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 } @@ -361,51 +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 - ) - } - 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 - ) - } - case .translatePrompt: - return current.doCopy( - translation: current.translation, - tldr: current.tldr, - type: current.type, - translatePrompt: value, - tldrPrompt: current.tldrPrompt - ) - case .tldrPrompt: - return current.doCopy( - translation: current.translation, - tldr: current.tldr, - type: current.type, - translatePrompt: current.translatePrompt, - tldrPrompt: value - ) - } + 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/androidMain/kotlin/dev/dimension/flare/common/Locale.android.kt b/shared/src/androidMain/kotlin/dev/dimension/flare/common/Locale.android.kt index 007ff7bca..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 @@ -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().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 ff47c3c2f..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,8 +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 = NSLocale.currentLocale.languageCode + actual val language: String + get() = NSLocale.currentLocale.localeIdentifier.replace('_', '-') } 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..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 = 29 +internal const val CACHE_DATABASE_VERSION = 32 @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/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 new file mode 100644 index 000000000..330e39c4c --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDao.kt @@ -0,0 +1,124 @@ +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.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 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, " + + "displayMode = :displayMode, " + + "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, + 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", + ) + 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..ad163849b --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbTranslation.kt @@ -0,0 +1,107 @@ +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.AccountType +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, + val displayMode: TranslationDisplayMode = TranslationDisplayMode.Auto, + @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 enum class TranslationDisplayMode { + Auto, + Original, + Translated, +} + +@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 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()) + + @TypeConverter + fun toPayload(value: String?): TranslationPayload? = value?.decodeJson(TranslationPayload.serializer()) +} + +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) + +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..9fead652c --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/TranslationDisplay.kt @@ -0,0 +1,244 @@ +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 translationEnabled: Boolean, + val autoDisplayEnabled: Boolean, + val providerCacheKey: String, +) + +internal fun UiTimelineV2.applyTranslation( + options: TranslationDisplayOptions, + translations: List, +): UiTimelineV2 { + if (!options.translationEnabled) { + return this + } + val payload = translationPayload() ?: return this + val translation = + translations.firstOrNull { + it.targetLanguage == Locale.language && + it.sourceHash == payload.sourceHash(options.providerCacheKey) + } + + return when (this) { + is UiTimelineV2.Feed -> + copy( + 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 -> + 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 + is UiTimelineV2.UserList -> this + } +} + +internal fun UiProfile.applyTranslation( + options: TranslationDisplayOptions, + translation: DbTranslation?, +): UiProfile { + if (!options.autoDisplayEnabled) { + return this + } + val payload = translationPayload() + val matchedTranslation = + translation?.takeIf { + it.targetLanguage == Locale.language && + it.sourceHash == payload.sourceHash(options.providerCacheKey) + } + val displayState = matchedTranslation.toDisplayState() + return copy( + description = matchedTranslation.takeIf { it?.status == TranslationStatus.Completed }?.payload?.description ?: description, + translationDisplayState = displayState, + ) +} + +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(providerCacheKey: String): String = + buildString { + append(providerCacheKey) + append('\u0000') + append(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 -> + 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/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 c02d3de16..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 @@ -8,7 +8,10 @@ import dev.dimension.flare.data.database.cache.model.DbPagingTimeline import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus 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 @@ -27,6 +30,12 @@ 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 { + TranslationSettingsSupport.displayOptionsFlow(appDataStore) + } fun post(postKey: MicroBlogKey): Cacheable { val pagingKey = "post_only_$postKey" @@ -40,33 +49,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/handler/UserHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt index 9c586cb2c..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 @@ -1,13 +1,24 @@ 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.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 +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapNotNull import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -17,23 +28,32 @@ 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 { + TranslationSettingsSupport.displayOptionsFlow(appDataStore) + } 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 +61,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.autoDisplayEnabled) { + flowOf(user?.content) + } else { + combine( + flowOf(user), + database + .translationDao() + .find( + entityType = TranslationEntityType.Profile, + entityKey = user.translationEntityKey(), + targetLanguage = Locale.language, + ), + ) { 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..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 @@ -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, ): 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..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 @@ -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 @@ -14,7 +16,9 @@ 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( database = database, ), @@ -107,6 +111,14 @@ 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 }, + 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 0047de9e2..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 @@ -4,11 +4,16 @@ 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" + + "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" + + "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/data/datastore/model/AppSettings.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt index c6d1a344d..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,15 +17,45 @@ 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 { + // for iOS + public val default: AiConfig = AiConfig() + } + @Serializable public sealed interface Type { @Serializable 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/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..5661cffd3 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationModels.kt @@ -0,0 +1,54 @@ +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, + val providerCacheKey: String, +) + +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 new file mode 100644 index 000000000..8dfc0d37e --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/PreTranslationService.kt @@ -0,0 +1,557 @@ +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.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.network.ai.AiCompletionService +import dev.dimension.flare.data.repository.tryRun +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 kotlin.time.Clock + +internal interface PreTranslationService { + fun enqueueStatuses( + statuses: List, + allowLongText: Boolean = false, + ) + + 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 { + override fun enqueueStatuses( + statuses: List, + allowLongText: Boolean, + ) = 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 OnlinePreTranslationService( + private val database: CacheDatabase, + private val appDataStore: AppDataStore, + private val aiCompletionService: AiCompletionService, + private val coroutineScope: CoroutineScope, +) : PreTranslationService { + private val semaphore = Semaphore(permits = 1) + + init { + coroutineScope.launch { + cleanupStaleInFlightTranslations() + } + } + + override fun enqueueStatuses( + statuses: List, + allowLongText: Boolean, + ) { + val snapshot = statuses.distinctBy { it.id } + if (snapshot.isEmpty()) { + return + } + coroutineScope.launch { + enqueuePreparedCandidates(requirePreTranslation = true) { settings -> + prepareStatusCandidates( + statuses = snapshot, + targetLanguage = settings.targetLanguage, + providerCacheKey = settings.providerCacheKey, + allowLongText = allowLongText, + ) + } + } + } + + override fun enqueueProfile(user: DbUser) { + coroutineScope.launch { + enqueuePreparedCandidates(requirePreTranslation = true) { settings -> + listOfNotNull( + prepareProfileCandidate( + user = user, + targetLanguage = settings.targetLanguage, + providerCacheKey = settings.providerCacheKey, + ), + ) + } + } + } + + override fun retryStatus( + accountType: dev.dimension.flare.model.AccountType, + statusKey: dev.dimension.flare.model.MicroBlogKey, + ) { + coroutineScope.launch { + val settings = activeTranslationSettings(requirePreTranslation = false) ?: return@launch + setStatusDisplayMode( + accountType = accountType, + statusKey = statusKey, + mode = TranslationDisplayMode.Translated, + ) + val candidates = + prepareRetryCandidates( + accountType = accountType, + statusKey = statusKey, + targetLanguage = settings.targetLanguage, + providerCacheKey = settings.providerCacheKey, + ) + processPreparedCandidates( + settings = settings, + candidates = candidates, + ) + } + } + + override fun setStatusDisplayMode( + accountType: dev.dimension.flare.model.AccountType, + statusKey: dev.dimension.flare.model.MicroBlogKey, + mode: TranslationDisplayMode, + ) { + coroutineScope.launch { + 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 canTranslate = + if (requirePreTranslation) { + appSettings.translateConfig.preTranslate + } else { + true + } + if (!canTranslate || targetLanguage.isBlank()) { + return null + } + return ActivePreTranslationSettings( + targetLanguage = targetLanguage, + appSettings = appSettings, + providerCacheKey = appSettings.translationProviderCacheKey(), + ) + } + + private fun currentTargetLanguage(): String = Locale.language + + private suspend fun cleanupStaleInFlightTranslations() { + val updatedAt = Clock.System.now().toEpochMilliseconds() + database.translationDao().markStaleInFlightAsFailed( + 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, + providerCacheKey: 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, + providerCacheKey = providerCacheKey, + allowLongText = true, + preferredDisplayMode = TranslationDisplayMode.Translated, + ) + } + + private suspend fun prepareStatusCandidates( + statuses: List, + targetLanguage: String, + providerCacheKey: String, + allowLongText: Boolean, + preferredDisplayMode: TranslationDisplayMode? = null, + ): 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 -> + val entityKey = status.translationEntityKey() + prepareCandidate( + entityType = TranslationEntityType.Status, + entityKey = entityKey, + payload = status.content.translationPayload(), + sourceLanguages = PreTranslationContentRules.sourceLanguages(status.content), + existing = existingByKey[entityKey], + targetLanguage = targetLanguage, + providerCacheKey = providerCacheKey, + now = now, + allowLongText = allowLongText, + preferredDisplayMode = preferredDisplayMode, + )?.let(::add) + } + } + } + + private suspend fun prepareProfileCandidate( + user: DbUser, + targetLanguage: String, + providerCacheKey: String, + ): PreparedTranslationCandidate? = + prepareCandidate( + entityType = TranslationEntityType.Profile, + entityKey = user.translationEntityKey(), + payload = user.content.translationPayload(), + sourceLanguages = user.content.sourceLanguages, + existing = + database + .translationDao() + .get( + entityType = TranslationEntityType.Profile, + entityKey = user.translationEntityKey(), + targetLanguage = targetLanguage, + ), + targetLanguage = targetLanguage, + providerCacheKey = providerCacheKey, + now = Clock.System.now().toEpochMilliseconds(), + allowLongText = true, + ) + + private suspend fun prepareCandidate( + entityType: TranslationEntityType, + entityKey: String, + payload: TranslationPayload?, + sourceLanguages: List, + existing: DbTranslation?, + targetLanguage: String, + providerCacheKey: String, + now: Long, + allowLongText: Boolean, + preferredDisplayMode: TranslationDisplayMode? = null, + ): PreparedTranslationCandidate? { + if (payload == null) { + return null + } + if (!allowLongText && payload.content?.isLongText == true) { + return null + } + val sourceHash = payload.sourceHash(providerCacheKey) + val displayMode = preferredDisplayMode ?: existing?.displayMode ?: TranslationDisplayMode.Auto + 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 (!PreTranslationStoreSupport.shouldTranslate(existing = existing, sourceHash = sourceHash)) { + return null + } + return PreparedTranslationCandidate( + entityType = entityType, + entityKey = entityKey, + targetLanguage = targetLanguage, + sourceHash = sourceHash, + 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 -> + PreTranslationStoreSupport.toDbTranslation( + candidate = candidate, + status = TranslationStatus.Pending, + updatedAt = updatedAt, + ) + }, + ) + } + + private suspend fun translatePreparedCandidates( + settings: ActivePreTranslationSettings, + candidates: List, + ) { + PreTranslationStoreSupport + .chunkCandidatesForBatching(candidates) + .forEach { batch -> + translateBatch( + settings = settings, + candidates = batch, + ) + } + } + + private suspend fun translateBatch( + settings: ActivePreTranslationSettings, + candidates: List, + ) { + if (candidates.isEmpty()) { + return + } + val startedAt = Clock.System.now().toEpochMilliseconds() + database.translationDao().insertAll( + candidates.map { candidate -> + PreTranslationStoreSupport.toDbTranslation( + candidate = candidate, + status = TranslationStatus.Translating, + updatedAt = startedAt, + ) + }, + ) + + runBatchTranslationWithRetry( + settings = settings, + candidates = candidates, + ).getOrElse { throwable -> + val failedAt = Clock.System.now().toEpochMilliseconds() + database.translationDao().insertAll( + candidates.map { candidate -> + PreTranslationStoreSupport.toDbTranslation( + candidate = candidate, + status = TranslationStatus.Failed, + updatedAt = failedAt, + statusReason = throwable.message, + ) + }, + ) + } + } + + private suspend fun runBatchTranslationWithRetry( + settings: ActivePreTranslationSettings, + candidates: List, + ): Result { + var lastFailure: Throwable? = null + repeat(PreTranslationStoreSupport.PRE_TRANSLATION_BATCH_MAX_ATTEMPTS) { attempt -> + val result = + tryRun { + val sourceDocument = + PreTranslationBatchDocument( + 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 translatedJson = + TranslationProvider.translateBatchDocumentJson( + settings = settings.appSettings, + aiCompletionService = aiCompletionService, + sourceJson = sourceJson, + sourceDocument = sourceDocument, + targetLanguage = settings.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 < PreTranslationStoreSupport.PRE_TRANSLATION_BATCH_MAX_ATTEMPTS - 1) { + delay(PreTranslationStoreSupport.PRE_TRANSLATION_BATCH_RETRY_DELAY) + } + } + } + return Result.failure(requireNotNull(lastFailure)) + } + + private suspend fun applyBatchResult( + translatedJson: String, + candidates: List, + ) { + val translatedDocument = + TranslationResponseSanitizer + .clean(translatedJson) + .decodeJson(PreTranslationBatchDocument.serializer()) + val translatedItems = translatedDocument.items.associateBy { it.entityKey } + val updatedAt = Clock.System.now().toEpochMilliseconds() + database.translationDao().insertAll( + candidates.map { candidate -> + tryRun { + val translatedItem = + translatedItems[candidate.entityKey] + ?: error("Missing translated item for ${candidate.entityKey}") + translatedDbTranslation( + candidate = candidate, + translatedItem = translatedItem, + updatedAt = updatedAt, + ) + }.getOrElse { throwable -> + PreTranslationStoreSupport.toDbTranslation( + candidate = candidate, + status = TranslationStatus.Failed, + updatedAt = updatedAt, + statusReason = throwable.message, + ) + } + }, + ) + } + + 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, + ) + } + } + + PreTranslationBatchItemStatus.Skipped -> + PreTranslationStoreSupport.toDbTranslation( + candidate = candidate, + status = TranslationStatus.Skipped, + updatedAt = updatedAt, + statusReason = + translatedItem.reason ?: PreTranslationStoreSupport.SKIPPED_AI_SAME_LANGUAGE_REASON, + ) + } +} 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 new file mode 100644 index 000000000..fb47cc454 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationProvider.kt @@ -0,0 +1,291 @@ +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.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 + +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, + ) + } + + 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, + ) + } +} + +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 = + 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 = + 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, + 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)}'"), + ) + } + }, + ) + }, + ) + + fun applyTranslations( + document: PreTranslationBatchDocument, + targetLanguage: String, + translatedTexts: Map, + ): PreTranslationBatchDocument = + document.copy( + targetLanguage = targetLanguage, + items = + document.items.map { item -> + item.copy( + payload = + item.payload?.let { payload -> + applyTranslations( + payload = payload, + targetLanguage = targetLanguage, + translatedTexts = translatedTexts, + ) + }, + ) + }, + ) + + 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 { 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) }, + ) +} + +internal object GoogleWebTranslationWhitespaceSupport { + fun trimBoundaryWhitespace(text: String): String = text.trim { it.isWhitespace() } + + fun preserveSourceBoundaryWhitespace( + sourceText: String, + translatedText: String, + ): String { + 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/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 new file mode 100644 index 000000000..0db4ba718 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/translation/TranslationSupport.kt @@ -0,0 +1,54 @@ +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, + providerCacheKey = settings.translationProviderCacheKey(), + ) +} + +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 b1fa117c3..1020f0407 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.OnlinePreTranslationService +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 { OnlinePreTranslationService(get(), get(), get(), get()) } } 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 398a7c83b..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 @@ -23,6 +24,9 @@ public data class UiProfile internal constructor( private val clickEvent: ClickEvent, 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?, @@ -60,6 +64,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..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 @@ -125,6 +125,9 @@ public sealed class UiTimelineV2 { val title: String?, 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, @@ -190,6 +193,9 @@ public sealed class UiTimelineV2 { val sensitive: Boolean, val contentWarning: UiRichText?, val user: UiProfile?, + internal val sourceLanguages: SerializableImmutableList = persistentListOf(), + @Transient + public val translationDisplayState: TranslationDisplayState = TranslationDisplayState.Hidden, @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/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/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/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 6e5ff206e..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 @@ -19,6 +19,8 @@ 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 import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader @@ -26,8 +28,11 @@ 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.data.translation.TranslationSettingsSupport import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.presenter.PresenterBase import kotlinx.coroutines.CoroutineScope @@ -48,32 +53,54 @@ 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() - private val filterFlow by lazy { + private val filterFlow: Flow> by lazy { localFilterRepository.getFlow(forTimeline = true) } + private val translationSettingsFlow: Flow by lazy { + TranslationSettingsSupport.displayOptionsFlow(appDataStore) + } + + internal open fun allowLongTextTranslationDisplay(loader: RemoteLoader): Boolean = false + @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 -> { cachePager( - loader = it, - ).cachedIn(scope) + 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 -> @@ -84,7 +111,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 { @@ -96,31 +125,29 @@ public abstract class TimelinePresenter : emitAll(PagingData.emptyFlow(isError = true)) } - @OptIn(ExperimentalCoroutinesApi::class) - private fun cachePager(loader: CacheableRemoteLoader): Flow> = - Pager( - config = pagingConfig, - remoteMediator = - TimelineRemoteMediator( - loader = loader, - database = database, - 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, loader.pagingKey, useDbKeyInItemKey) - } - } + private fun cachePager(loader: CacheableRemoteLoader): Flow> = + 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, + ) + }, + ).flow } protected open suspend fun transform(data: UiTimelineV2): UiTimelineV2 = data 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..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 @@ -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 @@ -36,6 +35,11 @@ public enum class AiTypeOption { OpenAI, } +public enum class TranslateProviderOption { + AI, + Google, +} + public class AiConfigPresenter : PresenterBase(), KoinComponent { @@ -45,28 +49,62 @@ public class AiConfigPresenter : @Immutable public interface State { - public val aiConfig: AppSettings.AiConfig + 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 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) @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 = "")) 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 = @@ -79,32 +117,7 @@ public class AiConfigPresenter : } 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 } + snapshotFlow { appSettings.aiConfig.type as? AppSettings.AiConfig.Type.OpenAI } .map { (it?.serverUrl ?: "") to (it?.apiKey ?: "") } .distinctUntilChanged() .debounce(666L) @@ -128,19 +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 openAIModels: UiState> = openAIModels override val supportedTypes: ImmutableList = supportedTypes + 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)) - } - } + 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 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 + }, + ) } } @@ -163,10 +322,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 = this + +private fun AppSettings.TranslateConfig.normalized(): AppSettings.TranslateConfig = this + private val SERVER_SUGGESTIONS = persistentListOf( "https://api.openai.com/v1/", 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 08065dc31..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 @@ -3,69 +3,80 @@ 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.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 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.datastore.model.AppSettings -import dev.dimension.flare.data.network.ai.OpenAIService -import dev.dimension.flare.data.network.ktorClient +import dev.dimension.flare.data.network.ai.AiCompletionService +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 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 -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 public class TranslatePresenter( private val source: UiRichText, private val targetLanguage: String = Locale.language, + private val cacheTarget: TranslateCacheTarget? = null, ) : 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 database: CacheDatabase by inject() private val sourceText: String by lazy { source.toTranslatableText() } + private val sourceJson: String by lazy { source.toTranslationJson(targetLanguage) } @Composable override fun body(): UiState { return produceState>(initialValue = UiState.Loading()) { value = - runCatching { - val aiConfig = + tryRun { + val settings = appDataStore.appSettingsStore.data .first() - .aiConfig - if (!aiConfig.translation) { - return@runCatching toUiRichText(legacyGoogleTranslate()) + val providerCacheKey = settings.translationProviderCacheKey() + cachedTranslation(providerCacheKey)?.let { + return@tryRun it } - val promptTemplate = - aiConfig.translatePrompt.ifBlank { - AiPromptDefaults.TRANSLATE_PROMPT - } - val prompt = buildTranslatePrompt(promptTemplate, targetLanguage, source) - 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) + val prompt = + TranslationPromptFormatter.buildTranslatePrompt( + settings = settings, + targetLanguage = targetLanguage, + sourceText = sourceText, + sourceJson = sourceJson, + ) + val translatedContent = + TranslationProvider.translateDocumentJson( + settings = settings, + aiCompletionService = aiCompletionService, + sourceText = sourceText, + sourceJson = sourceJson, + targetLanguage = targetLanguage, + prompt = prompt, + ) + if (translatedContent != null) { + val translated = toUiRichText(translatedContent) + cacheTranslation(translated, providerCacheKey) + return@tryRun translated + } + error("Translation returned empty response") }.fold( onSuccess = { UiState.Success(it) }, onFailure = { UiState.Error(it) }, @@ -73,49 +84,121 @@ 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 toUiRichText(translatedContent: String): UiRichText = + TranslationResponseSanitizer + .clean(translatedContent) + .let { cleaned -> + tryRun { + source.applyTranslationJson(cleaned) + }.getOrElse { + cleaned.toUiPlainText() } } + + private suspend fun cachedTranslation(providerCacheKey: String): 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(providerCacheKey)) { + return null + } + return when (translation.status) { + TranslationStatus.Completed -> target.readField(translation.payload) + TranslationStatus.Skipped -> source + TranslationStatus.Pending, + TranslationStatus.Translating, + TranslationStatus.Failed, + -> null } } - private fun buildTranslatePrompt( - template: String, - targetLanguage: String, - source: UiRichText, - ): String = - template - .replace("{target_language}", targetLanguage) - .replace("{source_text}", sourceText) - .replace("{source_html}", sourceText) + private suspend fun cacheTranslation( + translated: UiRichText, + providerCacheKey: String, + ) { + 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(providerCacheKey) }?.payload, + translated = translated, + ) + database.translationDao().insert( + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = target.entityKey(), + targetLanguage = targetLanguage, + sourceHash = sourcePayload.sourceHash(providerCacheKey), + status = TranslationStatus.Completed, + displayMode = TranslationDisplayMode.Translated, + payload = mergedPayload, + statusReason = null, + attemptCount = existing?.attemptCount ?: 0, + updatedAt = Clock.System.now().toEpochMilliseconds(), + ), + ) + } +} - private fun toUiRichText(translatedContent: String): UiRichText = - translatedContent - .removePrefix("```html") - .removePrefix("```text") - .removePrefix("```") - .removeSuffix("```") - .trim() - .toUiPlainText() +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/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/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..bd46c7bc1 --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/dao/TranslationDaoTest.kt @@ -0,0 +1,317 @@ +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.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.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, + displayMode = TranslationDisplayMode.Translated, + 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(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 { + 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..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 @@ -6,16 +6,30 @@ 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 +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 +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.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 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 @@ -36,12 +50,19 @@ 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 @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() { @@ -68,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 { @@ -89,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) @@ -145,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, @@ -177,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")) @@ -197,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, @@ -210,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) @@ -230,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 = @@ -292,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, @@ -322,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, @@ -331,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, @@ -365,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, @@ -377,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) @@ -390,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, @@ -424,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, @@ -455,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, @@ -474,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) @@ -482,11 +548,107 @@ 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 = Locale.language, + sourceHash = + rootPost + .translationPayload()!! + .sourceHash(googleTranslationProviderCacheKey), + status = TranslationStatus.Completed, + payload = TranslationPayload(content = "根帖子".toUiPlainText()), + updatedAt = 1L, + ), + DbTranslation( + entityType = TranslationEntityType.Status, + entityKey = savedParentStatus.translationEntityKey(), + targetLanguage = Locale.language, + sourceHash = + parentPost + .translationPayload()!! + .sourceHash(googleTranslationProviderCacheKey), + 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(), + ) + val post = assertIs(ui) + + assertEquals("根帖子", post.content.raw) + assertEquals(1, post.parents.size) + assertEquals( + "父帖子", + post.parents + .first() + .content.raw, + ) + } + @Test 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, @@ -510,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) @@ -522,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, @@ -543,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 { @@ -557,6 +724,7 @@ class MicroblogTest : RobolectricTest() { dbItem, pagingKey = "home", useDbKeyInItemKey = false, + translationDisplayOptions(), ), ) assertEquals("Embedded User", rendered.user?.name?.raw) @@ -566,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, @@ -602,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) @@ -613,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) @@ -675,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) @@ -717,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( @@ -766,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 { @@ -779,6 +966,7 @@ class MicroblogTest : RobolectricTest() { dbItem, pagingKey = "home", useDbKeyInItemKey = false, + translationDisplayOptions(), ), ) val internalRepost = assertNotNull(rendered.internalRepost) @@ -800,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( @@ -861,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 = @@ -891,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) @@ -912,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( @@ -960,6 +1169,504 @@ 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 = Locale.language, + sourceHash = + post + .translationPayload()!! + .sourceHash(googleTranslationProviderCacheKey), + 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(), + ) + val detailUi = + TimelinePagingMapper.toUi( + item = dbItem, + pagingKey = "post_only_${post.statusKey}", + useDbKeyInItemKey = false, + 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 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 = Locale.language, + sourceHash = + post + .translationPayload()!! + .sourceHash(googleTranslationProviderCacheKey), + 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(), + ), + ) + + 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 = Locale.language, + sourceHash = + post + .translationPayload()!! + .sourceHash(googleTranslationProviderCacheKey), + 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(), + ), + ) + + 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 = 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(), + ), + ) + + 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 = Locale.language, + sourceHash = + post + .translationPayload()!! + .sourceHash(googleTranslationProviderCacheKey), + 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(), + ), + ) + + 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 = 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(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, @@ -977,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 a58cf03e8..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 @@ -8,13 +8,30 @@ 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 +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 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.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 import dev.dimension.flare.model.MicroBlogKey @@ -24,13 +41,26 @@ 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.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +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 import org.koin.core.context.stopKoin import org.koin.dsl.module @@ -47,6 +77,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 +111,7 @@ class MixedRemoteMediatorTest : RobolectricTest() { fun tearDown() { db.close() stopKoin() + deleteTestRootPath(root) } @Test @@ -214,7 +256,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( @@ -298,7 +340,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, @@ -330,12 +372,575 @@ class MixedRemoteMediatorTest : RobolectricTest() { item = page.data.single(), pagingKey = mediator.pagingKey, useDbKeyInItemKey = false, + translationDisplayOptions = + TranslationDisplayOptions( + translationEnabled = false, + autoDisplayEnabled = false, + providerCacheKey = "", + ), ), ) assertEquals(postC.statusKey, post.statusKey) 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 = Locale.language, + translateConfig = aiPreTranslateConfig(), + aiConfig = + AppSettings.AiConfig( + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + val preTranslationService: PreTranslationService = + OnlinePreTranslationService( + 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, + 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 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 = Locale.language, + ).filterNotNull() + .first { it.status == TranslationStatus.Completed } + val parentTranslation = + db + .translationDao() + .find( + entityType = TranslationEntityType.Status, + entityKey = savedParent.id, + targetLanguage = Locale.language, + ).filterNotNull() + .first { it.status == TranslationStatus.Completed } + + assertEquals("root source (${Locale.language})", rootTranslation.payload?.content?.raw) + assertEquals("parent source (${Locale.language})", parentTranslation.payload?.content?.raw) + } + + @OptIn(ExperimentalPagingApi::class) + @Test + fun homeTimelineSkipsPreTranslationForLongTextPosts() = + runTest { + val appDataStore = AppDataStore(pathProducer) + appDataStore.appSettingsStore.updateData { + it.copy( + language = Locale.language, + translateConfig = aiPreTranslateConfig(), + aiConfig = + AppSettings.AiConfig( + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + val preTranslationService: PreTranslationService = + OnlinePreTranslationService( + 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 = Locale.language, + ) + assertNull(translation) + } + + @OptIn(ExperimentalPagingApi::class) + @Test + fun homeTimelineSkipsAiTranslationWhenSourceLanguageMatchesTargetLanguage() = + runTest { + val appDataStore = AppDataStore(pathProducer) + appDataStore.appSettingsStore.updateData { + it.copy( + language = Locale.language, + translateConfig = aiPreTranslateConfig(), + aiConfig = + AppSettings.AiConfig( + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + val preTranslationService: PreTranslationService = + OnlinePreTranslationService( + 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(Locale.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 = + 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) + } + + @OptIn(ExperimentalPagingApi::class) + @Test + fun homeTimelineAcceptsAiSkippedTranslationResult() = + runTest { + val appDataStore = AppDataStore(pathProducer) + appDataStore.appSettingsStore.updateData { + it.copy( + language = Locale.language, + translateConfig = aiPreTranslateConfig(), + aiConfig = + AppSettings.AiConfig( + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + val preTranslationService: PreTranslationService = + OnlinePreTranslationService( + 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 = Locale.language, + ).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 = Locale.language, + translateConfig = aiPreTranslateConfig(), + aiConfig = + AppSettings.AiConfig( + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + val preTranslationService: PreTranslationService = + OnlinePreTranslationService( + 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 = Locale.language, + ).filterNotNull() + .first() + assertEquals(TranslationStatus.Skipped, translation.status) + 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 = Locale.language, + sourceHash = "hash-stale", + status = TranslationStatus.Translating, + updatedAt = 1L, + ), + ) + + val appDataStore = AppDataStore(pathProducer) + appDataStore.appSettingsStore.updateData { + it.copy( + language = Locale.language, + translateConfig = aiPreTranslateConfig(), + aiConfig = + AppSettings.AiConfig( + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + + OnlinePreTranslationService( + 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 = Locale.language, + ) + 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 = Locale.language, + translateConfig = aiPreTranslateConfig(), + aiConfig = + AppSettings.AiConfig( + type = AppSettings.AiConfig.Type.OnDevice, + ), + ) + } + val started = CompletableDeferred() + val release = CompletableDeferred() + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + val preTranslationService: PreTranslationService = + OnlinePreTranslationService( + database = db, + appDataStore = appDataStore, + aiCompletionService = AiCompletionService(OpenAIService(), BlockingOnDeviceAI(started, release)), + coroutineScope = scope, + ) + 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() + } + + 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() + } + } + } + @Test fun refreshDeduplicatesSamePostReturnedByMultipleSubTimelines() = runTest { @@ -528,3 +1133,143 @@ 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( + 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 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 + + 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( + 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/PostHandlerTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandlerTest.kt index 58ff2c91b..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 @@ -4,14 +4,30 @@ 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 +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.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 import dev.dimension.flare.model.MicroBlogKey @@ -20,6 +36,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 +48,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 +67,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 +96,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 { OnlinePreTranslationService(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 +160,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 +188,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 +230,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 +268,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 +307,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 +353,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 +385,54 @@ class PostHandlerTest : RobolectricTest() { assertTrue(pagingExists) } + @Test + fun postRefreshPreTranslatesLongTextWhenOpenedInDetail() = + runTest { + startTestKoin(this@runTest) + appDataStore.appSettingsStore.updateData { + it.copy( + language = "zh-CN", + translateConfig = aiPreTranslateConfig(), + aiConfig = + AppSettings.AiConfig( + 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 = Locale.language, + ).filterNotNull() + .first { it.status == TranslationStatus.Completed } + assertEquals("$longText (${Locale.language})", translation.payload?.content?.raw) + + val translated = + cacheable.data + .filterIsInstance>() + .first { (it.data as? UiTimelineV2.Post)?.content?.raw == "$longText (${Locale.language})" } + .data as UiTimelineV2.Post + assertEquals("$longText (${Locale.language})", translated.content.raw) + } + private fun createPost( statusKey: MicroBlogKey, parents: PersistentList = persistentListOf(), + text: String = "post content", ): UiTimelineV2.Post = UiTimelineV2.Post( message = null, @@ -400,7 +442,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 +480,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 ceeefab81..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 @@ -5,10 +5,34 @@ 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 +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.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.data.translation.cacheKey +import dev.dimension.flare.deleteTestRootPath import dev.dimension.flare.memoryDatabaseBuilder import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType @@ -16,15 +40,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,11 +66,27 @@ 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") + private val aiTranslationProviderCacheKey = + AppSettings.TranslateConfig.Provider.AI + .cacheKey() @BeforeTest fun setup() { @@ -51,13 +96,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 { OnlinePreTranslationService(get(), get(), get(), get()) } single { TestFormatter() } }, ) @@ -70,6 +123,7 @@ class UserHandlerTest : RobolectricTest() { fun tearDown() { db.close() stopKoin() + deleteTestRootPath(root) } @Test @@ -180,6 +234,186 @@ 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", + translateConfig = aiPreTranslateConfig(), + aiConfig = + AppSettings.AiConfig(), + ) + } + db.translationDao().insert( + DbTranslation( + entityType = TranslationEntityType.Profile, + entityKey = profile.translationEntityKey(), + targetLanguage = Locale.language, + sourceHash = profile.translationPayload().sourceHash(aiTranslationProviderCacheKey), + 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", + translateConfig = aiPreTranslateConfig(), + aiConfig = + AppSettings.AiConfig( + 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 = Locale.language, + ).filterNotNull() + .first { it.status == TranslationStatus.Completed } + + assertEquals("Original profile bio (${Locale.language})", 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", + translateConfig = aiPreTranslateConfig(), + aiConfig = + AppSettings.AiConfig( + 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 = Locale.language, + ).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 = Locale.language, + ).filterNotNull() + .first { it.status == TranslationStatus.Completed } + assertEquals(2, completed.attemptCount) + 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", + translateConfig = aiPreTranslateConfig(), + aiConfig = + AppSettings.AiConfig( + 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, @@ -224,3 +458,69 @@ 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 + + override suspend fun translate( + source: String, + targetLanguage: String, + prompt: String, + ): String? { + translationCallCount++ + if (remainingTranslationFailures > 0) { + remainingTranslationFailures-- + error("translation failed") + } + if (failTranslation) { + error("translation failed") + } + val document = source.decodeJson(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(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 + } + }, + ) + }, + ) 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 "), + ) + } +} 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, + ) 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"}]}]}""", + ) + } + } +} 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..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 @@ -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().toLanguageTag() } 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) + } +}