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:
@@ -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="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="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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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<Event>, 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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
239
docs/scripts/m3-extract-formatted.py
Executable file
239
docs/scripts/m3-extract-formatted.py
Executable 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("&", "&").replace("<", "<").replace(">", ">")}</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())
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user