052628df2835

note bodies parse the browser's tag vocabulary (b/strong, i/em, u, s, h2, h3, ul, ol, li, br, hr, p, plus contenteditable's divs) into an AnnotatedString: notes mode opens rendered and edits a plain-text markup that serialises back to the same tags on save, so a formatted note survives a phone edit; the TOC preview line and note search read rendered text instead of raw markup

time
2026-07-26 18:59 UTC · 2 months ago
machine
koh2
agent
claude-opus-5
session
android notes html
parent
231dc4b86232
4 files app/src/main/java/com/peachpasswords/ui/vault/NoteHtml.kt+274 −0app/src/main/java/com/peachpasswords/ui/vault/NotesModeScreen.kt+66 −47app/src/main/java/com/peachpasswords/ui/vault/VaultListViewModel.kt+1 −1app/src/test/java/com/peachpasswords/ui/vault/NoteHtmlTest.kt+156 −0
app/src/main/java/com/peachpasswords/ui/vault/NoteHtml.kt+274 −0
@@ -0,0 +1,274 @@1+package com.peachpasswords.ui.vault2+3+import androidx.compose.ui.text.AnnotatedString4+import androidx.compose.ui.text.SpanStyle5+import androidx.compose.ui.text.buildAnnotatedString6+import androidx.compose.ui.text.font.FontStyle7+import androidx.compose.ui.text.font.FontWeight8+import androidx.compose.ui.text.style.TextDecoration9+import androidx.compose.ui.text.withStyle10+import androidx.compose.ui.unit.sp11+12+/*13+ * Note bodies are authored in the browser's contenteditable, so a synced note14+ * arrives as HTML: the extension's sanitizer allow-list (NoteEditor.tsx) is15+ * b, strong, i, em, u, s, h2, h3, ul, ol, li, br, hr, p — attribute-less — plus16+ * the div wrappers Chrome's contenteditable inserts for new lines. Android read17+ * that field as plain text, which is why the phone showed raw tags.18+ *19+ * Parsed here rather than through HtmlCompat: the vocabulary is fourteen20+ * attribute-less tags, host-JVM unit tests cannot touch android.text, and21+ * Html.toHtml serialises back as `<p dir="ltr" style="…">` — outside the22+ * allow-list, so it could not round-trip to the browser.23+ *24+ * Compose drops AnnotatedString spans on every keystroke, so there is no sound25+ * WYSIWYG editing surface. Editing therefore goes through a plain-text markup26+ * (`**bold**`, `## heading`, `- item`) that converts back to the same tag27+ * vocabulary on save. The markup never leaves the device — it exists only28+ * between load and save.29+ */30+31+/** Inline marks, longest markup first so `**` wins over `*` when scanning. */32+private enum class Mark(val markup: String, val tag: String) {33+    BOLD("**", "b"),34+    UNDERLINE("__", "u"),35+    STRIKE("~~", "s"),36+    ITALIC("*", "i"),37+}38+39+private val TAG_HINT = Regex("</?[a-z]", RegexOption.IGNORE_CASE)40+private val TAG = Regex("<[^>]*>")41+private val WHITESPACE = Regex("\\s+")42+private val ORDERED_ITEM = Regex("^\\d+\\. ")43+44+/** Same test the browser's toHtml uses to decide plain text vs markup. */45+internal fun isNoteHtml(raw: String): Boolean = TAG_HINT.containsMatchIn(raw)46+47+/** Stored note -> the text the editor shows and the user edits. */48+internal fun noteEditorText(stored: String): String =49+    if (isNoteHtml(stored)) htmlToMarkup(stored) else stored50+51+/**52+ * Editor text -> stored note. A note that arrived as plain text stays plain53+ * text: markup is only interpreted once the user opts into formatting, so a54+ * shopping list with "- eggs" in it is not silently turned into a `<ul>`.55+ */56+internal fun noteStorageText(editorText: String, isHtml: Boolean): String =57+    if (isHtml) markupToHtml(editorText) else editorText58+59+/** Editor text -> rendered form, for the read views. */60+internal fun noteRendered(editorText: String, isHtml: Boolean): AnnotatedString =61+    if (isHtml) markupToAnnotated(editorText) else AnnotatedString(editorText)62+63+/** Stored note -> rendered form. */64+internal fun noteRendered(stored: String): AnnotatedString =65+    noteRendered(noteEditorText(stored), isNoteHtml(stored))66+67+/** Stored note -> plain text, for search and the clipboard. */68+internal fun notePlainText(stored: String): String = noteRendered(stored).text69+70+/** First non-blank rendered line — the note list's preview. */71+internal fun notePreviewLine(stored: String): String =72+    notePlainText(stored).lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty()73+74+// ── HTML -> markup ────────────────────────────────────────────────────────75+76+private fun htmlToMarkup(html: String): String {77+    val out = StringBuilder()78+    // Ordinal per open <ol>; -1 marks a <ul>.79+    val lists = ArrayDeque<Int>()80+81+    fun endLine() {82+        if (out.isNotEmpty() && !out.endsWith("\n")) out.append('\n')83+    }84+85+    fun text(chunk: String) {86+        if (chunk.isEmpty()) return87+        val decoded = decodeEntities(chunk).replace(WHITESPACE, " ")88+        // Whitespace between block tags is layout, not content.89+        if (decoded.isBlank() && (out.isEmpty() || out.endsWith("\n"))) return90+        out.append(decoded)91+    }92+93+    var cursor = 094+    for (match in TAG.findAll(html)) {95+        text(html.substring(cursor, match.range.first))96+        cursor = match.range.last + 197+        val token = match.value98+        val closing = token.startsWith("</")99+        val name = token.removePrefix("</").removePrefix("<").takeWhile { it.isLetterOrDigit() }.lowercase()100+        when (name) {101+            "b", "strong" -> out.append(Mark.BOLD.markup)102+            "i", "em" -> out.append(Mark.ITALIC.markup)103+            "u" -> out.append(Mark.UNDERLINE.markup)104+            "s", "strike", "del" -> out.append(Mark.STRIKE.markup)105+            "h2" -> { endLine(); if (!closing) out.append("## ") }106+            "h3" -> { endLine(); if (!closing) out.append("### ") }107+            "ul" -> { endLine(); if (closing) lists.removeLastOrNull() else lists.addLast(-1) }108+            "ol" -> { endLine(); if (closing) lists.removeLastOrNull() else lists.addLast(1) }109+            "li" -> {110+                endLine()111+                if (!closing) {112+                    val ordinal = lists.lastOrNull() ?: -1113+                    if (ordinal < 0) {114+                        out.append("- ")115+                    } else {116+                        out.append("$ordinal. ")117+                        lists.removeLast()118+                        lists.addLast(ordinal + 1)119+                    }120+                }121+            }122+            "br" -> out.append('\n')123+            "hr" -> { endLine(); out.append("---\n") }124+            "p", "div" -> endLine()125+            // Anything else is dropped tag-only, keeping its text — the same126+            // thing the browser's sanitizer does with a disallowed element.127+        }128+    }129+    text(html.substring(cursor))130+    return out.toString().trim('\n')131+}132+133+private fun decodeEntities(s: String): String {134+    if (!s.contains('&')) return s135+    return s136+        .replace("&lt;", "<")137+        .replace("&gt;", ">")138+        .replace("&quot;", "\"")139+        .replace("&#39;", "'")140+        .replace("&apos;", "'")141+        .replace("&nbsp;", " ")142+        .replace(' ', ' ') // contenteditable emits the raw character too, not just the entity143+        .replace("&amp;", "&") // last: &amp;lt; must decode to &lt;, not <144+}145+146+private fun escape(s: String): String =147+    s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")148+149+// ── markup -> HTML / AnnotatedString ──────────────────────────────────────150+151+private data class Run(val text: String, val marks: Set<Mark>)152+153+/**154+ * Split a line on its inline markers. The innermost open marker is matched155+ * first so `**a *b***` closes the italic before the bold instead of tripping156+ * over the shared `*`.157+ */158+private fun inlineRuns(line: String): List<Run> {159+    val runs = mutableListOf<Run>()160+    val open = ArrayDeque<Mark>()161+    val buf = StringBuilder()162+163+    fun flush() {164+        if (buf.isNotEmpty()) {165+            runs += Run(buf.toString(), LinkedHashSet(open))166+            buf.clear()167+        }168+    }169+170+    var i = 0171+    while (i < line.length) {172+        val innermost = open.lastOrNull()173+        if (innermost != null && line.startsWith(innermost.markup, i)) {174+            flush()175+            open.removeLast()176+            i += innermost.markup.length177+            continue178+        }179+        val opening = Mark.entries.firstOrNull { line.startsWith(it.markup, i) && it !in open }180+        if (opening != null) {181+            flush()182+            open.addLast(opening)183+            i += opening.markup.length184+            continue185+        }186+        buf.append(line[i])187+        i++188+    }189+    flush()190+    return runs191+}192+193+private fun inlineHtml(line: String): String = inlineRuns(line).joinToString("") { run ->194+    val marks = Mark.entries.filter { it in run.marks }195+    marks.joinToString("") { "<${it.tag}>" } +196+        escape(run.text) +197+        marks.reversed().joinToString("") { "</${it.tag}>" }198+}199+200+private fun markupToHtml(markup: String): String {201+    val out = StringBuilder()202+    var openList: String? = null203+    var prevWasPlain = false204+205+    fun closeList() {206+        openList?.let { out.append("</$it>"); openList = null }207+    }208+209+    fun startList(kind: String) {210+        if (openList != kind) {211+            closeList()212+            out.append("<$kind>")213+            openList = kind214+        }215+    }216+217+    for (line in markup.split("\n")) {218+        val plain = when {219+            line.trim() == "---" -> { closeList(); out.append("<hr>"); false }220+            line.startsWith("## ") -> { closeList(); out.append("<h2>${inlineHtml(line.removePrefix("## "))}</h2>"); false }221+            line.startsWith("### ") -> { closeList(); out.append("<h3>${inlineHtml(line.removePrefix("### "))}</h3>"); false }222+            line.startsWith("- ") -> { startList("ul"); out.append("<li>${inlineHtml(line.removePrefix("- "))}</li>"); false }223+            ORDERED_ITEM.containsMatchIn(line) -> { startList("ol"); out.append("<li>${inlineHtml(line.replaceFirst(ORDERED_ITEM, ""))}</li>"); false }224+            else -> {225+                closeList()226+                if (prevWasPlain) out.append("<br>")227+                out.append(inlineHtml(line))228+                true229+            }230+        }231+        prevWasPlain = plain232+    }233+    closeList()234+    return out.toString()235+}236+237+private val HEADING2 = SpanStyle(fontWeight = FontWeight.SemiBold, fontSize = 19.sp)238+private val HEADING3 = SpanStyle(fontWeight = FontWeight.SemiBold, fontSize = 16.sp)239+240+private fun markupToAnnotated(markup: String): AnnotatedString = buildAnnotatedString {241+    markup.split("\n").forEachIndexed { index, line ->242+        if (index > 0) append('\n')243+        when {244+            line.trim() == "---" -> append("──────────")245+            line.startsWith("## ") -> appendRuns(line.removePrefix("## "), HEADING2)246+            line.startsWith("### ") -> appendRuns(line.removePrefix("### "), HEADING3)247+            line.startsWith("- ") -> { append("•  "); appendRuns(line.removePrefix("- "), null) }248+            ORDERED_ITEM.containsMatchIn(line) -> {249+                append(ORDERED_ITEM.find(line)!!.value.trimEnd() + "  ")250+                appendRuns(line.replaceFirst(ORDERED_ITEM, ""), null)251+            }252+            else -> appendRuns(line, null)253+        }254+    }255+}256+257+private fun AnnotatedString.Builder.appendRuns(line: String, base: SpanStyle?) {258+    for (run in inlineRuns(line)) {259+        val style = spanStyle(run.marks).let { if (base == null) it else base.merge(it) }260+        withStyle(style) { append(run.text) }261+    }262+}263+264+private fun spanStyle(marks: Set<Mark>): SpanStyle {265+    val decorations = buildList {266+        if (Mark.UNDERLINE in marks) add(TextDecoration.Underline)267+        if (Mark.STRIKE in marks) add(TextDecoration.LineThrough)268+    }269+    return SpanStyle(270+        fontWeight = if (Mark.BOLD in marks) FontWeight.Bold else null,271+        fontStyle = if (Mark.ITALIC in marks) FontStyle.Italic else null,272+        textDecoration = if (decorations.isEmpty()) null else TextDecoration.combine(decorations),273+    )274+}

view file

app/src/main/java/com/peachpasswords/ui/vault/NotesModeScreen.kt+66 −47
@@ -41,16 +41,12 @@4141 import androidx.compose.ui.draw.alpha4242 import androidx.compose.ui.draw.clip4343 import androidx.compose.ui.graphics.SolidColor44-import androidx.compose.ui.text.AnnotatedString45-import androidx.compose.ui.text.SpanStyle4644 import androidx.compose.ui.text.TextStyle4745 import androidx.compose.ui.text.TextRange48-import androidx.compose.ui.text.buildAnnotatedString4946 import androidx.compose.ui.text.font.FontStyle5047 import androidx.compose.ui.text.font.FontWeight5148 import androidx.compose.ui.text.input.TextFieldValue5249 import androidx.compose.ui.text.style.TextDecoration53-import androidx.compose.ui.text.withStyle5450 import androidx.compose.ui.unit.dp5551 import androidx.compose.ui.unit.sp5652 import androidx.compose.runtime.collectAsState@@ -67,13 +63,14 @@6763 6864 /**6965  * E2 — full-screen editor for NOTE-type entries. Editable inline title, a B/I/U70- * formatting toolbar that applies [SpanStyle] to the current selection, a71- * full-width Inter body field, and an 800ms-debounced auto-save with a "saved."72- * pulse. FLAG_SECURE blocks screenshots while open (same as EntryDetail).66+ * formatting toolbar, a full-width Inter body, and an 800ms-debounced auto-save67+ * with a "saved." pulse. FLAG_SECURE blocks screenshots while open (same as68+ * EntryDetail).7369  *74- * Span support is intentionally basic (no markdown/persistence of styles): the75- * toolbar styles the live [AnnotatedString] selection; only the plain text is76- * persisted via the ViewModel.70+ * The body opens rendered (see NoteHtml.kt) and switches to the editor on tap,71+ * because a note written in the browser is HTML and Compose has no text field72+ * that can edit styled text without dropping the styles. Formatting typed here73+ * is saved back as the browser's own tag vocabulary.7774  */7875 @Composable7976 fun NotesModeScreen(@@ -113,8 +110,13 @@113110     }114111 115112     val e = entry113+    val stored = e?.notes.orEmpty()116114     var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }117-    var body by remember(e?.id) { mutableStateOf(TextFieldValue(e?.notes ?: "")) }115+    var body by remember(e?.id) { mutableStateOf(TextFieldValue(noteEditorText(stored))) }116+    // A note stays plain text until the user asks for formatting, so typing117+    // "- eggs" into a shopping list never turns it into a <ul> behind their back.118+    var isHtml by remember(e?.id) { mutableStateOf(isNoteHtml(stored)) }119+    var editing by remember(e?.id) { mutableStateOf(stored.isBlank()) }118120 119121     ShellBackground {120122         Column(@@ -142,7 +144,8 @@142144                         contentDescription = "back",143145                         tint = peachColors.T0,144146                         modifier = Modifier.size(40.dp).clip(CircleShape)145-                            .clickable { vm.saveNow(title, body.text); vm.showToc() }.padding(8.dp),147+                            .clickable { vm.saveNow(title, noteStorageText(body.text, isHtml)); vm.showToc() }148+                            .padding(8.dp),146149                     )147150                     Box(Modifier.weight(1f).padding(horizontal = PeachSpacing.xs)) {148151                         if (title.isEmpty()) {@@ -190,20 +193,27 @@190193                 return@Column191194             }192195 193-            // Formatting toolbar: browser-compatible inline and block actions.194-            Row(195-                Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm),196-                horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),197-                verticalAlignment = Alignment.CenterVertically,198-            ) {199-                FormatButton("B", FontWeight.Bold) { body = applySpan(body, SpanStyle(fontWeight = FontWeight.Bold)) }200-                FormatButton("I", fontStyle = FontStyle.Italic) { body = applySpan(body, SpanStyle(fontStyle = FontStyle.Italic)) }201-                FormatButton("U", underline = true) { body = applySpan(body, SpanStyle(textDecoration = TextDecoration.Underline)) }202-                Spacer(Modifier.width(4.dp))203-                FormatButton("H") { body = applyLinePrefix(body, "## ") }204-                FormatButton("\"") { body = applyLinePrefix(body, "> ") }205-                FormatButton("•") { body = applyLinePrefix(body, "- ") }206-                FormatButton("1.") { body = applyLinePrefix(body, "1. ") }196+            // Formatting toolbar: every action writes the markup that saves back197+            // as the browser's own tags, so formatting survives a phone edit.198+            if (editing) {199+                Row(200+                    Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm),201+                    horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),202+                    verticalAlignment = Alignment.CenterVertically,203+                ) {204+                    fun format(next: TextFieldValue) {205+                        body = next206+                        isHtml = true207+                        vm.onContentChanged(noteStorageText(next.text, isHtml = true))208+                    }209+                    FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }210+                    FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }211+                    FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }212+                    Spacer(Modifier.width(4.dp))213+                    FormatButton("H") { format(applyLinePrefix(body, "## ")) }214+                    FormatButton("•") { format(applyLinePrefix(body, "- ")) }215+                    FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }216+                }207217             }208218 209219             // Content area.@@ -215,13 +225,22 @@215225                         style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),216226                     )217227                 }218-                BasicTextField(219-                    value = body,220-                    onValueChange = { body = it; vm.onContentChanged(it.text) },221-                    textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),222-                    cursorBrush = SolidColor(accent),223-                    modifier = Modifier.fillMaxSize(),224-                )228+                if (editing) {229+                    BasicTextField(230+                        value = body,231+                        onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },232+                        textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),233+                        cursorBrush = SolidColor(accent),234+                        modifier = Modifier.fillMaxSize(),235+                    )236+                } else {237+                    Text(238+                        noteRendered(body.text, isHtml),239+                        color = peachColors.T0,240+                        style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),241+                        modifier = Modifier.fillMaxSize().clickable { editing = true },242+                    )243+                }225244             }226245         }227246     }@@ -370,7 +389,7 @@370389             color = peachColors.T0,371390             style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),372391         )373-        val preview = note.notes?.lineSequence()?.firstOrNull { it.isNotBlank() }?.trim().orEmpty()392+        val preview = notePreviewLine(note.notes.orEmpty())374393         Text(375394             preview.ifBlank { "No body text" },376395             color = peachColors.T2,@@ -381,21 +400,21 @@381400 }382401 383402 /**384- * Apply [style] to the current selection of [value], preserving the text and385- * existing spans. If the selection is collapsed, the value is returned unchanged386- * (basic span support — styling needs a non-empty selection).403+ * Wrap the current selection in [marker] (the inline markup NoteHtml serialises404+ * back to a tag). Collapsed selections are returned unchanged — there is nothing405+ * to mark.387406  */388-private fun applySpan(value: TextFieldValue, style: SpanStyle): TextFieldValue {407+internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {389408     val sel = value.selection390409     if (sel.collapsed) return value391-    val start = sel.min392-    val end = sel.max393-    val existing = value.annotatedString394-    val merged: AnnotatedString = buildAnnotatedString {395-        append(existing)396-        addStyle(style, start, end)397-    }398-    return value.copy(annotatedString = merged)410+    val text = value.text411+    val next = text.substring(0, sel.min) + marker +412+        text.substring(sel.min, sel.max) + marker +413+        text.substring(sel.max)414+    return value.copy(415+        text = next,416+        selection = TextRange(sel.min, sel.max + 2 * marker.length),417+    )399418 }400419 401420 internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {@@ -429,7 +448,7 @@429448     if (q.isBlank()) return notes430449     return notes.filter { note ->431450         note.title.lowercase().contains(q) ||432-            note.notes.orEmpty().lowercase().contains(q)451+            notePlainText(note.notes.orEmpty()).lowercase().contains(q)433452     }434453 }435454 

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListViewModel.kt+1 −1
@@ -241,7 +241,7 @@241241 }242242 243243 internal fun vaultEntrySearchText(entry: VaultEntry): String {244-    if (entry.isNote()) return listOf(entry.title, entry.notes).joinToString(" ")244+    if (entry.isNote()) return listOf(entry.title, notePlainText(entry.notes.orEmpty())).joinToString(" ")245245     val passkey = entry.passkey246246     return listOfNotNull(247247         entry.title,

view file

app/src/test/java/com/peachpasswords/ui/vault/NoteHtmlTest.kt+156 −0
@@ -0,0 +1,156 @@1+package com.peachpasswords.ui.vault2+3+import androidx.compose.ui.text.font.FontStyle4+import androidx.compose.ui.text.font.FontWeight5+import androidx.compose.ui.text.style.TextDecoration6+import com.peachpasswords.vault.VaultEntry7+import org.junit.Assert.assertEquals8+import org.junit.Assert.assertFalse9+import org.junit.Assert.assertTrue10+import org.junit.Test11+12+/**13+ * The vocabulary under test is the browser sanitizer's allow-list14+ * (NoteEditor.tsx ALLOWED_NOTE_TAGS): b, i, u, s, strong, em, h2, h3, ul, ol,15+ * li, br, hr, p — plus the div wrappers contenteditable adds on its own.16+ */17+class NoteHtmlTest {18+19+    private fun note(title: String, body: String?) =20+        VaultEntry(id = title, title = title, type = "note", notes = body, createdAt = 0, modifiedAt = 0)21+22+    // ── every allow-listed tag renders as text, never as markup ──────────────23+24+    @Test fun every_allowed_tag_renders_without_markup() {25+        val cases = listOf(26+            "<b>bold</b>" to "bold",27+            "<strong>bold</strong>" to "bold",28+            "<i>it</i>" to "it",29+            "<em>it</em>" to "it",30+            "<u>under</u>" to "under",31+            "<s>struck</s>" to "struck",32+            "<h2>Title</h2>" to "Title",33+            "<h3>Sub</h3>" to "Sub",34+            "<ul><li>one</li></ul>" to "•  one",35+            "<ol><li>one</li></ol>" to "1.  one",36+            "<p>para</p>" to "para",37+            "<div>div text</div>" to "div text",38+            "a<br>b" to "a\nb",39+        )40+        for ((html, expected) in cases) {41+            assertEquals(html, expected, notePlainText(html))42+            assertFalse(html, notePlainText(html).contains("<"))43+        }44+        // hr has no text of its own; it renders as a rule, not as "<hr>".45+        assertFalse(notePlainText("a<hr>b").contains("hr"))46+    }47+48+    @Test fun inline_tags_produce_the_matching_span_styles() {49+        assertTrue(noteRendered("<b>x</b>").spanStyles.any { it.item.fontWeight == FontWeight.Bold })50+        assertTrue(noteRendered("<strong>x</strong>").spanStyles.any { it.item.fontWeight == FontWeight.Bold })51+        assertTrue(noteRendered("<i>x</i>").spanStyles.any { it.item.fontStyle == FontStyle.Italic })52+        assertTrue(noteRendered("<em>x</em>").spanStyles.any { it.item.fontStyle == FontStyle.Italic })53+        assertTrue(noteRendered("<u>x</u>").spanStyles.any { it.item.textDecoration == TextDecoration.Underline })54+        assertTrue(noteRendered("<s>x</s>").spanStyles.any { it.item.textDecoration == TextDecoration.LineThrough })55+        // Headings are the only block style that changes the type size.56+        assertTrue(noteRendered("<h2>x</h2>").spanStyles.any { it.item.fontSize.value > 16f })57+    }58+59+    @Test fun nested_inline_tags_keep_both_styles() {60+        val styles = noteRendered("<b>a<i>b</i></b>").spanStyles61+        assertTrue(styles.any { it.item.fontWeight == FontWeight.Bold && it.item.fontStyle == FontStyle.Italic })62+    }63+64+    @Test fun entities_decode_and_re_encode() {65+        assertEquals("a & b <tag>", notePlainText("<p>a &amp; b &lt;tag&gt;</p>"))66+        // Round trip must not double-escape.67+        val html = "<div>a &amp; b</div>"68+        assertEquals("a &amp; b", noteStorageText(noteEditorText(html), isHtml = true))69+    }70+71+    // ── plain-text notes are untouched ───────────────────────────────────────72+73+    @Test fun plain_text_note_is_passed_through_unchanged() {74+        val plain = "buy milk\n- eggs\n1. bread\n**not bold**"75+        assertFalse(isNoteHtml(plain))76+        assertEquals(plain, noteEditorText(plain))77+        assertEquals(plain, notePlainText(plain))78+        assertEquals(plain, noteStorageText(plain, isHtml = false))79+        assertTrue("plain text carries no styling", noteRendered(plain).spanStyles.isEmpty())80+    }81+82+    @Test fun plain_text_note_survives_an_edit_without_becoming_html() {83+        val stored = "line one\nline two"84+        val edited = noteEditorText(stored) + "\nline three"85+        assertEquals("line one\nline two\nline three", noteStorageText(edited, isNoteHtml(stored)))86+    }87+88+    // ── the round trip that protects formatting on save ──────────────────────89+90+    @Test fun formatted_note_round_trips_through_the_editor() {91+        val html = "<h2>Trip</h2><b>packing</b><br>list" +92+            "<ul><li>socks</li><li>tent</li></ul>" +93+            "<ol><li>book</li><li>map</li></ol>" +94+            "<hr><i>later</i>"95+96+        val editorText = noteEditorText(html)97+        assertEquals(98+            "## Trip\n**packing**\nlist\n- socks\n- tent\n1. book\n2. map\n---\n*later*",99+            editorText,100+        )101+        assertEquals(html, noteStorageText(editorText, isHtml = true))102+    }103+104+    @Test fun round_trip_is_stable_over_repeated_edits() {105+        val html = "<h2>Notes</h2><u>read</u> and <s>done</s><br><div>tail</div>"106+        val once = noteStorageText(noteEditorText(html), isHtml = true)107+        val twice = noteStorageText(noteEditorText(once), isHtml = true)108+        assertEquals(once, twice)109+        // The formatting survives — not just the text.110+        assertTrue(once.contains("<h2>"))111+        assertTrue(once.contains("<u>read</u>"))112+        assertTrue(once.contains("<s>done</s>"))113+    }114+115+    @Test fun appending_text_to_a_formatted_note_keeps_the_formatting() {116+        val html = "<h2>Trip</h2><ul><li>socks</li></ul>"117+        val edited = noteEditorText(html) + "\n- tent"118+        val saved = noteStorageText(edited, isHtml = true)119+        assertEquals("<h2>Trip</h2><ul><li>socks</li><li>tent</li></ul>", saved)120+    }121+122+    // ── search and previews see text, never markup ───────────────────────────123+124+    @Test fun toc_search_does_not_match_markup() {125+        val list = listOf(note("Trip", "<div><b>hotel</b> ideas</div>"))126+        assertTrue("markup must not be searchable", filterNotesForToc(list, "div").isEmpty())127+        assertTrue(filterNotesForToc(list, "strong").isEmpty())128+        assertEquals(listOf("Trip"), filterNotesForToc(list, "hotel").map { it.title })129+    }130+131+    @Test fun vault_search_text_for_a_note_carries_no_markup() {132+        val text = vaultEntrySearchText(note("Trip", "<ul><li>socks</li></ul>"))133+        assertFalse(text.contains("<"))134+        assertTrue(text.contains("socks"))135+    }136+137+    @Test fun preview_line_is_the_first_rendered_line() {138+        assertEquals("Trip", notePreviewLine("<h2>Trip</h2><div>details</div>"))139+        assertEquals("details", notePreviewLine("<div></div><div>details</div>"))140+        assertEquals("plain first line", notePreviewLine("plain first line\nsecond"))141+        assertEquals("", notePreviewLine(""))142+        assertFalse(notePreviewLine("<div><b>hotel</b> ideas</div>").contains("<"))143+    }144+145+    // ── the editor's formatting buttons write markup that survives a save ────146+147+    @Test fun toolbar_wrap_marks_the_selection_and_serialises_to_a_tag() {148+        val value = androidx.compose.ui.text.input.TextFieldValue(149+            "hello world",150+            selection = androidx.compose.ui.text.TextRange(0, 5),151+        )152+        val bolded = wrapSelection(value, "**")153+        assertEquals("**hello** world", bolded.text)154+        assertEquals("<b>hello</b> world", noteStorageText(bolded.text, isHtml = true))155+    }156+}

view file