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
↓ peach-android-052628df.tar.gzatlas at this save
files changed4 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 −0app/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("<", "<")137+ .replace(">", ">")138+ .replace(""", "\"")139+ .replace("'", "'")140+ .replace("'", "'")141+ .replace(" ", " ")142+ .replace(' ', ' ') // contenteditable emits the raw character too, not just the entity143+ .replace("&", "&") // last: &lt; must decode to <, not <144+}145+146+private fun escape(s: String): String =147+ s.replace("&", "&").replace("<", "<").replace(">", ">")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+}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 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,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 & b <tag></p>"))66+ // Round trip must not double-escape.67+ val html = "<div>a & b</div>"68+ assertEquals("a & 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+}