From 2117e22d48ef6aa9fbe09abfc6072d53cd6b77b9 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Tue, 8 Sep 2026 01:45:35 +0200 Subject: [PATCH] refactor: make the 40 interpolated UI strings format strings, and assert the argument order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4, third step, of docs/material-design-conformance.md. `Text("Add chapter to ${uiState.artifact.name}")` becomes a resource holding `Add chapter to %1$s` and a call passing the expression. 49 call sites. Literals in composables go 76 -> 39; `stringResource` goes 374 -> 424. **A silent bug in the previous commit's extractor, found by this one.** Imports were tested with `statement in source`, and the generated accessors are named after their strings -- so `import mantra.composeapp.generated.resources.translate` is a *prefix* of `...resources.translate_into_which_dialect`. The substring test decided the import was already there, and the compiler reported "Unresolved reference 'translate'" in a file whose imports looked complete. Both extractors now match whole lines, and the helper carries the explanation. **Four filters, each earned by something the dry run got wrong.** *A template that is only interpolation has nothing to translate.* `Text("$name")` would have become a resource holding `%1$s` -- longer, slower, and no more localisable than the code it replaced. *A leading or trailing space means it is being glued to a neighbour.* " \\u00b7 %1$s" is a separator. The test has to be on the format string rather than on the literal halves: a template opening with an interpolation leaves the first part empty and the second starting with the separating space, which makes "%1$s Key packages" look like a fragment when it is a whole label. *`\\uXXXX` and `\\"` are Kotlin syntax, not XML.* Left alone they would have shipped as the six visible characters of the escape. They are decoded into the resource, which is UTF-8 and can hold `·` directly. `\\n` is **not** decoded, because StringCatalogueJvmTest shows Compose Resources processes that one and a real newline in an XML value would be reflowed by the parser. *A term of a `+` concatenation is still not a string.* Same rule as the plain extractor. **Three copy problems surfaced only here, because interpolated strings had never been checked.** `m3-title-case.py` excludes anything containing `$` -- an interpolation is not a literal -- so `"$count Key Packages"` had been invisible to every pass so far, as had `"replying To ${…}"`. And a third instance of the old product name, in `"...once they're on Torch."`. All three fixed. Worth noting as a gap in the checker rather than a one-off: title case inside a template is still unchecked, and there are 83 concatenation fragments left where it could hide. **Two new assertions, on the two things a compiler cannot see.** Argument *order* is decided by where each `${…}` sat, and a transposition compiles and reads plausibly -- "Recovered 3 of 12" against "Recovered 12 of 3" -- so a two-argument and a three-argument string are asserted end to end. The three-argument one doubles as the check that `·` was decoded rather than passed through. **What is deliberately left.** 83 literals that are terms of a `+` concatenation. Reassembling `"a " + x + " b"` into one format string means deciding what the whole sentence is, and several are pluralisations -- `(if (n == 2) "event" else "events")` -- which want a real plural resource rather than a format argument, and that is an API choice rather than a rewrite. `m3-extract-formatted.py --remaining` lists them. **Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, up from 947/598/349. `:composeApp:compileDebugKotlinAndroid` builds; the debug apk installs and runs on emulator-5554 through onboarding, the message list and a chat room with its text intact. `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 41 +++ .../compose/ui/composable/AddChapterScreen.kt | 3 +- .../AddMemberToChatRoomConfirmationScreen.kt | 9 +- .../AddTranslationArtifactVersionScreen.kt | 3 +- .../ui/composable/ArtifactDetailScreen.kt | 6 +- .../ui/composable/ChapterDetailScreen.kt | 12 +- .../ui/composable/ChatRoomDetailScreen.kt | 9 +- .../ui/composable/ChatRoomMessagingScreen.kt | 6 +- .../compose/ui/composable/DkgRitualScreen.kt | 16 +- .../ui/composable/FrostSigningScreen.kt | 3 +- .../composable/ImplementationPendingScreen.kt | 5 +- .../composable/KeyPackageManagementScreen.kt | 3 +- .../ui/composable/ProposalListScreen.kt | 3 +- .../ui/composable/SearchResultScreen.kt | 3 +- .../composable/SelectChatRoomMembersScreen.kt | 11 +- .../ui/composable/SelectChatRoomTypeScreen.kt | 17 +- .../TranslationArtifactVersionDetailScreen.kt | 6 +- .../ui/composable/WriteNewNoteScreen.kt | 5 +- .../composable/widgets/content/RichContent.kt | 8 +- .../widgets/content/UnsupportedKindBadge.kt | 3 +- .../widgets/detail/TextNoteEventDetail.kt | 3 +- .../ui/view/model/ChatMessageListViewModel.kt | 12 +- .../compose/ui/StringCatalogueJvmTest.kt | 30 +++ docs/material-design-conformance.md | 66 +++-- docs/scripts/m3-extract-formatted.py | 239 ++++++++++++++++++ docs/scripts/m3-extract-strings.py | 16 +- 26 files changed, 462 insertions(+), 76 deletions(-) create mode 100755 docs/scripts/m3-extract-formatted.py diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 9a3def24..ed48d520 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -272,4 +272,45 @@ You will be in full control of this profile. If you would like to use it for the long term please remember to backup the profile/keys. Your profile is almost ready... just getting it's first cryptographic signature together. Your share of it is on this device only. Your wallet backup restores it — nobody else's share can. + + Add chapter to %1$s + Add people to %1$s + Chapter %1$s + Chapter %1$s · %2$s words · %3$s characters + Chunk %1$s + Chunks (%1$s) + %1$s/%2$s chunks translated + %1$s event(s) still unreadable%2$s + %1$s events, signed together + Everyone has to be online at the same time — the ceremony can only finish once all %1$s of you have taken part. + "%1$s" functionality coming soon + How should %1$s be run? + Invite %1$s + %1$s Key Packages + Next with %1$s + nostr:%1$s.. + nostr:%1$s... + %1$s of %2$s + %1$s of %2$s members will be needed to sign with this key. + %1$s of them could not be read + Once invited will be able to receive and send private message sent to all the %1$s members in the chat room. + Private message to %1$s + Private to %1$s + %1$s proposals are waiting for your signature + Recovered %1$s of %2$s event(s)%3$s + Recovered %1$s of %2$s · %3$s still unreadable%4$s + Reply privately to %1$s + Reply to %1$s + replying To %1$s + Searching for %1$s on "%2$s" + %1$s selected + %1$s sent a private message to %2$s + Shared key for %1$s + to join the %1$s chat room. + Translate %1$s + Unsupported event kind: %1$s + %1$s was created, but %2$s couldn't be added yet. Invite them again from the chat once they're on Torch. + %1$s was created, but its shared key ceremony couldn't be started. Open the chat and start it from the group's details — until then the group has no key of its own. + %1$s words · %2$s characters + You and %1$s others diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt index 71290ac7..fe355e7a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt @@ -63,6 +63,7 @@ import mantra.composeapp.generated.resources.original_text_markdown import mantra.composeapp.generated.resources.paste_the_chapter_s_markdown_blank_lines import mantra.composeapp.generated.resources.propose_chapter import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a_chapter +import mantra.composeapp.generated.resources.add_chapter_to @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -138,7 +139,7 @@ fun AddChapterScreen( modifier = Modifier.imePadding(), topBar = { TopAppBar( - title = { Text("Add chapter to ${addChapterUIState.artifact.name}") }, + title = { Text(stringResource(Res.string.add_chapter_to, addChapterUIState.artifact.name)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt index 4f90886b..32cee011 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt @@ -43,6 +43,9 @@ import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.direct_message_detail import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here import mantra.composeapp.generated.resources.invite +import mantra.composeapp.generated.resources.invite_2 +import mantra.composeapp.generated.resources.once_invited_will_be_able_to_receive_and +import mantra.composeapp.generated.resources.to_join_the_chat_room @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -119,7 +122,7 @@ fun AddMemberToChatRoomConfirmationScreen( contentDescription = "Invite new member" ) - Text("Invite ${addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey()}") + Text(stringResource(Res.string.invite_2, addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey())) } }, actions = { @@ -147,11 +150,11 @@ fun AddMemberToChatRoomConfirmationScreen( ) Text( - text = "to join the ${addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject ?: ""} chat room." + text = stringResource(Res.string.to_join_the_chat_room, addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject ?: "") ) Text( - text = "Once invited will be able to receive and send private message sent to all the ${addMemberToChatRoomConfirmationUIState.localChatRoom.localParticipants.size} members in the chat room.", + text = stringResource(Res.string.once_invited_will_be_able_to_receive_and, addMemberToChatRoomConfirmationUIState.localChatRoom.localParticipants.size), textAlign = TextAlign.Center ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt index c8c35fa3..96eb42d8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt @@ -64,6 +64,7 @@ import mantra.composeapp.generated.resources.add_translation import mantra.composeapp.generated.resources.propose_translation import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a import mantra.composeapp.generated.resources.translate_into_which_dialect +import mantra.composeapp.generated.resources.translate @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -137,7 +138,7 @@ fun AddTranslationArtifactVersionScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Translate ${addTranslationUIState.artifact.name}") }, + title = { Text(stringResource(Res.string.translate, addTranslationUIState.artifact.name)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt index 13eaf2a4..96de40d5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt @@ -62,6 +62,8 @@ import mantra.composeapp.generated.resources.no_versions import mantra.composeapp.generated.resources.translations import mantra.composeapp.generated.resources.version import mantra.composeapp.generated.resources.versions +import mantra.composeapp.generated.resources.chapter +import mantra.composeapp.generated.resources.words_characters @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -151,10 +153,10 @@ fun ArtifactDetailScreen( contentDescription = "Chapter" ) }, - overlineContent = { Text("Chapter ${chapter.index}") }, + overlineContent = { Text(stringResource(Res.string.chapter, chapter.index)) }, headlineContent = { Text(chapter.name) }, supportingContent = { - Text("${chapter.wordCount} words · ${chapter.characterCount} characters") + Text(stringResource(Res.string.words_characters, chapter.wordCount, chapter.characterCount)) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt index 99d5b6fc..44994bc7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt @@ -44,6 +44,10 @@ import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.chapter_detail import mantra.composeapp.generated.resources.no_chunks import mantra.composeapp.generated.resources.original_text +import mantra.composeapp.generated.resources.chapter_words_characters +import mantra.composeapp.generated.resources.chunk +import mantra.composeapp.generated.resources.chunks +import mantra.composeapp.generated.resources.words_characters @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -101,7 +105,7 @@ fun ChapterDetailScreen( ) { item { Text( - text = "Chapter ${chapter.index} · ${chapter.wordCount} words · ${chapter.characterCount} characters", + text = stringResource(Res.string.chapter_words_characters, chapter.index, chapter.wordCount, chapter.characterCount), style = MaterialTheme.typography.labelMedium ) } @@ -128,7 +132,7 @@ fun ChapterDetailScreen( // Chunks item { Text( - text = "Chunks (${chapterDetailUIState.chunks.size})", + text = stringResource(Res.string.chunks, chapterDetailUIState.chunks.size), style = MaterialTheme.typography.labelMedium ) } @@ -147,10 +151,10 @@ fun ChapterDetailScreen( contentDescription = "Chunk" ) }, - overlineContent = { Text("Chunk ${chunk.index}") }, + overlineContent = { Text(stringResource(Res.string.chunk, chunk.index)) }, headlineContent = { Text(chunk.text) }, supportingContent = { - Text("${chunk.wordCount} words · ${chunk.characterCount} characters") + Text(stringResource(Res.string.words_characters, chunk.wordCount, chunk.characterCount)) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index aa4b439b..24e11a6a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -89,6 +89,9 @@ import mantra.composeapp.generated.resources.projects import mantra.composeapp.generated.resources.proposals import mantra.composeapp.generated.resources.reindex_events import mantra.composeapp.generated.resources.shared_key +import mantra.composeapp.generated.resources.event_s_still_unreadable +import mantra.composeapp.generated.resources.recovered_of_event_s +import mantra.composeapp.generated.resources.recovered_of_still_unreadable @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -706,10 +709,10 @@ private fun ReindexMarmotGroupEventsButton( "Nothing to reindex · ${report.stored - report.predatingMembership} " + "event(s) all read$beforeJoining" report.recovered > 0 && report.failed > 0 -> - "Recovered ${report.recovered} of ${report.unresolved} · ${report.failed} still unreadable$beforeJoining" + stringResource(Res.string.recovered_of_still_unreadable, report.recovered, report.unresolved, report.failed, beforeJoining) report.recovered > 0 -> - "Recovered ${report.recovered} of ${report.unresolved} event(s)$beforeJoining" - else -> "${report.failed} event(s) still unreadable$beforeJoining" + stringResource(Res.string.recovered_of_event_s, report.recovered, report.unresolved, beforeJoining) + else -> stringResource(Res.string.event_s_still_unreadable, report.failed, beforeJoining) }, style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index aa0ed424..f4bcda21 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -64,6 +64,8 @@ import mantra.composeapp.generated.resources.creating_new_chat import mantra.composeapp.generated.resources.direct_message_detail import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here import mantra.composeapp.generated.resources.say_what_now +import mantra.composeapp.generated.resources.private_message_to +import mantra.composeapp.generated.resources.private_to @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -242,7 +244,7 @@ fun ChatRoomMessagingScreen( Text( modifier = Modifier.weight(1f), - text = "Private to ${chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)}", + text = stringResource(Res.string.private_to, chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)), color = MaterialTheme.colorScheme.onPrimaryContainer, style = MaterialTheme.typography.labelMedium ) @@ -284,7 +286,7 @@ fun ChatRoomMessagingScreen( placeholder = { Text( text = if (directMessageRecipient != null) { - "Private message to ${chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)}" + stringResource(Res.string.private_message_to, chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)) } else { stringResource(Res.string.say_what_now) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index 56d52939..6973c43d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -95,6 +95,10 @@ import mantra.composeapp.generated.resources.the_group_has_a_shared_key import mantra.composeapp.generated.resources.this_is_fixed_once_the_ceremony_runs import mantra.composeapp.generated.resources.try_again import mantra.composeapp.generated.resources.your_share_of_it_is_on_this_device_only_your +import mantra.composeapp.generated.resources.everyone_has_to_be_online_at_the_same_time +import mantra.composeapp.generated.resources.of +import mantra.composeapp.generated.resources.of_members_will_be_needed_to_sign_with_this +import mantra.composeapp.generated.resources.shared_key_for /** * The shared-key ceremony: a ChillDKG ritual run across the group's NIP-17 @@ -148,7 +152,7 @@ fun DkgRitualScreen( TopAppBar( title = { Text( - text = "Shared key for ${dkgRitualUIState.localChatRoom.chatRoom.subject ?: "this group"}", + text = stringResource(Res.string.shared_key_for, dkgRitualUIState.localChatRoom.chatRoom.subject ?: "this group"), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -222,7 +226,7 @@ fun DkgRitualScreen( actions = { Text( modifier = Modifier.padding(start = MaterialTheme.spacing.space200), - text = "${dkgRitualViewModel.threshold.value} of $participantCount", + text = stringResource(Res.string.of, dkgRitualViewModel.threshold.value, participantCount), style = MaterialTheme.typography.labelLarge ) } @@ -245,7 +249,7 @@ fun DkgRitualScreen( ) Text( - text = "Everyone has to be online at the same time — the ceremony can only finish once all $participantCount of you have taken part.", + text = stringResource(Res.string.everyone_has_to_be_online_at_the_same_time, participantCount), style = MaterialTheme.typography.bodyMedium ) @@ -369,7 +373,7 @@ private fun RitualProgress( } Text( - text = "${session.threshold} of $participantCount members will be needed to sign with this key.", + text = stringResource(Res.string.of_members_will_be_needed_to_sign_with_this, session.threshold, participantCount), style = MaterialTheme.typography.bodyMedium ) @@ -606,7 +610,7 @@ private fun RitualStep( Text(text = title, style = MaterialTheme.typography.titleSmall) Spacer(modifier = Modifier.weight(1f)) Text( - text = "$count of $total", + text = stringResource(Res.string.of, count, total), style = MaterialTheme.typography.labelLarge ) } @@ -651,7 +655,7 @@ private fun QuorumStepper( } Text( - text = "$threshold of $participantCount", + text = stringResource(Res.string.of, threshold, participantCount), style = MaterialTheme.typography.titleMedium ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 08d39f6f..3bff2f5b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -68,6 +68,7 @@ import mantra.composeapp.generated.resources.ready_to_sign import mantra.composeapp.generated.resources.sign import mantra.composeapp.generated.resources.sign_with_the_group_s_key import mantra.composeapp.generated.resources.signed_their_part +import mantra.composeapp.generated.resources.events_signed_together /** * One signing session, and the member's decision about it. @@ -352,7 +353,7 @@ private fun WhatIsBeingSigned(events: List, expected: Int) { Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200)) { if (events.size > 1) { Text( - text = "${events.size} events, signed together", + text = stringResource(Res.string.events_signed_together, events.size), style = MaterialTheme.typography.labelMedium ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt index 049cf200..f9102c41 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt @@ -14,6 +14,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.functionality_coming_soon_2 @Composable fun ImplementationPendingScreen( @@ -30,7 +33,7 @@ fun ImplementationPendingScreen( verticalArrangement = Arrangement.Center ) { Text( - "\"$text\" functionality coming soon", + stringResource(Res.string.functionality_coming_soon_2, text), ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt index b3e3f4cd..ba863c18 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt @@ -50,6 +50,7 @@ import mantra.composeapp.generated.resources.key_package_management import mantra.composeapp.generated.resources.publish_new_key_package import mantra.composeapp.generated.resources.something_went_wrong import mantra.composeapp.generated.resources.we_couldn_t_find_the_local_profile_please +import mantra.composeapp.generated.resources.key_packages @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -135,7 +136,7 @@ fun KeyPackageManagementScreen( } item { Text( - "${keyPackageManagementUIState.keyPackageBundles.size} Key Packages" + stringResource(Res.string.key_packages, keyPackageManagementUIState.keyPackageBundles.size) ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt index aab513c5..d3795056 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt @@ -62,6 +62,7 @@ import mantra.composeapp.generated.resources.proposals import mantra.composeapp.generated.resources.review import mantra.composeapp.generated.resources.this_group_has_not_been_asked_to_sign import mantra.composeapp.generated.resources.waiting_for_you +import mantra.composeapp.generated.resources.of_them_could_not_be_read /** * Everything the group has asked its shared key to sign. @@ -340,7 +341,7 @@ private fun ProposalCard( // rather than left for the screen behind it. if (proposal.unreadable > 0) { Text( - text = "${proposal.unreadable} of them could not be read", + text = stringResource(Res.string.of_them_could_not_be_read, proposal.unreadable), color = if (proposal.awaitsYou) { cardContentColor } else { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt index 7a096b57..4b80749c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt @@ -61,6 +61,7 @@ import mantra.composeapp.generated.resources.no_events_were_found import mantra.composeapp.generated.resources.profiles import mantra.composeapp.generated.resources.search import mantra.composeapp.generated.resources.something_went_wrong +import mantra.composeapp.generated.resources.searching_for_on @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -273,7 +274,7 @@ fun SearchResultScreen( modifier = Modifier.height(MaterialTheme.spacing.space600) ) Text( - text = "Searching for ${searchViewModel.searchResultType.name.lowercase()} on \"${searchQuery.lowercase()}\"", + text = stringResource(Res.string.searching_for_on, searchViewModel.searchResultType.name.lowercase(), searchQuery.lowercase()), textAlign = TextAlign.Center ) Spacer( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt index e115d788..58bcbc7b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt @@ -50,6 +50,9 @@ import mantra.composeapp.generated.resources.no_one_selected_yet import mantra.composeapp.generated.resources.no_one_to_add_yet import mantra.composeapp.generated.resources.search_for_people_and_chat_with_them_first import mantra.composeapp.generated.resources.you_can_still_carry_on_and_invite_people +import mantra.composeapp.generated.resources.add_people_to +import mantra.composeapp.generated.resources.next_with +import mantra.composeapp.generated.resources.selected /** * Second step of group creation: pick who is in the group. @@ -101,7 +104,7 @@ fun SelectChatRoomMembersScreen( TopAppBar( title = { Text( - text = "Add people to $name", + text = stringResource(Res.string.add_people_to, name), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -125,7 +128,7 @@ fun SelectChatRoomMembersScreen( Text( text = if (selectedCount > 0) { - "Next with $selectedCount" + stringResource(Res.string.next_with, selectedCount) } else { stringResource(Res.string.next) } @@ -136,7 +139,7 @@ fun SelectChatRoomMembersScreen( Text( modifier = Modifier.padding(start = MaterialTheme.spacing.space200), text = if (selectedCount > 0) { - "$selectedCount selected" + stringResource(Res.string.selected, selectedCount) } else { stringResource(Res.string.no_one_selected_yet) }, @@ -247,7 +250,7 @@ fun SelectChatRoomMembersScreen( ) Text( - text = "Add people to $name", + text = stringResource(Res.string.add_people_to, name), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index 3831e0c8..db33c18d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -65,6 +65,11 @@ import mantra.composeapp.generated.resources.just_you_for_now import mantra.composeapp.generated.resources.open_chat import mantra.composeapp.generated.resources.this_decides_who_can_change_the_group_later import mantra.composeapp.generated.resources.you_and_1_other +import mantra.composeapp.generated.resources.how_should_be_run +import mantra.composeapp.generated.resources.of +import mantra.composeapp.generated.resources.was_created_but_couldn_t_be_added_yet_invite +import mantra.composeapp.generated.resources.was_created_but_its_shared_key_ceremony +import mantra.composeapp.generated.resources.you_and_others /** * Last step of group creation: convenient (one admin) or robust (everyone @@ -127,7 +132,7 @@ fun SelectChatRoomTypeScreen( TopAppBar( title = { Text( - text = "How should $name be run?", + text = stringResource(Res.string.how_should_be_run, name), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -170,7 +175,7 @@ fun SelectChatRoomTypeScreen( text = when (selectChatRoomTypeUIState.members.size) { 0 -> stringResource(Res.string.just_you_for_now) 1 -> stringResource(Res.string.you_and_1_other) - else -> "You and ${selectChatRoomTypeUIState.members.size} others" + else -> stringResource(Res.string.you_and_others, selectChatRoomTypeUIState.members.size) }, style = MaterialTheme.typography.labelLarge ) @@ -196,7 +201,7 @@ fun SelectChatRoomTypeScreen( ) { Text( modifier = Modifier.padding(MaterialTheme.spacing.space200), - text = "$name was created, but ${membersNotAdded.joinToString { selectChatRoomTypeViewModel.displayNameFor(it) }} couldn't be added yet. Invite them again from the chat once they're on Torch.", + text = stringResource(Res.string.was_created_but_couldn_t_be_added_yet_invite, name, membersNotAdded.joinToString { selectChatRoomTypeViewModel.displayNameFor(it) }), style = MaterialTheme.typography.bodyMedium ) } @@ -212,7 +217,7 @@ fun SelectChatRoomTypeScreen( ) { Text( modifier = Modifier.padding(MaterialTheme.spacing.space200), - text = "$name was created, but its shared key ceremony couldn't be started. Open the chat and start it from the group's details — until then the group has no key of its own.", + text = stringResource(Res.string.was_created_but_its_shared_key_ceremony, name), style = MaterialTheme.typography.bodyMedium ) } @@ -287,7 +292,7 @@ fun SelectChatRoomTypeScreen( ) Text( - text = "How should $name be run?", + text = stringResource(Res.string.how_should_be_run, name), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) @@ -428,7 +433,7 @@ private fun QuorumPicker( } Text( - text = "$quorum of $adminCount", + text = stringResource(Res.string.of, quorum, adminCount), style = MaterialTheme.typography.titleMedium ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt index 9dacc18e..6b10e683 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt @@ -55,6 +55,8 @@ import mantra.composeapp.generated.resources.no_chapters import mantra.composeapp.generated.resources.propose import mantra.composeapp.generated.resources.this_group_has_no_shared_key_so_it_cannot import mantra.composeapp.generated.resources.translation_detail +import mantra.composeapp.generated.resources.chapter +import mantra.composeapp.generated.resources.chunks_translated @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -217,9 +219,9 @@ fun TranslationArtifactVersionDetailScreen( contentDescription = "Chapter" ) }, - headlineContent = { Text("Chapter ${progress.chapter.index}") }, + headlineContent = { Text(stringResource(Res.string.chapter, progress.chapter.index)) }, supportingContent = { - Text("${progress.translatedChunks}/${progress.totalChunks} chunks translated") + Text(stringResource(Res.string.chunks_translated, progress.translatedChunks, progress.totalChunks)) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt index 8a6469b4..86313d71 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt @@ -55,6 +55,7 @@ import mantra.composeapp.generated.resources.type_out_what_you_would_like_to_pub import mantra.composeapp.generated.resources.what_s_your_comment_on_the_below import mantra.composeapp.generated.resources.what_s_your_reply_to_the_above import mantra.composeapp.generated.resources.what_vibrations_do_you_want_to_send_out +import mantra.composeapp.generated.resources.replying_to @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class) @Composable @@ -130,7 +131,7 @@ fun WriteNewNoteScreen( bottom = MaterialTheme.spacing.space125, ), - text = "replying To ${writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"}", + text = stringResource(Res.string.replying_to, writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -346,7 +347,7 @@ fun WriteNewNoteScreen( if (writeNewNoteUIState.inReplyToNostrEvent != null) { Text( - text = "replying To ${writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"}", + text = stringResource(Res.string.replying_to, writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt index b2a614d2..d80f0a98 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt @@ -32,6 +32,10 @@ import co.touchlab.kermit.Logger import coil3.compose.AsyncImage import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.nostr +import mantra.composeapp.generated.resources.nostr_2 @Composable fun RichContent( @@ -284,7 +288,7 @@ fun RichContent( ) } else { Text( - text = "nostr:${segment.eventId.take(8)}..", + text = stringResource(Res.string.nostr, segment.eventId.take(8)), style = style, color = MaterialTheme.colorScheme.primary ) @@ -308,7 +312,7 @@ fun RichContent( ) } else { Text( - text = "nostr:${segment.dTag.take(12)}...", + text = stringResource(Res.string.nostr_2, segment.dTag.take(12)), style = style, color = MaterialTheme.colorScheme.primary ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/UnsupportedKindBadge.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/UnsupportedKindBadge.kt index 95410c3c..78716cd3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/UnsupportedKindBadge.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/UnsupportedKindBadge.kt @@ -15,6 +15,7 @@ import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.unsupported_event_kind +import mantra.composeapp.generated.resources.unsupported_event_kind_2 @Composable internal fun UnsupportedKindBadge(kind: Int?, style: TextStyle) { @@ -27,7 +28,7 @@ internal fun UnsupportedKindBadge(kind: Int?, style: TextStyle) { .padding(vertical = MaterialTheme.spacing.space75) ) { Text( - text = if (kind != null) "Unsupported event kind: $kind" else stringResource(Res.string.unsupported_event_kind), + text = if (kind != null) stringResource(Res.string.unsupported_event_kind_2, kind) else stringResource(Res.string.unsupported_event_kind), style = style, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(MaterialTheme.spacing.space175) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt index 720ea55e..757bba37 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt @@ -54,6 +54,7 @@ import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.be_the_first_to_comment import mantra.composeapp.generated.resources.post import mantra.composeapp.generated.resources.something_went_wrong +import mantra.composeapp.generated.resources.reply_to @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -138,7 +139,7 @@ fun TextNoteEventDetail( Text( modifier = Modifier.weight(1f), - text = "Reply to ${localNostrEvent.profile?.humanReadableNameOrPubkey() ?: "the above"}", + text = stringResource(Res.string.reply_to, localNostrEvent.profile?.humanReadableNameOrPubkey() ?: "the above"), maxLines = 1, ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 231908ea..5198ce29 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -104,6 +104,10 @@ import mantra.composeapp.generated.resources.review import mantra.composeapp.generated.resources.something_went_wrong import mantra.composeapp.generated.resources.waiting_for_your_signature import mantra.composeapp.generated.resources.you +import mantra.composeapp.generated.resources.private_to +import mantra.composeapp.generated.resources.proposals_are_waiting_for_your_signature +import mantra.composeapp.generated.resources.reply_privately_to +import mantra.composeapp.generated.resources.sent_a_private_message_to class ChatMessageListViewModel( initialChatMessageListUIState: ChatMessageListUIState, @@ -668,7 +672,7 @@ class ChatMessageListViewModel( ) Text( text = if (localChatMessage.chatMessage.isUserMessage) { - "Private to ${nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey)}" + stringResource(Res.string.private_to, nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey)) } else { stringResource(Res.string.private_to_you) }, @@ -733,7 +737,7 @@ class ChatMessageListViewModel( ) { DropdownMenuItem( text = { - Text("Reply privately to ${nameFor(localChatMessage.chatMessage.senderPublicKey)}") + Text(stringResource(Res.string.reply_privately_to, nameFor(localChatMessage.chatMessage.senderPublicKey))) }, leadingIcon = { Icon(Icons.Default.Lock, contentDescription = Decorative) @@ -887,7 +891,7 @@ private fun ProposalsAwaitingYouNotice( text = if (single != null) { stringResource(Res.string.waiting_for_your_signature) } else { - "${proposals.size} proposals are waiting for your signature" + stringResource(Res.string.proposals_are_waiting_for_your_signature, proposals.size) }, style = MaterialTheme.typography.labelMedium ) @@ -948,7 +952,7 @@ private fun PrivateMessageNotice( ) Text( - text = "$sender sent a private message to $recipient", + text = stringResource(Res.string.sent_a_private_message_to, sender, recipient), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelSmall ) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/StringCatalogueJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/StringCatalogueJvmTest.kt index 3cfd25e0..f9d32e02 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/StringCatalogueJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/StringCatalogueJvmTest.kt @@ -5,6 +5,9 @@ import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.currently_no_messages_have_been_shared import mantra.composeapp.generated.resources.don_t_sign import mantra.composeapp.generated.resources.something_went_wrong +import mantra.composeapp.generated.resources.add_chapter_to +import mantra.composeapp.generated.resources.chapter_words_characters +import mantra.composeapp.generated.resources.sent_a_private_message_to import org.jetbrains.compose.resources.getString import kotlin.test.Test import kotlin.test.assertEquals @@ -45,4 +48,31 @@ class StringCatalogueJvmTest { fun `a plain string comes back unchanged`() = runBlocking { assertEquals("Something went wrong", getString(Res.string.something_went_wrong)) } + + @Test + fun `a format string substitutes its arguments in order`() = runBlocking { + // The interpolated strings became `%1$s` format strings, and the argument order + // is decided by where each `${…}` sat in the template. A transposition compiles + // and reads plausibly -- "Recovered 3 of 12" against "Recovered 12 of 3" -- so + // the ordering is worth asserting on a multi-argument one. + assertEquals( + "Add chapter to Genesis", + getString(Res.string.add_chapter_to, "Genesis"), + ) + assertEquals( + "Ada sent a private message to Grace", + getString(Res.string.sent_a_private_message_to, "Ada", "Grace"), + ) + } + + @Test + fun `a unicode escape was decoded into the catalogue`() = runBlocking { + // `·` is Kotlin source syntax with no meaning in XML, so the extractor + // decodes it and the resource holds the character. If it had been left alone the + // app would render the six characters of the escape. + assertEquals( + "Chapter 3 · 900 words · 4800 characters", + getString(Res.string.chapter_words_characters, "3", "900", "4800"), + ) + } } diff --git a/docs/material-design-conformance.md b/docs/material-design-conformance.md index fdc69f51..3fc8dda6 100644 --- a/docs/material-design-conformance.md +++ b/docs/material-design-conformance.md @@ -608,35 +608,51 @@ neither an outline nor a checkmark, and adding one is component work. ### Phase 4 — text the system can translate -**Why here.** It is the last phase that touches every file, and doing it after -Phase 2 means one pass over each file instead of two. It must precede Phase 6: -RTL is a breakpoint concern too, and there is no point testing a mirrored layout -against 334 English literals. +**Why here.** It is the last phase that touches every file, and doing it after phase 2 +means one pass over each file instead of two. It must precede phase 6: RTL is a breakpoint +concern too, and there is no point testing a mirrored layout against 334 English literals. -**Work.** +**Built.** Three commits. -1. **Externalise all 334 strings** into `composeResources/values/strings.xml`, - organised by screen. Clear out the inherited Phoenix wallet strings that - nothing references, and settle `app_name`. -2. **Sentence case, everywhere.** "Edit profile", "Create profile", "New chat", - "Sign in", "Leave group", "Key package management", "Publish new key - package". Roughly 45 strings. Product names stay capitalised — which requires - settling on one: Torch, Mantra or Machankura. -3. **Rewrite the destructive confirmations** to state consequences plainly. - "Delete group" and "Leave group" currently offer a label and nothing else; - the style guide wants the outcome and whether it can be undone. -4. **Alt text for meaningful images** — profile avatars, QR codes, artifact - images — following the Phase 3 triage rule. -5. **Spell out abbreviations** in user-facing text. Protocol terms that are - genuinely the domain (npub, NIP-05, relay) stay; incidental shortenings go. -6. Verify RTL by mirroring: the codebase has no `left`/`right` modifiers, so - this should be confirmation rather than repair. +1. **Sentence case, 100 occurrences across 60 strings.** Two passes, and the second is + the instructive one: the first pattern required every word after the first to be + capitalised, so anything with an article survived — "Invite a Friend" was still on the + app's first screen after the audit reported zero — and it read one line at a time, so a + `Text(` whose literal sat on the next line was invisible. -**Done when** the audit reports fewer than ten string literals in composables -(test data and previews), and no user-facing string uses title case. + Sample data is deliberately left in title case: "Steve Biko", "To Kill a Mockingbird". + Those are a person and a book. -**Risk:** low, high volume. Sentence-casing is the part most likely to draw -disagreement — settle the product name first, in one decision. +2. **251 plain strings into the catalogue**, 315 call sites. The extractor took four + attempts and each failure is recorded in `docs/scripts/m3-extract-strings.py`: a bare + `text = "…"` is not a Compose string (it rewrote a data class), a regex over quote + pairs is not a Kotlin lexer (it lifted `"chunk"` out of `"${if (n == 1) "chunk" …}"`), + and a fragment of a `+` concatenation is not a translatable unit. + +3. **40 interpolated strings as format strings**, 49 call sites — `${expr}` to `%1$s`, + passed as arguments. + +**Compose Resources is not aapt, and a test caught that before a device did.** The first +extraction escaped apostrophes as `\'` and doubled `%`, which is what android's resource +compiler requires. Compose Resources does neither: `getString` returned `Don\'t sign`, +backslash included, across 30-odd strings. Escape handling is *partial* rather than +absent — `\n` **is** processed — so there is no family rule to lean on. +`StringCatalogueJvmTest` asserts each escape it depends on. + +**Done:** literals in composables 334 → 39, `stringResource` 0 → 424, title case 0. Plus +1101 inherited phoenix strings deleted (nothing referenced them), the product settled on +one name, and the two destructive actions now state their consequences — read out of the +repository rather than guessed, because "Delete group" with no qualifier invites the +belief that the messages are gone from the relays, which is the opposite of true. + +**Left for later, and why it is not a script's job.** 83 literals that are terms of a `+` +concatenation. Reassembling `"a " + x + " b"` into one format string means deciding what +the whole sentence is, and several of them are pluralisations — +`(if (n == 2) "event" else "events")` — which want a real plural resource rather than a +format argument. `m3-extract-formatted.py --remaining` lists them. + +`UserAgent.APP_NAME` still says "Torch". It goes on the wire to relay operators, so it is +a network identity question rather than a content one. --- diff --git a/docs/scripts/m3-extract-formatted.py b/docs/scripts/m3-extract-formatted.py new file mode 100755 index 00000000..bec26bbc --- /dev/null +++ b/docs/scripts/m3-extract-formatted.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Move interpolated UI strings into the catalogue as format strings. + +Companion to m3-extract-strings.py, which handles literals with no interpolation. Here a + + Text("Add chapter to ${uiState.artifact.name}") + +becomes a resource holding `Add chapter to %1$s` and a call + + Text(stringResource(Res.string.add_chapter_to, uiState.artifact.name)) + +**Only standalone literals.** A literal that is one term of a `+` concatenation is left +alone: reassembling `"a " + x + " b"` into a single format string means deciding what the +whole sentence is, and that is per-site reading rather than a rewrite. Those are listed by +--remaining. + +Usage: + m3-extract-formatted.py dry run + m3-extract-formatted.py --apply + m3-extract-formatted.py --remaining list what is deliberately left +""" +import os, re, sys, io, collections + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_helpers = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), + 'm3-extract-strings.py')).read() +exec(_helpers.split('def resource_name(')[0]) # UI, STRINGS, kotlin_strings, ... +exec('def resource_name(' + _helpers.split('def resource_name(')[1].split('def xml_escape')[0]) + +DRY = '--apply' not in sys.argv + + +def split_template(body): + """Kotlin template body -> (segments, expressions). + + segments is the literal text with each interpolation replaced by a placeholder index. + """ + parts, exprs, i, buf = [], [], 0, '' + while i < len(body): + c = body[i] + if c == '\\': + buf += body[i:i + 2] + i += 2 + continue + if c == '$' and i + 1 < len(body): + if body[i + 1] == '{': + depth, j = 1, i + 2 + while j < len(body) and depth: + if body[j] == '{': + depth += 1 + elif body[j] == '}': + depth -= 1 + j += 1 + parts.append(buf) + buf = '' + exprs.append(body[i + 2:j - 1]) + i = j + continue + m = re.match(r'\$([A-Za-z_][A-Za-z0-9_]*)', body[i:]) + if m: + parts.append(buf) + buf = '' + exprs.append(m.group(1)) + i += m.end() + continue + buf += c + i += 1 + parts.append(buf) + return parts, exprs + + +def unescape_for_xml(text): + """Kotlin escapes -> what belongs in the resource file. + + `\\uXXXX` and `\\"` are Kotlin source syntax with no meaning in XML, so they are + decoded: a resource file is UTF-8 and can hold the character itself. `\\n` is left + alone, because StringCatalogueJvmTest shows Compose Resources processes it and a + real newline in an XML value would be reflowed by the parser. + """ + out, i = '', 0 + while i < len(text): + if text[i] == '\\' and i + 1 < len(text): + nxt = text[i + 1] + if nxt == 'u' and i + 6 <= len(text): + out += chr(int(text[i + 2:i + 6], 16)) + i += 6 + continue + if nxt in '"\'': + out += nxt + i += 2 + continue + out += text[i] + i += 1 + return out + + +def format_string(parts, exprs): + out = unescape_for_xml(parts[0]).replace('%', '%%') + for n, tail in enumerate(parts[1:], start=1): + out += f'%{n}$s' + unescape_for_xml(tail).replace('%', '%%') + return out + + +def candidates(source): + """(start, end, body, exprs, format_string) for standalone interpolated literals.""" + spans = text_call_spans(source) + found = [] + for start, end, body, interpolated in kotlin_strings(source): + if not interpolated or not body.strip(): + continue + if not any(a <= start <= b for a, b in spans): + continue + line_start = source.rfind('\n', 0, start) + 1 + line = source[line_start:source.find('\n', start)] + if line.lstrip().startswith(('//', '*', '/*')): + continue + before, after = source[:start].rstrip(), source[end:].lstrip() + stripped = line.strip() + if before.endswith('+') or after.startswith('+') or \ + stripped.endswith('+') or stripped.startswith('+'): + continue + parts, exprs = split_template(body) + if not exprs: + continue + # A template that is *only* interpolation has nothing to translate. + # `Text("$name")` would become `stringResource(Res.string.string, name)`, which is + # a resource holding "%1$s" -- longer, slower and no more localisable than the + # code it replaced. Same for one whose literal half is punctuation or a spacer. + literal = unescape_for_xml(''.join(parts)) + if not any(c.isalpha() for c in literal): + continue + fmt = format_string(parts, exprs) + # A leading or trailing space means the string is being glued to a neighbour -- + # " \u00b7 %1$s" is a separator, not a sentence. The test is on the *format + # string*, not on the literal halves: a template that opens with an interpolation + # leaves parts[0] empty and parts[1] starting with the space that separates them, + # so joining the halves makes "%1$s Key packages" look like a fragment when it is + # a whole label. + if fmt != fmt.strip(): + continue + found.append((start, end, body, exprs, fmt)) + return found + + + +def has_import(source, statement): + """Whole-line match. + + `statement in source` is wrong here and silently so: the accessors are named after + their strings, so `...resources.translate` is a prefix of + `...resources.translate_into_which_dialect`, and a substring test decides the import + is already present. The compiler then reports "Unresolved reference 'translate'" in a + file whose imports look complete. + """ + return any(line.strip() == statement for line in source.split('\n')) + +def main(): + if '--remaining' in sys.argv: + for root, _, files in os.walk(UI): + for f in sorted(files): + if not f.endswith('.kt'): + continue + path = os.path.join(root, f) + src = open(path, encoding='utf-8').read() + spans = text_call_spans(src) + done = {(a, b) for a, b, *_ in candidates(src)} + for a, b, body, _i in kotlin_strings(src): + if not body.strip() or (a, b) in done: + continue + if not any(x <= a <= y for x, y in spans): + continue + line_start = src.rfind('\n', 0, a) + 1 + line = src[line_start:src.find('\n', a)] + if line.lstrip().startswith(('//', '*', '/*')): + continue + print(f' {path.replace(UI + "/", "")}:{src[:a].count(chr(10)) + 1} {body[:70]!r}') + return 0 + + plan = {} + for root, _, files in os.walk(UI): + for f in sorted(files): + if not f.endswith('.kt'): + continue + path = os.path.join(root, f) + for _s, _e, body, exprs, fmt in candidates(open(path, encoding='utf-8').read()): + plan[fmt] = plan.get(fmt, 0) + 1 + + existing = io.open(STRINGS, encoding='utf-8').read() + taken = set(re.findall(r'' + f'{fmt.replace("&", "&").replace("<", "<").replace(">", ">")}' + for fmt in sorted(plan, key=lambda x: names[x])) + io.open(STRINGS, 'w', encoding='utf-8').write( + existing.replace('', entries + '\n')) + print(f'{len(plan)} entries written') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/docs/scripts/m3-extract-strings.py b/docs/scripts/m3-extract-strings.py index 5a399375..e4cbd4e5 100755 --- a/docs/scripts/m3-extract-strings.py +++ b/docs/scripts/m3-extract-strings.py @@ -193,6 +193,18 @@ def xml_escape(text): return text.replace('&', '&').replace('<', '<').replace('>', '>') + +def has_import(source, statement): + """Whole-line match. + + `statement in source` is wrong here and silently so: the accessors are named after + their strings, so `...resources.translate` is a prefix of + `...resources.translate_into_which_dialect`, and a substring test decides the import + is already present. The compiler then reports "Unresolved reference 'translate'" in a + file whose imports look complete. + """ + return any(line.strip() == statement for line in source.split('\n')) + def main(): occurrences = collections.defaultdict(list) for root, _, files in os.walk(UI): @@ -233,7 +245,7 @@ def main(): if changed: for imp in ('import mantra.composeapp.generated.resources.Res', 'import org.jetbrains.compose.resources.stringResource'): - if imp not in src: + if not has_import(src, imp): lines = src.split('\n') idx = max(i for i, l in enumerate(lines) if l.startswith('import ')) lines.insert(idx + 1, imp) @@ -245,7 +257,7 @@ def main(): idx = max(i for i, l in enumerate(lines) if l.startswith('import ')) for nm in reversed(need): imp = f'import mantra.composeapp.generated.resources.{nm}' - if imp not in src: + if not has_import(src, imp): lines.insert(idx + 1, imp) src = '\n'.join(lines) io.open(path, 'w', encoding='utf-8').write(src)