diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreview.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreview.kt index 3ba0b7f..8e14941 100644 --- a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreview.kt +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreview.kt @@ -12,6 +12,10 @@ import com.interlinedlist.android.core.materialize.domain.RowDataStyle * field list, so showing renamed list columns here would promise something the * request does not ask for. * + * A source that already **is** a document short-circuits all of that: when the + * entry point supplies [MaterializePreview.documentMarkdown] the destination + * copies it through, so it is shown verbatim rather than re-rendered from rows. + * * The result is a preview, not the document: the server re-derives the real one * from its own copy of the source. */ @@ -20,6 +24,16 @@ internal fun renderDocumentPreview( preview: MaterializePreview, listStyle: DocumentListStyle, rowDataStyle: RowDataStyle, +): String { + preview.documentMarkdown?.takeIf { it.isNotBlank() }?.let { return it.trimEnd() } + return renderRowsAsDocument(title, preview, listStyle, rowDataStyle) +} + +private fun renderRowsAsDocument( + title: String, + preview: MaterializePreview, + listStyle: DocumentListStyle, + rowDataStyle: RowDataStyle, ): String = buildString { if (title.isNotBlank()) appendLine("# ${title.trim()}").appendLine() preview.suggestedDescription?.takeIf { it.isNotBlank() }?.let { diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindow.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindow.kt index 538041a..3288c96 100644 --- a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindow.kt +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindow.kt @@ -73,6 +73,7 @@ object MaterializeWindowTestTags { const val ADD_COLUMN = "materializeAddColumn" const val TABLE_PREVIEW = "materializeTablePreview" const val DOCUMENT_PREVIEW = "materializeDocumentPreview" + const val DRAFT_PREVIEW = "materializeDraftPreview" const val FILE_NAME = "materializeFileName" const val CONFIRM = "materializeConfirm" const val CANCEL = "materializeCancel" @@ -286,7 +287,7 @@ private fun EditorPane( Banners(state) if (state.createsDraft) { - DraftNotice() + DraftNotice(state.preview.draftBody) return@Column } @@ -373,7 +374,7 @@ private fun Banners(state: MaterializeWindowUiState) { /** The one destination that creates nothing: it hands the composer a draft. */ @Composable -private fun DraftNotice() { +private fun DraftNotice(draftBody: String?) { Card { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text("Opens in the composer", style = MaterialTheme.typography.titleMedium) @@ -385,6 +386,18 @@ private fun DraftNotice() { ) } } + // What the source reads as, when the entry point could work it out. The + // server still builds and sizes the body it hands the composer, so this is + // shown as a preview and never sent. + draftBody?.takeIf { it.isNotBlank() }?.let { body -> + Card(Modifier.testTag(MaterializeWindowTestTags.DRAFT_PREVIEW)) { + Text( + text = body, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp), + ) + } + } } @Composable diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowState.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowState.kt index 685c6bd..320bbdb 100644 --- a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowState.kt +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowState.kt @@ -52,6 +52,24 @@ data class MaterializePreview( val totalRowCount: Int = rows.size, /** The account's `defaultPubliclyVisible`, so the toggle opens where the user expects. */ val defaultIsPublic: Boolean = false, + /** + * The markdown a `doc` destination copies through, when the entry point + * already has it verbatim. A document source is copied, not rendered from + * rows, so laying [rows] out as bullets would misdescribe what gets created. + * + * Display only, like the rest of this class: the server re-derives the real + * document from its own copy of the source. + */ + val documentMarkdown: String? = null, + /** + * The plain-text body a `message` destination would produce, when the entry + * point can derive it — so the draft is visible before it is asked for. + * + * Display only, and deliberately **not** sent: `MaterializeRequest.ToMessageDraft` + * carries no `content`, leaving the server to build the body and size it + * against the account's own limit. + */ + val draftBody: String? = null, ) { /** How many rows exist beyond the ones being shown. */ val hiddenRowCount: Int get() = (totalRowCount - rows.size).coerceAtLeast(0) diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModel.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModel.kt index fc87f3c..2ad2495 100644 --- a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModel.kt +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModel.kt @@ -50,6 +50,21 @@ class MaterializeWindowViewModel @Inject constructor( _uiState.value = state } + /** + * Ends the flow: the next [start] opens a clean window even if it is handed + * the identical launch. + * + * [start] deliberately keeps the edits when it is re-entered with the same + * launch, so a recomposition or a rotation does not throw the user's work + * away. That leaves the host to say when the flow is actually over — closing + * the window — because only the host can tell the two apart. + */ + fun reset() { + activeLaunch = null + nextUiId = 0L + _uiState.value = null + } + /** * Switches destination from inside the window. Nothing is discarded here: * the edits stay in state and [MaterializeWindowUiState.toRequest] decides diff --git a/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreviewTest.kt b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreviewTest.kt index fa008af..2f158f7 100644 --- a/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreviewTest.kt +++ b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreviewTest.kt @@ -85,4 +85,33 @@ class DocumentPreviewTest { assertThat(rendered).isEqualTo("# Launch notes") } + + @Test + fun `a source that is already a document is previewed verbatim`() { + val markdown = "# Launch notes\n\n- Ship it\n- Tell everyone\n" + + val rendered = renderDocumentPreview( + title = "Copy of Launch notes", + preview = preview.copy(documentMarkdown = markdown), + listStyle = DocumentListStyle.NUMBERED, + rowDataStyle = RowDataStyle.SUB_ITEMS, + ) + + // The rows and the edited title are ignored: this destination copies the + // source document through rather than rendering a table as bullets. + assertThat(rendered).isEqualTo("# Launch notes\n\n- Ship it\n- Tell everyone") + assertThat(rendered).doesNotContain("The Dream Machine") + } + + @Test + fun `a blank verbatim markdown falls back to rendering the rows`() { + val rendered = renderDocumentPreview( + title = "Books to Read", + preview = preview.copy(documentMarkdown = " "), + listStyle = DocumentListStyle.BULLETED, + rowDataStyle = RowDataStyle.INLINE, + ) + + assertThat(rendered).contains("- The Dream Machine") + } } diff --git a/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModelTest.kt b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModelTest.kt index ec42bd9..feb0459 100644 --- a/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModelTest.kt +++ b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModelTest.kt @@ -252,6 +252,57 @@ class MaterializeWindowViewModelTest { assertThat(success.canOpenDocument).isFalse() } + @Test + fun `reopening after a reset starts a clean window`() = runTest(dispatcher) { + repository.result = ApiResult.Success( + MaterializeOutcome.ListCreated(MaterializedList("lst_new", "Books to Read")), + ) + val vm = viewModel() + vm.updateTitle("Edited title") + vm.confirm() + advanceUntilIdle() + assertThat(vm.state.success).isNotNull() + + // The host closed the window: the flow is over. + vm.reset() + assertThat(vm.uiState.value).isNull() + + // The identical launch must not resurrect the finished one. + vm.start(MaterializeLaunch(source, MaterializeTarget.LIST, preview())) + + assertThat(vm.state.success).isNull() + assertThat(vm.state.title).isEqualTo("Books to Read") + } + + @Test + fun `a previewed draft body is shown but never sent`() = runTest(dispatcher) { + repository.result = ApiResult.Success( + MaterializeOutcome.DraftReady( + MessageDraft("Server copy", listOf("Server copy"), isThread = false, charLimit = 300), + ), + ) + val vm = MaterializeWindowViewModel(repository).also { + it.start( + MaterializeLaunch( + source = source, + initialTarget = MaterializeTarget.MESSAGE, + preview = preview().copy(draftBody = "Books to Read\n\nMy reading backlog."), + ), + ) + } + + assertThat(vm.state.preview.draftBody).isEqualTo("Books to Read\n\nMy reading backlog.") + + vm.confirm() + advanceUntilIdle() + + // The composer must receive the body the server sized, not the one the + // entry point guessed, so the guess stays on the device. + val request = sentRequest() as MaterializeRequest.ToMessageDraft + assertThat(request.messageConfig).isNull() + assertThat(vm.state.success?.draft?.content).isEqualTo("Server copy") + } + @Test fun `a messages source does not offer the message destination`() = runTest(dispatcher) { val vm = viewModel(source = MaterializeSource.Messages(listOf("msg_1"))) diff --git a/feature/documents/build.gradle.kts b/feature/documents/build.gradle.kts index 0c1432e..b491514 100644 --- a/feature/documents/build.gradle.kts +++ b/feature/documents/build.gradle.kts @@ -37,6 +37,11 @@ dependencies { // feature modules that use them, which is what the Powered Document entry // point on this surface is. No other feature module is depended on. implementation(project(":feature:ai")) + // "Create from…": the shared materialize domain, repository and the one + // preview/edit/confirm window every entry point opens. It is a :core: + // capability, not a feature dependency — documents only supplies the source + // and the preview it can derive locally. + implementation(project(":core:materialize")) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) diff --git a/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/CreateFromEntryPointsTest.kt b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/CreateFromEntryPointsTest.kt new file mode 100644 index 0000000..45790eb --- /dev/null +++ b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/CreateFromEntryPointsTest.kt @@ -0,0 +1,152 @@ +package com.interlinedlist.android.feature.documents.ui + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInputSelection +import androidx.compose.ui.text.TextRange +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.core.materialize.domain.MaterializeTarget +import com.interlinedlist.android.feature.documents.domain.Document +import com.interlinedlist.android.feature.documents.domain.FolderContents +import com.interlinedlist.android.feature.documents.domain.FolderNode +import com.interlinedlist.android.feature.documents.domain.FolderSummary +import com.interlinedlist.android.feature.documents.ui.browser.DocumentsBrowserScreen +import com.interlinedlist.android.feature.documents.ui.browser.DocumentsBrowserUiState +import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorScreen +import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorTestTags +import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorUiState +import com.interlinedlist.android.feature.documents.ui.materialize.CreateFromTestTags +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The + Create entry points on a document: the browser row, the editor, and the + * editor's highlighted selection. What each one *builds* is covered by the JVM + * tests; this covers that the controls exist and raise the destination picked. + */ +@RunWith(AndroidJUnit4::class) +class CreateFromEntryPointsTest { + + @get:Rule + val composeRule = createComposeRule() + + private val document = Document( + id = "d1", + title = "Launch plan", + content = "# Launch plan\n- Ship it", + snippet = "Launch plan", + folderId = null, + folderName = null, + isPublic = false, + updatedAt = null, + ) + + @Test + fun browserRow_createMenu_raisesThePickedDestination() { + var picked: Pair? = null + composeRule.setContent { + InterlinedListTheme { + DocumentsBrowserScreen( + state = DocumentsBrowserUiState( + isLoading = false, + contents = FolderContents( + folderId = FolderNode.ROOT_ID, + folderName = FolderNode.ROOT_NAME, + parentId = null, + subfolders = emptyList(), + documents = listOf(document), + breadcrumb = listOf(FolderSummary(FolderNode.ROOT_ID, FolderNode.ROOT_NAME)), + ), + ), + onOpenFolder = {}, + onOpenDocument = {}, + onCreateDocument = {}, + onCreateFolder = {}, + onRenameFolder = { _, _ -> }, + onDeleteFolder = {}, + onMoveDocument = { _, _ -> }, + onDeleteDocument = {}, + onOpenSearch = {}, + onCloseSearch = {}, + onSearchQueryChange = {}, + onBack = null, + onCreateFrom = { doc, target -> picked = doc to target }, + ) + } + } + + composeRule.onNodeWithTag(CreateFromTestTags.row("d1")).performClick() + composeRule.onNodeWithTag(CreateFromTestTags.target(MaterializeTarget.LIST)).performClick() + + assertThat(picked).isEqualTo(document to MaterializeTarget.LIST) + } + + @Test + fun editor_createMenu_raisesThePickedDestination() { + var picked: MaterializeTarget? = null + setEditorContent(onCreateFrom = { picked = it }) + + composeRule.onNodeWithTag(CreateFromTestTags.EDITOR).performClick() + composeRule.onNodeWithTag(CreateFromTestTags.target(MaterializeTarget.DOC)).performClick() + + assertThat(picked).isEqualTo(MaterializeTarget.DOC) + } + + @Test + fun editor_selectionAction_isHidden_untilSomethingIsHighlighted() { + setEditorContent() + + composeRule.onAllNodesWithTag(DocumentEditorTestTags.SELECTION_BAR).assertCountEquals(0) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun editor_selectionAction_raisesTheHighlightedMarkdown() { + var picked: Pair? = null + setEditorContent(onCreateFromSelection = { markdown, target -> picked = markdown to target }) + + // Highlight the "## Week one\n- Ship it" passage. + val body = "# Launch plan\n\n## Week one\n- Ship it" + val start = body.indexOf("## Week one") + composeRule.onNodeWithTag(DocumentEditorTestTags.BODY) + .performTextInputSelection(TextRange(start, body.length)) + + composeRule.onNodeWithTag(CreateFromTestTags.SELECTION).performClick() + composeRule.onNodeWithTag(CreateFromTestTags.target(MaterializeTarget.LIST)).performClick() + + assertThat(picked).isEqualTo("## Week one\n- Ship it" to MaterializeTarget.LIST) + } + + private fun setEditorContent( + onCreateFrom: (MaterializeTarget) -> Unit = {}, + onCreateFromSelection: (String, MaterializeTarget) -> Unit = { _, _ -> }, + ) { + composeRule.setContent { + InterlinedListTheme { + DocumentEditorScreen( + state = DocumentEditorUiState( + documentId = "d1", + title = "Launch plan", + content = "# Launch plan\n\n## Week one\n- Ship it", + isLoading = false, + ), + onTitleChange = {}, + onContentChange = {}, + onTogglePreview = {}, + onSave = {}, + onDelete = {}, + onBack = {}, + onCreateFrom = onCreateFrom, + onCreateFromSelection = onCreateFromSelection, + ) + } + } + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentElements.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentElements.kt new file mode 100644 index 0000000..66a77cd --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentElements.kt @@ -0,0 +1,277 @@ +package com.interlinedlist.android.feature.documents.domain + +/** + * The block kinds "Create from…" recognises in a document body. + * + * [apiValue] is what the `type` source attribute reads as in a materialized + * list, so the Type column previews the same word the server stores. + */ +enum class DocumentElementType(val apiValue: String) { + HEADING("heading"), + LIST_ITEM("list-item"), + PARAGRAPH("paragraph"), + QUOTE("quote"), + CODE("code"), +} + +/** + * One parsed block of a document body. + * + * [section] is the heading this block sits under (empty above the first + * heading), which is what lets a materialized row say where it came from. + * [level] is the heading depth (1..6) or the bullet's nesting depth (1 for a + * top-level bullet); it is 0 for blocks that cannot nest. + */ +data class DocumentElement( + val type: DocumentElementType, + val level: Int, + val text: String, + val section: String, +) + +/** + * Parses a document body into the blocks "Create from…" works with. + * + * Pure and dependency-free so both the row mapping and the plain-text + * conversion are unit-testable without Compose or a device. It is deliberately + * a *small* markdown subset — headings, list items, quotes, code blocks and + * paragraphs — because that is all the conversions distinguish. + * + * Nothing parsed here is ever sent: `POST /api/materialize` takes ids, and the + * server re-derives the rows from its own copy of the document. This exists so + * the preview can show that mapping before anything is created. + */ +fun parseDocumentElements(markdown: String?): List { + val lines = markdown.orEmpty().replace("\r\n", "\n").split("\n") + val elements = mutableListOf() + val paragraph = mutableListOf() + var section = "" + + fun flushParagraph() { + if (paragraph.isEmpty()) return + val text = paragraph.joinToString(" ").trim() + paragraph.clear() + if (text.isNotEmpty()) { + elements += DocumentElement(DocumentElementType.PARAGRAPH, 0, text, section) + } + } + + var index = 0 + while (index < lines.size) { + val line = lines[index] + + val fence = FENCE.find(line) + if (fence != null) { + flushParagraph() + val closing = if (fence.groupValues[1].startsWith("`")) BACKTICK_CLOSE else TILDE_CLOSE + val body = mutableListOf() + index++ + while (index < lines.size && !closing.matches(lines[index])) { + body += lines[index] + index++ + } + // Past the closing fence, or past the end for an unterminated block. + index++ + elements += DocumentElement(DocumentElementType.CODE, 0, body.joinToString("\n"), section) + continue + } + + if (line.isBlank()) { + flushParagraph() + index++ + continue + } + + val heading = HEADING.find(line) + if (heading != null) { + flushParagraph() + val text = heading.groupValues[2].trim() + // Everything after this heading belongs to its section. + section = text + elements += DocumentElement( + type = DocumentElementType.HEADING, + level = heading.groupValues[1].length, + text = text, + section = text, + ) + index++ + continue + } + + // Checked before the indented-code rule so a nested bullet stays a bullet. + val item = LIST_ITEM.find(line) + if (item != null) { + flushParagraph() + val indent = item.groupValues[1].replace("\t", " ").length + elements += DocumentElement( + type = DocumentElementType.LIST_ITEM, + level = indent / INDENT_UNIT + 1, + text = item.groupValues[2].trim(), + section = section, + ) + index++ + continue + } + + val quote = QUOTE.find(line) + if (quote != null) { + flushParagraph() + elements += DocumentElement( + type = DocumentElementType.QUOTE, + level = 0, + text = quote.groupValues[1].trim(), + section = section, + ) + index++ + continue + } + + // An indented code block, which — as in CommonMark — cannot interrupt a + // paragraph: it has to start a block of its own. + if (paragraph.isEmpty() && line.isIndentedCode() && startsBlock(lines, index)) { + val body = mutableListOf() + while ( + index < lines.size && + (lines[index].isIndentedCode() || (lines[index].isBlank() && continuesIndentedCode(lines, index))) + ) { + body += lines[index].withoutCodeIndent() + index++ + } + elements += DocumentElement( + type = DocumentElementType.CODE, + level = 0, + text = body.joinToString("\n").trim('\n'), + section = section, + ) + continue + } + + paragraph += line.trim() + index++ + } + flushParagraph() + return elements +} + +/** + * The blocks that become rows when a document is materialized as a list: + * **each heading and bullet point becomes its own row**. + * + * A document with neither is not left with an empty list — every block it does + * have becomes a row instead, which is the only reading that turns a plain + * prose document into something. + */ +fun documentListRows(markdown: String?): List { + val elements = parseDocumentElements(markdown) + val headingsAndBullets = elements.filter { + it.type == DocumentElementType.HEADING || it.type == DocumentElementType.LIST_ITEM + } + return headingsAndBullets.ifEmpty { elements } +} + +/** + * Converts a document body to the plain text a message destination reads as: + * **paragraphs preserved, code blocks dropped**. + * + * Blocks are separated by a blank line so the paragraph structure survives, + * bullets keep a `•` marker so a list still reads as a list, and inline + * markdown (links, emphasis, code spans, HTML) is flattened to its text. Code + * blocks — fenced or indented — are removed entirely rather than pasted into a + * post that cannot render them. + * + * This is a preview: the body that actually reaches the composer is built and + * size-checked by the server from its own copy of the source. + */ +fun markdownToPlainText(markdown: String?): String { + val elements = parseDocumentElements(markdown) + if (elements.isEmpty()) return stripInlineMarkdown(markdown.orEmpty()) + return elements + .asSequence() + .filterNot { it.type == DocumentElementType.CODE } + .mapNotNull { element -> + stripInlineMarkdown(element.text) + .takeIf { it.isNotEmpty() } + ?.let { if (element.type == DocumentElementType.LIST_ITEM) "$BULLET $it" else it } + } + .joinToString("\n\n") + .trim() +} + +/** + * Flattens inline markdown to plain text: images dropped, links reduced to + * their label, emphasis/strikethrough/code-span markers removed, HTML tags and + * the handful of entities a body picks up resolved, and whitespace collapsed. + */ +internal fun stripInlineMarkdown(text: String): String { + if (text.isEmpty()) return "" + var result = text.replace("\r\n", "\n") + result = IMAGE.replace(result, "") + result = INLINE_LINK.replace(result, "$1") + result = REFERENCE_LINK.replace(result, "$1") + result = LINK_DEFINITION.replace(result, "") + result = HEADING_MARKER.replace(result, "") + result = QUOTE_MARKER.replace(result, "") + result = LIST_MARKER.replace(result, "") + result = STRONG.replace(result, "$2") + result = EMPHASIS.replace(result, "$2") + result = STRIKETHROUGH.replace(result, "$1") + result = CODE_SPAN.replace(result, "$1") + result = HTML_TAG.replace(result, "") + ENTITIES.forEach { (entity, replacement) -> result = result.replace(entity, replacement) } + return WHITESPACE.replace(result, " ").trim() +} + +/** The marker a bullet keeps once its markdown is gone. */ +private const val BULLET = "•" + +/** Two spaces of leading indentation is one level of bullet nesting. */ +private const val INDENT_UNIT = 2 + +private val HEADING = Regex("^(#{1,6})\\s+(.*)$") +private val LIST_ITEM = Regex("^(\\s*)(?:[-*+]|\\d+[.)])\\s+(.*)$") +private val QUOTE = Regex("^\\s*>\\s?(.*)$") +private val FENCE = Regex("^\\s*(`{3,}|~{3,})") +private val BACKTICK_CLOSE = Regex("^\\s*`{3,}\\s*$") +private val TILDE_CLOSE = Regex("^\\s*~{3,}\\s*$") + +private val IMAGE = Regex("!\\[[^\\]]*\\]\\([^)]*\\)") +private val INLINE_LINK = Regex("\\[([^\\]]*)\\]\\([^)]*\\)") +private val REFERENCE_LINK = Regex("\\[([^\\]]+)\\]\\[[^\\]]*\\]") +private val LINK_DEFINITION = Regex("^\\s*\\[[^\\]]+\\]:\\s*\\S+.*$", RegexOption.MULTILINE) +private val HEADING_MARKER = Regex("^\\s{0,3}#{1,6}\\s+", RegexOption.MULTILINE) +private val QUOTE_MARKER = Regex("^\\s*>\\s?", RegexOption.MULTILINE) +private val LIST_MARKER = Regex("^\\s*(?:[-*+]|\\d+[.)])\\s+", RegexOption.MULTILINE) +private val STRONG = Regex("(\\*\\*|__)(.+?)\\1") +private val EMPHASIS = Regex("(\\*|_)(.+?)\\1") +private val STRIKETHROUGH = Regex("~~(.+?)~~") +private val CODE_SPAN = Regex("`([^`]+)`") +private val HTML_TAG = Regex("<[^>]+>") +private val WHITESPACE = Regex("\\s+") + +private val ENTITIES = listOf( + " " to " ", + "&" to "&", + "<" to "<", + ">" to ">", + """ to "\"", + "'" to "'", +) + +private fun String.isIndentedCode(): Boolean = startsWith(" ") || startsWith("\t") + +private fun String.withoutCodeIndent(): String = when { + startsWith(" ") -> substring(4) + startsWith("\t") -> substring(1) + else -> trim() +} + +/** True when the line at [index] opens a block rather than continuing prose. */ +private fun startsBlock(lines: List, index: Int): Boolean = + index == 0 || lines[index - 1].isBlank() + +/** True when more indented code follows the blank line at [index]. */ +private fun continuesIndentedCode(lines: List, index: Int): Boolean { + var next = index + while (next < lines.size && lines[next].isBlank()) next++ + return next < lines.size && lines[next].isIndentedCode() +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt index 405bf85..9e0539c 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt @@ -54,10 +54,15 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.core.materialize.domain.MaterializeTarget +import com.interlinedlist.android.core.materialize.ui.MaterializeWindow +import com.interlinedlist.android.core.materialize.ui.MaterializeWindowViewModel import com.interlinedlist.android.feature.documents.domain.Document import com.interlinedlist.android.feature.documents.domain.FolderContents import com.interlinedlist.android.feature.documents.domain.FolderNode import com.interlinedlist.android.feature.documents.domain.FolderSummary +import com.interlinedlist.android.feature.documents.ui.materialize.CreateFromMenu +import com.interlinedlist.android.feature.documents.ui.materialize.CreateFromTestTags /** Stable test tags for the documents browser. */ object DocumentsBrowserTestTags { @@ -94,6 +99,7 @@ fun DocumentsRoute( modifier: Modifier = Modifier, onOpenTemplates: () -> Unit = {}, onOpenPoweredDocument: () -> Unit = {}, + onOpenList: (String) -> Unit = {}, viewModel: DocumentsBrowserViewModel = hiltViewModel(), ) { DocumentsFolderRoute( @@ -103,6 +109,7 @@ fun DocumentsRoute( modifier = modifier, onOpenTemplates = onOpenTemplates, onOpenPoweredDocument = onOpenPoweredDocument, + onOpenList = onOpenList, viewModel = viewModel, ) } @@ -119,9 +126,37 @@ fun DocumentsFolderRoute( modifier: Modifier = Modifier, onOpenTemplates: () -> Unit = {}, onOpenPoweredDocument: () -> Unit = {}, + onOpenList: (String) -> Unit = {}, viewModel: DocumentsBrowserViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + + // The shared "Create from…" window, opened on whichever destination the row's + // + Create menu picked. It is hosted here rather than in the stateless screen + // so the screen stays free of Hilt and testable on its own. Its ViewModel is + // hoisted so closing the window can end the flow: it survives recomposition + // (and rotation) with the edits intact, and only a close throws them away. + val materializeViewModel: MaterializeWindowViewModel = hiltViewModel() + val closeCreateFrom = { + materializeViewModel.reset() + viewModel.dismissCreateFrom() + } + state.createFrom?.let { launch -> + MaterializeWindow( + launch = launch, + onDismiss = closeCreateFrom, + onOpenList = { list -> + closeCreateFrom() + onOpenList(list.id) + }, + onOpenDocument = { document -> + closeCreateFrom() + onOpenDocument(document.id) + }, + viewModel = materializeViewModel, + ) + } + DocumentsBrowserScreen( state = state, onOpenFolder = onOpenFolder, @@ -138,6 +173,7 @@ fun DocumentsFolderRoute( onBack = onBack, onOpenTemplates = onOpenTemplates, onOpenPoweredDocument = onOpenPoweredDocument, + onCreateFrom = viewModel::createFrom, modifier = modifier, ) } @@ -161,6 +197,7 @@ fun DocumentsBrowserScreen( modifier: Modifier = Modifier, onOpenTemplates: () -> Unit = {}, onOpenPoweredDocument: () -> Unit = {}, + onCreateFrom: (Document, MaterializeTarget) -> Unit = { _, _ -> }, ) { var dialog by remember { mutableStateOf(BrowserDialog.None) } @@ -241,6 +278,7 @@ fun DocumentsBrowserScreen( state = state, onOpenFolder = onOpenFolder, onOpenDocument = onOpenDocument, + onCreateFrom = onCreateFrom, onRequestRename = { dialog = BrowserDialog.RenameFolder(it) }, onRequestDeleteFolder = { dialog = BrowserDialog.ConfirmDeleteFolder(it) }, onRequestMoveDoc = { dialog = BrowserDialog.MoveDocument(it) }, @@ -279,6 +317,7 @@ private fun BrowserContent( state: DocumentsBrowserUiState, onOpenFolder: (String) -> Unit, onOpenDocument: (String) -> Unit, + onCreateFrom: (Document, MaterializeTarget) -> Unit, onRequestRename: (FolderSummary) -> Unit, onRequestDeleteFolder: (FolderSummary) -> Unit, onRequestMoveDoc: (Document) -> Unit, @@ -328,6 +367,7 @@ private fun BrowserContent( DocumentRow( document = document, onClick = { onOpenDocument(document.id) }, + onCreateFrom = { target -> onCreateFrom(document, target) }, onMove = { onRequestMoveDoc(document) }, onDelete = { onRequestDeleteDoc(document) }, ) @@ -411,6 +451,7 @@ private fun FolderRow( private fun DocumentRow( document: Document, onClick: () -> Unit, + onCreateFrom: (MaterializeTarget) -> Unit, onMove: () -> Unit, onDelete: () -> Unit, ) { @@ -436,6 +477,11 @@ private fun DocumentRow( ) } } + CreateFromMenu( + onSelectTarget = onCreateFrom, + contentDescription = "Create from this document", + modifier = Modifier.testTag(CreateFromTestTags.row(document.id)), + ) RowOverflowMenu( actions = listOf( OverflowAction("Move", Icons.AutoMirrored.Filled.DriveFileMove, onMove), diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt index 0c04e9f..e12ae71 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.materialize.domain.MaterializeTarget +import com.interlinedlist.android.core.materialize.ui.MaterializeLaunch import com.interlinedlist.android.feature.ai.domain.AiGate import com.interlinedlist.android.feature.documents.data.DocumentsRepository import com.interlinedlist.android.feature.documents.domain.Document @@ -12,6 +14,7 @@ import com.interlinedlist.android.feature.documents.domain.FolderNode import com.interlinedlist.android.feature.documents.domain.FolderSummary import com.interlinedlist.android.feature.documents.ui.common.isSubscriptionGate import com.interlinedlist.android.feature.documents.ui.common.toUserMessage +import com.interlinedlist.android.feature.documents.ui.materialize.documentMaterializeLaunch import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -46,6 +49,11 @@ data class DocumentsBrowserUiState( * deployment with no provider configured — never sees the entry point at all. */ val isAiEnabled: Boolean = false, + /** + * The "Create from…" window to show, once a row's + Create menu has picked a + * destination. Null while nothing is being converted. + */ + val createFrom: MaterializeLaunch? = null, ) { val isEmpty: Boolean get() = contents.isEmpty && !isLoading @@ -173,6 +181,45 @@ class DocumentsBrowserViewModel @Inject constructor( } } + /** + * Opens "Create from…" on a whole document from its browser row. + * + * The index endpoint omits document bodies, so a row usually knows only its + * snippet; the body is fetched first so the window can preview the rows the + * headings and bullets will become. A fetch that fails still opens the + * window: the request carries the document **id**, and the server derives + * everything from its own copy — a missing preview is a worse preview, not a + * wrong creation. + */ + fun createFrom(document: Document, target: MaterializeTarget) { + val cached = document.content + if (!cached.isNullOrBlank()) { + showCreateFrom(document.id, document.title, cached, target) + return + } + viewModelScope.launch { + val loaded = (repository.refreshDocument(document.id) as? ApiResult.Success)?.data + showCreateFrom( + documentId = document.id, + title = loaded?.title ?: document.title, + markdown = loaded?.content, + target = target, + ) + } + } + + /** Closes the "Create from…" window. */ + fun dismissCreateFrom() = _uiState.update { it.copy(createFrom = null) } + + private fun showCreateFrom( + documentId: String, + title: String, + markdown: String?, + target: MaterializeTarget, + ) = _uiState.update { + it.copy(createFrom = documentMaterializeLaunch(documentId, title, markdown, target)) + } + /** Moves [documentId] into [targetFolderId] (null == root/unfiled). */ fun moveDocument(documentId: String, targetFolderId: String?) { viewModelScope.launch { diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt index 2fd1021..7efb765 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt @@ -33,18 +33,29 @@ import androidx.compose.material3.TopAppBar import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.core.materialize.domain.MaterializeTarget +import com.interlinedlist.android.core.materialize.ui.MaterializeWindow +import com.interlinedlist.android.core.materialize.ui.MaterializeWindowViewModel import com.interlinedlist.android.feature.documents.domain.Presence import com.interlinedlist.android.feature.documents.ui.common.MarkdownText +import com.interlinedlist.android.feature.documents.ui.materialize.CreateFromMenu +import com.interlinedlist.android.feature.documents.ui.materialize.CreateFromTestTags import com.interlinedlist.android.feature.documents.ui.presence.PresenceIndicator /** Stable test tags for the editor. */ @@ -64,6 +75,7 @@ object DocumentEditorTestTags { const val CONFLICT_RELOAD = "editorConflictReload" const val CONFLICT_RETRY = "editorConflictRetry" const val OFFLINE_HINT = "editorOfflineHint" + const val SELECTION_BAR = "editorSelectionBar" } /** @@ -78,6 +90,8 @@ fun DocumentEditorRoute( modifier: Modifier = Modifier, onOpenShare: () -> Unit = {}, onOpenManageAccess: () -> Unit = {}, + onOpenDocument: (String) -> Unit = {}, + onOpenList: (String) -> Unit = {}, presenceViewModel: com.interlinedlist.android.feature.documents.ui.presence.DocumentPresenceViewModel = hiltViewModel(), viewModel: DocumentEditorViewModel = hiltViewModel(), ) { @@ -108,6 +122,31 @@ fun DocumentEditorRoute( } } + // The shared "Create from…" window, opened on whichever destination the + // + Create menu picked — for the whole document or for the selection. Its + // ViewModel is hoisted so that closing the window ends the flow, while a + // recomposition or a rotation keeps the edits. + val materializeViewModel: MaterializeWindowViewModel = hiltViewModel() + val closeCreateFrom = { + materializeViewModel.reset() + viewModel.dismissCreateFrom() + } + state.createFrom?.let { launch -> + MaterializeWindow( + launch = launch, + onDismiss = closeCreateFrom, + onOpenList = { list -> + closeCreateFrom() + onOpenList(list.id) + }, + onOpenDocument = { document -> + closeCreateFrom() + onOpenDocument(document.id) + }, + viewModel = materializeViewModel, + ) + } + DocumentEditorScreen( state = state, onTitleChange = viewModel::onTitleChange, @@ -125,6 +164,8 @@ fun DocumentEditorRoute( onOpenManageAccess = onOpenManageAccess, onReloadConflict = viewModel::reloadForConflict, onRetrySave = { viewModel.save() }, + onCreateFrom = viewModel::createFrom, + onCreateFromSelection = viewModel::createFromSelection, presenceParticipants = presenceState.participants, modifier = modifier, ) @@ -147,8 +188,25 @@ fun DocumentEditorScreen( onOpenManageAccess: () -> Unit = {}, onReloadConflict: () -> Unit = {}, onRetrySave: () -> Unit = {}, + onCreateFrom: (MaterializeTarget) -> Unit = {}, + onCreateFromSelection: (String, MaterializeTarget) -> Unit = { _, _ -> }, presenceParticipants: List = emptyList(), ) { + // The body is edited through a TextFieldValue so the highlighted range is + // known: "Create from selection" needs the selected markdown, not just the + // text. The ViewModel keeps owning the content; this only tracks selection, + // and re-syncs when the content changes underneath us (a reload, a conflict + // resolution, an inserted image). + var body by remember { mutableStateOf(TextFieldValue(state.content)) } + var selection by remember { mutableStateOf(null) } + LaunchedEffect(state.content) { + if (body.text != state.content) { + body = body.copy(text = state.content, selection = TextRange(state.content.length)) + selection = null + } + } + val selectedMarkdown = body.textIn(selection) + Scaffold( modifier = modifier.fillMaxSize(), topBar = { @@ -181,6 +239,11 @@ fun DocumentEditorScreen( Icon(Icons.Default.Image, contentDescription = "Insert image") } } + CreateFromMenu( + onSelectTarget = onCreateFrom, + contentDescription = "Create from this document", + modifier = Modifier.testTag(CreateFromTestTags.EDITOR), + ) IconButton( onClick = onOpenManageAccess, modifier = Modifier.testTag(DocumentEditorTestTags.MANAGE_ACCESS), @@ -294,9 +357,19 @@ fun DocumentEditorScreen( .testTag(DocumentEditorTestTags.PREVIEW), ) } else { + if (selectedMarkdown.isNotBlank()) { + SelectionBar( + onCreateFrom = { target -> onCreateFromSelection(selectedMarkdown, target) }, + ) + } OutlinedTextField( - value = state.content, - onValueChange = onContentChange, + value = body, + onValueChange = { edited -> + val textChanged = edited.text != body.text + selection = pinnedSelection(selection, edited, textChanged) + body = edited + if (textChanged) onContentChange(edited.text) + }, label = { Text("Markdown") }, modifier = Modifier .fillMaxSize() @@ -309,6 +382,33 @@ fun DocumentEditorScreen( } } +/** + * The Selection action: turn just the highlighted markdown into a list, a + * document or a post. It appears only while something is highlighted, which is + * the only time a `docElements` source can be built. + */ +@Composable +private fun SelectionBar(onCreateFrom: (MaterializeTarget) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(DocumentEditorTestTags.SELECTION_BAR), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Selection", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + CreateFromMenu( + onSelectTarget = onCreateFrom, + label = "Create", + modifier = Modifier.testTag(CreateFromTestTags.SELECTION), + ) + } +} + /** A save-conflict banner offering Reload (take server copy) or Retry (overwrite). */ @Composable private fun ConflictBanner( diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt index 6e4dccb..a6e4909 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt @@ -4,10 +4,14 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.materialize.domain.MaterializeTarget +import com.interlinedlist.android.core.materialize.ui.MaterializeLaunch import com.interlinedlist.android.feature.documents.data.DocumentsRepository import com.interlinedlist.android.feature.documents.data.SaveOutcome import com.interlinedlist.android.feature.documents.ui.common.isSubscriptionGate import com.interlinedlist.android.feature.documents.ui.common.toUserMessage +import com.interlinedlist.android.feature.documents.ui.materialize.documentMaterializeLaunch +import com.interlinedlist.android.feature.documents.ui.materialize.documentSelectionMaterializeLaunch import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -36,6 +40,11 @@ data class DocumentEditorUiState( val isQueuedOffline: Boolean = false, val errorMessage: String? = null, val subscriptionRequired: Boolean = false, + /** + * The "Create from…" window to show, once + Create — on the whole document + * or on the highlighted passage — has picked a destination. + */ + val createFrom: MaterializeLaunch? = null, ) { val canSave: Boolean get() = hasUnsavedChanges && !isSaving && !isLoading } @@ -208,6 +217,41 @@ class DocumentEditorViewModel @Inject constructor( } } + /** + * Opens "Create from…" on this whole document. + * + * Only the document id is sent, so the conversion is always of the **saved** + * document; the preview is seeded from the editor's current text because + * that is what the user is looking at. Unsaved edits can therefore show in + * the preview without being created — save first to convert them. + */ + fun createFrom(target: MaterializeTarget) = _uiState.update { + it.copy(createFrom = documentMaterializeLaunch(documentId, it.title, it.content, target)) + } + + /** + * Opens "Create from…" on the highlighted passage, as a `docElements` + * source: the document id the server re-authorizes, plus the selected + * markdown, which is the only identity a selection has. A blank selection is + * ignored — there is nothing to convert. + */ + fun createFromSelection(selectedMarkdown: String, target: MaterializeTarget) { + if (selectedMarkdown.isBlank()) return + _uiState.update { + it.copy( + createFrom = documentSelectionMaterializeLaunch( + documentId = documentId, + documentTitle = it.title, + selectedMarkdown = selectedMarkdown, + target = target, + ), + ) + } + } + + /** Closes the "Create from…" window. */ + fun dismissCreateFrom() = _uiState.update { it.copy(createFrom = null) } + /** Deletes the document; invokes [onDeleted] on success. */ fun delete(onDeleted: () -> Unit) { _uiState.update { it.copy(isSaving = true, errorMessage = null) } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/SelectionPin.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/SelectionPin.kt new file mode 100644 index 0000000..8b66f90 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/SelectionPin.kt @@ -0,0 +1,36 @@ +package com.interlinedlist.android.feature.documents.ui.editor + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue + +/** + * Which passage the editor's Selection action is offering, as the selection + * changes. + * + * A fresh highlight always wins. A *collapsed* selection usually means the user + * deselected — but tapping the Selection button moves focus off the body field, + * and a text field collapses its highlight to the end of the range when it loses + * focus. Dropping the passage there would take the action away at the exact + * moment it was asked for, so a caret that lands **inside** the pinned passage + * keeps it. A caret anywhere else, or an edit, drops it. + * + * Pure so the rule is unit-testable; the editor only feeds it selections. + */ +internal fun pinnedSelection( + previous: TextRange?, + value: TextFieldValue, + textChanged: Boolean, +): TextRange? = when { + textChanged -> null + !value.selection.collapsed -> value.selection + previous != null && value.selection.start in previous.min..previous.max -> previous + else -> null +} + +/** The text [range] covers, clamped so a stale range can never throw. */ +internal fun TextFieldValue.textIn(range: TextRange?): String { + if (range == null) return "" + val start = range.min.coerceIn(0, text.length) + val end = range.max.coerceIn(start, text.length) + return text.substring(start, end) +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/materialize/CreateFromMenu.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/materialize/CreateFromMenu.kt new file mode 100644 index 0000000..1e52886 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/materialize/CreateFromMenu.kt @@ -0,0 +1,95 @@ +package com.interlinedlist.android.feature.documents.ui.materialize + +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import com.interlinedlist.android.core.materialize.domain.MaterializeTarget + +/** Stable test tags for the "+ Create" entry points. */ +object CreateFromTestTags { + /** The + Create control on a document browser row. */ + fun row(documentId: String) = "docCreateFrom_$documentId" + + /** The + Create control in the editor's app bar (the whole document). */ + const val EDITOR = "editorCreateFrom" + + /** The + Create control for the editor's highlighted selection. */ + const val SELECTION = "editorCreateFromSelection" + + fun target(target: MaterializeTarget) = "createFromTarget_${target.apiValue}" +} + +/** + * The "+ Create" menu: pick a destination, then the shared preview / edit / + * confirm window opens on it. + * + * The menu opens for everyone. The subscriber gate is deliberately not consulted + * here — it applies when a conversion is *confirmed*, and hiding the entry point + * would leave a free account with no way to discover the feature. + * + * [excludedTargets] lets a surface drop a destination the product does not offer + * from that source. + */ +@Composable +fun CreateFromMenu( + onSelectTarget: (MaterializeTarget) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + enabled: Boolean = true, + contentDescription: String = "Create from this", + excludedTargets: Set = emptySet(), +) { + var expanded by remember { mutableStateOf(false) } + Box(modifier) { + if (label == null) { + IconButton(onClick = { expanded = true }, enabled = enabled) { + Icon(Icons.Default.Add, contentDescription = contentDescription) + } + } else { + TextButton(onClick = { expanded = true }, enabled = enabled) { + Icon(Icons.Default.Add, contentDescription = null) + Text(label) + } + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + MaterializeTarget.entries + .filterNot { it in excludedTargets } + .forEach { target -> + DropdownMenuItem( + text = { Text(target.menuLabel) }, + onClick = { + expanded = false + onSelectTarget(target) + }, + modifier = Modifier.testTag(CreateFromTestTags.target(target)), + ) + } + } + } +} + +/** + * The four destination labels the web menu uses. They are spelled out here + * rather than borrowed from the window, which keeps its own labels private — + * four words is a smaller price than widening another module's API. + */ +private val MaterializeTarget.menuLabel: String + get() = when (this) { + MaterializeTarget.LIST -> "To List" + MaterializeTarget.DOC -> "To Doc" + MaterializeTarget.BOTH -> "To List & Doc" + MaterializeTarget.MESSAGE -> "To Message" + } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/materialize/DocumentMaterializeLaunch.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/materialize/DocumentMaterializeLaunch.kt new file mode 100644 index 0000000..4712d28 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/materialize/DocumentMaterializeLaunch.kt @@ -0,0 +1,136 @@ +package com.interlinedlist.android.feature.documents.ui.materialize + +import com.interlinedlist.android.core.materialize.domain.ListColumnType +import com.interlinedlist.android.core.materialize.domain.MaterializeColumn +import com.interlinedlist.android.core.materialize.domain.MaterializeSource +import com.interlinedlist.android.core.materialize.domain.MaterializeTarget +import com.interlinedlist.android.core.materialize.ui.MaterializeLaunch +import com.interlinedlist.android.core.materialize.ui.MaterializePreview +import com.interlinedlist.android.core.materialize.ui.MaterializePreviewRow +import com.interlinedlist.android.feature.documents.domain.DocumentElement +import com.interlinedlist.android.feature.documents.domain.documentListRows +import com.interlinedlist.android.feature.documents.domain.markdownToPlainText + +/** + * The columns a document source produces, in the order the list gets them. + * + * `sourceKey` is the contract that keeps this honest: it names the source + * attribute the server re-derives each cell from — it never accepts cell values + * from the client — so each column is pinned to an attribute a parsed document + * element actually has (`section`, `text`, `type`), and the preview shows the + * same values the server will fill in. + */ +internal val DOCUMENT_SOURCE_COLUMNS: List = listOf( + MaterializeColumn("section", "Section", ListColumnType.TEXT, sourceKey = "section"), + MaterializeColumn("text", "Text", ListColumnType.TEXTAREA, sourceKey = "text"), + MaterializeColumn("type", "Type", ListColumnType.TEXT, sourceKey = "type"), +) + +/** How many rows the window previews; [MaterializePreview.totalRowCount] keeps the real size. */ +internal const val PREVIEW_ROW_LIMIT = 20 + +/** + * Opens "Create from…" on a **whole document**. + * + * The request that eventually goes out carries nothing but the document id: the + * server re-fetches and re-authorizes the document and derives the new list or + * document from its own copy. Everything built here is preview material, which + * is why it is safe to derive it from [markdown] the editor may not have saved + * yet — a stale preview cannot produce a wrong creation. + * + * [target] is the destination the + Create menu picked, and it shapes the + * suggested title the same way the web window does: a document destination + * suggests "Copy of …" so the copy is not indistinguishable from its original. + */ +fun documentMaterializeLaunch( + documentId: String, + title: String, + markdown: String?, + target: MaterializeTarget, +): MaterializeLaunch { + val documentTitle = title.ifBlank { UNTITLED } + val suggested = if (target == MaterializeTarget.DOC) "Copy of $documentTitle" else documentTitle + return MaterializeLaunch( + source = MaterializeSource.Document(documentId), + initialTarget = target, + preview = previewOf( + suggestedTitle = suggested, + markdown = markdown, + fileName = "${slugify("Copy of $documentTitle")}-${documentId.take(ID_PREFIX)}.md", + ), + ) +} + +/** + * Opens "Create from…" on a **highlighted passage** of a document. + * + * This is the one source with no id of its own, so the wire `docElements` kind + * carries the document id *and* the selected markdown — the document is still + * re-authorized server-side, and the selection is the part of it being asked + * for. Callers must not offer the action for a blank selection; the domain type + * rejects one. + */ +fun documentSelectionMaterializeLaunch( + documentId: String, + documentTitle: String, + selectedMarkdown: String, + target: MaterializeTarget, +): MaterializeLaunch { + val selection = selectedMarkdown.trim() + val suggested = "${documentTitle.ifBlank { UNTITLED }} (selection)" + return MaterializeLaunch( + source = MaterializeSource.DocumentSelection(documentId, selection), + initialTarget = target, + preview = previewOf( + suggestedTitle = suggested, + markdown = selection, + fileName = "${slugify(suggested)}.md", + ), + ) +} + +/** + * The preview both document entry points show: one row per heading and bullet, + * the markdown a document destination would copy through, and the plain text a + * message destination reads as. + */ +private fun previewOf( + suggestedTitle: String, + markdown: String?, + fileName: String, +): MaterializePreview { + val rows = documentListRows(markdown) + return MaterializePreview( + suggestedTitle = suggestedTitle, + suggestedFileName = fileName, + columns = DOCUMENT_SOURCE_COLUMNS, + rows = rows.take(PREVIEW_ROW_LIMIT).map { it.toPreviewRow() }, + totalRowCount = rows.size, + documentMarkdown = markdown?.trimEnd(), + draftBody = markdownToPlainText(markdown), + ) +} + +/** Values keyed by the column each one is derived from server-side. */ +private fun DocumentElement.toPreviewRow() = MaterializePreviewRow( + mapOf( + "section" to section, + "text" to text, + "type" to type.apiValue, + ), +) + +/** `Launch plan` → `launch-plan`; the file-name shape `relativePath` expects. */ +internal fun slugify(title: String): String = title + .trim() + .lowercase() + .replace(Regex("\\s+"), "-") + .replace(Regex("[^a-z0-9\\-_]+"), "-") + .replace(Regex("-+"), "-") + .trim('-') + .ifBlank { "document" } + +private const val UNTITLED = "Untitled" + +/** How much of the document id disambiguates the file name. */ +private const val ID_PREFIX = 8 diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentElementsTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentElementsTest.kt new file mode 100644 index 0000000..9d2c5e8 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentElementsTest.kt @@ -0,0 +1,215 @@ +package com.interlinedlist.android.feature.documents.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * The two conversions "Create from…" performs on a document body: + * heading/bullet → rows, and markdown → the plain text a post is drafted from. + * + * Both are pure functions, so they are pinned here rather than through the UI. + */ +class DocumentElementsTest { + + // --- Document -> list: each heading and bullet becomes its own row ------- + + @Test + fun `each heading and bullet point becomes its own row`() { + val rows = documentListRows( + """ + # Launch plan + + ## Week one + - Draft the announcement + - Line up the beta list + + ## Week two + * Ship it + """.trimIndent(), + ) + + assertThat(rows.map { it.text }).containsExactly( + "Launch plan", + "Week one", + "Draft the announcement", + "Line up the beta list", + "Week two", + "Ship it", + ).inOrder() + assertThat(rows.map { it.type }).containsExactly( + DocumentElementType.HEADING, + DocumentElementType.HEADING, + DocumentElementType.LIST_ITEM, + DocumentElementType.LIST_ITEM, + DocumentElementType.HEADING, + DocumentElementType.LIST_ITEM, + ).inOrder() + } + + @Test + fun `a row remembers the heading it sits under`() { + val rows = documentListRows("# Launch plan\n- Draft the announcement") + + assertThat(rows[0].section).isEqualTo("Launch plan") + assertThat(rows[1].section).isEqualTo("Launch plan") + } + + @Test + fun `nested bullets each become a row and keep their depth`() { + val rows = documentListRows( + """ + - Fruit + - Apples + - Braeburn + ${"\t"}- Tabbed + """.trimIndent(), + ) + + assertThat(rows.map { it.text }) + .containsExactly("Fruit", "Apples", "Braeburn", "Tabbed").inOrder() + // Two spaces of indentation is one level of nesting; a tab counts as two. + assertThat(rows.map { it.level }).containsExactly(1, 2, 3, 2).inOrder() + } + + @Test + fun `mixed content keeps only the headings and bullets`() { + val rows = documentListRows( + """ + # Notes + + Some prose that is not a row. + + > A quote, also not a row. + + - A real row + + ```kotlin + fun notARow() = Unit + ``` + """.trimIndent(), + ) + + assertThat(rows.map { it.text }).containsExactly("Notes", "A real row").inOrder() + } + + @Test + fun `a document with neither headings nor bullets falls back to its blocks`() { + val rows = documentListRows("First thought.\n\nSecond thought.") + + assertThat(rows.map { it.text }).containsExactly("First thought.", "Second thought.").inOrder() + assertThat(rows.map { it.type }) + .containsExactly(DocumentElementType.PARAGRAPH, DocumentElementType.PARAGRAPH) + } + + @Test + fun `an empty document has no rows`() { + assertThat(documentListRows("")).isEmpty() + assertThat(documentListRows(null)).isEmpty() + assertThat(documentListRows(" \n\n ")).isEmpty() + } + + @Test + fun `consecutive prose lines are one block, not one per line`() { + val elements = parseDocumentElements("A sentence\nwrapped over lines.\n\nA second block.") + + assertThat(elements.map { it.text }) + .containsExactly("A sentence wrapped over lines.", "A second block.").inOrder() + } + + // --- Document -> message: plain text, paragraphs kept, code dropped ----- + + @Test + fun `paragraphs are preserved as blank-line separated blocks`() { + val text = markdownToPlainText("# Title\n\nFirst paragraph.\n\nSecond paragraph.") + + assertThat(text).isEqualTo("Title\n\nFirst paragraph.\n\nSecond paragraph.") + } + + @Test + fun `a fenced code block is dropped`() { + val text = markdownToPlainText( + """ + Before the code. + + ```kotlin + fun main() { + println("hello") + } + ``` + + After the code. + """.trimIndent(), + ) + + assertThat(text).isEqualTo("Before the code.\n\nAfter the code.") + assertThat(text).doesNotContain("println") + } + + @Test + fun `a tilde fenced code block is dropped`() { + val text = markdownToPlainText("Before.\n\n~~~\nraw = 1\n~~~\n\nAfter.") + + assertThat(text).isEqualTo("Before.\n\nAfter.") + } + + @Test + fun `an unterminated fenced code block is dropped to the end`() { + val text = markdownToPlainText("Before.\n\n```\nnever closed") + + assertThat(text).isEqualTo("Before.") + } + + @Test + fun `an indented code block is dropped`() { + val text = markdownToPlainText( + "Before the code.\n\n fun main() {\n println(\"hi\")\n }\n\nAfter the code.", + ) + + assertThat(text).isEqualTo("Before the code.\n\nAfter the code.") + assertThat(text).doesNotContain("println") + } + + @Test + fun `a document that is only code converts to nothing`() { + assertThat(markdownToPlainText("```\nfun main() = Unit\n```")).isEmpty() + } + + @Test + fun `an indented bullet stays a bullet instead of becoming code`() { + val text = markdownToPlainText("- Fruit\n - Apples") + + assertThat(text).isEqualTo("• Fruit\n\n• Apples") + } + + @Test + fun `bullets keep a marker and quotes keep their text`() { + val text = markdownToPlainText("- First\n- Second\n\n> Quoted thought") + + assertThat(text).isEqualTo("• First\n\n• Second\n\nQuoted thought") + } + + @Test + fun `inline markdown is flattened to its text`() { + val text = markdownToPlainText( + "A **bold** word, some _emphasis_, `code`, a [link](https://example.com) " + + "and an image ![alt](https://example.com/a.png).", + ) + + assertThat(text).isEqualTo( + "A bold word, some emphasis, code, a link and an image .", + ) + } + + @Test + fun `html and entities are resolved`() { + val text = markdownToPlainText("Bold & brave") + + assertThat(text).isEqualTo("Bold & brave") + } + + @Test + fun `an empty document converts to an empty string`() { + assertThat(markdownToPlainText(null)).isEmpty() + assertThat(markdownToPlainText("")).isEmpty() + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt index 0395f3d..7606d39 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.materialize.domain.MaterializeSource +import com.interlinedlist.android.core.materialize.domain.MaterializeTarget import com.interlinedlist.android.feature.documents.data.SaveOutcome import com.interlinedlist.android.feature.documents.ui.editor.DOCUMENT_ID_ARG import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorViewModel @@ -205,6 +207,69 @@ class DocumentEditorViewModelTest { assertThat(vm.uiState.value.isUploadingImage).isFalse() } + // --- "Create from…" in the editor --------------------------------------- + + @Test + fun `create from the editor opens the window on an id-only document source`() = + runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Success( + testDocument("d1", title = "Launch plan", content = "# Launch plan\n- Ship it"), + ) + val vm = viewModel() + advanceUntilIdle() + + vm.createFrom(MaterializeTarget.DOC) + + val launch = requireNotNull(vm.uiState.value.createFrom) + assertThat(launch.source).isEqualTo(MaterializeSource.Document("d1")) + assertThat(launch.initialTarget).isEqualTo(MaterializeTarget.DOC) + assertThat(launch.preview.suggestedTitle).isEqualTo("Copy of Launch plan") + assertThat(launch.preview.rows.map { it.values["text"] }) + .containsExactly("Launch plan", "Ship it").inOrder() + } + + @Test + fun `a highlighted selection opens the window on a docElements source`() = runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Success( + testDocument("d1", title = "Launch plan", content = "# Launch plan\n\n## Week one\n- Ship it"), + ) + val vm = viewModel() + advanceUntilIdle() + + vm.createFromSelection("## Week one\n- Ship it", MaterializeTarget.LIST) + + val launch = requireNotNull(vm.uiState.value.createFrom) + assertThat(launch.source) + .isEqualTo(MaterializeSource.DocumentSelection("d1", "## Week one\n- Ship it")) + assertThat(launch.source.kind).isEqualTo("docElements") + assertThat(launch.preview.suggestedTitle).isEqualTo("Launch plan (selection)") + // Only what was highlighted is previewed. + assertThat(launch.preview.rows.map { it.values["text"] }) + .containsExactly("Week one", "Ship it").inOrder() + } + + @Test + fun `a blank selection has nothing to convert`() = runTest(dispatcher) { + val vm = viewModel() + advanceUntilIdle() + + vm.createFromSelection(" \n ", MaterializeTarget.LIST) + + assertThat(vm.uiState.value.createFrom).isNull() + } + + @Test + fun `dismissing the create-from window clears it`() = runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Success(testDocument("d1", content = "- Ship it")) + val vm = viewModel() + advanceUntilIdle() + vm.createFrom(MaterializeTarget.LIST) + + vm.dismissCreateFrom() + + assertThat(vm.uiState.value.createFrom).isNull() + } + @Test fun `refresh does not overwrite in-progress edits`() = runTest(dispatcher) { repo.refreshDocumentResult = ApiResult.Success(testDocument("d1", content = "server")) diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt index 7b4e2fb..166d892 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.materialize.domain.MaterializeSource +import com.interlinedlist.android.core.materialize.domain.MaterializeTarget import com.interlinedlist.android.feature.ai.domain.AiAvailability import com.interlinedlist.android.feature.ai.domain.AiGate import com.interlinedlist.android.feature.ai.domain.AiQuota @@ -258,6 +260,87 @@ class DocumentsBrowserViewModelTest { assertThat(repo.lastSearchQuery).isNull() } + // --- "Create from…" on a browser row ----------------------------------- + + @Test + fun `create from a row opens the window on an id-only document source`() = + runTest(dispatcher) { + val document = testDocument("d1", title = "Launch plan", content = "# Launch plan\n- Ship it") + repo.documents.value = listOf(document) + + val vm = rootViewModel() + advanceUntilIdle() + vm.createFrom(document, MaterializeTarget.LIST) + advanceUntilIdle() + + val launch = requireNotNull(vm.uiState.value.createFrom) + assertThat(launch.source).isEqualTo(MaterializeSource.Document("d1")) + assertThat(launch.initialTarget).isEqualTo(MaterializeTarget.LIST) + // The heading and the bullet are previewed as the rows they become. + assertThat(launch.preview.rows.map { it.values["text"] }) + .containsExactly("Launch plan", "Ship it").inOrder() + } + + @Test + fun `the destination the menu picked is the one the window opens on`() = runTest(dispatcher) { + val document = testDocument("d1", content = "- Ship it") + val vm = rootViewModel() + advanceUntilIdle() + + vm.createFrom(document, MaterializeTarget.BOTH) + advanceUntilIdle() + + assertThat(vm.uiState.value.createFrom?.initialTarget).isEqualTo(MaterializeTarget.BOTH) + } + + @Test + fun `a row with no cached body fetches it before previewing`() = runTest(dispatcher) { + // The index endpoint omits bodies, so the row knows only its snippet. + val row = testDocument("d1", title = "Launch plan") + repo.refreshDocumentResult = ApiResult.Success( + testDocument("d1", title = "Launch plan", content = "# Launch plan\n- Ship it"), + ) + + val vm = rootViewModel() + advanceUntilIdle() + vm.createFrom(row, MaterializeTarget.LIST) + advanceUntilIdle() + + val launch = requireNotNull(vm.uiState.value.createFrom) + assertThat(launch.preview.rows.map { it.values["text"] }) + .containsExactly("Launch plan", "Ship it").inOrder() + } + + @Test + fun `a body that cannot be fetched still opens the window on the document`() = + runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Failure(AppError.Network("offline")) + + val vm = rootViewModel() + advanceUntilIdle() + vm.createFrom(testDocument("d1", title = "Launch plan"), MaterializeTarget.LIST) + advanceUntilIdle() + + // Only the id is ever sent, so a missing preview is a worse preview — + // not a wrong creation. + val launch = requireNotNull(vm.uiState.value.createFrom) + assertThat(launch.source).isEqualTo(MaterializeSource.Document("d1")) + assertThat(launch.preview.suggestedTitle).isEqualTo("Launch plan") + assertThat(launch.preview.rows).isEmpty() + } + + @Test + fun `dismissing the window clears it`() = runTest(dispatcher) { + val vm = rootViewModel() + advanceUntilIdle() + vm.createFrom(testDocument("d1", content = "- Ship it"), MaterializeTarget.LIST) + advanceUntilIdle() + + vm.dismissCreateFrom() + + assertThat(vm.uiState.value.createFrom).isNull() + } + @Test fun `deleted folder route falls back to root contents`() = runTest(dispatcher) { // Folder "ghost" is not in the tree; contents should degrade to root. diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/editor/SelectionPinTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/editor/SelectionPinTest.kt new file mode 100644 index 0000000..cb08aa3 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/editor/SelectionPinTest.kt @@ -0,0 +1,77 @@ +package com.interlinedlist.android.feature.documents.ui.editor + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** The rule that decides which passage the editor's Selection action offers. */ +class SelectionPinTest { + + private val body = TextFieldValue("# Launch plan\n\n## Week one\n- Ship it") + + @Test + fun `a highlight becomes the selection`() { + val pinned = pinnedSelection( + previous = null, + value = body.copy(selection = TextRange(15, 26)), + textChanged = false, + ) + + assertThat(pinned).isEqualTo(TextRange(15, 26)) + } + + @Test + fun `a new highlight replaces the previous one`() { + val pinned = pinnedSelection( + previous = TextRange(0, 13), + value = body.copy(selection = TextRange(15, 26)), + textChanged = false, + ) + + assertThat(pinned).isEqualTo(TextRange(15, 26)) + } + + @Test + fun `a caret collapsing inside the passage keeps it`() { + // Blurring the field to tap the Selection menu collapses the highlight + // to its end; the action must still be about what was highlighted. + val pinned = pinnedSelection( + previous = TextRange(15, 26), + value = body.copy(selection = TextRange(26)), + textChanged = false, + ) + + assertThat(pinned).isEqualTo(TextRange(15, 26)) + } + + @Test + fun `a caret moved elsewhere drops the passage`() { + val pinned = pinnedSelection( + previous = TextRange(15, 26), + value = body.copy(selection = TextRange(2)), + textChanged = false, + ) + + assertThat(pinned).isNull() + } + + @Test + fun `editing the text drops the passage`() { + val pinned = pinnedSelection( + previous = TextRange(15, 26), + value = TextFieldValue("edited", TextRange(15, 26)), + textChanged = true, + ) + + assertThat(pinned).isNull() + } + + @Test + fun `the pinned passage reads back as the highlighted markdown`() { + assertThat(body.textIn(TextRange(15, 26))).isEqualTo("## Week one") + assertThat(body.textIn(null)).isEmpty() + // A range that outlived its text is clamped rather than thrown. + assertThat(TextFieldValue("short").textIn(TextRange(2, 99))).isEqualTo("ort") + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/materialize/DocumentMaterializeLaunchTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/materialize/DocumentMaterializeLaunchTest.kt new file mode 100644 index 0000000..6be3f54 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/materialize/DocumentMaterializeLaunchTest.kt @@ -0,0 +1,158 @@ +package com.interlinedlist.android.feature.documents.ui.materialize + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.materialize.domain.MaterializeSource +import com.interlinedlist.android.core.materialize.domain.MaterializeTarget +import org.junit.Test + +/** + * What a document entry point hands the shared "Create from…" window. + * + * The source is the contract that matters — it is all that goes over the wire — + * so it is asserted exactly; the preview is display material the window shows + * before anything is created. + */ +class DocumentMaterializeLaunchTest { + + private val markdown = """ + # Launch plan + + Some prose. + + - Draft the announcement + - Line up the beta list + + ```kotlin + fun notARow() = Unit + ``` + """.trimIndent() + + @Test + fun `a whole document is an id-only document source`() { + val launch = documentMaterializeLaunch( + documentId = "doc_1234567890", + title = "Launch plan", + markdown = markdown, + target = MaterializeTarget.LIST, + ) + + assertThat(launch.source).isEqualTo(MaterializeSource.Document("doc_1234567890")) + assertThat(launch.source.kind).isEqualTo("document") + assertThat(launch.initialTarget).isEqualTo(MaterializeTarget.LIST) + } + + @Test + fun `the preview maps each heading and bullet to a row`() { + val launch = documentMaterializeLaunch( + documentId = "doc_1", + title = "Launch plan", + markdown = markdown, + target = MaterializeTarget.LIST, + ) + val preview = launch.preview + + assertThat(preview.columns.map { it.propertyName }) + .containsExactly("Section", "Text", "Type").inOrder() + // Every column takes its values from a source attribute the server + // re-derives; none of them carries a client value. + assertThat(preview.columns.map { it.sourceKey }) + .containsExactly("section", "text", "type").inOrder() + assertThat(preview.rows.map { it.values["text"] }) + .containsExactly("Launch plan", "Draft the announcement", "Line up the beta list") + .inOrder() + assertThat(preview.rows.map { it.values["type"] }) + .containsExactly("heading", "list-item", "list-item").inOrder() + assertThat(preview.rows[1].values["section"]).isEqualTo("Launch plan") + assertThat(preview.totalRowCount).isEqualTo(3) + } + + @Test + fun `the preview carries the document verbatim and its plain-text draft`() { + val preview = documentMaterializeLaunch( + documentId = "doc_1", + title = "Launch plan", + markdown = markdown, + target = MaterializeTarget.DOC, + ).preview + + assertThat(preview.documentMarkdown).isEqualTo(markdown.trimEnd()) + // The message destination reads as plain text with the code block gone. + assertThat(preview.draftBody).isEqualTo( + "Launch plan\n\nSome prose.\n\n• Draft the announcement\n\n• Line up the beta list", + ) + } + + @Test + fun `a document destination suggests a copy rather than the same name`() { + val toDoc = documentMaterializeLaunch("doc_12345678ab", "Launch plan", markdown, MaterializeTarget.DOC) + val toList = documentMaterializeLaunch("doc_12345678ab", "Launch plan", markdown, MaterializeTarget.LIST) + + assertThat(toDoc.preview.suggestedTitle).isEqualTo("Copy of Launch plan") + assertThat(toList.preview.suggestedTitle).isEqualTo("Launch plan") + // The file name only applies to a document destination, so both carry it. + assertThat(toDoc.preview.suggestedFileName).isEqualTo("copy-of-launch-plan-doc_1234.md") + assertThat(toList.preview.suggestedFileName).isEqualTo("copy-of-launch-plan-doc_1234.md") + } + + @Test + fun `an untitled document still gets a usable title and file name`() { + val preview = documentMaterializeLaunch("doc_1", " ", null, MaterializeTarget.LIST).preview + + assertThat(preview.suggestedTitle).isEqualTo("Untitled") + assertThat(preview.suggestedFileName).isEqualTo("copy-of-untitled-doc_1.md") + assertThat(preview.rows).isEmpty() + assertThat(preview.totalRowCount).isEqualTo(0) + } + + @Test + fun `a long document previews a window of rows but reports the true total`() { + val long = (1..50).joinToString("\n") { "- Item $it" } + + val preview = documentMaterializeLaunch("doc_1", "Long", long, MaterializeTarget.LIST).preview + + assertThat(preview.rows).hasSize(PREVIEW_ROW_LIMIT) + assertThat(preview.totalRowCount).isEqualTo(50) + assertThat(preview.hiddenRowCount).isEqualTo(30) + } + + @Test + fun `a highlighted selection is a docElements source carrying the selection`() { + val selection = "## Week one\n- Draft the announcement" + + val launch = documentSelectionMaterializeLaunch( + documentId = "doc_1234567890", + documentTitle = "Launch plan", + selectedMarkdown = "\n$selection\n", + target = MaterializeTarget.DOC, + ) + + // A selection has no id of its own: the document id is re-authorized + // server-side and the markdown says which part of it was asked for. + assertThat(launch.source) + .isEqualTo(MaterializeSource.DocumentSelection("doc_1234567890", selection)) + assertThat(launch.source.kind).isEqualTo("docElements") + assertThat(launch.initialTarget).isEqualTo(MaterializeTarget.DOC) + } + + @Test + fun `a selection previews only the rows inside it`() { + val launch = documentSelectionMaterializeLaunch( + documentId = "doc_1", + documentTitle = "Launch plan", + selectedMarkdown = "## Week one\n- Draft the announcement", + target = MaterializeTarget.LIST, + ) + + assertThat(launch.preview.suggestedTitle).isEqualTo("Launch plan (selection)") + assertThat(launch.preview.suggestedFileName).isEqualTo("launch-plan-selection.md") + assertThat(launch.preview.rows.map { it.values["text"] }) + .containsExactly("Week one", "Draft the announcement").inOrder() + assertThat(launch.preview.draftBody).isEqualTo("Week one\n\n• Draft the announcement") + } + + @Test + fun `slugs are file-name safe`() { + assertThat(slugify("Launch plan: Q3 / 2026!")).isEqualTo("launch-plan-q3-2026") + assertThat(slugify(" ")).isEqualTo("document") + } +}