diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt index 874585de1..12921c076 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt @@ -14,6 +14,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -22,6 +24,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playSoundOnFocus +import com.github.damontecres.wholphin.util.stripMarkdown /** * Show the overview text for an item. Uses a fixed size and allows for clicking. @@ -59,7 +62,7 @@ fun OverviewText( }, ) { Text( - text = overview, + text = overview.stripMarkdown(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = maxLines, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index 8e7c89b14..c6d7057b2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -15,6 +15,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -27,6 +29,7 @@ import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec import com.github.damontecres.wholphin.util.languageName +import com.mikepenz.markdown.m3.Markdown import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType @@ -88,10 +91,7 @@ fun ItemDetailsDialog( ) } if (info.overview.isNotNullOrBlank()) { - Text( - text = info.overview, - style = MaterialTheme.typography.bodyMedium, - ) + Markdown(content = info.overview) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index ef0fdbadd..feeecd9ea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -85,6 +86,7 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.stripMarkdown import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaType @@ -526,7 +528,7 @@ fun HomePageHeader( .width(400.dp) if (overview.isNotNullOrBlank()) { Text( - text = overview, + text = overview.stripMarkdown(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = if (overviewTwoLines) 2 else 3, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TextUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TextUtils.kt new file mode 100644 index 000000000..f5140908b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TextUtils.kt @@ -0,0 +1,52 @@ +package com.github.damontecres.wholphin.util + +private val MARKDOWN_CHARS = charArrayOf( + '#', '*', '_', '~', '`', '[', '!', '>', '<', '-', '+', '.', '(', ')', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' +) + +/** + * Strips common Markdown and HTML tags from a string for plain-text display. + */ +fun String.stripMarkdown(): String { + // Early return if no markdown-like characters are present and no trimming is needed. + if (this.none { it in MARKDOWN_CHARS } && this.trim() == this) return this + + return this + // Code blocks (``` ... ```) - Remove first to avoid matching contents + .replace(Regex("(?s)```.*?```"), "") + // Horizontal rules (--- or ***) - MUST be before bold/italic + .replace(Regex("(?m)^([-*_]){3,}\\s*$"), "") + // Headers (# Heading) + .replace(Regex("(?m)^#{1,6}\\s+"), "") + // Bold + italic (***text*** or ___text___) + .replace(Regex("\\*{3}(.+?)\\*{3}"), "$1") + .replace(Regex("_{3}(.+?)_{3}"), "$1") + // Bold (**text** or __text__) + .replace(Regex("\\*{2}(.+?)\\*{2}"), "$1") + .replace(Regex("_{2}(.+?)_{2}"), "$1") + // Italic (*text* or _text_) + .replace(Regex("\\*(.+?)\\*"), "$1") + .replace(Regex("_(.+?)_"), "$1") + // Strikethrough (~~text~~) + .replace(Regex("~~(.+?)~~"), "$1") + // Inline code (`code`) + .replace(Regex("`(.+?)`"), "$1") + // Images (![alt](url)) + .replace(Regex("!\\[.*?]\\(.*?\\)"), "") + // Links ([text](url)) → keep text + .replace(Regex("\\[(.+?)]\\(.*?\\)"), "$1") + // Blockquotes (> text) + .replace(Regex("(?m)^>\\s+"), "") + // Unordered lists (- item, * item, + item) + .replace(Regex("(?m)^[\\-*+]\\s+"), "") + // Ordered lists (1. item) + .replace(Regex("(?m)^\\d+\\.\\s+"), "") + // HTML tags + .replace(Regex("<[^>]+>"), "") + // Clean up extra blank lines (more than 2) + .replace(Regex("(\\r?\\n){3,}"), "\n\n") + // Collapse multiple spaces to a single space + .replace(Regex(" {2,}"), " ") + .trim() +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/TextUtilsKtTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/TextUtilsKtTest.kt new file mode 100644 index 000000000..75a113fbc --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/TextUtilsKtTest.kt @@ -0,0 +1,163 @@ +package com.github.damontecres.wholphin.util + +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertSame +import junit.framework.TestCase.assertTrue +import org.junit.Test + +class TextUtilsKtTest { + @Test + fun `Empty string input`() { + assertEquals("", "".stripMarkdown()) + } + + @Test + fun `String without markdown characters`() { + val plain = "Hello, world! This is plain text." + val result = plain.stripMarkdown() + assertSame(plain, result) // fast path must return the exact same reference + } + + @Test + fun `Headers level 1 to 6 removal`() { + assertEquals("Heading", "# Heading".stripMarkdown()) + assertEquals("Heading", "## Heading".stripMarkdown()) + assertEquals("Heading", "### Heading".stripMarkdown()) + assertEquals("Heading", "#### Heading".stripMarkdown()) + assertEquals("Heading", "##### Heading".stripMarkdown()) + assertEquals("Heading", "###### Heading".stripMarkdown()) + } + + @Test + fun `Triple emphasis Bold and Italic stripping`() { + assertEquals("text", "***text***".stripMarkdown()) + assertEquals("text", "___text___".stripMarkdown()) + } + + @Test + fun `Double emphasis Bold stripping`() { + assertEquals("text", "**text**".stripMarkdown()) + assertEquals("text", "__text__".stripMarkdown()) + } + + @Test + fun `Single emphasis Italic stripping`() { + assertEquals("text", "*text*".stripMarkdown()) + assertEquals("text", "_text_".stripMarkdown()) + } + + @Test + fun `Strikethrough stripping`() { + assertEquals("text", "~~text~~".stripMarkdown()) + } + + @Test + fun `Inline code stripping`() { + assertEquals("code", "`code`".stripMarkdown()) + assertEquals("Use code here", "Use `code` here".stripMarkdown()) + } + + @Test + fun `Multi line code block removal`() { + val input = + """ + Before + ``` + fun foo() = 42 + ``` + After + """.trimIndent() + val result = input.stripMarkdown() + assertFalse(result.contains("fun foo")) + assertTrue(result.contains("Before")) + assertTrue(result.contains("After")) + } + + @Test + fun `Markdown link conversion`() { + assertEquals("Click here", "[Click here](https://example.com)".stripMarkdown()) + assertFalse("[text](https://example.com)".stripMarkdown().contains("https")) + } + + @Test + fun `Blockquote prefix removal`() { + assertEquals("A quote", "> A quote".stripMarkdown()) + assertEquals("Line one\nLine two", "> Line one\n> Line two".stripMarkdown()) + } + + @Test + fun `Unordered list marker removal`() { + assertEquals("Item", "- Item".stripMarkdown()) + assertEquals("Item", "* Item".stripMarkdown()) + assertEquals("Item", "+ Item".stripMarkdown()) + } + + @Test + fun `Ordered list marker removal`() { + assertEquals("First", "1. First".stripMarkdown()) + assertEquals("Second", "2. Second".stripMarkdown()) + assertEquals("Tenth", "10. Tenth".stripMarkdown()) + } + + @Test + fun `Horizontal rule removal`() { + assertEquals("", "---".stripMarkdown()) + assertEquals("", "***".stripMarkdown()) + assertEquals("", "___".stripMarkdown()) + assertEquals("", "------".stripMarkdown()) + } + + @Test + fun `HTML tag stripping`() { + assertEquals("text", "text".stripMarkdown()) + assertEquals("Hello world", "Hello
world".stripMarkdown()) + } + + @Test + fun `Nested emphasis handling`() { + // ***text*** should collapse to just "text" + assertEquals("text", "***text***".stripMarkdown()) + // **bold *italic*** — inner markers stripped, text preserved + val result = "**bold *italic***".stripMarkdown() + assertTrue(result.contains("bold")) + assertTrue(result.contains("italic")) + } + + @Test + fun `Escaped markdown character behavior`() { + // Current impl does NOT handle escapes — document the actual behaviour + val result = """\*not italic\*""".stripMarkdown() + // Backslashes are left intact; no italic stripping occurs on escaped markers + assertTrue(result.contains("not italic")) + } + + @Test + fun `Non matching markdown characters`() { + // '#' mid-word must not be stripped (regex is anchored to line start) + assertEquals("colour #FF0000", "colour #FF0000".stripMarkdown()) + // '>' inside a sentence must not be stripped + assertEquals("a > b", "a > b".stripMarkdown()) + } + + @Test + fun `Incomplete markdown syntax`() { + // Unmatched markers — no crash, text is preserved as-is + val result = "**bold without close".stripMarkdown() + assertTrue(result.contains("bold without close")) + + val result2 = "[link(url)".stripMarkdown() + assertTrue(result2.isNotEmpty()) + } + + @Test + fun `Large text performance`() { + val chunk = "# Title\n\n**bold** and *italic* with [link](url)\n\n" + val large = chunk.repeat(10_000) + val start = System.currentTimeMillis() + val result = large.stripMarkdown() + val elapsed = System.currentTimeMillis() - start + assertTrue(result.isNotEmpty()) + assertTrue("Took ${elapsed}ms, expected < 2000ms", elapsed < 2000) + } +}