From df058dc61ca3ac24c5bc1119e625c3b18e4b448b Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 12:03:54 +0200 Subject: [PATCH] feat: let a translation ask for the chapters it is missing A translation is scaffolded from both ends now -- the chapters that existed when it was proposed, and each chapter signed afterwards putting itself in. Neither end closes the gap on its own, and no snapshot taken at proposal time can. Two ways they miss each other. A chapter and a translation proposed at the same moment each read what exists when they are proposed, so neither sees the other and nothing retries. And a scaffolding session that fails to reach a quorum leaves nothing behind to try again with -- the chapter's id is spent, since a re-proposed chapter is a different one. So the translation's own screen says what it is missing and offers to ask for it. Above the chapter list rather than below, because a chapter that is not in a translation is invisible from a list of the ones that are: the whole failure is that nothing looks wrong. **Matched on the chapter named, not counted.** `chaptersMissingFrom` compares which source chapter each translation chapter stands for. A count would read a translation that is missing its second chapter but picked up its third as one missing its last, and would then scaffold the wrong chapter -- leaving the real gap open and a duplicate beside it. It also means running a catch-up on a translation that is already complete proposes nothing at all, rather than a second copy of every chapter under fresh ids. **More sessions rather than a cap.** The missing chapters are chunked at MAX_BATCH_SIZE, one session each. A translation far enough behind to need more than a batch holds is not one to refuse; it is one the group answers for more than once. They share a timestamp, so a catch-up reads as the one act it is. The screen lands on the first session -- the rest are beside it in the room's list -- and the card stays until a quorum arrives, which is honest: the chapters are still missing until then. **Tests.** Two more in TranslationScaffoldTest, both about the matching rather than the counting: a gap in the middle, and a complete translation being missing nothing. Checked against a broken implementation -- taking the missing chapters as the tail after a count passes on a translation that fell behind at the end, which is the easy case, and is caught by the gap. Not covered: `catchUpMissingChapters` itself, which is plumbing over the templates those tests pin and the batch API the jvm tests pin. Co-Authored-By: Claude Opus 5 --- .../compose/managers/TranslationScaffold.kt | 30 ++++- .../TranslationArtifactVersionDetailScreen.kt | 80 +++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 2 + ...anslationArtifactVersionDetailViewModel.kt | 108 ++++++++++++++++-- ...TranslationArtifactVersionDetailUIState.kt | 24 ++++ .../managers/TranslationScaffoldTest.kt | 56 ++++++++- 6 files changed, 279 insertions(+), 21 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/TranslationScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/TranslationScaffold.kt index 62fb0d05..84c89849 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/TranslationScaffold.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/TranslationScaffold.kt @@ -3,6 +3,7 @@ package press.mantra.compose.managers import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import press.mantra.compose.database.model.MantraChapter +import press.mantra.compose.database.model.MantraTranslationChapter import press.mantra.compose.nostr.nip30303.TranslationChapterEvent /** @@ -15,9 +16,10 @@ import press.mantra.compose.nostr.nip30303.TranslationChapterEvent * * Both ends of that join arrive on their own schedule -- a chapter is signed * into an artifact that already has translations, a translation is started on - * an artifact that already has chapters -- so the same scaffolding has to be - * built from either side. One function for both, because two copies of a cross - * product is two chances to disagree about what a translation covers. + * an artifact that already has chapters -- so the same scaffolding is built + * from either side and, when the two miss each other, afterwards. One function + * for all three, because three copies of a cross product is three chances to + * disagree about what a translation covers. */ object TranslationScaffold { @@ -62,13 +64,29 @@ object TranslationScaffold { TranslationChapterEvent.build( translationArtifactVersionId = translationArtifactVersionId, chapterId = chapter.id, - // The source chapter's own position, never this list's: - // a caller may hold a subset, and counting the list would - // renumber the chapters it happens to be holding. + // The source chapter's own position, never this list's: a + // catch-up proposes a subset, so counting the list would + // renumber the chapters it happens to be filling in. index = chapter.index, createdAt = createdAt, ) } } + /** + * The source chapters a translation has nothing for yet. + * + * Matched on the source chapter each translation chapter names rather than + * on how many there are of each. A translation that is missing its second + * chapter and has picked up its third is not a translation that is missing + * its last one, and a count would say it was. + */ + fun chaptersMissingFrom( + translationChapters: List, + sourceChapters: List, + ): List { + val covered = translationChapters.mapTo(mutableSetOf()) { it.chapterId } + + return sourceChapters.filterNot { it.id in covered } + } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt index c93680c6..2bb9427b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Article +import androidx.compose.material.icons.filled.PlaylistAdd import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -22,6 +23,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -33,7 +35,10 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute import press.mantra.compose.ui.composable.widgets.DetailRow @@ -51,6 +56,8 @@ fun TranslationArtifactVersionDetailScreen( relayHint: String?, initialTranslationArtifactVersionDetailUIState: TranslationArtifactVersionDetailUIState = TranslationArtifactVersionDetailUIState.Loading, mantraRepository: MantraRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, onNavigateToRoute: (Route) -> Unit, onNavigateBack: () -> Unit, ) { @@ -62,6 +69,8 @@ fun TranslationArtifactVersionDetailScreen( relayHint = relayHint, initialTranslationArtifactVersionDetailUIState = initialTranslationArtifactVersionDetailUIState, mantraRepository = mantraRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, ) ) @@ -97,6 +106,75 @@ fun TranslationArtifactVersionDetailScreen( modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { + // Chapters the source version has and this translation + // does not. Above the list rather than after it: a chapter + // that is not here is invisible from a list of the ones + // that are. + val localChatRoom = translationDetailUIState.localChatRoom + val missingChapters = translationDetailUIState.missingChapters + + if (missingChapters.isNotEmpty()) { + item { + Card { + ListItem( + leadingContent = { + Icon( + Icons.Default.PlaylistAdd, + contentDescription = "Chapters missing from this translation" + ) + }, + headlineContent = { + Text( + "${missingChapters.size} " + + (if (missingChapters.size == 1) "chapter is" else "chapters are") + + " not in this translation yet" + ) + }, + supportingContent = { + Text( + if (!translationDetailUIState.canSign) { + "This group has no shared key, so it cannot sign them in." + } else { + "They were signed into the artifact after this " + + "translation. Ask the group to add them so they " + + "can be translated." + } + ) + }, + trailingContent = { + TextButton( + enabled = localChatRoom != null && + translationDetailUIState.canSign && + !translationDetailViewModel.isActionPending.value, + onClick = { + if (localChatRoom == null) return@TextButton + + translationDetailViewModel.catchUpMissingChapters( + localChatRoom = localChatRoom, + missingChapters = missingChapters, + onSuccess = { sessionId -> + onNavigateToRoute.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = {} + ) + } + ) { + Text("Propose") + } + } + ) + } + } + + item { HorizontalDivider() } + } + // Chapters with translation progress item { Text( @@ -212,6 +290,8 @@ private fun TranslationArtifactVersionDetailScreenPreview() { ) ), mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, onNavigateToRoute = {}, onNavigateBack = {} ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index be061fcf..e0ada82a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -932,6 +932,8 @@ fun MantraNavHost( chatRoomId = route.chatRoomId, relayHint = route.relayHint, mantraRepository = databaseMantraRepository, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, onNavigateToRoute = { actionRoute -> navController.navigate( route = actionRoute diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt index 3e0acde8..d959e224 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt @@ -1,5 +1,6 @@ package press.mantra.compose.ui.view.model +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -10,9 +11,16 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch +import press.mantra.compose.database.model.MantraChapter +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.managers.TranslationScaffold +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.view.state.TranslationChapterProgress import press.mantra.compose.ui.view.state.TranslationArtifactVersionDetailUIState @@ -24,6 +32,8 @@ class TranslationArtifactVersionDetailViewModel( val relayHint: String?, initialTranslationArtifactVersionDetailUIState: TranslationArtifactVersionDetailUIState, val mantraRepository: MantraRepository, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, ): ViewModel() { var translationDetailUIState: TranslationArtifactVersionDetailUIState by mutableStateOf(initialTranslationArtifactVersionDetailUIState) @@ -31,6 +41,8 @@ class TranslationArtifactVersionDetailViewModel( private val logger = Logger.withTag(TAG) + val isActionPending: MutableState = mutableStateOf(false) + fun initiateTranslationArtifactVersionDetail() { logger.d("initiateTranslationArtifactVersionDetail: $translationArtifactVersionId") viewModelScope.launch(Dispatchers.IO) { @@ -39,23 +51,97 @@ class TranslationArtifactVersionDetailViewModel( translationDetailUIState = if (translation == null) { TranslationArtifactVersionDetailUIState.Error("Couldn't find the translation") } else { - val chapters = mantraRepository.getTranslationChapters(translationArtifactVersionId) - .map { chapter -> - val chunks = mantraRepository.getTranslationChunks(chapter.id) - TranslationChapterProgress( - chapter = chapter, - totalChunks = chunks.size, - translatedChunks = chunks.count { it.text.isNotBlank() }, - ) - } + val translationChapters = mantraRepository.getTranslationChapters(translationArtifactVersionId) + val chapters = translationChapters.map { chapter -> + val chunks = mantraRepository.getTranslationChunks(chapter.id) + TranslationChapterProgress( + chapter = chapter, + totalChunks = chunks.size, + translatedChunks = chunks.count { it.text.isNotBlank() }, + ) + } + TranslationArtifactVersionDetailUIState.Loaded( translation = translation, chapters = chapters, + localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId), + missingChapters = TranslationScaffold.chaptersMissingFrom( + translationChapters = translationChapters, + sourceChapters = mantraRepository + .getChaptersForArtifactVersion(translation.artifactVersionId), + ), + canSign = frostSigningRepository.canSign(chatRoomId), ) } } } + /** + * Asks the group to put the chapters this translation is missing into it. + * + * The scaffolding a chapter is signed with covers the translations that + * existed when it was proposed, and the scaffolding a translation is signed + * with covers the chapters that existed when *it* was proposed. Two things + * proposed at the same moment see neither the other, and a session that + * fails to reach a quorum leaves nothing behind to try again with. Neither + * is a bug that can be designed out of a snapshot taken at proposal time -- + * so this is the pass that reconciles them, run when somebody notices. + * + * It proposes only what is missing, matched on the source chapter each + * translation chapter names, so running it on a translation that is already + * complete proposes nothing rather than a second copy of everything. + */ + fun catchUpMissingChapters( + localChatRoom: LocalChatRoom, + missingChapters: List, + onSuccess: (sessionId: String) -> Unit, + onFailure: () -> Unit + ) { + if (missingChapters.isEmpty()) { + onFailure.invoke() + return + } + + // Guard against double submits from repeated taps. + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + // One timestamp for the lot, so a catch-up reads as the one act it + // is rather than as chapters that happen to share a minute. + val createdAt = TimeUtils.now() + + val sessions = runCatching { + // One session per MAX_BATCH_SIZE chapters. A translation far + // enough behind to need more than a batch holds is not one to + // refuse; it is one the group answers for more than once. + missingChapters.chunked(FrostSigningManager.MAX_BATCH_SIZE).mapNotNull { batch -> + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + events = TranslationScaffold.chaptersOf( + translationArtifactVersionIds = listOf(translationArtifactVersionId), + chapters = batch.map(TranslationScaffold.SourceChapter::of), + createdAt = createdAt, + ) + ) + } + }.onFailure { error -> + logger.e("Failed to propose this translation's missing chapters", error) + }.getOrNull().orEmpty() + + // The first, because it is the one that opened; the rest are in the + // room's session list beside it. + val session = sessions.firstOrNull() + + viewModelScope.launch(Dispatchers.Main) { + if (session != null) onSuccess.invoke(session.id) else onFailure.invoke() + } + + isActionPending.value = false + } + } + companion object { private const val TAG = "TranslationArtifactVersionDetailViewModel" @@ -66,6 +152,8 @@ class TranslationArtifactVersionDetailViewModel( relayHint: String?, initialTranslationArtifactVersionDetailUIState: TranslationArtifactVersionDetailUIState = TranslationArtifactVersionDetailUIState.Loading, mantraRepository: MantraRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, ): ViewModelProvider.Factory = viewModelFactory { initializer { TranslationArtifactVersionDetailViewModel( @@ -75,6 +163,8 @@ class TranslationArtifactVersionDetailViewModel( relayHint = relayHint, initialTranslationArtifactVersionDetailUIState = initialTranslationArtifactVersionDetailUIState, mantraRepository = mantraRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt index 94204fc8..af891f2c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt @@ -1,7 +1,9 @@ package press.mantra.compose.ui.view.state +import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraTranslationArtifactVersion import press.mantra.compose.database.model.MantraTranslationChapter +import press.mantra.compose.database.model.intermdiate.LocalChatRoom /** A translation chapter with how many of its chunks have been translated. */ data class TranslationChapterProgress( @@ -14,6 +16,28 @@ sealed interface TranslationArtifactVersionDetailUIState { data class Loaded( val translation: MantraTranslationArtifactVersion, val chapters: List = emptyList(), + + /** + * The room this translation belongs to, which is who would sign the + * chapters it is missing. Null when it cannot be read, which is a + * translation nothing can be proposed for. + */ + val localChatRoom: LocalChatRoom? = null, + + /** + * Chapters of the source version this translation has nothing for. + * + * A translation covers the chapters that existed when it was proposed, + * and a chapter signed afterwards is scaffolded into it by a session of + * that chapter's own -- which the two can miss, by being proposed at the + * same moment or by one of them not reaching a quorum. This is what is + * left over when they do, and it is shown rather than inferred because + * a chapter nobody can translate is invisible from the translation. + */ + val missingChapters: List = emptyList(), + + /** Whether the group holds a shared key, without which nothing can be proposed. */ + val canSign: Boolean = false, ): TranslationArtifactVersionDetailUIState data class Error( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt index 83ef6e38..3beec9f7 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt @@ -4,6 +4,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue import press.mantra.compose.database.model.MantraChapter +import press.mantra.compose.database.model.MantraTranslationChapter import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.tags.ChapterIdTag import press.mantra.compose.nostr.nip30303.tags.IndexTag @@ -12,10 +13,11 @@ import press.mantra.compose.nostr.nip30303.tags.TranslationArtifactVersionIdTag /** * The join between a translation and the chapters it is of, as a pure function. * - * Both ends arrive on their own schedule, so this is built from two - * directions -- a translation being proposed, and a chapter being proposed. - * What it must not do is disagree with itself about what a translation covers - * depending on which direction it was built from. + * Both ends arrive on their own schedule, so this is built from three + * directions -- a translation being proposed, a chapter being proposed, and a + * catch-up filling in what the two missed. What it must not do is disagree with + * itself about what a translation covers depending on which direction it was + * built from. */ class TranslationScaffoldTest { @@ -35,6 +37,17 @@ class TranslationScaffoldTest { chatRoomId = "r".repeat(64), ) + private fun translationChapterFor(chapter: MantraChapter, translationId: String) = + MantraTranslationChapter( + id = "t${chapter.id}".take(64), + publicKey = "p".repeat(64), + chapterId = chapter.id, + translationArtifactVersionId = translationId, + index = chapter.index, + signature = "", + chatRoomId = "r".repeat(64), + ) + private fun parse(templates: List>) = templates.map { template -> Triple( @@ -90,10 +103,10 @@ class TranslationScaffoldTest { } @Test - fun `a chapter keeps its own position, not its position in the list`() { + fun `a catch-up keeps each chapter's own position, not the position in the gap`() { val chapters = (0..5).map(::sourceChapter) - // A caller may hold a subset, and the subset is not contiguous. + // A catch-up proposes a subset, and the subset is not contiguous. // Counting the list would renumber chapters 3 and 5 as 1 and 2, which // is a translation that reads in a different order than the work. val templates = TranslationScaffold.chaptersOf( @@ -136,4 +149,35 @@ class TranslationScaffoldTest { ).isEmpty() ) } + + @Test + fun `what is missing is matched on the chapter named, not on how many there are`() { + val chapters = (0..3).map(::sourceChapter) + + // Chapter 1 never landed but chapter 2 did, which a count would read as + // "the last one is missing" and scaffold the wrong chapter. + val missing = TranslationScaffold.chaptersMissingFrom( + translationChapters = listOf(chapters[0], chapters[2]).map { + translationChapterFor(it, translationA) + }, + sourceChapters = chapters, + ) + + assertEquals(listOf(chapters[1].id, chapters[3].id), missing.map { it.id }) + } + + @Test + fun `a translation that is already complete is missing nothing`() { + val chapters = (0..2).map(::sourceChapter) + + // So that running a catch-up twice proposes nothing the second time, + // rather than a second copy of every chapter under fresh ids. + assertEquals( + emptyList(), + TranslationScaffold.chaptersMissingFrom( + translationChapters = chapters.map { translationChapterFor(it, translationA) }, + sourceChapters = chapters, + ) + ) + } }