Skip to content
Merged
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 @@ -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 = " "),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
),
)
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -330,7 +333,7 @@ class ArticleViewModel(
val textToRead =
when (readFullText) {
false ->
Either.catching(
Either.catching<TSSError, List<AnnotatedString>>(
onCatch = {
when (it) {
is FileNotFoundException -> TTSFileNotFound
Expand All @@ -347,7 +350,7 @@ class ArticleViewModel(
}

true ->
Either.catching(
Either.catching<TSSError, List<AnnotatedString>>(
onCatch = {
when (it) {
is FileNotFoundException -> TTSFileNotFound
Expand Down Expand Up @@ -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<AnnotatedString> =
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)
Expand Down Expand Up @@ -505,6 +530,7 @@ sealed interface OpenAISummaryState {

data class Result(
val value: OpenAIApi.SummaryResult,
val annotatedStrings: List<AnnotatedString>,
) : OpenAISummaryState
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ fun htmlToAnnotatedString(
)
} ?: emptyList()

/**
* Returns "plain text" with annotations for TTS from HTML string
*/
fun htmlStringToAnnotatedString(
html: String,
baseUrl: String = "",
): List<AnnotatedString> =
Jsoup
.parse(html)
?.body()
?.let { body ->
formatBody(
element = body,
baseUrl = baseUrl,
)
} ?: emptyList()

private fun formatBody(
element: Element,
baseUrl: String,
Expand Down Expand Up @@ -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" -> {
Expand Down Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
}
Original file line number Diff line number Diff line change
@@ -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 = "<p>This is <strong>bold</strong> and <em>italic</em> text.</p>"

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 =
"""
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
""".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"))
}
}
Loading
Loading