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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 12:03:54 +02:00
parent 66119f34df
commit df058dc61c
6 changed files with 279 additions and 21 deletions

View File

@@ -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<MantraTranslationChapter>,
sourceChapters: List<MantraChapter>,
): List<MantraChapter> {
val covered = translationChapters.mapTo(mutableSetOf()) { it.chapterId }
return sourceChapters.filterNot { it.id in covered }
}
}

View File

@@ -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 = {}
)

View File

@@ -932,6 +932,8 @@ fun MantraNavHost(
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
mantraRepository = databaseMantraRepository,
chatRepository = databaseChatRepository,
frostSigningRepository = databaseFrostSigningRepository,
onNavigateToRoute = { actionRoute ->
navController.navigate(
route = actionRoute

View File

@@ -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<Boolean> = 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<MantraChapter>,
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,
)
}
}

View File

@@ -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<TranslationChapterProgress> = 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<MantraChapter> = emptyList(),
/** Whether the group holds a shared key, without which nothing can be proposed. */
val canSign: Boolean = false,
): TranslationArtifactVersionDetailUIState
data class Error(

View File

@@ -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<com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<TranslationChapterEvent>>) =
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,
)
)
}
}