feat(chapters): a chapter can be loaded from a file

Phase 1 of docs/chapter-uploads.md. The picker and the decode, with one file
going into the add-chapter form that already works -- so the platform seam is
exercised on its own, before anything knows about several chapters at once.

Three actuals rather than FileKit. This is the app's only dialog, the calls
are a dozen lines each, and the project already keeps four platform seams of
this shape; the library's Compose-version coupling buys nothing here, and the
ios target cannot be compiled on a linux host either way.

ChapterText.decode is the part that matters. The web app does
`file.data.toString()`, where a .docx becomes a chapter of zip-header garbage
the uploader can go back and delete; here the next thing that happens to the
text is that a quorum signs it. So: strict UTF-8, because a lenient decode
hides U+FFFD inside something permanent; a NUL sniff before decoding, because
mostly-ASCII binary decodes cleanly; the byte-order mark stripped and CRLF
normalised, because both change the bytes without changing a word and the
same prose from two machines has to give the same chunk ids.

The pickers stop one byte past the cap rather than at it, so an over-size
file reads as over-size instead of as a chapter with its ending missing.
Android registers both open contracts so asking for one file does not offer a
picker that returns six. The dialog title is a parameter read from the
catalogue, since stringResource is composable and a FileDialog is not.

ChapterNaming came out of the two answers the web app gives on its two routes
-- the filename on the upload, the first chunk on the paste form -- both of
which are right some of the time. The filename minus extension and ordinal
prefix, falling through to a heading and then the first line, because `01.md`
is not a chapter title and `03 - The Wreck.md` is.

FilePicker.ios.kt is unverified: the ios targets are declared only on macos,
so it is not compiled here. Written to the shape of the eight .ios.kt actuals
beside it and says so in its KDoc. A stub returning no files would read as a
working picker that a reader's chapters vanish into.

22 new tests, all pure; 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 18:29:20 +02:00
1 parent 6ab4f0e557
commit eb191d945a
10 files changed
+826

No files matched your search

@@ -0,0 +1,72 @@
package press.mantra.compose.files
import androidx.compose.runtime.Composable
/**
* A file the reader picked, as its name and its bytes.
*
* Bytes rather than a path or an open stream, and that is a decision rather
* than laziness. A path is meaningless on iOS the moment the picker's security
* scope closes, so a path would be a handle that works on two platforms and
* expires on the third. A stream would push the size cap and the decode into
* whichever coroutine happened to read it. Chapters are tens of kilobytes;
* `ChapterText.MAX_FILE_BYTES` bounds the read and the whole file fits in hand.
*
* [name] is for naming the chapter, not for deciding what the file is -- that
* is `ChapterText.decode`'s job, by looking at the bytes. On android the name
* comes from the document provider and can be absent or a lie; it is a default
* a person edits, never a check.
*/
data class PickedFile(
val name: String,
val bytes: ByteArray,
) {
// ByteArray makes the generated equals/hashCode identity-based, which is
// wrong for a data class that will sit in a list in UI state and be
// compared across recompositions.
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is PickedFile) return false
return name == other.name && bytes.contentEquals(other.bytes)
}
override fun hashCode(): Int = 31 * name.hashCode() + bytes.contentHashCode()
}
/** Opens the platform's file chooser. */
fun interface FilePicker {
fun launch(multiple: Boolean)
}
/**
* The platform's file chooser, wired to hand [onPicked] what came back.
*
* `expect` rather than a dependency. The obvious alternative is FileKit, which
* covers all three targets from common code -- but this is the only thing in
* the app that needs a dialog, the three calls below are a dozen lines each,
* and the project already keeps four platform seams of exactly this shape
* (`PlatformContext`, `PlatformDatabaseBuilder`, `platformReducedMotion`,
* `platformThemeContrast`). A dependency whose Compose version has to track
* ours is a poor trade for that.
*
* **Nothing is read beyond `ChapterText.MAX_FILE_BYTES`.** Each actual stops
* there, so picking a video by mistake costs a bounded read rather than the
* whole file, and the over-size file still arrives -- with more bytes than the
* cap allows -- so that `decode` can say `TooLarge` instead of the picker
* silently dropping it.
*
* Cancelling hands back an empty list, which is how a caller tells "no files"
* from "files that would not decode".
*
* [title] is the dialog's own title, read from the string catalogue by the
* caller because `stringResource` is composable and the dialog is not. Only
* desktop uses it: android's Storage Access Framework and iOS's document
* browser each title themselves and offer no way to say otherwise. Taken on all
* three anyway, so that the one platform which shows text to the reader gets it
* from the catalogue like every other string in the app.
*/
@Composable
expect fun rememberFilePicker(
title: String,
onPicked: (List<PickedFile>) -> Unit,
): FilePicker
@@ -0,0 +1,66 @@
package press.mantra.compose.text
/**
* What to call a chapter that arrived as a file.
*
* The web app this mirrors has two answers and uses a different one per route:
* the upload names a chapter `file.name` and the paste form names it
* `chunks[0].trim()`. Both are right some of the time. `chapter-01.md` is a
* filename, not a title, and a chapter whose first paragraph is prose has a
* better name in its own text; a chapter whose file is called
* `03 - The Wreck of the Hesperus.md` does not.
*
* So: the filename first, because somebody chose it, and the text only when the
* filename says nothing a reader would want -- and a heading in the text before
* a paragraph of it, because a heading is a title and a paragraph is the
* beginning of the work.
*
* Whatever this returns is a **default in an editable field**, never the final
* answer. A quorum is asked to sign the name along with the chapter, so the
* person uploading has to be able to fix it first.
*/
object ChapterNaming {
/** Past this, a name is the beginning of a paragraph rather than a title. */
private const val MAX_DERIVED_LENGTH = 80
private val LEADING_ORDINAL = Regex("^[\\s\\-_.0-9]+")
fun forFile(fileName: String, markdown: String): String =
fromFileName(fileName) ?: fromMarkdown(markdown) ?: ""
/**
* The filename with its extension and its ordinal prefix removed.
*
* `03 - The Wreck.md` is `The Wreck`; `chapter-01.md` is `chapter`, which is
* a poor title but an honest reflection of a poor filename and is the one
* the person will edit. What is dropped entirely is a name that is *only*
* an ordinal -- `01.md`, `1.txt` -- because "01" as a chapter title is worse
* than falling through to the text.
*/
private fun fromFileName(fileName: String): String? =
fileName.substringBeforeLast('.', fileName)
.replace('_', ' ')
.replace(LEADING_ORDINAL, "")
.trim()
.takeIf { it.isNotEmpty() }
/**
* The first heading in the text, or failing that its first line.
*
* Reads the markdown rather than [Markdown]'s split, because this runs while
* a file is being looked at rather than while a chapter is being proposed,
* and a heading's `#` has to be stripped either way.
*/
private fun fromMarkdown(markdown: String): String? {
val lines = markdown.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }
val heading = lines.firstOrNull { it.startsWith("#") }
?.trimStart('#')
?.trim()
?.takeIf { it.isNotEmpty() }
if (heading != null) return heading.take(MAX_DERIVED_LENGTH)
return lines.firstOrNull()?.take(MAX_DERIVED_LENGTH)
}
}
@@ -0,0 +1,97 @@
package press.mantra.compose.text
/**
* Turning the bytes of a picked file into the markdown a chapter is made of.
*
* Deliberately not `bytes.decodeToString()`, which is what the web app this
* mirrors does (`file.data.toString()` in `server/router/versions/index.js`).
* There, a `.docx` dropped into the form becomes a chapter of zip-header
* garbage that the uploader can go back and delete. Here the next thing that
* happens to this text is that a quorum is asked to sign it, so what cannot be
* read as prose has to be refused at the door rather than corrected afterwards.
*
* All four rules below exist because of that one difference. None of them is
* about being tidy; each is about something that would otherwise be permanent.
*/
object ChapterText {
/**
* The most bytes a picked file may be.
*
* A crash guard, not the real limit -- what a chapter may actually carry is
* bounded by what one signing proposal can put on the wire, which is far
* tighter. This is here so that picking a video by mistake fails
* on a comparison rather than on a megabyte-counting read, and so the
* platform pickers have a number to stop reading at.
*/
const val MAX_FILE_BYTES: Int = 1 * 1024 * 1024
/** How far into a file to look for the NUL that says it is not text. */
private const val SNIFF_BYTES: Int = 8 * 1024
sealed interface Result {
/** Decoded, BOM-stripped, newline-normalised markdown. */
data class Text(val markdown: String) : Result
/** Not decodable as UTF-8, or carrying a NUL. */
data object NotText : Result
/** Over [MAX_FILE_BYTES]. */
data object TooLarge : Result
}
fun decode(bytes: ByteArray): Result {
if (bytes.size > MAX_FILE_BYTES) return Result.TooLarge
// Sniffed before decoding, not after. A file of UTF-16 text decodes as
// invalid UTF-8 and would be refused anyway, but a file of mostly-ASCII
// binary -- a zip's local header, a PDF's preamble -- can decode
// cleanly and only reads as prose if nobody looks for the NUL.
if (hasNul(bytes)) return Result.NotText
// Strict, so an invalid sequence is refused rather than replaced. A
// lenient decode substitutes U+FFFD, which is invisible in a text
// field, is signed into the chapter's `original_text` tag, and is then
// part of the group's record of the work for good.
val decoded = runCatching {
bytes.decodeToString(throwOnInvalidSequence = true)
}.getOrElse { return Result.NotText }
return Result.Text(normalise(decoded))
}
/**
* The form of a chapter's markdown that is safe to hash.
*
* Two rewrites, both of which change the text's bytes without changing a
* word of it, which is exactly why they belong here rather than at a call
* site:
*
* **The byte-order mark goes.** A leading `U+FEFF` is invisible in the form
* and in the chapter, and it is part of the first chunk's text -- so the
* same opening paragraph saved by two editors is two different chunk ids,
* and a translation of one does not match the other.
*
* **CRLF becomes LF.** [Markdown]'s paragraph rule already tolerates `\r\n`,
* so splitting is unaffected; what is affected is the `original_text` tag,
* which would carry the carriage returns. The same chapter uploaded from
* Windows and from Linux would be two different events over identical
* prose. A lone `\r` -- classic Mac line endings, and what some converters
* still emit -- is normalised too, for the same reason.
*
* Public because the duplicate check on an upload compares chapters by
* their text, and it has to compare the form that will be signed.
*/
fun normalise(text: String): String =
text.removePrefix("")
.replace("\r\n", "\n")
.replace("\r", "\n")
private fun hasNul(bytes: ByteArray): Boolean {
val end = minOf(bytes.size, SNIFF_BYTES)
for (index in 0 until end) {
if (bytes[index] == 0.toByte()) return true
}
return false
}
}
@@ -6,12 +6,15 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DriveFileRenameOutline
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -19,10 +22,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.TextButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
@@ -36,6 +41,9 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.files.rememberFilePicker
import press.mantra.compose.text.ChapterNaming
import press.mantra.compose.text.ChapterText
import press.mantra.compose.text.Markdown
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
@@ -55,8 +63,13 @@ import mantra.composeapp.generated.resources.paste_the_chapter_s_markdown_blank_
import mantra.composeapp.generated.resources.propose_chapter
import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a_chapter
import mantra.composeapp.generated.resources.add_chapter_to
import mantra.composeapp.generated.resources.choose_chapter_files
import mantra.composeapp.generated.resources.load_from_file
import mantra.composeapp.generated.resources.that_file_is_too_large
import mantra.composeapp.generated.resources.that_file_isn_t_text
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.composable.widgets.rememberNotifier
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
@@ -107,6 +120,42 @@ fun AddChapterScreen(
val nameFieldState = rememberTextFieldState()
val originalTextFieldState = rememberTextFieldState()
// Read above the handler, not inside it: `rememberNotifier` and
// `stringResource` are composable and a click lambda is not.
val notify = rememberNotifier(rememberCoroutineScope())
val notText = stringResource(Res.string.that_file_isn_t_text)
val tooLarge = stringResource(Res.string.that_file_is_too_large)
// One file, into the form that already works. Picking several
// chapters at once is the upload screen's job; here the point is
// to stop a chapter having to be pasted through a keyboard.
val picker = rememberFilePicker(
title = stringResource(Res.string.choose_chapter_files)
) { files ->
val file = files.firstOrNull() ?: return@rememberFilePicker
when (val decoded = ChapterText.decode(file.bytes)) {
is ChapterText.Result.Text -> {
originalTextFieldState.setTextAndPlaceCursorAtEnd(decoded.markdown)
// The filename only fills a name nobody has typed.
// Overwriting one that was typed would make loading a
// file a destructive act on a field beside it.
if (nameFieldState.text.isBlank()) {
nameFieldState.setTextAndPlaceCursorAtEnd(
ChapterNaming.forFile(file.name, decoded.markdown)
)
}
// No snackbar on success. The text arriving in the
// field below is the feedback, and a message over
// the top of it would say less than the thing
// itself.
}
ChapterText.Result.NotText -> notify(notText)
ChapterText.Result.TooLarge -> notify(tooLarge)
}
}
// Live counts / paragraph (chunk) preview from the markdown text.
val originalText = originalTextFieldState.text.toString()
val wordCount = Markdown.wordCount(originalText)
@@ -221,6 +270,15 @@ fun AddChapterScreen(
placeholder = { Text(stringResource(Res.string.eg_chapter_1_the_beginning)) },
)
TextButton(onClick = { picker.launch(multiple = false) }) {
Icon(
Icons.Default.UploadFile,
contentDescription = "Load the chapter from a file"
)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
Text(stringResource(Res.string.load_from_file))
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth().weight(1f),
state = originalTextFieldState,