Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -59,7 +62,7 @@ fun OverviewText(
},
) {
Text(
text = overview,
text = overview.stripMarkdown(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = maxLines,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -88,10 +91,7 @@ fun ItemDetailsDialog(
)
}
if (info.overview.isNotNullOrBlank()) {
Text(
text = info.overview,
style = MaterialTheme.typography.bodyMedium,
)
Markdown(content = info.overview)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -526,7 +528,7 @@ fun HomePageHeader(
.width(400.dp)
if (overview.isNotNullOrBlank()) {
Text(
text = overview,
text = overview.stripMarkdown(),
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the overview needs any processing here. I would just leave it as is.

Doing the processing here will run on the main thread. And pre-computing it will a bit more memory overhead which I'm trying to limit.

style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = if (overviewTwoLines) 2 else 3,
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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", "<b>text</b>".stripMarkdown())
assertEquals("Hello world", "Hello <br/> 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)
}
}