From 2f8df359a809f70dff9ffe22219c362a07f465f8 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:12:34 +0200 Subject: [PATCH] feat(chapters): upload several files, one proposal each Phase 4 of docs/chapter-uploads.md: the feature. A review step rather than a submit button, and that is the one place this departs visibly from the web app it mirrors. mantra.press can post twelve files and redirect, because what it creates belongs to the uploader and the uploader can fix it. Here each file becomes a request for the group's own signature, so a filename standing in for a title, a file that decoded to nothing, or a chapter too long to carry is wrong in something a quorum is about to put its name to. Picking files produces a list; the list is what is proposed. Per-row problems, because a bad file should not cost the other eleven: not text, too large, nothing to translate, no name, more chunks than a session signs, more bytes than a proposal carries -- and a duplicate, which warns rather than blocks. A book can repeat a page, but the common cause is picking the same directory twice, and two chapters with the same text are two event ids nothing later can merge. Proposals go out sequentially, never concurrently: each writes a session and publishes group events, and a group event that arrives a moment out of order is dropped for good (docs/mls-skipped-keys.md). Indices are allocated once from a freshly read base, so numbering does not depend on how fast the sessions are written. Partial success is the expected outcome and not an error. Nothing can retract a proposal, so if the fourth fails the first three are real and the group can still sign them: no rollback, no navigating away, the rows that went are marked and the rest keep their reason, which is the only place a retry can be aimed from. A pass that fully succeeds lands on the proposal list rather than a signing screen -- at N there is no one session to watch. Two shapes the writing settled. UploadReview holds the row building and the problem decisions, which are pure functions of the files, the names typed over them and the chapters the version already has; the view model keeps only the orchestration, and the 13 tests need no view-model harness. And uiState is a StateFlow, not `by mutableStateOf`: initiate() writes from Dispatchers.IO after a primary-key lookup, which is exactly the few milliseconds in which a write from another thread into a composition's snapshot is silently discarded -- the bug that once left this app on "Decrypting..." for ever. Scaffolding into translations is not here yet; Phase 5 lifts it out of AddChapterViewModel so one batch covers every chapter rather than one session each. Until then an uploaded chapter reaches a translation through that translation's own catch-up. m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 22 + .../ui/composable/ArtifactDetailScreen.kt | 76 ++- .../ui/composable/UploadChaptersScreen.kt | 559 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 29 + .../navigation/routes/UploadChaptersRoute.kt | 19 + .../ui/view/model/UploadChaptersViewModel.kt | 332 +++++++++++ .../ui/view/state/UploadChaptersUIState.kt | 165 ++++++ .../compose/ui/view/state/UploadReview.kt | 121 ++++ .../compose/ui/view/state/UploadReviewTest.kt | 180 ++++++ 9 files changed, 1485 insertions(+), 18 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/UploadChaptersRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadChaptersUIState.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadReview.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 161e370e..8a55b6cf 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -501,4 +501,26 @@ %1$d words · %2$d characters · %3$s · %4$d of %5$d chunks %1$s of %2$s %1$s KiB + Upload chapters + Upload chapters to %1$s + Choose files + Add more files + No files picked yet. + Every file becomes its own proposal, and each one needs a quorum of the group to sign it. Twelve files is twelve things every other member is asked to read and approve. + At most %1$d files in one upload. + Propose %1$d chapters + Propose the remaining %1$d + %1$d proposed, %2$d failed. + %1$d chapters proposed. + Chapter %1$d of the version + Waiting for the group to sign + Remove from this upload + Not text. A chapter is markdown or plain text. + Too large to be a chapter. + Nothing in it to translate. + Give this chapter a name. + %1$d chunks, and the group signs at most %2$d in one go. Split this file. + %1$s, and a group message carries %2$s. Split this file. + The same text as %1$s. Proposing both makes two chapters. + Proposing this one failed. Try again. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt index b8fd9da9..06c128fd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt @@ -1,6 +1,7 @@ package press.mantra.compose.ui.composable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -36,7 +37,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.composable.widgets.DetailRow +import androidx.compose.material.icons.filled.UploadFile +import mantra.composeapp.generated.resources.upload_chapters import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute +import press.mantra.compose.ui.composable.navigation.routes.UploadChaptersRoute import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute import press.mantra.compose.ui.composable.navigation.routes.Route @@ -162,25 +166,61 @@ fun ArtifactDetailScreen( } item { - TextButton( - enabled = artifactDetailUIState.versions.isNotEmpty(), - onClick = { - onNavigateToRoute.invoke( - AddChapterRoute( - activeUserPublicKey = activeUserPublicKey, - artifactId = artifact.id, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - ) { - Icon( - Icons.Default.PostAdd, - contentDescription = "Add chapter" + // Two ways in, because they are two different acts. + // One chapter typed or pasted is a text field and one + // proposal; a folder of files is a review list and one + // proposal each, which is a different thing to put in + // front of the room. + Row( + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.itemGap ) - Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) - Text(stringResource(Res.string.add_chapter)) + ) { + TextButton( + enabled = artifactDetailUIState.versions.isNotEmpty(), + onClick = { + onNavigateToRoute.invoke( + AddChapterRoute( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifact.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.PostAdd, + contentDescription = "Add chapter" + ) + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + Text(stringResource(Res.string.add_chapter)) + } + + TextButton( + enabled = artifactDetailUIState.versions.isNotEmpty(), + onClick = { + onNavigateToRoute.invoke( + UploadChaptersRoute( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifact.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.UploadFile, + contentDescription = "Upload chapters from files" + ) + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + Text(stringResource(Res.string.upload_chapters)) + } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreen.kt new file mode 100644 index 00000000..23ae2785 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreen.kt @@ -0,0 +1,559 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.HourglassTop +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +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 +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.add_more_files +import mantra.composeapp.generated.resources.at_most_d_files_in_one_upload +import mantra.composeapp.generated.resources.chapter_d_of_the_version +import mantra.composeapp.generated.resources.chapter_name +import mantra.composeapp.generated.resources.choose_chapter_files +import mantra.composeapp.generated.resources.choose_files +import mantra.composeapp.generated.resources.d_chapters_proposed +import mantra.composeapp.generated.resources.d_chunks_is_too_many_to_sign +import mantra.composeapp.generated.resources.d_proposed_d_failed +import mantra.composeapp.generated.resources.no_files_picked_yet +import mantra.composeapp.generated.resources.one_proposal_per_file +import mantra.composeapp.generated.resources.propose_d_chapters +import mantra.composeapp.generated.resources.propose_the_remaining_d +import mantra.composeapp.generated.resources.proposing_this_one_failed +import mantra.composeapp.generated.resources.remove_from_this_upload +import mantra.composeapp.generated.resources.s_is_more_than_a_proposal_carries +import mantra.composeapp.generated.resources.s_kib +import mantra.composeapp.generated.resources.same_text_as_s +import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a_chapter +import mantra.composeapp.generated.resources.this_file_has_no_name +import mantra.composeapp.generated.resources.this_file_is_empty +import mantra.composeapp.generated.resources.this_file_is_not_text +import mantra.composeapp.generated.resources.this_file_is_too_large +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_for_a_chapter +import mantra.composeapp.generated.resources.upload_chapters_to +import mantra.composeapp.generated.resources.waiting_for_the_group_to_sign +import mantra.composeapp.generated.resources.words_characters +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.files.rememberFilePicker +import press.mantra.compose.nostr.nip30303.ChapterProposal +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.text.kibibytes +import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.UploadChaptersViewModel +import press.mantra.compose.ui.view.state.PickedChapter +import press.mantra.compose.ui.view.state.UploadChaptersUIState +import press.mantra.compose.ui.view.state.UploadProblem +import press.mantra.compose.ui.view.state.UploadReview + +/** + * Picking files, reviewing what they would become, and putting each to the + * group. + * + * **A review step, not a submit button**, and that is where this departs + * visibly from the web app it mirrors. mantra.press can afford to post twelve + * files and redirect, because what it creates belongs to the uploader and the + * uploader can fix it. Here each file becomes a request for the group's own + * signature, so a filename standing in for a title, a file that decoded to + * nothing, or a chapter too long to carry is wrong in something a quorum is + * about to put its name to. Picking files therefore produces a list, and the + * list is what is proposed. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UploadChaptersScreen( + activeUserPublicKey: HexKey, + artifactId: String, + chatRoomId: String, + relayHint: String?, + initialUIState: UploadChaptersUIState = UploadChaptersUIState.Loading, + mantraRepository: MantraRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val viewModel: UploadChaptersViewModel = viewModel( + factory = UploadChaptersViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifactId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialUIState = initialUIState, + mantraRepository = mantraRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, + ) + ) + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + ScreenStateTransition(uiState) { state -> + when (state) { + UploadChaptersUIState.Loading -> LoadingDataIndicator(fillScreen = true) + + is UploadChaptersUIState.Error -> + ErrorState( + message = state.message, + onRetry = { viewModel.initiate() }, + ) + + is UploadChaptersUIState.Loaded -> { + val notify = rememberNotifier(rememberCoroutineScope()) + + // The sentence is built in composition and shown from an effect, + // because `stringResource` is composable and the propose + // callback is not. Formatting it by hand in the callback was the + // first draft and meant doing a resource's own job with + // `replaceFirst`. + val outcomeMessage = state.lastOutcome?.let { outcome -> + if (outcome.failed > 0) { + stringResource( + Res.string.d_proposed_d_failed, + outcome.proposed, + outcome.failed, + ) + } else { + stringResource(Res.string.d_chapters_proposed, outcome.proposed) + } + } + + LaunchedEffect(state.lastOutcome?.pass) { + outcomeMessage?.let(notify) + } + + val picker = rememberFilePicker( + title = stringResource(Res.string.choose_chapter_files) + ) { files -> viewModel.addFiles(files) } + + val version = state.artifactVersion + + // Where each row lands, counted over the rows not yet proposed: + // a proposed chapter has taken its number and the next one does + // not take it again. + val offsets = remember(state.chapters) { + var next = 0 + buildMap { + state.chapters.forEach { chapter -> + if (chapter.sessionId == null) put(chapter.id, next++) + } + } + } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + // A name field per row, so the keyboard must not cover the + // row being named. + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + Text( + stringResource( + Res.string.upload_chapters_to, + state.artifact.name, + ) + ) + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + val remaining = state.proposable.size + val isRetry = state.proposed.isNotEmpty() + + ExtendedFab( + label = if (isRetry) { + stringResource(Res.string.propose_the_remaining_d, remaining) + } else { + stringResource(Res.string.propose_d_chapters, remaining) + }, + icon = { + Icon( + Icons.Default.UploadFile, + contentDescription = "Propose these chapters" + ) + }, + enabled = state.canPropose, + onClick = { + if (version == null) return@ExtendedFab + + viewModel.propose( + localChatRoom = state.localChatRoom, + artifactVersion = version, + ) { proposed, failed -> + // Leaves only when there is nothing + // left to aim a retry from. On a + // partial pass the screen stays: the + // rows that went are marked and the + // rest keep their reason, and that + // list is the only place a retry can + // be aimed from. + if (failed == 0 && proposed > 0) { + // The proposal list, not a + // signing screen: at N there is + // no one session to watch, and + // the list is the screen that + // exists for showing several. + onNavigateToRouteAndPopUpInclusive( + ProposalListRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + ) + ) + } + } + } + ) + } + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + item { + // Said before the picker opens, not after. An upload + // spends the room's attention, and the person + // picking files is the only one who can weigh that. + Text( + text = stringResource(Res.string.one_proposal_per_file), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (!state.canSign) { + item { + Text( + text = stringResource( + Res.string.this_group_has_no_shared_key_for_a_chapter + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } else if (version == null) { + item { + Text( + text = stringResource( + Res.string.this_artifact_has_no_version_for_a_chapter + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.itemGap + ), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + enabled = state.chapters.size < + UploadChaptersViewModel.MAX_FILES_PER_UPLOAD, + onClick = { picker.launch(multiple = true) } + ) { + Icon( + Icons.Default.UploadFile, + contentDescription = "Choose files to upload" + ) + Text( + modifier = Modifier.padding( + start = MaterialTheme.spacing.itemGap + ), + text = if (state.chapters.isEmpty()) { + stringResource(Res.string.choose_files) + } else { + stringResource(Res.string.add_more_files) + } + ) + } + + Text( + text = stringResource( + Res.string.at_most_d_files_in_one_upload, + UploadChaptersViewModel.MAX_FILES_PER_UPLOAD, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (state.chapters.isEmpty()) { + item { + EmptyState( + message = stringResource(Res.string.no_files_picked_yet), + icon = Icons.Default.UploadFile, + ) + } + } + + items(items = state.chapters, key = { it.id }) { chapter -> + PickedChapterCard( + chapter = chapter, + index = state.baseIndex + (offsets[chapter.id] ?: 0), + onRename = { viewModel.rename(chapter.id, it) }, + onRemove = { viewModel.remove(chapter.id) }, + ) + } + } + } + } + } + } + + LaunchedEffect(true) { + if (initialUIState == UploadChaptersUIState.Loading) viewModel.initiate() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PickedChapterCard( + chapter: PickedChapter, + index: Int, + onRename: (String) -> Unit, + onRemove: () -> Unit, +) { + val proposed = chapter.sessionId != null + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.compactPadding) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = chapter.fileName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = if (proposed) { + stringResource(Res.string.waiting_for_the_group_to_sign) + } else { + stringResource(Res.string.chapter_d_of_the_version, index) + }, + style = MaterialTheme.typography.labelMedium + ) + } + + if (proposed) { + Icon( + Icons.Default.HourglassTop, + contentDescription = "Waiting for the group to sign" + ) + } else { + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Close, + contentDescription = stringResource( + Res.string.remove_from_this_upload + ) + ) + } + } + } + + if (!proposed) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = chapter.name, + onValueChange = onRename, + isError = chapter.problem == UploadProblem.Unnamed, + label = { Text(stringResource(Res.string.chapter_name)) }, + singleLine = true, + ) + } + + Text( + text = stringResource( + Res.string.words_characters, + chapter.wordCount, + chapter.characterCount, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + chapter.problem?.let { problem -> + Text( + text = problem.describe(), + style = MaterialTheme.typography.bodySmall, + // A warning is not an error. A duplicate is a thing worth + // saying and not a thing that stops the upload, and giving + // the two one colour would make the second look like the + // first. + color = if (problem.blocking) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } + } +} + +@Composable +private fun UploadProblem.describe(): String = when (this) { + UploadProblem.NotText -> stringResource(Res.string.this_file_is_not_text) + UploadProblem.TooLarge -> stringResource(Res.string.this_file_is_too_large) + UploadProblem.Empty -> stringResource(Res.string.this_file_is_empty) + UploadProblem.Unnamed -> stringResource(Res.string.this_file_has_no_name) + UploadProblem.ProposalFailed -> stringResource(Res.string.proposing_this_one_failed) + is UploadProblem.TooManyChunks -> stringResource( + Res.string.d_chunks_is_too_many_to_sign, + count, + UploadReview.MAX_CHUNKS_PER_CHAPTER, + ) + is UploadProblem.TooLargeToPropose -> stringResource( + Res.string.s_is_more_than_a_proposal_carries, + stringResource(Res.string.s_kib, kibibytes(bytes)), + stringResource(Res.string.s_kib, kibibytes(ChapterProposal.MAX_PROPOSAL_BYTES)), + ) + is UploadProblem.Duplicate -> stringResource(Res.string.same_text_as_s, of) +} + +@ConformancePreviews +@Composable +private fun UploadChaptersScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + UploadChaptersScreen( + activeUserPublicKey = "", + artifactId = "artifactId", + chatRoomId = "chatRoomId", + relayHint = null, + initialUIState = UploadChaptersUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "chatRoomId", + userPublicKey = "", + subject = "Message title", + description = "See something. Say somethin", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = null + ), + ), + artifact = MantraArtifact( + id = "artifactId", + publicKey = "author", + name = "To Kill a Mockingbird", + url = "https://harper.com/2-kill-Bird", + visibility = "private", + dialectId = "dialectId", + license = "cc", + chatRoomId = "chatRoomId", + signature = "" + ), + artifactVersion = MantraArtifactVersion( + id = "artifactVersionId", + artifactId = "artifactId", + publicKey = "author", + versionLabel = "1.0", + chatRoomId = "chatRoomId", + signature = "" + ), + canSign = true, + baseIndex = 3, + chapters = listOf( + PickedChapter( + id = "0:01-the-wreck.md", + fileName = "01-the-wreck.md", + name = "The wreck", + markdown = "One.\n\nTwo.", + chunks = listOf("One.", "Two."), + wordCount = 2, + characterCount = 10, + proposalBytes = 1400, + ), + PickedChapter( + id = "1:02-cover.png", + fileName = "02-cover.png", + name = "", + markdown = "", + chunks = emptyList(), + wordCount = 0, + characterCount = 0, + proposalBytes = 448, + problem = UploadProblem.NotText, + ), + ), + ), + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, + 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 92f022d0..de29594f 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 @@ -140,6 +140,7 @@ import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSigned import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute +import press.mantra.compose.ui.composable.navigation.routes.UploadChaptersRoute import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute @@ -148,6 +149,7 @@ import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRo import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute import press.mantra.compose.ui.composable.ArtifactDetailScreen import press.mantra.compose.ui.composable.AddChapterScreen +import press.mantra.compose.ui.composable.UploadChaptersScreen import press.mantra.compose.ui.composable.AddDialectScreen import press.mantra.compose.ui.composable.FrostSigningScreen import press.mantra.compose.ui.composable.ProposalListScreen @@ -1334,6 +1336,33 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + UploadChaptersScreen( + activeUserPublicKey = route.activeUserPublicKey, + artifactId = route.artifactId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { listRoute -> + // Replace this screen, so back returns to the artifact + // rather than to a review list whose proposals have + // already gone out. + navController.navigate(route = listRoute) { + popUpTo { + inclusive = true + } + } + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/UploadChaptersRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/UploadChaptersRoute.kt new file mode 100644 index 00000000..e3dd24ec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/UploadChaptersRoute.kt @@ -0,0 +1,19 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * Several files at once, each becoming a chapter the group is asked to sign. + * + * The same arguments as [AddChapterRoute], because it is the same act at a + * different scale -- and deliberately a separate destination rather than a mode + * on that one. The add form is a text field and one proposal; this is a review + * list and N of them, with a different thing to say before it is used. + */ +@Serializable +data class UploadChaptersRoute( + val activeUserPublicKey: String, + val artifactId: String, + val chatRoomId: String, + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt new file mode 100644 index 00000000..6ea233e5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt @@ -0,0 +1,332 @@ +package press.mantra.compose.ui.view.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.files.PickedFile +import press.mantra.compose.managers.ChapterIndexing +import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.text.Markdown +import press.mantra.compose.ui.view.state.PickedChapter +import press.mantra.compose.ui.view.state.ProposeOutcome +import press.mantra.compose.ui.view.state.UploadChaptersUIState +import press.mantra.compose.ui.view.state.UploadReview +import press.mantra.compose.ui.view.state.UploadProblem + +/** + * Several files in, one signing session per chapter out. + * + * **Why not one session for the lot.** The web app this mirrors does N files in + * one transaction; a batch cannot. A session signs at most + * [FrostSigningManager.MAX_BATCH_SIZE] events and a chapter spends one place + * with its paragraphs taking the rest, so two chapters of thirty paragraphs + * already overflow. Worse, a batch is all-or-nothing: one chapter that failed to + * reach a quorum would take the other eleven with it, and a book is precisely a + * set of things that do not need to arrive together. And the approval gate's own + * rule is that it shows every event rather than a count, so twelve chapters in + * one gate would be a worse prompt than the twelve it replaced. + * + * So an upload is N proposals, each needing its own quorum. That is a real + * imposition on every other member of the room, and the only person in a + * position to weigh it is the one picking files -- which is why the screen says + * so before the picker opens, and why [MAX_FILES_PER_UPLOAD] exists. + * + * **Partial success is the expected outcome, not an error.** Nothing can retract + * a proposal, so if the fourth fails the first three are real and the group can + * still sign them. There is no rollback and no navigating away on failure: the + * rows that went are marked, the rest keep their reason, and the button becomes + * a retry. + */ +class UploadChaptersViewModel( + val artifactId: String, + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialUIState: UploadChaptersUIState, + val mantraRepository: MantraRepository, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, +) : ViewModel() { + + /** + * A `StateFlow` rather than `by mutableStateOf`, and not as a matter of + * taste. + * + * View models here are built by `viewModel(factory = ...)` *inside* + * composition, so a `mutableStateOf` created in a constructor belongs to + * that composition's snapshot -- and a write from another thread that lands + * before the composition is applied is silently discarded. No exception, no + * log; the state keeps its initial value for the life of the process. + * + * [initiate] writes from `Dispatchers.IO`, and the reads it does are a + * primary-key lookup and a `canSign` check: a few milliseconds, which is + * exactly the width of that window. The failure would be this screen sitting + * on its loading indicator for ever on a fast device while a slow one worked. + * That bug has already been had here once; see + * `SovereignWalletViewModel.listWalletState`. + */ + private val _uiState = MutableStateFlow(initialUIState) + val uiState: StateFlow = _uiState.asStateFlow() + + private val logger = Logger.withTag(TAG) + + /** + * The text of every chapter this version already has, against its name. + * + * Held so a picked file can be recognised as one that is already in the + * version. The whole artifact's prose in memory is not free, and it is the + * price of answering the question at all: two chapters with the same text + * are two different event ids, and nothing later can merge them. + */ + private var existingByText: Map = emptyMap() + + /** Counts passes, so two with the same outcome are still two announcements. */ + private var passes: Int = 0 + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val artifact = mantraRepository.getArtifact(artifactId) + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + _uiState.value = when { + artifact == null -> UploadChaptersUIState.Error("Couldn't find the artifact") + localChatRoom == null -> UploadChaptersUIState.Error("Couldn't find the chat room") + else -> { + val version = mantraRepository.getArtifactVersions(artifactId).firstOrNull() + + existingByText = version?.let { v -> + mantraRepository.getChaptersForArtifactVersion(v.id) + .associate { it.originalText to it.name } + }.orEmpty() + + UploadChaptersUIState.Loaded( + localChatRoom = localChatRoom, + artifact = artifact, + artifactVersion = version, + canSign = frostSigningRepository.canSign(chatRoomId), + baseIndex = version?.let { nextIndex(it) } ?: 0, + ) + } + } + } + } + + /** + * Decodes picked files into rows, appending to whatever is already listed. + * + * Appending rather than replacing, so a second visit to the picker adds to + * the upload instead of discarding it -- a reader who meant to replace can + * remove rows, and one who meant to add cannot undo a replacement. + */ + fun addFiles(files: List) { + if (files.isEmpty()) return + + updateLoaded { loaded -> + val room = loaded.chapters.size + val added = UploadReview.rows( + files = files.take((MAX_FILES_PER_UPLOAD - room).coerceAtLeast(0)), + startOrdinal = room, + ) + + loaded.copy(chapters = withProblems(loaded.chapters + added)) + } + } + + fun rename(id: String, name: String) { + updateLoaded { loaded -> + loaded.copy( + chapters = withProblems( + loaded.chapters.map { if (it.id == id) it.copy(name = name) else it } + ) + ) + } + } + + fun remove(id: String) { + updateLoaded { loaded -> + loaded.copy(chapters = withProblems(loaded.chapters.filterNot { it.id == id })) + } + } + + private fun withProblems(chapters: List): List = + UploadReview.withProblems(chapters, existingByText) + + private inline fun updateLoaded( + crossinline block: (UploadChaptersUIState.Loaded) -> UploadChaptersUIState.Loaded, + ) { + _uiState.update { state -> + if (state is UploadChaptersUIState.Loaded) block(state) else state + } + } + + /** + * Puts every proposable chapter to the group, one session each, in order. + * + * **Sequentially, not concurrently.** Each proposal writes a session and + * publishes group events, so N at once is N interleaved MLS epochs -- and a + * group event that arrives a moment out of order is dropped for good; see + * `docs/mls-skipped-keys.md`. An upload is not in a hurry. + * + * Indices are allocated once here rather than re-read between sessions. The + * base is read fresh, because a chapter may have arrived since the files + * were picked, and then they are handed out in order -- re-asking per + * chapter would make the numbering depend on how fast the sessions are + * written. + */ + fun propose( + localChatRoom: LocalChatRoom, + artifactVersion: MantraArtifactVersion, + onDone: (proposed: Int, failed: Int) -> Unit, + ) { + val loaded = _uiState.value as? UploadChaptersUIState.Loaded ?: return + if (loaded.isProposing) return + + val queue = loaded.proposable.map { it.id } + if (queue.isEmpty()) return + + updateLoaded { it.copy(isProposing = true) } + + viewModelScope.launch(Dispatchers.IO) { + var index = nextIndex(artifactVersion) + var proposed = 0 + var failed = 0 + + for (id in queue) { + val row = (_uiState.value as? UploadChaptersUIState.Loaded) + ?.chapters + ?.firstOrNull { it.id == id } + ?: continue + + val session = runCatching { + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + lead = ChapterEvent.build( + artifactVersionId = artifactVersion.id, + name = row.name, + originalText = row.markdown, + index = index, + wordCount = row.wordCount, + characterCount = row.characterCount, + ), + // The chunks are cut from the chapter the session + // authored, because a chunk names its chapter's id and + // that id is a hash over the group's key at the room's + // path -- which this view model neither knows nor should. + dependents = ChunkEvent::splitOf, + ) + }.onFailure { error -> + logger.e("Could not propose ${row.fileName} as a chapter", error) + }.getOrNull() + + if (session != null) { + markProposed(id, session.id) + proposed++ + index++ + } else { + markFailed(id) + failed++ + } + } + + passes++ + updateLoaded { + it.copy( + isProposing = false, + lastOutcome = ProposeOutcome( + proposed = proposed, + failed = failed, + pass = passes, + ), + ) + } + + viewModelScope.launch(Dispatchers.Main) { onDone(proposed, failed) } + } + } + + private fun markProposed(id: String, sessionId: String) { + updateLoaded { loaded -> + loaded.copy( + chapters = loaded.chapters.map { + if (it.id == id) it.copy(sessionId = sessionId, problem = null) else it + } + ) + } + } + + private fun markFailed(id: String) { + updateLoaded { loaded -> + loaded.copy( + chapters = loaded.chapters.map { + if (it.id == id) it.copy(problem = UploadProblem.ProposalFailed) else it + } + ) + } + } + + private suspend fun nextIndex(artifactVersion: MantraArtifactVersion): Int = + ChapterIndexing.nextIndex( + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + chatRoomId = chatRoomId, + artifactVersionId = artifactVersion.id, + ) + + companion object { + private const val TAG = "UploadChaptersViewModel" + + /** + * The most files one upload will take. + * + * Not a protocol limit -- the protocol has no opinion -- but a limit on + * how much of the room's attention one tap may spend. Twenty files is + * twenty proposals, each of which every other member is asked to read + * and approve. Somebody who means to upload a hundred chapters can do it + * in five passes and will know they did. + */ + const val MAX_FILES_PER_UPLOAD: Int = 20 + + fun factory( + activeUserPublicKey: HexKey, + artifactId: String, + chatRoomId: String, + relayHint: String?, + initialUIState: UploadChaptersUIState = UploadChaptersUIState.Loading, + mantraRepository: MantraRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + UploadChaptersViewModel( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifactId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialUIState = initialUIState, + mantraRepository = mantraRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadChaptersUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadChaptersUIState.kt new file mode 100644 index 00000000..78070b7e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadChaptersUIState.kt @@ -0,0 +1,165 @@ +package press.mantra.compose.ui.view.state + +import androidx.compose.runtime.Immutable +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +sealed interface UploadChaptersUIState { + data object Loading : UploadChaptersUIState + + data class Error(val message: String) : UploadChaptersUIState + + data class Loaded( + val localChatRoom: LocalChatRoom, + val artifact: MantraArtifact, + /** Chapters hang off a version; without one there is nothing to upload into. */ + val artifactVersion: MantraArtifactVersion?, + val canSign: Boolean, + /** + * The files picked so far, in the order they will be proposed. + * + * Nothing here has been proposed until a row carries a session id. The + * screen is a review step, which is the one place this departs visibly + * from the web app it mirrors: there, twelve files become twelve + * chapters that belong to the uploader and can be fixed afterwards. Here + * they become twelve requests for the group's own signature, and + * anything wrong in one is wrong in something a quorum is about to put + * its name to. + */ + val chapters: List = emptyList(), + /** + * The index the first proposable chapter will take. + * + * Read when the files are picked so the list can show where each one + * lands, and read again when proposing -- a chapter may have arrived in + * between, and the signed number has to be the later answer. + */ + val baseIndex: Int = 0, + val isProposing: Boolean = false, + /** + * What the last pass did, so the screen can say so. + * + * In the state rather than handed to the callback, because the sentence + * is a formatted string resource and `stringResource` is composable + * while a callback is not. Read in composition, shown from an effect. + */ + val lastOutcome: ProposeOutcome? = null, + ) : UploadChaptersUIState { + + /** Rows that could be proposed as they stand. */ + val proposable: List + get() = chapters.filter { it.sessionId == null && it.problem?.blocking != true } + + val proposed: List get() = chapters.filter { it.sessionId != null } + + val canPropose: Boolean + get() = artifactVersion != null && canSign && !isProposing && proposable.isNotEmpty() + } +} + +/** The result of one pass of proposing. */ +@Immutable +data class ProposeOutcome( + val proposed: Int, + val failed: Int, + /** Distinguishes two passes with identical counts, so the second is still announced. */ + val pass: Int, +) + +/** + * One picked file, as the chapter it would become. + * + * [markdown] is already decoded, BOM-stripped and newline-normalised by + * `ChapterText.decode`, so it is the text that would be signed rather than + * whatever the file held. + */ +@Immutable +data class PickedChapter( + /** Stable across edits and removals, so the list keeps its identity. */ + val id: String, + val fileName: String, + /** + * Editable, defaulting to `ChapterNaming.forFile`. + * + * A filename is right often enough to be the default and wrong often enough + * -- `chapter-01.md`, `ch1_final_v2.md` -- that it has to be fixable before a + * quorum sees it. The name is signed into the chapter along with the text. + */ + val name: String, + val markdown: String, + val chunks: List, + val wordCount: Int, + val characterCount: Int, + val proposalBytes: Int, + val problem: UploadProblem? = null, + /** Set once this chapter has been put to the group. Never unset. */ + val sessionId: String? = null, +) + +/** + * What is wrong with one row. + * + * Per row rather than per upload, because a bad file should not cost the other + * eleven. [blocking] is the difference between "this cannot be proposed" and + * "this is probably not what you meant". + */ +@Immutable +sealed interface UploadProblem { + val blocking: Boolean + + /** Not decodable as UTF-8, or carrying a NUL. */ + data object NotText : UploadProblem { + override val blocking: Boolean get() = true + } + + /** Over `ChapterText.MAX_FILE_BYTES`. */ + data object TooLarge : UploadProblem { + override val blocking: Boolean get() = true + } + + /** Nothing left after trimming, which is a chapter with nothing to translate. */ + data object Empty : UploadProblem { + override val blocking: Boolean get() = true + } + + /** More paragraphs than one session signs. */ + data class TooManyChunks(val count: Int) : UploadProblem { + override val blocking: Boolean get() = true + } + + /** Within the chunk cap but over what a proposal carries. */ + data class TooLargeToPropose(val bytes: Int) : UploadProblem { + override val blocking: Boolean get() = true + } + + /** No name, and nothing in the file to derive one from. */ + data object Unnamed : UploadProblem { + override val blocking: Boolean get() = true + } + + /** + * The same text as a chapter this version already has, or as another row. + * + * **A warning, not a block.** A book can repeat a page. But the common cause + * is picking the same directory twice, and two chapters with the same text + * are two different event ids that no later de-duplication can merge -- so + * it is worth saying before a quorum signs both. + */ + data class Duplicate(val of: String) : UploadProblem { + override val blocking: Boolean get() = false + } + + /** + * Proposing this one failed. + * + * **Not blocking**, because a retry is exactly what should happen: a failed + * proposal is retried by making a new one, never by re-opening the session + * that failed, and `proposeSigningBatch` mints fresh nonce seeds by + * construction. The row keeps its reason so "propose the remaining" has + * something to point at. + */ + data object ProposalFailed : UploadProblem { + override val blocking: Boolean get() = false + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadReview.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadReview.kt new file mode 100644 index 00000000..cf817af2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadReview.kt @@ -0,0 +1,121 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.files.PickedFile +import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.nostr.nip30303.ChapterProposal +import press.mantra.compose.text.ChapterNaming +import press.mantra.compose.text.ChapterText +import press.mantra.compose.text.Markdown + +/** + * Turning picked files into reviewable rows, and deciding what is wrong with + * each. + * + * Pure, and separate from `UploadChaptersViewModel` on purpose. Everything here + * is a function of the files, the names typed over them and the chapters the + * version already has -- no repository, no coroutine, no database -- which is + * most of what the upload screen actually does and all of what is worth testing + * closely. What the view model keeps is the orchestration: reading the version, + * allocating indices and opening a session per chapter. + */ +object UploadReview { + + /** The most paragraphs a chapter can be proposed with; the chapter takes a place too. */ + const val MAX_CHUNKS_PER_CHAPTER: Int = FrostSigningManager.MAX_BATCH_SIZE - 1 + + /** + * Rows for [files], numbered from [startOrdinal] so they can be appended to + * rows already listed. + * + * Problems are not decided here -- see [withProblems] -- beyond the two that + * are properties of the bytes rather than of the list. + */ + fun rows(files: List, startOrdinal: Int): List = + files.mapIndexed { offset, file -> row(file, startOrdinal + offset) } + + private fun row(file: PickedFile, ordinal: Int): PickedChapter { + val decoded = ChapterText.decode(file.bytes) + val markdown = (decoded as? ChapterText.Result.Text)?.markdown.orEmpty() + val chunks = Markdown.splitParagraphs(markdown) + + return PickedChapter( + // Neither the file's name nor its text is unique -- two directories + // can each hold a `01.md`, and a book can repeat a page -- and the + // ordinal is, within the one upload this id has to be unique in. + id = "$ordinal:${file.name}", + fileName = file.name, + name = if (decoded is ChapterText.Result.Text) { + ChapterNaming.forFile(file.name, markdown) + } else { + "" + }, + markdown = markdown, + chunks = chunks, + wordCount = Markdown.wordCount(markdown), + characterCount = Markdown.characterCount(markdown), + proposalBytes = ChapterProposal.proposalBytes(markdown, chunks), + problem = when (decoded) { + ChapterText.Result.NotText -> UploadProblem.NotText + ChapterText.Result.TooLarge -> UploadProblem.TooLarge + is ChapterText.Result.Text -> null + }, + ) + } + + /** + * Re-decides every row's problem. + * + * Whole-list rather than per row, because two of the answers are about the + * list: a duplicate is a relation between rows, and removing one row can + * clear the warning on another. + * + * A row already proposed is returned untouched. Its chapter is in front of + * the group, and saying something new about it would be a comment on a + * decision already taken. + * + * [existingByText] maps the text of each chapter the version already has to + * its name, so a file that is already in the version can be recognised as + * one -- by its text, since that is what two chapters would have in common. + */ + fun withProblems( + chapters: List, + existingByText: Map, + ): List { + val seen = mutableMapOf() + + return chapters.map { chapter -> + if (chapter.sessionId != null) return@map chapter + + // Nothing about the list can fix a file that is not text, so those + // two survive whatever else is decided below. + if (chapter.problem == UploadProblem.NotText || + chapter.problem == UploadProblem.TooLarge + ) { + return@map chapter + } + + val duplicateOf = existingByText[chapter.markdown] ?: seen[chapter.markdown] + if (!seen.containsKey(chapter.markdown)) { + seen[chapter.markdown] = chapter.name.ifBlank { chapter.fileName } + } + + chapter.copy( + problem = when { + chapter.chunks.isEmpty() -> UploadProblem.Empty + chapter.name.isBlank() -> UploadProblem.Unnamed + chapter.chunks.size > MAX_CHUNKS_PER_CHAPTER -> + UploadProblem.TooManyChunks(chapter.chunks.size) + !ChapterProposal.fits(chapter.markdown, chapter.chunks) -> + UploadProblem.TooLargeToPropose(chapter.proposalBytes) + duplicateOf != null -> UploadProblem.Duplicate(duplicateOf) + // A row that failed to propose keeps saying so until it is + // retried, which is what makes the button honest about there + // being something left to do. + chapter.problem == UploadProblem.ProposalFailed -> + UploadProblem.ProposalFailed + else -> null + } + ) + } + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt new file mode 100644 index 00000000..479ed1f3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt @@ -0,0 +1,180 @@ +package press.mantra.compose.ui.view.state + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import press.mantra.compose.files.PickedFile + +/** + * What a picked file is offered as, and what the screen refuses to offer. + * + * The point of the review step is that nothing here reaches a quorum without + * somebody having looked at it, so these assert the two halves of that: the + * defaults are good enough that looking is quick, and everything that would be + * wrong in a signed chapter is said before it is signed. + */ +class UploadReviewTest { + + private fun file(name: String, text: String) = + PickedFile(name = name, bytes = text.encodeToByteArray()) + + private fun reviewed( + vararg files: PickedFile, + existing: Map = emptyMap(), + ): List = + UploadReview.withProblems(UploadReview.rows(files.toList(), startOrdinal = 0), existing) + + private fun paragraphs(count: Int) = + (1..count).joinToString("\n\n") { "Paragraph $it." } + + @Test + fun `an ordinary markdown file is offered with no problem`() { + val rows = reviewed(file("03 - The Wreck.md", "# The wreck\n\nIt began badly.\n")) + + assertEquals(1, rows.size) + assertNull(rows.single().problem) + assertEquals("The Wreck", rows.single().name) + assertEquals(listOf("# The wreck", "It began badly."), rows.single().chunks) + } + + @Test + fun `the name falls through to the text when the filename is only an ordinal`() { + val rows = reviewed(file("01.md", "# Chapter one\n\nIt began badly.\n")) + + assertEquals("Chapter one", rows.single().name) + } + + @Test + fun `counts are of the decoded text, not of the file`() { + // CRLF in, LF counted: the row shows what would be signed. + val rows = reviewed(file("a.md", "One two.\r\n\r\nThree.\r\n")) + + assertEquals(listOf("One two.", "Three."), rows.single().chunks) + assertEquals(3, rows.single().wordCount) + } + + @Test + fun `a file that is not text is refused, and blocks only its own row`() { + val rows = reviewed( + file("good.md", "Fine.\n"), + PickedFile("cover.png", byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x00, 0x0D)), + file("also-good.md", "Also fine.\n"), + ) + + assertEquals(UploadProblem.NotText, rows[1].problem) + assertNull(rows[0].problem) + assertNull(rows[2].problem) + } + + @Test + fun `an empty file has nothing to translate`() { + assertEquals(UploadProblem.Empty, reviewed(file("blank.md", " \n\n ")).single().problem) + } + + @Test + fun `a name cleared by hand blocks the row until it is typed`() { + val rows = reviewed(file("The Wreck.md", "It began badly.\n")) + val cleared = UploadReview.withProblems( + rows.map { it.copy(name = " ") }, + emptyMap(), + ) + + assertEquals(UploadProblem.Unnamed, cleared.single().problem) + + val named = UploadReview.withProblems( + cleared.map { it.copy(name = "The Wreck") }, + emptyMap(), + ) + assertNull(named.single().problem) + } + + @Test + fun `too many paragraphs for one session is said per file`() { + val rows = reviewed( + file("long.md", paragraphs(UploadReview.MAX_CHUNKS_PER_CHAPTER + 1)), + file("fine.md", paragraphs(UploadReview.MAX_CHUNKS_PER_CHAPTER)), + ) + + assertEquals( + UploadProblem.TooManyChunks(UploadReview.MAX_CHUNKS_PER_CHAPTER + 1), + rows[0].problem, + ) + assertNull(rows[1].problem) + } + + @Test + fun `a chapter within the chunk cap but over what a proposal carries is refused`() { + // Forty paragraphs is well inside the chunk cap, and 28 KiB of them is + // not inside what one group message carries -- the two caps are + // different limits and this is the file that proves it. + val long = (1..40).joinToString("\n\n") { "w ".repeat(360).trim() } + + val problem = reviewed(file("fat.md", long)).single().problem + + assertTrue(problem is UploadProblem.TooLargeToPropose, "got $problem") + } + + @Test + fun `the same file picked twice warns rather than blocks`() { + val rows = reviewed( + file("the-wreck.md", "It began badly.\n"), + file("copy-of-the-wreck.md", "It began badly.\n"), + ) + + assertNull(rows[0].problem) + assertEquals(UploadProblem.Duplicate("the-wreck"), rows[1].problem) + // Not blocking, because a book can repeat a page -- but two chapters + // with the same text are two event ids that nothing later can merge. + assertEquals(false, rows[1].problem?.blocking) + } + + @Test + fun `a file already in the version is recognised by its text`() { + val rows = reviewed( + file("ch1.md", "It began badly.\n"), + existing = mapOf("It began badly.\n" to "The Wreck"), + ) + + assertEquals(UploadProblem.Duplicate("The Wreck"), rows.single().problem) + } + + @Test + fun `removing one row clears the warning on the other`() { + val rows = reviewed( + file("a.md", "Same words.\n"), + file("b.md", "Same words.\n"), + ) + assertEquals(UploadProblem.Duplicate("a"), rows[1].problem) + + val remaining = UploadReview.withProblems(rows.drop(1), emptyMap()) + + // The reason a whole-list pass exists rather than a per-row check. + assertNull(remaining.single().problem) + } + + @Test + fun `a proposed row is left exactly as it is`() { + val proposed = reviewed(file("a.md", "Same words.\n")) + .map { it.copy(sessionId = "session", name = " ") } + + // Blank name, duplicate text, and still untouched: its chapter is in + // front of the group and nothing here is a comment on that. + val after = UploadReview.withProblems( + proposed, + existingByText = mapOf("Same words.\n" to "The Wreck"), + ) + + assertEquals(proposed, after) + } + + @Test + fun `rows appended later keep distinct ids`() { + val first = UploadReview.rows(listOf(file("01.md", "One.")), startOrdinal = 0) + val second = UploadReview.rows(listOf(file("01.md", "Two.")), startOrdinal = first.size) + + // Two directories can each hold a `01.md`, and a list keyed by name + // would drop the second or animate the two into each other. + assertEquals(2, (first + second).map { it.id }.toSet().size) + } +}