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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-10-02 19:12:34 +02:00
1 parent 83bd91f46c
commit 2f8df359a8
9 files changed
+1485 -18

No files matched your search

@@ -501,4 +501,26 @@
<string name="words_characters_size_chunks">%1$d words · %2$d characters · %3$s · %4$d of %5$d chunks</string>
<string name="s_of_s">%1$s of %2$s</string>
<string name="s_kib">%1$s KiB</string>
<string name="upload_chapters">Upload chapters</string>
<string name="upload_chapters_to">Upload chapters to %1$s</string>
<string name="choose_files">Choose files</string>
<string name="add_more_files">Add more files</string>
<string name="no_files_picked_yet">No files picked yet.</string>
<string name="one_proposal_per_file">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.</string>
<string name="at_most_d_files_in_one_upload">At most %1$d files in one upload.</string>
<string name="propose_d_chapters">Propose %1$d chapters</string>
<string name="propose_the_remaining_d">Propose the remaining %1$d</string>
<string name="d_proposed_d_failed">%1$d proposed, %2$d failed.</string>
<string name="d_chapters_proposed">%1$d chapters proposed.</string>
<string name="chapter_d_of_the_version">Chapter %1$d of the version</string>
<string name="waiting_for_the_group_to_sign">Waiting for the group to sign</string>
<string name="remove_from_this_upload">Remove from this upload</string>
<string name="this_file_is_not_text">Not text. A chapter is markdown or plain text.</string>
<string name="this_file_is_too_large">Too large to be a chapter.</string>
<string name="this_file_is_empty">Nothing in it to translate.</string>
<string name="this_file_has_no_name">Give this chapter a name.</string>
<string name="d_chunks_is_too_many_to_sign">%1$d chunks, and the group signs at most %2$d in one go. Split this file.</string>
<string name="s_is_more_than_a_proposal_carries">%1$s, and a group message carries %2$s. Split this file.</string>
<string name="same_text_as_s">The same text as %1$s. Proposing both makes two chapters.</string>
<string name="proposing_this_one_failed">Proposing this one failed. Try again.</string>
</resources>
@@ -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))
}
}
}
@@ -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 = {}
)
}
}
}
@@ -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<UploadChaptersRoute> { backStackEntry ->
val route = backStackEntry.toRoute<UploadChaptersRoute>()
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<UploadChaptersRoute> {
inclusive = true
}
}
},
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<AddChapterRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddChapterRoute>()
@@ -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()
@@ -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<UploadChaptersUIState> = _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<String, String> = 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<PickedFile>) {
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<PickedChapter>): List<PickedChapter> =
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,
)
}
}
}
}
@@ -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<PickedChapter> = 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<PickedChapter>
get() = chapters.filter { it.sessionId == null && it.problem?.blocking != true }
val proposed: List<PickedChapter> 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<String>,
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
}
}
@@ -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<PickedFile>, startOrdinal: Int): List<PickedChapter> =
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<PickedChapter>,
existingByText: Map<String, String>,
): List<PickedChapter> {
val seen = mutableMapOf<String, String>()
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
}
)
}
}
}
@@ -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<String, String> = emptyMap(),
): List<PickedChapter> =
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)
}
}