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:
1 parent
6ab4f0e557
commit
eb191d945a
10 files changed
+826
No files matched your search
@@ -0,0 +1,91 @@
|
||||
package press.mantra.compose.files
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import press.mantra.compose.text.ChapterText
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* AWT's `FileDialog`, which is the native chooser on all three desktops.
|
||||
*
|
||||
* `JFileChooser` was the alternative and is worse here: it is Swing's own
|
||||
* dialog, so it looks like Swing rather than like the host, and on macos it is
|
||||
* notably not the file panel every other application shows. `FileDialog` maps
|
||||
* to the platform's own in each case.
|
||||
*
|
||||
* **It blocks the calling thread**, which on desktop Compose is the UI thread,
|
||||
* so showing it from a click handler freezes the window until it closes -- and
|
||||
* reading the files afterwards would freeze it again. Both go to
|
||||
* [Dispatchers.IO] and the result comes back through [onPicked] on the main
|
||||
* dispatcher, which is the same shape the android launcher has by construction.
|
||||
*
|
||||
* The scope is the composition's. A dialog whose composable has left is a
|
||||
* dialog whose result has nowhere to go, and cancelling the read is the right
|
||||
* behaviour rather than a leak to avoid.
|
||||
*/
|
||||
@Composable
|
||||
actual fun rememberFilePicker(
|
||||
title: String,
|
||||
onPicked: (List<PickedFile>) -> Unit,
|
||||
): FilePicker {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
return remember(scope, title, onPicked) {
|
||||
FilePicker { multiple ->
|
||||
scope.launch {
|
||||
val picked = withContext(Dispatchers.IO) { chooseFiles(title, multiple) }
|
||||
onPicked(picked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun chooseFiles(title: String, multiple: Boolean): List<PickedFile> {
|
||||
// A null parent is allowed and gives an unowned dialog. It is what a Compose
|
||||
// desktop window gets us without reaching for the AWT window behind it,
|
||||
// which `ComposeWindow` exposes but only from a `FrameWindowScope` that the
|
||||
// app's navigation graph does not thread through.
|
||||
val dialog = object : FileDialog(null as Frame?, title, LOAD) {}
|
||||
dialog.isMultipleMode = multiple
|
||||
dialog.isVisible = true
|
||||
|
||||
val chosen = if (multiple) {
|
||||
dialog.files?.toList().orEmpty()
|
||||
} else {
|
||||
listOfNotNull(dialog.directory?.let { dir -> dialog.file?.let { File(dir, it) } })
|
||||
}
|
||||
|
||||
return chosen.mapNotNull(::readPickedFile)
|
||||
}
|
||||
|
||||
/** As on android: one byte past the cap, so an over-size file reads as over-size. */
|
||||
private fun readPickedFile(file: File): PickedFile? =
|
||||
runCatching {
|
||||
PickedFile(
|
||||
name = file.name,
|
||||
bytes = file.inputStream().use { it.readAtMost(ChapterText.MAX_FILE_BYTES + 1) },
|
||||
)
|
||||
}.onFailure { error ->
|
||||
Logger.withTag(TAG).e("Could not read the picked file ${file.path}", error)
|
||||
}.getOrNull()
|
||||
|
||||
private fun InputStream.readAtMost(limit: Int): ByteArray {
|
||||
val buffer = ByteArray(limit)
|
||||
var filled = 0
|
||||
while (filled < limit) {
|
||||
val read = read(buffer, filled, limit - filled)
|
||||
if (read < 0) break
|
||||
filled += read
|
||||
}
|
||||
return buffer.copyOf(filled)
|
||||
}
|
||||
|
||||
private const val TAG = "FilePicker"
|
||||
Reference in new issue
Block a user