diff --git a/app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIApi.kt b/app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIApi.kt index 39462f93e2..30c32b380e 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIApi.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIApi.kt @@ -147,9 +147,10 @@ class OpenAIApi( "You are an assistant in an RSS reader app, summarizing article content.", "The app language is '$appLang'.", "Provide summaries in the article's language if 99% recognizable; otherwise, use the app language.", - "First line must be: 'Lang: \"ISO code\"'", + "First line must be exactly: 'Lang: \"ISO code\"' with NO markdown formatting around the Lang line whatsoever.", "Keep summaries up to 100 words, 3 paragraphs, with up to 3 bullet points per paragraph.", - "For readability use bullet points, titles, quotes and new lines using plain text only.", + "For readability use markdown formatting: **bold** for emphasis, *italics* for quotes, bullet points (-) for lists, # headers for sections, and > for block quotes.", + "Use markdown to structure content and improve readability.", "Use only single language.", "Keep full quotes if any.", ).joinToString(separator = " "), diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt index 74b4e7ef77..d559470444 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.core.MutableTransitionState import androidx.compose.foundation.ScrollState import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxWidth @@ -511,12 +512,19 @@ private fun SummarySection(summary: OpenAISummaryState) { LinearProgressIndicator( modifier = Modifier.padding(16.dp).fillMaxWidth(), ) - - is OpenAISummaryState.Result -> - Text( - modifier = Modifier.padding(8.dp), - text = summary.value.content, - ) + is OpenAISummaryState.Result -> { + Column(modifier = Modifier.padding(8.dp)) { + summary.annotatedStrings.forEachIndexed { index, annotatedString -> + Text( + text = annotatedString, + modifier = + Modifier.padding( + bottom = if (index < summary.annotatedStrings.size - 1) 8.dp else 0.dp, + ), + ) + } + } + } } } } diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleViewModel.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleViewModel.kt index caae2cab08..53b78a58f3 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleViewModel.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleViewModel.kt @@ -2,6 +2,7 @@ package com.nononsenseapps.feeder.ui.compose.feedarticle import android.util.Log import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.AnnotatedString import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.nononsenseapps.feeder.ApplicationCoroutineScope @@ -33,7 +34,9 @@ import com.nononsenseapps.feeder.model.html.HtmlLinearizer import com.nononsenseapps.feeder.model.html.LinearArticle import com.nononsenseapps.feeder.openai.OpenAIApi import com.nononsenseapps.feeder.openai.isValid +import com.nononsenseapps.feeder.ui.compose.text.htmlStringToAnnotatedString import com.nononsenseapps.feeder.ui.compose.text.htmlToAnnotatedString +import com.nononsenseapps.feeder.ui.text.MarkdownToHtmlConverter import com.nononsenseapps.feeder.util.Either import com.nononsenseapps.feeder.util.FilePathProvider import com.nononsenseapps.feeder.util.logDebug @@ -330,7 +333,7 @@ class ArticleViewModel( val textToRead = when (readFullText) { false -> - Either.catching( + Either.catching>( onCatch = { when (it) { is FileNotFoundException -> TTSFileNotFound @@ -347,7 +350,7 @@ class ArticleViewModel( } true -> - Either.catching( + Either.catching>( onCatch = { when (it) { is FileNotFoundException -> TTSFileNotFound @@ -398,19 +401,41 @@ class ArticleViewModel( try { openAiSummary.value = OpenAISummaryState.Loading val content = loadArticleContent() + val summaryResult = openAIApi.summarize(content) + val annotatedStrings = convertSummaryToAnnotatedStrings(summaryResult) openAiSummary.value = OpenAISummaryState.Result( - value = openAIApi.summarize(content), + value = summaryResult, + annotatedStrings = annotatedStrings, ) } catch (e: Exception) { + val errorResult = OpenAIApi.SummaryResult.Error(content = e.message ?: "Unknown error") + val annotatedStrings = convertSummaryToAnnotatedStrings(errorResult) openAiSummary.value = OpenAISummaryState.Result( - value = OpenAIApi.SummaryResult.Error(content = e.message ?: "Unknown error"), + value = errorResult, + annotatedStrings = annotatedStrings, ) } } } + private suspend fun convertSummaryToAnnotatedStrings(summaryResult: OpenAIApi.SummaryResult): List = + withContext(Dispatchers.Default) { + return@withContext when (summaryResult) { + is OpenAIApi.SummaryResult.Success -> { + val markdownConverter = MarkdownToHtmlConverter() + val htmlContent = markdownConverter.convertToHtml(summaryResult.content) + htmlStringToAnnotatedString(htmlContent) + } + is OpenAIApi.SummaryResult.Error -> { + // For error messages, create a simple AnnotatedString directly + // without going through markdown/HTML conversion + listOf(AnnotatedString(summaryResult.content)) + } + } + } + private suspend fun loadArticleContent(): String { val viewState = viewState.value val blobFile = blobFullFile(viewState.articleId, filePathProvider.fullArticleDir) @@ -505,6 +530,7 @@ sealed interface OpenAISummaryState { data class Result( val value: OpenAIApi.SummaryResult, + val annotatedStrings: List, ) : OpenAISummaryState } diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToAnnotatedString.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToAnnotatedString.kt index 557cac69e2..76309a0d51 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToAnnotatedString.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToAnnotatedString.kt @@ -26,6 +26,23 @@ fun htmlToAnnotatedString( ) } ?: emptyList() +/** + * Returns "plain text" with annotations for TTS from HTML string + */ +fun htmlStringToAnnotatedString( + html: String, + baseUrl: String = "", +): List = + Jsoup + .parse(html) + ?.body() + ?.let { body -> + formatBody( + element = body, + baseUrl = baseUrl, + ) + } ?: emptyList() + private fun formatBody( element: Element, baseUrl: String, @@ -153,24 +170,30 @@ private fun AnnotatedStringComposer.appendTextChildren( } "strong", "b" -> { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - ) + withStyle(SpanStyle(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold)) { + appendTextChildren( + element.childNodes(), + baseUrl = baseUrl, + ) + } } "i", "em", "cite", "dfn" -> { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - ) + withStyle(SpanStyle(fontStyle = androidx.compose.ui.text.font.FontStyle.Italic)) { + appendTextChildren( + element.childNodes(), + baseUrl = baseUrl, + ) + } } "tt" -> { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - ) + withStyle(SpanStyle(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace)) { + appendTextChildren( + element.childNodes(), + baseUrl = baseUrl, + ) + } } "u" -> { @@ -219,11 +242,13 @@ private fun AnnotatedStringComposer.appendTextChildren( "code" -> { emitParagraph() // TODO some TTS annotation? - appendTextChildren( - element.childNodes(), - preFormatted = preFormatted, - baseUrl = baseUrl, - ) + withStyle(SpanStyle(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace)) { + appendTextChildren( + element.childNodes(), + preFormatted = preFormatted, + baseUrl = baseUrl, + ) + } emitParagraph() } diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/text/MarkdownToHtmlConverter.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/text/MarkdownToHtmlConverter.kt new file mode 100644 index 0000000000..0a1de72f15 --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/text/MarkdownToHtmlConverter.kt @@ -0,0 +1,61 @@ +package com.nononsenseapps.feeder.ui.text + +import org.intellij.markdown.flavours.commonmark.CommonMarkFlavourDescriptor +import org.intellij.markdown.html.HtmlGenerator +import org.intellij.markdown.parser.MarkdownParser + +/** + * Converts Markdown text to HTML for display in the app. + * This is used primarily for rendering AI-generated summaries that contain Markdown formatting. + */ +class MarkdownToHtmlConverter { + private val flavour = CommonMarkFlavourDescriptor() + private val parser = MarkdownParser(flavour) + + /** + * Converts Markdown text to HTML + */ + fun convertToHtml(markdown: String): String { + if (markdown.isBlank()) { + return "" + } + + return try { + val parsedTree = parser.buildMarkdownTreeFromString(markdown) + val htmlGenerator = HtmlGenerator(markdown, parsedTree, flavour) + htmlGenerator.generateHtml() + } catch (e: Exception) { + // If Markdown processing fails, return the original text escaped for HTML + escapeHtml(markdown) + } + } + + /** + * Converts Markdown text to plain text (similar to the existing HTML converter) + */ + fun convertToPlainText(markdown: String): String { + if (markdown.isBlank()) { + return "" + } + + return try { + // First convert to HTML, then strip HTML tags to get clean plain text + val html = convertToHtml(markdown) + html.replace(Regex("<[^>]*>"), "") + } catch (e: Exception) { + // If Markdown processing fails, return the original text with basic markdown cleanup + markdown + .replace(Regex("\\*\\*(.*?)\\*\\*"), "$1") // Remove **bold** + .replace(Regex("\\*(.*?)\\*"), "$1") // Remove *italic* + .replace(Regex("`(.*?)`"), "$1") // Remove `code` + } + } + + private fun escapeHtml(text: String): String = + text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") +} diff --git a/app/src/test/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToAnnotatedStringTest.kt b/app/src/test/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToAnnotatedStringTest.kt new file mode 100644 index 0000000000..3bb19f9c11 --- /dev/null +++ b/app/src/test/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToAnnotatedStringTest.kt @@ -0,0 +1,77 @@ +package com.nononsenseapps.feeder.ui.compose.text + +import com.nononsenseapps.feeder.ui.text.MarkdownToHtmlConverter +import org.junit.Assert.assertTrue +import org.junit.Test + +class HtmlToAnnotatedStringTest { + @Test + fun `test markdown to html to annotated string flow`() { + val markdown = + """ + # AI Summary + + This is a **summary** with *formatting*: + + - **Key point 1**: Important information + - *Key point 2*: More details + + > Quote from the article + + ## Conclusion + + The article discusses `code` and more. + """.trimIndent() + + val markdownConverter = MarkdownToHtmlConverter() + val html = markdownConverter.convertToHtml(markdown) + + // Convert HTML to AnnotatedString + val annotatedStrings = htmlStringToAnnotatedString(html) + + // Should have some annotated strings + assertTrue(annotatedStrings.isNotEmpty()) + + // Check that the content is preserved + val combinedText = annotatedStrings.joinToString("\n") { it.text } + assertTrue(combinedText.contains("AI Summary")) + assertTrue(combinedText.contains("summary")) + assertTrue(combinedText.contains("Key point")) + } + + @Test + fun `test simple html conversion`() { + val html = "

This is bold and italic text.

" + + val annotatedStrings = htmlStringToAnnotatedString(html) + + assertTrue(annotatedStrings.isNotEmpty()) + + val combinedText = annotatedStrings.joinToString("\n") { it.text } + assertTrue(combinedText.contains("This is")) + assertTrue(combinedText.contains("bold")) + assertTrue(combinedText.contains("italic")) + assertTrue(combinedText.contains("text")) + } + + @Test + fun `test list html conversion`() { + val html = + """ +
    +
  • Item 1
  • +
  • Item 2
  • +
  • Item 3
  • +
+ """.trimIndent() + + val annotatedStrings = htmlStringToAnnotatedString(html) + + assertTrue(annotatedStrings.isNotEmpty()) + + val combinedText = annotatedStrings.joinToString("\n") { it.text } + assertTrue(combinedText.contains("Item 1")) + assertTrue(combinedText.contains("Item 2")) + assertTrue(combinedText.contains("Item 3")) + } +} diff --git a/app/src/test/java/com/nononsenseapps/feeder/ui/text/MarkdownToHtmlConverterTest.kt b/app/src/test/java/com/nononsenseapps/feeder/ui/text/MarkdownToHtmlConverterTest.kt new file mode 100644 index 0000000000..7538167e7e --- /dev/null +++ b/app/src/test/java/com/nononsenseapps/feeder/ui/text/MarkdownToHtmlConverterTest.kt @@ -0,0 +1,101 @@ +package com.nononsenseapps.feeder.ui.text + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarkdownToHtmlConverterTest { + private val converter = MarkdownToHtmlConverter() + + @Test + fun `test basic markdown conversion`() { + val markdown = "This is **bold** and this is *italic*" + val html = converter.convertToHtml(markdown) + + // Should contain HTML tags + assertTrue(html.contains("")) + assertTrue(html.contains("")) + assertTrue(html.contains("")) + assertTrue(html.contains("")) + } + + @Test + fun `test list conversion`() { + val markdown = + """ + - Item 1 + - Item 2 + - Item 3 + """.trimIndent() + + val html = converter.convertToHtml(markdown) + + // Should contain list HTML tags + assertTrue(html.contains("
    ")) + assertTrue(html.contains("
")) + assertTrue(html.contains("
  • ")) + assertTrue(html.contains("
  • ")) + } + + @Test + fun `test heading conversion`() { + val markdown = "# Heading 1\n## Heading 2" + val html = converter.convertToHtml(markdown) + + // Should contain heading HTML tags + assertTrue(html.contains("

    ")) + assertTrue(html.contains("

    ")) + assertTrue(html.contains("

    ")) + assertTrue(html.contains("

    ")) + } + + @Test + fun `test plain text conversion`() { + val markdown = "This is **bold** and this is *italic*" + val plainText = converter.convertToPlainText(markdown) + + // Should not contain markdown syntax + assertTrue(!plainText.contains("**")) + assertTrue(!plainText.contains("*")) + assertTrue(plainText.contains("bold")) + assertTrue(plainText.contains("italic")) + } + + @Test + fun `test empty input`() { + val html = converter.convertToHtml("") + assertEquals("", html) + + val plainText = converter.convertToPlainText("") + assertEquals("", plainText) + } + + @Test + fun `test complex markdown`() { + val markdown = + """ + # Summary + + This is a **summary** of the article: + + - **Key point 1**: Important information + - *Key point 2*: More details + + > Quote from the article + + ## Conclusion + + The article discusses `code` and more. + """.trimIndent() + + val html = converter.convertToHtml(markdown) + + // Should contain various HTML elements + assertTrue(html.contains("

    ")) + assertTrue(html.contains("")) + assertTrue(html.contains("
      ")) + assertTrue(html.contains("
      ")) + assertTrue(html.contains("

      ")) + assertTrue(html.contains("")) + } +}