refactor: make the 40 interpolated UI strings format strings, and assert the argument order

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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-08 01:45:35 +02:00
1 parent 419504c982
commit 2117e22d48
26 files changed
+462 -76

No files matched your search

@@ -272,4 +272,45 @@
<string name="you_will_be_in_full_control_of_this_profile">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.</string> <string name="you_will_be_in_full_control_of_this_profile">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.</string>
<string name="your_profile_is_almost_ready_just_getting_it">Your profile is almost ready... just getting it's first cryptographic signature together.</string> <string name="your_profile_is_almost_ready_just_getting_it">Your profile is almost ready... just getting it's first cryptographic signature together.</string>
<string name="your_share_of_it_is_on_this_device_only_your">Your share of it is on this device only. Your wallet backup restores it — nobody else's share can.</string> <string name="your_share_of_it_is_on_this_device_only_your">Your share of it is on this device only. Your wallet backup restores it — nobody else's share can.</string>
<string name="add_chapter_to">Add chapter to %1$s</string>
<string name="add_people_to">Add people to %1$s</string>
<string name="chapter">Chapter %1$s</string>
<string name="chapter_words_characters">Chapter %1$s · %2$s words · %3$s characters</string>
<string name="chunk">Chunk %1$s</string>
<string name="chunks">Chunks (%1$s)</string>
<string name="chunks_translated">%1$s/%2$s chunks translated</string>
<string name="event_s_still_unreadable">%1$s event(s) still unreadable%2$s</string>
<string name="events_signed_together">%1$s events, signed together</string>
<string name="everyone_has_to_be_online_at_the_same_time">Everyone has to be online at the same time — the ceremony can only finish once all %1$s of you have taken part.</string>
<string name="functionality_coming_soon_2">"%1$s" functionality coming soon</string>
<string name="how_should_be_run">How should %1$s be run?</string>
<string name="invite_2">Invite %1$s</string>
<string name="key_packages">%1$s Key Packages</string>
<string name="next_with">Next with %1$s</string>
<string name="nostr">nostr:%1$s..</string>
<string name="nostr_2">nostr:%1$s...</string>
<string name="of">%1$s of %2$s</string>
<string name="of_members_will_be_needed_to_sign_with_this">%1$s of %2$s members will be needed to sign with this key.</string>
<string name="of_them_could_not_be_read">%1$s of them could not be read</string>
<string name="once_invited_will_be_able_to_receive_and">Once invited will be able to receive and send private message sent to all the %1$s members in the chat room.</string>
<string name="private_message_to">Private message to %1$s</string>
<string name="private_to">Private to %1$s</string>
<string name="proposals_are_waiting_for_your_signature">%1$s proposals are waiting for your signature</string>
<string name="recovered_of_event_s">Recovered %1$s of %2$s event(s)%3$s</string>
<string name="recovered_of_still_unreadable">Recovered %1$s of %2$s · %3$s still unreadable%4$s</string>
<string name="reply_privately_to">Reply privately to %1$s</string>
<string name="reply_to">Reply to %1$s</string>
<string name="replying_to">replying To %1$s</string>
<string name="searching_for_on">Searching for %1$s on "%2$s"</string>
<string name="selected">%1$s selected</string>
<string name="sent_a_private_message_to">%1$s sent a private message to %2$s</string>
<string name="shared_key_for">Shared key for %1$s</string>
<string name="to_join_the_chat_room">to join the %1$s chat room.</string>
<string name="translate">Translate %1$s</string>
<string name="unsupported_event_kind_2">Unsupported event kind: %1$s</string>
<string name="was_created_but_couldn_t_be_added_yet_invite">%1$s was created, but %2$s couldn't be added yet. Invite them again from the chat once they're on Torch.</string>
<string name="was_created_but_its_shared_key_ceremony">%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.</string>
<string name="words_characters">%1$s words · %2$s characters</string>
<string name="you_and_others">You and %1$s others</string>
</resources> </resources>
@@ -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.paste_the_chapter_s_markdown_blank_lines
import mantra.composeapp.generated.resources.propose_chapter 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.this_artifact_has_no_version_for_a_chapter
import mantra.composeapp.generated.resources.add_chapter_to
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -138,7 +139,7 @@ fun AddChapterScreen(
modifier = Modifier.imePadding(), modifier = Modifier.imePadding(),
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Add chapter to ${addChapterUIState.artifact.name}") }, title = { Text(stringResource(Res.string.add_chapter_to, addChapterUIState.artifact.name)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon( Icon(
@@ -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_detail
import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here
import mantra.composeapp.generated.resources.invite 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) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -119,7 +122,7 @@ fun AddMemberToChatRoomConfirmationScreen(
contentDescription = "Invite new member" contentDescription = "Invite new member"
) )
Text("Invite ${addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey()}") Text(stringResource(Res.string.invite_2, addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey()))
} }
}, },
actions = { actions = {
@@ -147,11 +150,11 @@ fun AddMemberToChatRoomConfirmationScreen(
) )
Text( 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(
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 textAlign = TextAlign.Center
) )
@@ -64,6 +64,7 @@ import mantra.composeapp.generated.resources.add_translation
import mantra.composeapp.generated.resources.propose_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.this_artifact_has_no_version_for_a
import mantra.composeapp.generated.resources.translate_into_which_dialect import mantra.composeapp.generated.resources.translate_into_which_dialect
import mantra.composeapp.generated.resources.translate
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
@@ -137,7 +138,7 @@ fun AddTranslationArtifactVersionScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Translate ${addTranslationUIState.artifact.name}") }, title = { Text(stringResource(Res.string.translate, addTranslationUIState.artifact.name)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon( Icon(
@@ -62,6 +62,8 @@ import mantra.composeapp.generated.resources.no_versions
import mantra.composeapp.generated.resources.translations import mantra.composeapp.generated.resources.translations
import mantra.composeapp.generated.resources.version import mantra.composeapp.generated.resources.version
import mantra.composeapp.generated.resources.versions import mantra.composeapp.generated.resources.versions
import mantra.composeapp.generated.resources.chapter
import mantra.composeapp.generated.resources.words_characters
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -151,10 +153,10 @@ fun ArtifactDetailScreen(
contentDescription = "Chapter" contentDescription = "Chapter"
) )
}, },
overlineContent = { Text("Chapter ${chapter.index}") }, overlineContent = { Text(stringResource(Res.string.chapter, chapter.index)) },
headlineContent = { Text(chapter.name) }, headlineContent = { Text(chapter.name) },
supportingContent = { supportingContent = {
Text("${chapter.wordCount} words · ${chapter.characterCount} characters") Text(stringResource(Res.string.words_characters, chapter.wordCount, chapter.characterCount))
} }
) )
} }
@@ -44,6 +44,10 @@ import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.chapter_detail import mantra.composeapp.generated.resources.chapter_detail
import mantra.composeapp.generated.resources.no_chunks import mantra.composeapp.generated.resources.no_chunks
import mantra.composeapp.generated.resources.original_text 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -101,7 +105,7 @@ fun ChapterDetailScreen(
) { ) {
item { item {
Text( 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 style = MaterialTheme.typography.labelMedium
) )
} }
@@ -128,7 +132,7 @@ fun ChapterDetailScreen(
// Chunks // Chunks
item { item {
Text( Text(
text = "Chunks (${chapterDetailUIState.chunks.size})", text = stringResource(Res.string.chunks, chapterDetailUIState.chunks.size),
style = MaterialTheme.typography.labelMedium style = MaterialTheme.typography.labelMedium
) )
} }
@@ -147,10 +151,10 @@ fun ChapterDetailScreen(
contentDescription = "Chunk" contentDescription = "Chunk"
) )
}, },
overlineContent = { Text("Chunk ${chunk.index}") }, overlineContent = { Text(stringResource(Res.string.chunk, chunk.index)) },
headlineContent = { Text(chunk.text) }, headlineContent = { Text(chunk.text) },
supportingContent = { supportingContent = {
Text("${chunk.wordCount} words · ${chunk.characterCount} characters") Text(stringResource(Res.string.words_characters, chunk.wordCount, chunk.characterCount))
} }
) )
} }
@@ -89,6 +89,9 @@ import mantra.composeapp.generated.resources.projects
import mantra.composeapp.generated.resources.proposals import mantra.composeapp.generated.resources.proposals
import mantra.composeapp.generated.resources.reindex_events import mantra.composeapp.generated.resources.reindex_events
import mantra.composeapp.generated.resources.shared_key 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) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -706,10 +709,10 @@ private fun ReindexMarmotGroupEventsButton(
"Nothing to reindex · ${report.stored - report.predatingMembership} " + "Nothing to reindex · ${report.stored - report.predatingMembership} " +
"event(s) all read$beforeJoining" "event(s) all read$beforeJoining"
report.recovered > 0 && report.failed > 0 -> 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 -> report.recovered > 0 ->
"Recovered ${report.recovered} of ${report.unresolved} event(s)$beforeJoining" stringResource(Res.string.recovered_of_event_s, report.recovered, report.unresolved, beforeJoining)
else -> "${report.failed} event(s) still unreadable$beforeJoining" else -> stringResource(Res.string.event_s_still_unreadable, report.failed, beforeJoining)
}, },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center textAlign = TextAlign.Center
@@ -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_detail
import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here
import mantra.composeapp.generated.resources.say_what_now 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) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -242,7 +244,7 @@ fun ChatRoomMessagingScreen(
Text( Text(
modifier = Modifier.weight(1f), 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, color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.labelMedium style = MaterialTheme.typography.labelMedium
) )
@@ -284,7 +286,7 @@ fun ChatRoomMessagingScreen(
placeholder = { placeholder = {
Text( Text(
text = if (directMessageRecipient != null) { text = if (directMessageRecipient != null) {
"Private message to ${chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)}" stringResource(Res.string.private_message_to, chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey))
} else { } else {
stringResource(Res.string.say_what_now) stringResource(Res.string.say_what_now)
} }
@@ -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.this_is_fixed_once_the_ceremony_runs
import mantra.composeapp.generated.resources.try_again 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.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 * The shared-key ceremony: a ChillDKG ritual run across the group's NIP-17
@@ -148,7 +152,7 @@ fun DkgRitualScreen(
TopAppBar( TopAppBar(
title = { title = {
Text( 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, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -222,7 +226,7 @@ fun DkgRitualScreen(
actions = { actions = {
Text( Text(
modifier = Modifier.padding(start = MaterialTheme.spacing.space200), 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 style = MaterialTheme.typography.labelLarge
) )
} }
@@ -245,7 +249,7 @@ fun DkgRitualScreen(
) )
Text( 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 style = MaterialTheme.typography.bodyMedium
) )
@@ -369,7 +373,7 @@ private fun RitualProgress(
} }
Text( 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 style = MaterialTheme.typography.bodyMedium
) )
@@ -606,7 +610,7 @@ private fun RitualStep(
Text(text = title, style = MaterialTheme.typography.titleSmall) Text(text = title, style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Text( Text(
text = "$count of $total", text = stringResource(Res.string.of, count, total),
style = MaterialTheme.typography.labelLarge style = MaterialTheme.typography.labelLarge
) )
} }
@@ -651,7 +655,7 @@ private fun QuorumStepper(
} }
Text( Text(
text = "$threshold of $participantCount", text = stringResource(Res.string.of, threshold, participantCount),
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
@@ -68,6 +68,7 @@ import mantra.composeapp.generated.resources.ready_to_sign
import mantra.composeapp.generated.resources.sign import mantra.composeapp.generated.resources.sign
import mantra.composeapp.generated.resources.sign_with_the_group_s_key import mantra.composeapp.generated.resources.sign_with_the_group_s_key
import mantra.composeapp.generated.resources.signed_their_part 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. * One signing session, and the member's decision about it.
@@ -352,7 +353,7 @@ private fun WhatIsBeingSigned(events: List<Event>, expected: Int) {
Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200)) { Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200)) {
if (events.size > 1) { if (events.size > 1) {
Text( Text(
text = "${events.size} events, signed together", text = stringResource(Res.string.events_signed_together, events.size),
style = MaterialTheme.typography.labelMedium style = MaterialTheme.typography.labelMedium
) )
} }
@@ -14,6 +14,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import press.mantra.compose.ui.theme.spacing 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 @Composable
fun ImplementationPendingScreen( fun ImplementationPendingScreen(
@@ -30,7 +33,7 @@ fun ImplementationPendingScreen(
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text( Text(
"\"$text\" functionality coming soon", stringResource(Res.string.functionality_coming_soon_2, text),
) )
} }
@@ -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.publish_new_key_package
import mantra.composeapp.generated.resources.something_went_wrong 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.we_couldn_t_find_the_local_profile_please
import mantra.composeapp.generated.resources.key_packages
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -135,7 +136,7 @@ fun KeyPackageManagementScreen(
} }
item { item {
Text( Text(
"${keyPackageManagementUIState.keyPackageBundles.size} Key Packages" stringResource(Res.string.key_packages, keyPackageManagementUIState.keyPackageBundles.size)
) )
} }
@@ -62,6 +62,7 @@ import mantra.composeapp.generated.resources.proposals
import mantra.composeapp.generated.resources.review import mantra.composeapp.generated.resources.review
import mantra.composeapp.generated.resources.this_group_has_not_been_asked_to_sign 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.waiting_for_you
import mantra.composeapp.generated.resources.of_them_could_not_be_read
/** /**
* Everything the group has asked its shared key to sign. * 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. // rather than left for the screen behind it.
if (proposal.unreadable > 0) { if (proposal.unreadable > 0) {
Text( 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) { color = if (proposal.awaitsYou) {
cardContentColor cardContentColor
} else { } else {
@@ -61,6 +61,7 @@ import mantra.composeapp.generated.resources.no_events_were_found
import mantra.composeapp.generated.resources.profiles import mantra.composeapp.generated.resources.profiles
import mantra.composeapp.generated.resources.search import mantra.composeapp.generated.resources.search
import mantra.composeapp.generated.resources.something_went_wrong import mantra.composeapp.generated.resources.something_went_wrong
import mantra.composeapp.generated.resources.searching_for_on
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -273,7 +274,7 @@ fun SearchResultScreen(
modifier = Modifier.height(MaterialTheme.spacing.space600) modifier = Modifier.height(MaterialTheme.spacing.space600)
) )
Text( 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 textAlign = TextAlign.Center
) )
Spacer( Spacer(
@@ -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.no_one_to_add_yet
import mantra.composeapp.generated.resources.search_for_people_and_chat_with_them_first 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.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. * Second step of group creation: pick who is in the group.
@@ -101,7 +104,7 @@ fun SelectChatRoomMembersScreen(
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = "Add people to $name", text = stringResource(Res.string.add_people_to, name),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -125,7 +128,7 @@ fun SelectChatRoomMembersScreen(
Text( Text(
text = if (selectedCount > 0) { text = if (selectedCount > 0) {
"Next with $selectedCount" stringResource(Res.string.next_with, selectedCount)
} else { } else {
stringResource(Res.string.next) stringResource(Res.string.next)
} }
@@ -136,7 +139,7 @@ fun SelectChatRoomMembersScreen(
Text( Text(
modifier = Modifier.padding(start = MaterialTheme.spacing.space200), modifier = Modifier.padding(start = MaterialTheme.spacing.space200),
text = if (selectedCount > 0) { text = if (selectedCount > 0) {
"$selectedCount selected" stringResource(Res.string.selected, selectedCount)
} else { } else {
stringResource(Res.string.no_one_selected_yet) stringResource(Res.string.no_one_selected_yet)
}, },
@@ -247,7 +250,7 @@ fun SelectChatRoomMembersScreen(
) )
Text( Text(
text = "Add people to $name", text = stringResource(Res.string.add_people_to, name),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
@@ -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.open_chat
import mantra.composeapp.generated.resources.this_decides_who_can_change_the_group_later 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.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 * Last step of group creation: convenient (one admin) or robust (everyone
@@ -127,7 +132,7 @@ fun SelectChatRoomTypeScreen(
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = "How should $name be run?", text = stringResource(Res.string.how_should_be_run, name),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -170,7 +175,7 @@ fun SelectChatRoomTypeScreen(
text = when (selectChatRoomTypeUIState.members.size) { text = when (selectChatRoomTypeUIState.members.size) {
0 -> stringResource(Res.string.just_you_for_now) 0 -> stringResource(Res.string.just_you_for_now)
1 -> stringResource(Res.string.you_and_1_other) 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 style = MaterialTheme.typography.labelLarge
) )
@@ -196,7 +201,7 @@ fun SelectChatRoomTypeScreen(
) { ) {
Text( Text(
modifier = Modifier.padding(MaterialTheme.spacing.space200), 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 style = MaterialTheme.typography.bodyMedium
) )
} }
@@ -212,7 +217,7 @@ fun SelectChatRoomTypeScreen(
) { ) {
Text( Text(
modifier = Modifier.padding(MaterialTheme.spacing.space200), 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 style = MaterialTheme.typography.bodyMedium
) )
} }
@@ -287,7 +292,7 @@ fun SelectChatRoomTypeScreen(
) )
Text( Text(
text = "How should $name be run?", text = stringResource(Res.string.how_should_be_run, name),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
@@ -428,7 +433,7 @@ private fun QuorumPicker(
} }
Text( Text(
text = "$quorum of $adminCount", text = stringResource(Res.string.of, quorum, adminCount),
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
@@ -55,6 +55,8 @@ import mantra.composeapp.generated.resources.no_chapters
import mantra.composeapp.generated.resources.propose 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.this_group_has_no_shared_key_so_it_cannot
import mantra.composeapp.generated.resources.translation_detail import mantra.composeapp.generated.resources.translation_detail
import mantra.composeapp.generated.resources.chapter
import mantra.composeapp.generated.resources.chunks_translated
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -217,9 +219,9 @@ fun TranslationArtifactVersionDetailScreen(
contentDescription = "Chapter" contentDescription = "Chapter"
) )
}, },
headlineContent = { Text("Chapter ${progress.chapter.index}") }, headlineContent = { Text(stringResource(Res.string.chapter, progress.chapter.index)) },
supportingContent = { supportingContent = {
Text("${progress.translatedChunks}/${progress.totalChunks} chunks translated") Text(stringResource(Res.string.chunks_translated, progress.translatedChunks, progress.totalChunks))
} }
) )
} }
@@ -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_comment_on_the_below
import mantra.composeapp.generated.resources.what_s_your_reply_to_the_above 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.what_vibrations_do_you_want_to_send_out
import mantra.composeapp.generated.resources.replying_to
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@Composable @Composable
@@ -130,7 +131,7 @@ fun WriteNewNoteScreen(
bottom = MaterialTheme.spacing.space125, 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -346,7 +347,7 @@ fun WriteNewNoteScreen(
if (writeNewNoteUIState.inReplyToNostrEvent != null) { if (writeNewNoteUIState.inReplyToNostrEvent != null) {
Text( Text(
text = "replying To ${writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"}", text = stringResource(Res.string.replying_to, writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -32,6 +32,10 @@ import co.touchlab.kermit.Logger
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.ui.theme.spacing 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 @Composable
fun RichContent( fun RichContent(
@@ -284,7 +288,7 @@ fun RichContent(
) )
} else { } else {
Text( Text(
text = "nostr:${segment.eventId.take(8)}..", text = stringResource(Res.string.nostr, segment.eventId.take(8)),
style = style, style = style,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@@ -308,7 +312,7 @@ fun RichContent(
) )
} else { } else {
Text( Text(
text = "nostr:${segment.dTag.take(12)}...", text = stringResource(Res.string.nostr_2, segment.dTag.take(12)),
style = style, style = style,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@@ -15,6 +15,7 @@ import press.mantra.compose.ui.theme.spacing
import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.unsupported_event_kind import mantra.composeapp.generated.resources.unsupported_event_kind
import mantra.composeapp.generated.resources.unsupported_event_kind_2
@Composable @Composable
internal fun UnsupportedKindBadge(kind: Int?, style: TextStyle) { internal fun UnsupportedKindBadge(kind: Int?, style: TextStyle) {
@@ -27,7 +28,7 @@ internal fun UnsupportedKindBadge(kind: Int?, style: TextStyle) {
.padding(vertical = MaterialTheme.spacing.space75) .padding(vertical = MaterialTheme.spacing.space75)
) { ) {
Text( 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, style = style,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(MaterialTheme.spacing.space175) modifier = Modifier.padding(MaterialTheme.spacing.space175)
@@ -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.be_the_first_to_comment
import mantra.composeapp.generated.resources.post import mantra.composeapp.generated.resources.post
import mantra.composeapp.generated.resources.something_went_wrong import mantra.composeapp.generated.resources.something_went_wrong
import mantra.composeapp.generated.resources.reply_to
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -138,7 +139,7 @@ fun TextNoteEventDetail(
Text( Text(
modifier = Modifier.weight(1f), 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, maxLines = 1,
) )
@@ -104,6 +104,10 @@ import mantra.composeapp.generated.resources.review
import mantra.composeapp.generated.resources.something_went_wrong import mantra.composeapp.generated.resources.something_went_wrong
import mantra.composeapp.generated.resources.waiting_for_your_signature import mantra.composeapp.generated.resources.waiting_for_your_signature
import mantra.composeapp.generated.resources.you 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( class ChatMessageListViewModel(
initialChatMessageListUIState: ChatMessageListUIState, initialChatMessageListUIState: ChatMessageListUIState,
@@ -668,7 +672,7 @@ class ChatMessageListViewModel(
) )
Text( Text(
text = if (localChatMessage.chatMessage.isUserMessage) { text = if (localChatMessage.chatMessage.isUserMessage) {
"Private to ${nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey)}" stringResource(Res.string.private_to, nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey))
} else { } else {
stringResource(Res.string.private_to_you) stringResource(Res.string.private_to_you)
}, },
@@ -733,7 +737,7 @@ class ChatMessageListViewModel(
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text("Reply privately to ${nameFor(localChatMessage.chatMessage.senderPublicKey)}") Text(stringResource(Res.string.reply_privately_to, nameFor(localChatMessage.chatMessage.senderPublicKey)))
}, },
leadingIcon = { leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = Decorative) Icon(Icons.Default.Lock, contentDescription = Decorative)
@@ -887,7 +891,7 @@ private fun ProposalsAwaitingYouNotice(
text = if (single != null) { text = if (single != null) {
stringResource(Res.string.waiting_for_your_signature) stringResource(Res.string.waiting_for_your_signature)
} else { } else {
"${proposals.size} proposals are waiting for your signature" stringResource(Res.string.proposals_are_waiting_for_your_signature, proposals.size)
}, },
style = MaterialTheme.typography.labelMedium style = MaterialTheme.typography.labelMedium
) )
@@ -948,7 +952,7 @@ private fun PrivateMessageNotice(
) )
Text( Text(
text = "$sender sent a private message to $recipient", text = stringResource(Res.string.sent_a_private_message_to, sender, recipient),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall style = MaterialTheme.typography.labelSmall
) )
@@ -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.currently_no_messages_have_been_shared
import mantra.composeapp.generated.resources.don_t_sign import mantra.composeapp.generated.resources.don_t_sign
import mantra.composeapp.generated.resources.something_went_wrong 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 org.jetbrains.compose.resources.getString
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -45,4 +48,31 @@ class StringCatalogueJvmTest {
fun `a plain string comes back unchanged`() = runBlocking { fun `a plain string comes back unchanged`() = runBlocking {
assertEquals("Something went wrong", getString(Res.string.something_went_wrong)) 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"),
)
}
} }
+41 -25
View File
@@ -608,35 +608,51 @@ neither an outline nor a checkmark, and adding one is component work.
### Phase 4 — text the system can translate ### Phase 4 — text the system can translate
**Why here.** It is the last phase that touches every file, and doing it after **Why here.** It is the last phase that touches every file, and doing it after phase 2
Phase 2 means one pass over each file instead of two. It must precede Phase 6: means one pass over each file instead of two. It must precede phase 6: RTL is a breakpoint
RTL is a breakpoint concern too, and there is no point testing a mirrored layout concern too, and there is no point testing a mirrored layout against 334 English literals.
against 334 English literals.
**Work.** **Built.** Three commits.
1. **Externalise all 334 strings** into `composeResources/values/strings.xml`, 1. **Sentence case, 100 occurrences across 60 strings.** Two passes, and the second is
organised by screen. Clear out the inherited Phoenix wallet strings that the instructive one: the first pattern required every word after the first to be
nothing references, and settle `app_name`. capitalised, so anything with an article survived — "Invite a Friend" was still on the
2. **Sentence case, everywhere.** "Edit profile", "Create profile", "New chat", app's first screen after the audit reported zero — and it read one line at a time, so a
"Sign in", "Leave group", "Key package management", "Publish new key `Text(` whose literal sat on the next line was invisible.
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.
**Done when** the audit reports fewer than ten string literals in composables Sample data is deliberately left in title case: "Steve Biko", "To Kill a Mockingbird".
(test data and previews), and no user-facing string uses title case. Those are a person and a book.
**Risk:** low, high volume. Sentence-casing is the part most likely to draw 2. **251 plain strings into the catalogue**, 315 call sites. The extractor took four
disagreement — settle the product name first, in one decision. 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.
--- ---
+239
View File
@@ -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'<string name="([^"]+)"', existing))
names = {}
for fmt in sorted(plan):
# name from the literal half only -- "%1$s" contributes nothing readable
names[fmt] = resource_name(re.sub(r'%\d+\$s', ' ', fmt), taken)
taken.add(names[fmt])
print(f'{len(plan)} distinct format strings, {sum(plan.values())} occurrences')
if DRY:
for fmt in sorted(plan)[:12]:
print(f' {names[fmt]:44s} {fmt[:64]!r}')
return 0
rewritten = 0
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 = io.open(path, encoding='utf-8').read()
found = candidates(src)
if not found:
continue
for start, end, _body, exprs, fmt in reversed(found):
args = ', '.join(exprs)
src = src[:start] + f'stringResource(Res.string.{names[fmt]}, {args})' + src[end:]
rewritten += 1
need = sorted({names[fmt] for *_x, fmt in [(c[0], c[4]) for c in found]})
for imp in (['import mantra.composeapp.generated.resources.Res',
'import org.jetbrains.compose.resources.stringResource']
+ [f'import mantra.composeapp.generated.resources.{n}' for n in need]):
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)
src = '\n'.join(lines)
io.open(path, 'w', encoding='utf-8').write(src)
print(f'{rewritten} call sites rewritten')
entries = '\n'.join(
f' <string name="{names[fmt]}">'
f'{fmt.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")}</string>'
for fmt in sorted(plan, key=lambda x: names[x]))
io.open(STRINGS, 'w', encoding='utf-8').write(
existing.replace('</resources>', entries + '\n</resources>'))
print(f'{len(plan)} entries written')
return 0
if __name__ == '__main__':
sys.exit(main())
+14 -2
View File
@@ -193,6 +193,18 @@ def xml_escape(text):
return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;') return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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(): def main():
occurrences = collections.defaultdict(list) occurrences = collections.defaultdict(list)
for root, _, files in os.walk(UI): for root, _, files in os.walk(UI):
@@ -233,7 +245,7 @@ def main():
if changed: if changed:
for imp in ('import mantra.composeapp.generated.resources.Res', for imp in ('import mantra.composeapp.generated.resources.Res',
'import org.jetbrains.compose.resources.stringResource'): 'import org.jetbrains.compose.resources.stringResource'):
if imp not in src: if not has_import(src, imp):
lines = src.split('\n') lines = src.split('\n')
idx = max(i for i, l in enumerate(lines) if l.startswith('import ')) idx = max(i for i, l in enumerate(lines) if l.startswith('import '))
lines.insert(idx + 1, imp) lines.insert(idx + 1, imp)
@@ -245,7 +257,7 @@ def main():
idx = max(i for i, l in enumerate(lines) if l.startswith('import ')) idx = max(i for i, l in enumerate(lines) if l.startswith('import '))
for nm in reversed(need): for nm in reversed(need):
imp = f'import mantra.composeapp.generated.resources.{nm}' imp = f'import mantra.composeapp.generated.resources.{nm}'
if imp not in src: if not has_import(src, imp):
lines.insert(idx + 1, imp) lines.insert(idx + 1, imp)
src = '\n'.join(lines) src = '\n'.join(lines)
io.open(path, 'w', encoding='utf-8').write(src) io.open(path, 'w', encoding='utf-8').write(src)