package com.peachpasswords.ui.vault
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.sp
/*
* Note bodies are authored in the browser's contenteditable, so a synced note
* arrives as HTML: the extension's sanitizer allow-list (NoteEditor.tsx) is
* b, strong, i, em, u, s, h2, h3, blockquote, ul, ol, li, br, hr, p — attribute-less — plus
* the div wrappers Chrome's contenteditable inserts for new lines. Android read
* that field as plain text, which is why the phone showed raw tags.
*
* Parsed here rather than through HtmlCompat: the vocabulary is fifteen
* attribute-less tags, host-JVM unit tests cannot touch android.text, and
* Html.toHtml serialises back as `
` — outside the
* allow-list, so it could not round-trip to the browser.
*
* Compose drops AnnotatedString spans on every keystroke, so there is no sound
* WYSIWYG editing surface. Editing therefore goes through a plain-text markup
* (`**bold**`, `## heading`, `> quote`, `- item`) that converts back to the same tag
* vocabulary on save. The markup never leaves the device — it exists only
* between load and save.
*/
/** Inline marks, longest markup first so `**` wins over `*` when scanning. */
private enum class Mark(val markup: String, val tag: String) {
BOLD("**", "b"),
UNDERLINE("__", "u"),
STRIKE("~~", "s"),
ITALIC("*", "i"),
}
private val TAG_HINT = Regex("?[a-z]", RegexOption.IGNORE_CASE)
private val TAG = Regex("<[^>]*>")
private val WHITESPACE = Regex("\\s+")
private val ORDERED_ITEM = Regex("^\\d+\\. ")
/** Same test the browser's toHtml uses to decide plain text vs markup. */
internal fun isNoteHtml(raw: String): Boolean = TAG_HINT.containsMatchIn(raw)
/** Stored note -> the text the editor shows and the user edits. */
internal fun noteEditorText(stored: String): String =
if (isNoteHtml(stored)) htmlToMarkup(stored) else stored
/**
* Editor text -> stored note. A note that arrived as plain text stays plain
* text: markup is only interpreted once the user opts into formatting, so a
* shopping list with "- eggs" in it is not silently turned into a `
`.
*/
internal fun noteStorageText(editorText: String, isHtml: Boolean): String =
if (isHtml) markupToHtml(editorText) else editorText
/** Editor text -> rendered form, for the read views. */
internal fun noteRendered(editorText: String, isHtml: Boolean): AnnotatedString =
if (isHtml) markupToAnnotated(editorText) else AnnotatedString(editorText)
/** Stored note -> rendered form. */
internal fun noteRendered(stored: String): AnnotatedString =
noteRendered(noteEditorText(stored), isNoteHtml(stored))
/** Stored note -> plain text, for search and the clipboard. */
internal fun notePlainText(stored: String): String = noteRendered(stored).text
/** First non-blank rendered line — the note list's preview. */
internal fun notePreviewLine(stored: String): String =
notePlainText(stored).lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty()
// ── HTML -> markup ────────────────────────────────────────────────────────
private fun htmlToMarkup(html: String): String {
val out = StringBuilder()
// Ordinal per open ; -1 marks a .
val lists = ArrayDeque()
fun endLine() {
if (out.isNotEmpty() && !out.endsWith("\n")) out.append('\n')
}
fun text(chunk: String) {
if (chunk.isEmpty()) return
val decoded = decodeEntities(chunk).replace(WHITESPACE, " ")
// Whitespace between block tags is layout, not content.
if (decoded.isBlank() && (out.isEmpty() || out.endsWith("\n"))) return
out.append(decoded)
}
var cursor = 0
for (match in TAG.findAll(html)) {
text(html.substring(cursor, match.range.first))
cursor = match.range.last + 1
val token = match.value
val closing = token.startsWith("")
val name = token.removePrefix("").removePrefix("<").takeWhile { it.isLetterOrDigit() }.lowercase()
when (name) {
"b", "strong" -> out.append(Mark.BOLD.markup)
"i", "em" -> out.append(Mark.ITALIC.markup)
"u" -> out.append(Mark.UNDERLINE.markup)
"s", "strike", "del" -> out.append(Mark.STRIKE.markup)
"h2" -> { endLine(); if (!closing) out.append("## ") }
"h3" -> { endLine(); if (!closing) out.append("### ") }
"blockquote" -> { endLine(); if (!closing) out.append("> ") }
"ul" -> { endLine(); if (closing) lists.removeLastOrNull() else lists.addLast(-1) }
"ol" -> { endLine(); if (closing) lists.removeLastOrNull() else lists.addLast(1) }
"li" -> {
endLine()
if (!closing) {
val ordinal = lists.lastOrNull() ?: -1
if (ordinal < 0) {
out.append("- ")
} else {
out.append("$ordinal. ")
lists.removeLast()
lists.addLast(ordinal + 1)
}
}
}
"br" -> out.append('\n')
"hr" -> { endLine(); out.append("---\n") }
"p", "div" -> endLine()
// Anything else is dropped tag-only, keeping its text — the same
// thing the browser's sanitizer does with a disallowed element.
}
}
text(html.substring(cursor))
return out.toString().trim('\n')
}
private fun decodeEntities(s: String): String {
if (!s.contains('&')) return s
return s
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace(" ", " ")
.replace(' ', ' ') // contenteditable emits the raw character too, not just the entity
.replace("&", "&") // last: < must decode to <, not <
}
private fun escape(s: String): String =
s.replace("&", "&").replace("<", "<").replace(">", ">")
// ── markup -> HTML / AnnotatedString ──────────────────────────────────────
private data class Run(val text: String, val marks: Set)
/**
* Split a line on its inline markers. The innermost open marker is matched
* first so `**a *b***` closes the italic before the bold instead of tripping
* over the shared `*`.
*/
private fun inlineRuns(line: String): List {
val runs = mutableListOf()
val open = ArrayDeque()
val buf = StringBuilder()
fun flush() {
if (buf.isNotEmpty()) {
runs += Run(buf.toString(), LinkedHashSet(open))
buf.clear()
}
}
var i = 0
while (i < line.length) {
val innermost = open.lastOrNull()
if (innermost != null && line.startsWith(innermost.markup, i)) {
flush()
open.removeLast()
i += innermost.markup.length
continue
}
val opening = Mark.entries.firstOrNull { line.startsWith(it.markup, i) && it !in open }
if (opening != null) {
flush()
open.addLast(opening)
i += opening.markup.length
continue
}
buf.append(line[i])
i++
}
flush()
return runs
}
private fun inlineHtml(line: String): String = inlineRuns(line).joinToString("") { run ->
val marks = Mark.entries.filter { it in run.marks }
marks.joinToString("") { "<${it.tag}>" } +
escape(run.text) +
marks.reversed().joinToString("") { "${it.tag}>" }
}
private fun markupToHtml(markup: String): String {
val out = StringBuilder()
var openList: String? = null
var prevWasPlain = false
fun closeList() {
openList?.let { out.append("$it>"); openList = null }
}
fun startList(kind: String) {
if (openList != kind) {
closeList()
out.append("<$kind>")
openList = kind
}
}
for (line in markup.split("\n")) {
val plain = when {
line.trim() == "---" -> { closeList(); out.append("
"); false }
line.startsWith("## ") -> { closeList(); out.append("${inlineHtml(line.removePrefix("## "))}
"); false }
line.startsWith("### ") -> { closeList(); out.append("${inlineHtml(line.removePrefix("### "))}
"); false }
line.startsWith("> ") -> { closeList(); out.append("${inlineHtml(line.removePrefix("> "))}
"); false }
line.startsWith("- ") -> { startList("ul"); out.append("- ${inlineHtml(line.removePrefix("- "))}
"); false }
ORDERED_ITEM.containsMatchIn(line) -> { startList("ol"); out.append("- ${inlineHtml(line.replaceFirst(ORDERED_ITEM, ""))}
"); false }
else -> {
closeList()
if (prevWasPlain) out.append("
")
out.append(inlineHtml(line))
true
}
}
prevWasPlain = plain
}
closeList()
return out.toString()
}
private val HEADING2 = SpanStyle(fontWeight = FontWeight.SemiBold, fontSize = 19.sp)
private val HEADING3 = SpanStyle(fontWeight = FontWeight.SemiBold, fontSize = 16.sp)
private val QUOTE = SpanStyle(fontStyle = FontStyle.Italic)
private fun markupToAnnotated(markup: String): AnnotatedString = buildAnnotatedString {
markup.split("\n").forEachIndexed { index, line ->
if (index > 0) append('\n')
when {
line.trim() == "---" -> append("──────────")
line.startsWith("## ") -> appendRuns(line.removePrefix("## "), HEADING2)
line.startsWith("### ") -> appendRuns(line.removePrefix("### "), HEADING3)
line.startsWith("> ") -> appendRuns(line.removePrefix("> "), QUOTE)
line.startsWith("- ") -> { append("• "); appendRuns(line.removePrefix("- "), null) }
ORDERED_ITEM.containsMatchIn(line) -> {
append(ORDERED_ITEM.find(line)!!.value.trimEnd() + " ")
appendRuns(line.replaceFirst(ORDERED_ITEM, ""), null)
}
else -> appendRuns(line, null)
}
}
}
private fun AnnotatedString.Builder.appendRuns(line: String, base: SpanStyle?) {
for (run in inlineRuns(line)) {
val style = spanStyle(run.marks).let { if (base == null) it else base.merge(it) }
withStyle(style) { append(run.text) }
}
}
private fun spanStyle(marks: Set): SpanStyle {
val decorations = buildList {
if (Mark.UNDERLINE in marks) add(TextDecoration.Underline)
if (Mark.STRIKE in marks) add(TextDecoration.LineThrough)
}
return SpanStyle(
fontWeight = if (Mark.BOLD in marks) FontWeight.Bold else null,
fontStyle = if (Mark.ITALIC in marks) FontStyle.Italic else null,
textDecoration = if (decorations.isEmpty()) null else TextDecoration.combine(decorations),
)
}