diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt index 1a09371b..0e3c5a1b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt @@ -46,6 +46,7 @@ import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository @@ -109,16 +110,18 @@ fun AddTranslationArtifactVersionScreen( val artifactVersion = addTranslationUIState.artifactVersion val chapterCount = addTranslationUIState.chapters.size - // The translation and a chapter apiece are signed in one session, - // and a session signs a bounded number of events. Past that the - // artifact cannot be translated in one go at all, which is worth - // saying here rather than after a failed propose. - val tooManyChapters = chapterCount > AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION + // The translation and a chapter apiece are signed together, and a + // session signs a bounded number of events -- so a long artifact is + // proposed in more than one, and the admins answer once per + // session. That is a number worth seeing before the tap. + val sessionCount = 1 + ( + (chapterCount - AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION) + .coerceAtLeast(0) + FrostSigningManager.MAX_BATCH_SIZE - 1 + ) / FrostSigningManager.MAX_BATCH_SIZE val canProposeTranslation = artifactVersion != null && selectedDialect != null && - addTranslationUIState.canSign && - !tooManyChapters + addTranslationUIState.canSign // M3 gives a FAB no `enabled`, so borrow the disabled colours every // other button in the app uses rather than inventing a shade here. @@ -220,16 +223,6 @@ fun AddTranslationArtifactVersionScreen( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) - } else if (tooManyChapters) { - Text( - text = "$chapterCount chapters is more than the group can sign in " + - "one go, so this artifact cannot be translated here. It signs " + - "at most " + - "${AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION} " + - "chapters at a time.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) } Text("Translate into which dialect?") @@ -271,16 +264,11 @@ fun AddTranslationArtifactVersionScreen( "into it as they are added." } else { "The group signs the translation and " + - "$chapterCount ${if (chapterCount == 1) "chapter" else "chapters"} " + - "of ${AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION} " + - "together. Chunks are translated one at a time afterwards." + "$chapterCount ${if (chapterCount == 1) "chapter" else "chapters"}" + + (if (sessionCount == 1) "" else ", in $sessionCount sessions to sign") + + ". Chunks are translated one at a time afterwards." }, style = MaterialTheme.typography.labelMedium, - color = if (tooManyChapters) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.typography.labelMedium.color - } ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddTranslationArtifactVersionViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddTranslationArtifactVersionViewModel.kt index ee58d673..86df1fd9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddTranslationArtifactVersionViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddTranslationArtifactVersionViewModel.kt @@ -104,14 +104,6 @@ class AddTranslationArtifactVersionViewModel( onSuccess: (sessionId: String) -> Unit, onFailure: () -> Unit ) { - // The cap is the group's, not this screen's, and proposing past it - // throws rather than failing softly. The form says so before the tap; - // this is the check that has to hold when it does not. - if (chapters.size > MAX_CHAPTERS_PER_TRANSLATION) { - onFailure.invoke() - return - } - // Guard against double submits from repeated FAB taps. if (isActionPending.value) return isActionPending.value = true @@ -141,7 +133,8 @@ class AddTranslationArtifactVersionViewModel( dependents = { translation -> TranslationScaffold.chaptersOf( translationArtifactVersionIds = listOf(translation.id), - chapters = chapters.map(TranslationScaffold.SourceChapter::of), + chapters = chapters.take(MAX_CHAPTERS_PER_TRANSLATION) + .map(TranslationScaffold.SourceChapter::of), createdAt = translation.createdAt, ) } @@ -151,6 +144,15 @@ class AddTranslationArtifactVersionViewModel( }.getOrNull() if (session != null) { + // Before navigating, not after: the route this screen sits on + // is popped on success, which clears this view model and takes + // `viewModelScope` with it. + scaffoldRemainingChapters( + localChatRoom = localChatRoom, + chapters = chapters.drop(MAX_CHAPTERS_PER_TRANSLATION), + translationSessionId = session.id, + ) + viewModelScope.launch(Dispatchers.Main) { onSuccess.invoke(session.id) } @@ -164,17 +166,70 @@ class AddTranslationArtifactVersionViewModel( } } + /** + * Asks the group for the chapters that did not fit the translation's own + * session, in sessions of their own. + * + * The alternative was refusing to translate an artifact of more than + * [MAX_CHAPTERS_PER_TRANSLATION] chapters at all, which is a cap on how + * long a book may be dressed up as a cap on a batch. A group with a longer + * one answers more than once instead. + * + * The translation is named as the group will author it: the first item of + * [translationSessionId] is the unsigned translation, and its id is settled + * at proposal time rather than when the quorum arrives. + * + * These carry the same cost as any second session, and the same one the + * chapter side carries: if the translation itself fails to reach a quorum + * these are valid signatures over rows naming a translation nobody has. + * They fail a foreign key on the way in, are logged, and never become rows + * -- and the catch-up on the translation is what fills the gap afterwards. + */ + private suspend fun scaffoldRemainingChapters( + localChatRoom: LocalChatRoom, + chapters: List, + translationSessionId: String, + ) { + if (chapters.isEmpty()) return + + runCatching { + val translation = frostSigningRepository + .unsignedEvents(frostSigningRepository.getItems(translationSessionId)) + .firstOrNull() ?: return@runCatching + + chapters.chunked(FrostSigningManager.MAX_BATCH_SIZE).forEach { batch -> + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + events = TranslationScaffold.chaptersOf( + translationArtifactVersionIds = listOf(translation.id), + chapters = batch.map(TranslationScaffold.SourceChapter::of), + createdAt = translation.createdAt, + ) + ) + } + }.onFailure { error -> + logger.e( + "Proposed the translation but could not ask for the chapters past the first session", + error + ) + } + } + companion object { private const val TAG = "AddTranslationViewModel" /** - * The most chapters an artifact can be translated with in one session. + * How many chapters ride in the translation's own session. * * The translation is signed together with a chapter apiece, and a * session signs at most [FrostSigningManager.MAX_BATCH_SIZE] events. The - * translation is one of them, so the chapters get the rest. Unlike a - * chapter's paragraphs this is not something whoever is looking at the - * screen can shorten, so it is said plainly rather than as advice. + * translation is one of them, so the chapters get the rest. + * + * A cap on the session, not on the work: an artifact with more chapters + * than this is not refused, it is proposed in more than one session. A + * limit whoever is looking at the screen can do nothing about is not a + * limit worth telling them to work around. */ const val MAX_CHAPTERS_PER_TRANSLATION: Int = FrostSigningManager.MAX_BATCH_SIZE - 1 diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt index dee6f2cf..f215bb2c 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt @@ -380,14 +380,54 @@ class TranslationBatchProposalJvmTest { } @Test - fun `an artifact of one chapter too many is refused`() = runBlocking { + fun `an artifact longer than one session is split across two`() = runBlocking { openDevice() - // Refused here rather than truncated: a batch that quietly dropped its - // last chapters would sign a translation the group believes covers the - // whole work while the end of it can never be translated. The screen - // checks first so this is never what a member sees, but the screen is - // not what enforces it. + // What `addTranslation` does with an artifact past the cap: the + // translation and as many chapters as fit, then the rest naming the + // translation the first session is about to sign. Refusing instead + // would be a cap on how long a book may be, dressed up as a cap on a + // batch. + val chapters = sourceChapters(AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION + 3) + + val first = proposeTranslation( + chapters.take(AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION) + ) + val translation = itemEvents(first.id).first() + val second = FrostSigningManager.proposeSigningBatch( + database = db, + localChatRoom = room, + userPublicKey = proposer, + events = TranslationScaffold.chaptersOf( + translationArtifactVersionIds = listOf(translation.id), + chapters = chapters.drop(AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION) + .map(TranslationScaffold.SourceChapter::of), + createdAt = translation.createdAt, + ) + ) + + assertEquals(FrostSigningManager.MAX_BATCH_SIZE, itemEvents(first.id).size) + assertEquals(3, itemEvents(second.id).size) + + // Every chapter of the work is covered exactly once, across both, and + // each names the translation as the group will author it. + val scaffolded = (itemEvents(first.id).drop(1) + itemEvents(second.id)).map { + TranslationChapterEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig) + } + assertEquals(chapters.map { it.id }, scaffolded.map { it.chapterId() }) + assertEquals(chapters.map { it.index }, scaffolded.map { it.index() }) + assertTrue(scaffolded.all { it.translationArtifactVersionId() == translation.id }) + } + + @Test + fun `one session is still refused a chapter too many`() = runBlocking { + openDevice() + + // Refused rather than truncated: a batch that quietly dropped its last + // chapters would sign a translation the group believes covers the whole + // work while the end of it can never be translated. The screen splits + // before it gets here, so this is never what a member sees -- but the + // screen is not what enforces it. assertFailsWith { proposeTranslation(sourceChapters(FrostSigningManager.MAX_BATCH_SIZE)) }