Show HTML event descriptions as formatted text (#271) (#398)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped

Event descriptions from Exchange/Outlook and Google Calendar are often HTML. The detail view now renders them instead of showing the raw markup.

- A new converter in `domain/HtmlText.kt` turns HTML into text plus styles and links. It only kicks in when the description has real tags, so plain text with `<3` or `<https://…>` is left alone.
- Kept: bold, italic, underline, strikethrough, super/subscript, monospace, line breaks, paragraphs, lists (bullets, numbers, `start`, nesting) and `<a href>` links (http(s), mailto, tel). Dropped: fonts, sizes, colours, images, and `<head>`/`<style>`/`<script>`/comments.
- Each table row becomes one line with its cells joined by " · ", and empty spacer cells disappear. There's no real grid yet; one can go on top later if invites need it.
- URL and phone autolinking runs on the rendered text and skips text that's already a link.
- Long-press copy gives the rendered text, with each link's address in brackets after it.
- Search matches the rendered text and link addresses, and snippets show clean text.
- The edit form still shows the raw HTML, as discussed in the issue, so saving never breaks the formatting.

The issue suggested a settings toggle that strips tags. Following the discussion in the comments, this renders HTML automatically instead, with no setting.

Known limit: the SQL pre-filter in search still runs on the raw column. A word that's stored as an entity (`R&amp;D`) or split by tags (`<b>Kick</b>off`) isn't found.

I wrote my own parser instead of using `AnnotatedString.fromHtml`, because that goes through `android.text.Html`, which is stubbed in JVM unit tests, and search lives in the Compose-free domain layer.

Closes #271
This commit is contained in:
Jean-Luc Makiola
2026-10-14 17:30:16 +02:00
parent 2b2a31ecda
commit 0dd7e07aa0
8 changed files with 701 additions and 22 deletions
+2
View File
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. Read-only events can be selected but not moved or resized ([#141]).
- **Long press an empty slot to draft an event.** In the day, 3-day and week views, a long press on free time drops a block of your default event length there. Move it while still holding, or let go and drag it or its handles into place, then tap the tick to open a new event at that time. The ✕, a tap elsewhere or going back throws the draft away ([#153]).
- **Tap a phone number in an event to call it.** Phone numbers in an event's title, location and description are now links. Tapping one opens your phone app with the number filled in, ready to call. A conference dial-in like "+1 646 558 8656,,850123#" brings its meeting ID along. Meeting IDs and PINs on their own, dates, times and amounts stay plain text. Long-pressing a field copies it, even when you press on a link ([#339]).
- **Formatted event descriptions read properly.** Descriptions from Outlook, Exchange and Google Calendar are often stored as HTML, which used to show up as a wall of tags. The event view now shows them as formatted text: bold, italics, lists and links work, table rows sit on one line each, and the fonts and colours the sender picked are left out so the text matches the app. Search matches the text you see. The edit form still shows the original, so saving an event never changes its formatting ([#271]).
- **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]).
### Fixed
@@ -1865,3 +1866,4 @@ automatically, with zero telemetry and no internet permission.
[#153]: https://codeberg.org/jlmakiola/calendula/issues/153
[#339]: https://codeberg.org/jlmakiola/calendula/issues/339
[#387]: https://codeberg.org/jlmakiola/calendula/issues/387
[#271]: https://codeberg.org/jlmakiola/calendula/issues/271
@@ -162,16 +162,20 @@ object EventSearch {
val title = candidate.title?.takeIf { it.isNotBlank() }
val location = candidate.event.location?.takeIf { it.isNotBlank() }
// Collapsed so a multi-line description matches the way it is drawn.
val description = candidate.description
val rich = candidate.description?.let(::descriptionRichText)
val description = rich?.text
?.replace(WHITESPACE, " ")
?.trim()
?.takeIf { it.isNotEmpty() }
// A link's address isn't in the drawn text, but "zoom.us" should still find the invite.
val linkUrls = rich?.links.orEmpty().map { it.url }
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
val present = { token: String ->
title?.contains(token, ignoreCase = true) == true ||
location?.contains(token, ignoreCase = true) == true ||
description?.contains(token, ignoreCase = true) == true
description?.contains(token, ignoreCase = true) == true ||
linkUrls.any { it.contains(token, ignoreCase = true) }
}
if (!tokens.all(present)) return null
@@ -0,0 +1,387 @@
package de.jeanlucmakiola.calendula.domain
/** Inline formatting an HTML description keeps; fonts, sizes and colours are dropped. */
enum class InlineStyle { Bold, Italic, Underline, Strikethrough, Superscript, Subscript, Monospace }
data class StyleRange(val start: Int, val end: Int, val style: InlineStyle)
/** A link from an `<a href>`, spanning [start] until [end] (exclusive). */
data class LinkRange(val start: Int, val end: Int, val url: String)
/** Text as drawn, with the formatting and links that sit on it. Plain text has neither. */
data class RichText(
val text: String,
val styles: List<StyleRange> = emptyList(),
val links: List<LinkRange> = emptyList(),
) {
/**
* [text] with each link's address after it, where the text doesn't already
* show it, so a copied "Join the meeting" still carries its URL.
*/
fun copyText(): String {
if (links.isEmpty()) return text
val out = StringBuilder(text)
for (link in links.sortedByDescending { it.end }) {
val address = link.url.removePrefix("mailto:").removePrefix("tel:")
if (address !in text.substring(link.start, link.end)) out.insert(link.end, " ($address)")
}
return out.toString()
}
}
fun looksLikeHtml(text: String): Boolean = htmlTagRegex.containsMatchIn(text)
/** [description] as it reads: HTML rendered down to [RichText], plain text as it is. */
fun descriptionRichText(description: String): RichText =
if (looksLikeHtml(description)) parseHtml(description) else RichText(description)
private val BLOCK_TAGS = setOf(
"p", "div", "ul", "ol", "li", "table", "tr", "blockquote", "pre", "hr", "section", "article",
"header", "footer", "dl", "dt", "dd", "address", "center", "h1", "h2", "h3", "h4", "h5", "h6",
)
// Their content is never text to show.
private val SKIPPED_TAGS = setOf("head", "style", "script", "title", "xml", "template")
private val STYLE_TAGS = mapOf(
"b" to InlineStyle.Bold, "strong" to InlineStyle.Bold, "th" to InlineStyle.Bold,
"h1" to InlineStyle.Bold, "h2" to InlineStyle.Bold, "h3" to InlineStyle.Bold,
"h4" to InlineStyle.Bold, "h5" to InlineStyle.Bold, "h6" to InlineStyle.Bold,
"i" to InlineStyle.Italic, "em" to InlineStyle.Italic, "cite" to InlineStyle.Italic,
"u" to InlineStyle.Underline, "ins" to InlineStyle.Underline,
"s" to InlineStyle.Strikethrough, "strike" to InlineStyle.Strikethrough, "del" to InlineStyle.Strikethrough,
"sup" to InlineStyle.Superscript, "sub" to InlineStyle.Subscript,
"code" to InlineStyle.Monospace, "tt" to InlineStyle.Monospace, "pre" to InlineStyle.Monospace,
)
// A real tag the renderer knows, not prose like "<3" or "a < b". Google
// Calendar and Exchange both store their descriptions as HTML.
private val htmlTagRegex = Regex(
(BLOCK_TAGS + SKIPPED_TAGS + STYLE_TAGS.keys + setOf("html", "body", "br", "span", "a", "font", "img", "meta", "td"))
.joinToString("|", prefix = """</?(?:""", postfix = """)(?:\s[^<>]*)?/?>"""),
RegexOption.IGNORE_CASE,
)
private val LINK_SCHEMES = listOf("http://", "https://", "mailto:", "tel:")
private const val NBSP = '\u00A0'
private const val CELL_SEPARATOR = " · "
/**
* Render [html] down to text with its inline formatting and links. Blocks and
* `<br>` become line breaks, list items get a bullet or number, table rows a
* line each with their cells side by side. Lenient about broken markup:
* whatever doesn't parse as a tag stays text.
*/
fun parseHtml(html: String): RichText = HtmlRenderer(html).render()
// Markup that sets its own lines. Without any, the text's newlines are the
// lines: "Agenda:\n1. Intro" with one <a href> in it stays on two lines.
private val lineTagRegex = Regex("""<(?:br|p|div|li|tr|h[1-6]|pre|blockquote|table)\b""", RegexOption.IGNORE_CASE)
private class HtmlRenderer(private val html: String) {
private val out = StringBuilder()
private val styles = mutableListOf<StyleRange>()
private val links = mutableListOf<LinkRange>()
private val openStyles = mutableListOf<Pair<String, Int>>()
private var openLink: Pair<String, Int>? = null
private val lists = mutableListOf<ListState>()
private val keepNewlines = !lineTagRegex.containsMatchIn(html)
private var preDepth = 0
private var rowStart = 0
private var cellNeedsSeparator = false
// Where the current list item's bullet ends, so a <p> inside the item
// doesn't push its text off the bullet's line.
private var markerEnd = -1
private class ListState(val ordered: Boolean, var count: Int = 0)
fun render(): RichText {
var i = 0
while (i < html.length) {
val lt = html.indexOf('<', i)
if (lt < 0) {
text(html.substring(i))
break
}
if (lt > i) text(html.substring(i, lt))
i = tag(lt)
}
return normalize()
}
/** Handle the markup at [lt]; returns where reading continues. */
private fun tag(lt: Int): Int {
if (html.startsWith("<!--", lt)) {
val end = html.indexOf("-->", lt + 4)
return if (end < 0) html.length else end + 3
}
if (html.startsWith("<!", lt) || html.startsWith("<?", lt)) {
val end = html.indexOf('>', lt)
return if (end < 0) html.length else end + 1
}
val closing = html.getOrNull(lt + 1) == '/'
val nameStart = if (closing) lt + 2 else lt + 1
if (html.getOrNull(nameStart)?.isLetter() != true) {
text("<")
return lt + 1
}
val end = tagEnd(lt)
if (end < 0) {
text("<")
return lt + 1
}
var nameEnd = nameStart
while (nameEnd < end && (html[nameEnd].isLetterOrDigit() || html[nameEnd] == ':')) nameEnd++
val name = html.substring(nameStart, nameEnd).lowercase()
val attrs = html.substring(nameEnd, end)
if (closing) {
close(name)
} else if (name in SKIPPED_TAGS && !attrs.trimEnd().endsWith("/")) {
// Never closed (an omitted </head> is valid): just ignore the tag.
val closeAt = html.indexOf("</$name", end, ignoreCase = true)
if (closeAt < 0) return end + 1
val closeEnd = html.indexOf('>', closeAt)
return if (closeEnd < 0) html.length else closeEnd + 1
} else {
open(name, attrs)
}
return end + 1
}
/** The `>` closing the tag at [lt], skipping any inside quoted attributes. */
private fun tagEnd(lt: Int): Int {
var quote: Char? = null
for (j in lt + 1 until html.length) {
val c = html[j]
when {
quote != null -> if (c == quote) quote = null
c == '"' || c == '\'' -> quote = c
c == '>' -> return j
c == '<' -> return -1
}
}
return -1
}
private fun open(name: String, attrs: String) {
when (name) {
"br" -> newline()
"li" -> {
markerEnd = -1
cellNeedsSeparator = false
lineBreak()
val list = lists.lastOrNull()
out.append("$NBSP$NBSP".repeat((lists.size - 1).coerceAtLeast(0)))
if (list?.ordered == true) {
list.count++
out.append("${list.count}. ")
} else {
out.append("• ")
}
markerEnd = out.length
}
"ul", "ol" -> {
cellNeedsSeparator = false
lineBreak()
val start = startRegex.find(attrs)?.groupValues?.get(1)?.toIntOrNull() ?: 1
lists += ListState(ordered = name == "ol", count = start - 1)
}
"tr" -> {
lineBreak()
rowStart = out.length
cellNeedsSeparator = false
}
"td", "th" -> {
// An empty spacer cell leaves nothing behind, not even its &nbsp;.
if (rowStart <= out.length && (rowStart until out.length).none { out[it].isContent() }) {
cut(rowStart)
} else {
cellNeedsSeparator = true
}
}
"a" -> {
closeLink()
hrefRegex.find(attrs)
?.groupValues?.drop(1)?.firstOrNull { it.isNotEmpty() }
?.let(::decodeEntities)
?.trim()
?.let { href ->
val scheme = LINK_SCHEMES.firstOrNull { href.startsWith(it, ignoreCase = true) }
?: return@let
// Intent filters match the scheme case-sensitively.
openLink = scheme + href.substring(scheme.length) to out.length
}
}
else -> if (name in BLOCK_TAGS) lineBreak()
}
if (name == "pre") preDepth++
STYLE_TAGS[name]?.let { openStyles += name to out.length }
}
private fun close(name: String) {
// A row's last cell, if empty, leaves a separator pending; it ends with the row.
if (name == "tr" || name == "table") cellNeedsSeparator = false
val index = openStyles.indexOfLast { it.first == name }
if (index >= 0) {
val (_, start) = openStyles.removeAt(index)
if (out.length > start) styles += StyleRange(start, out.length, STYLE_TAGS.getValue(name))
}
if (name == "pre") preDepth = (preDepth - 1).coerceAtLeast(0)
if (name == "li" || name == "ul" || name == "ol") {
if (out.length == markerEnd) cut(out.lastIndexOf("\n") + 1)
markerEnd = -1
}
when (name) {
"ul", "ol" -> {
lists.removeLastOrNull()
lineBreak()
}
"a" -> closeLink()
else -> if (name in BLOCK_TAGS) lineBreak()
}
}
private fun closeLink() {
val (url, start) = openLink ?: return
if (out.length > start) links += LinkRange(start, out.length, url)
openLink = null
}
private fun text(raw: String) {
val decoded = decodeEntities(raw)
if (preDepth > 0) {
out.append(decoded.replace("\r\n", "\n"))
return
}
for (c in decoded) {
if (c == '\n' && keepNewlines) {
newline()
} else if (c == ' ' || c == '\n' || c == '\t' || c == '\r' || c == '\u000C') {
if (out.isNotEmpty() && out.last() != ' ' && out.last() != '\n') out.append(' ')
} else {
if (cellNeedsSeparator && c.isContent()) separateCell()
out.append(c)
}
}
}
/** Put this cell's text beside the row's last, even after a block in that cell ended its line. */
private fun separateCell() {
cellNeedsSeparator = false
var end = out.length
while (end > rowStart && !out[end - 1].isContent()) end--
cut(end)
out.append(CELL_SEPARATOR)
openStyles.replaceAll { (name, start) -> name to if (start >= end) out.length else start }
openLink = openLink?.let { (url, start) -> url to if (start >= end) out.length else start }
}
private fun Char.isContent() = !isWhitespace() && this != NBSP
private fun newline() {
trimTrailingSpaces()
out.append('\n')
}
/** End the current line, unless it's already ended or holds just a bullet. */
private fun lineBreak() {
if (out.length == markerEnd) return
trimTrailingSpaces()
if (out.isNotEmpty() && out.last() != '\n') out.append('\n')
}
private fun trimTrailingSpaces() {
while (out.isNotEmpty() && out.last() == ' ') out.setLength(out.length - 1)
}
/** Drop the output from [length] on, with whatever formatting lay only there. */
private fun cut(length: Int) {
if (length >= out.length) return
out.setLength(length)
styles.replaceAll { it.copy(end = minOf(it.end, length)) }
styles.removeAll { it.end <= it.start }
links.replaceAll { it.copy(end = minOf(it.end, length)) }
links.removeAll { it.end <= it.start }
openStyles.replaceAll { (name, start) -> name to minOf(start, length) }
openLink = openLink?.let { (url, start) -> url to minOf(start, length) }
}
/**
* Tidy the rendered text: no-break spaces become plain, lines lose trailing
* blanks, runs of empty lines shrink to one and the ends are trimmed. Spans
* move with the text they cover.
*/
private fun normalize(): RichText {
closeLink()
for ((name, start) in openStyles) {
if (out.length > start) styles += StyleRange(start, out.length, STYLE_TAGS.getValue(name))
}
val src = out.toString().replace(NBSP, ' ')
val map = IntArray(src.length + 1)
val result = StringBuilder()
var i = 0
while (i < src.length) {
var lineEnd = src.indexOf('\n', i)
if (lineEnd < 0) lineEnd = src.length
var contentEnd = lineEnd
while (contentEnd > i && src[contentEnd - 1].isWhitespace()) contentEnd--
for (j in i until lineEnd) {
map[j] = result.length
if (j < contentEnd) result.append(src[j])
}
if (lineEnd < src.length) {
map[lineEnd] = result.length
if (!result.endsWith("\n\n")) result.append('\n')
}
i = lineEnd + 1
}
map[src.length] = result.length
val leading = result.length - result.trimStart().length
val text = result.trim().toString()
fun at(index: Int) = (map[index] - leading).coerceIn(0, text.length)
return RichText(
text = text,
styles = styles.mapNotNull { r -> StyleRange(at(r.start), at(r.end), r.style).takeIf { it.end > it.start } },
links = links.mapNotNull { r -> LinkRange(at(r.start), at(r.end), r.url).takeIf { it.end > it.start } },
)
}
}
private val startRegex = Regex("""(?:^|\s)start\s*=\s*["']?(-?\d{1,6})""", RegexOption.IGNORE_CASE)
private val hrefRegex = Regex("""(?:^|\s)href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", RegexOption.IGNORE_CASE)
// Latin-1 (&nbsp; … &yuml;) in code point order, then the common extras.
private val LATIN1_ENTITIES = ("nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr " +
"deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest " +
"Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml " +
"ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig " +
"agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml " +
"eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml").split(' ')
private val ENTITIES: Map<String, String> = LATIN1_ENTITIES.withIndex()
.associate { (i, name) -> name to (0xA0 + i).toChar().toString() } + mapOf(
"amp" to "&", "lt" to "<", "gt" to ">", "quot" to "\"", "apos" to "'",
"ndash" to "–", "mdash" to "—", "hellip" to "…", "bull" to "•",
"lsquo" to "‘", "rsquo" to "’", "ldquo" to "“", "rdquo" to "”", "sbquo" to "‚", "bdquo" to "„",
"euro" to "€", "trade" to "™", "OElig" to "Œ", "oelig" to "œ", "Scaron" to "Š", "scaron" to "š",
"zwnj" to "\u200C", "zwj" to "\u200D", "ensp" to "\u2002", "emsp" to "\u2003", "thinsp" to "\u2009",
)
// A named reference needs its ";": "?id=5&reg=eu" in a link is a query, not "®".
private val entityRegex = Regex("""&(?:(#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6});?|([a-zA-Z][a-zA-Z0-9]{1,31});)""")
/** Decode character references; an unknown name is left as written. */
internal fun decodeEntities(text: String): String {
if ('&' !in text) return text
return entityRegex.replace(text) { match ->
val name = match.groupValues[2]
if (name.isNotEmpty()) return@replace ENTITIES[name] ?: match.value
val body = match.groupValues[1]
val code = when {
body.startsWith("#x", ignoreCase = true) -> body.substring(2).toIntOrNull(16)
else -> body.substring(1).toIntOrNull()
}
if (code == null || code == 0 || !Character.isValidCodePoint(code)) "�" else String(Character.toChars(code))
}
}
@@ -67,10 +67,14 @@ import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
@@ -83,7 +87,9 @@ import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.InlineStyle
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.RichText
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
@@ -476,8 +482,9 @@ private fun EventDetailContent(
}
}
// Description (conditional). URLs and phone numbers are auto-linked.
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
// Description (conditional). HTML is rendered (#271); URLs and phone
// numbers are auto-linked.
state.description?.let { description ->
val descriptionLabel = stringResource(R.string.event_detail_description)
Spacer(Modifier.height(gap))
DetailCard(
@@ -486,7 +493,7 @@ private fun EventDetailContent(
// The gesture sits on the card so the icon and padding answer
// it too: every linkified URL owns the pointer over its own
// glyphs, which leaves the text itself a patchy target.
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField),
modifier = Modifier.copyOnLongPress(descriptionLabel, description.copyText(), copyField),
) {
Text(
text = linkify(description, state.phoneLinks.description, MaterialTheme.colorScheme.primary),
@@ -774,33 +781,74 @@ private fun linkify(
phoneLinks: List<PhoneLink>,
linkColor: Color,
textDecoration: TextDecoration? = null,
): AnnotatedString = linkify(RichText(text), phoneLinks, linkColor, textDecoration)
/**
* [linkify] for text rendered from HTML: its own formatting and `<a href>`
* links come along, and a URL or number already inside one isn't linked again.
*/
@Composable
private fun linkify(
text: RichText,
phoneLinks: List<PhoneLink>,
linkColor: Color,
textDecoration: TextDecoration? = null,
): AnnotatedString {
val context = LocalContext.current
return remember(text, phoneLinks, linkColor, textDecoration, context) {
val decoration = textDecoration
?.let { TextDecoration.combine(listOf(it, TextDecoration.Underline)) }
?: TextDecoration.Underline
val styles = TextLinkStyles(style = SpanStyle(color = linkColor, textDecoration = decoration))
// Decorations go on separately so overlapping ones add up: a span's
// textDecoration replaces whatever sits under it.
val styles = TextLinkStyles(style = SpanStyle(color = linkColor))
val decorations = mutableListOf<Triple<Int, Int, TextDecoration>>()
textDecoration?.let { decorations += Triple(0, text.text.length, it) }
for (range in text.styles) {
when (range.style) {
InlineStyle.Underline -> decorations += Triple(range.start, range.end, TextDecoration.Underline)
InlineStyle.Strikethrough -> decorations += Triple(range.start, range.end, TextDecoration.LineThrough)
else -> Unit
}
}
fun linked(start: Int, end: Int) = text.links.any { start < it.end && it.start < end }
buildAnnotatedString {
append(text)
for (link in findWebLinks(text)) {
addLink(
LinkAnnotation.Url(link.url, styles) { openWebLink(context, link.url) },
link.start,
link.end,
)
append(text.text)
for (range in text.styles) range.style.toSpanStyle()?.let { addStyle(it, range.start, range.end) }
fun link(url: String, start: Int, end: Int, onClick: () -> Unit) {
addLink(LinkAnnotation.Url(url, styles) { onClick() }, start, end)
decorations += Triple(start, end, TextDecoration.Underline)
}
for (link in text.links) {
if (link.url.startsWith("tel:")) {
link(link.url, link.start, link.end) { dial(context, Uri.decode(link.url.substring(4))) }
} else {
link(link.url, link.start, link.end) { openWebLink(context, link.url) }
}
}
for (link in findWebLinks(text.text)) {
if (linked(link.start, link.end)) continue
link(link.url, link.start, link.end) { openWebLink(context, link.url) }
}
for (link in phoneLinks) {
addLink(
LinkAnnotation.Url("tel:${link.number}", styles) { dial(context, link.number) },
link.start,
link.end,
)
if (linked(link.start, link.end)) continue
link("tel:${link.number}", link.start, link.end) { dial(context, link.number) }
}
val bounds = decorations.flatMap { listOf(it.first, it.second) }.distinct().sorted()
for ((start, end) in bounds.zipWithNext()) {
val covering = decorations.filter { it.first <= start && end <= it.second }.map { it.third }.distinct()
if (covering.isNotEmpty()) addStyle(SpanStyle(textDecoration = TextDecoration.combine(covering)), start, end)
}
}
}
}
private fun InlineStyle.toSpanStyle(): SpanStyle? = when (this) {
InlineStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold)
InlineStyle.Italic -> SpanStyle(fontStyle = FontStyle.Italic)
InlineStyle.Superscript -> SpanStyle(baselineShift = BaselineShift.Superscript, fontSize = 0.75.em)
InlineStyle.Subscript -> SpanStyle(baselineShift = BaselineShift.Subscript, fontSize = 0.75.em)
InlineStyle.Monospace -> SpanStyle(fontFamily = FontFamily.Monospace)
InlineStyle.Underline, InlineStyle.Strikethrough -> null
}
/**
* Format an event's time into a primary line (date, or "All day") and an
* optional secondary line (time range). Multi-day timed events collapse into a
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.detail
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RichText
/**
* UI state for the event-detail screen (spec S4).
@@ -23,6 +24,8 @@ sealed interface EventDetailUiState {
val isManaged: Boolean = false,
/** Phone numbers to link in the title, location and description. */
val phoneLinks: EventPhoneLinks = EventPhoneLinks(),
/** The description as drawn — HTML rendered (#271); null when there is none. */
val description: RichText? = null,
) : EventDetailUiState
}
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.descriptionRichText
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent
@@ -164,6 +165,9 @@ class EventDetailViewModel @Inject constructor(
}
val calendar = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId }
val description = corrected.description
?.let(::descriptionRichText)
?.takeIf { it.text.isNotBlank() }
EventDetailUiState.Success(
detail = corrected,
calendarName = calendar?.displayName,
@@ -174,8 +178,9 @@ class EventDetailViewModel @Inject constructor(
phoneLinks = findEventPhoneLinks(
title = corrected.instance.title,
location = corrected.instance.location,
description = corrected.description,
description = description?.text,
),
description = description,
)
} catch (e: CancellationException) {
throw e
@@ -308,6 +308,23 @@ class EventSearchTest {
assertThat(snippet.text).isEqualTo("erste Zeile zweite Zeile")
}
@Test
fun `an html description matches and snippets on its text, not its tags`() {
val html = "<div class=\"x\"><p>Raum&nbsp;<b>4.12</b></p></div>"
assertThat(search(candidate("Notiz", description = html), query = "div").isEmpty).isTrue()
val snippet = search(candidate("Notiz", description = html), query = "raum 4.12")
.allHits.single().descriptionSnippet
requireNotNull(snippet)
assertThat(snippet.text).isEqualTo("Raum 4.12")
}
@Test
fun `a link address in an html description is still searchable`() {
val html = "<p><a href=\"https://zoom.us/j/81234567890\">Join Zoom Meeting</a></p>"
assertThat(search(candidate("Call", description = html), query = "81234567890").allHits).hasSize(1)
}
@Test
fun `a blank query matches nothing`() {
assertThat(search(candidate("Test"), query = " ").isEmpty).isTrue()
@@ -0,0 +1,213 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class HtmlTextTest {
@Test
fun `prose with angle brackets is not html`() {
assertThat(looksLikeHtml("I <3 this, and a < b > c")).isFalse()
assertThat(looksLikeHtml("Teams Meeting <https://teams.microsoft.com/l/x>")).isFalse()
assertThat(looksLikeHtml("Bring <cake>")).isFalse()
}
@Test
fun `common tags are html`() {
assertThat(looksLikeHtml("Line one<br>Line two")).isTrue()
assertThat(looksLikeHtml("<p class=MsoNormal>Hi</p>")).isTrue()
assertThat(looksLikeHtml("See <a href=\"https://x.y\">here</a>")).isTrue()
}
@Test
fun `plain text passes through untouched`() {
val text = " Agenda:\n\n\n- one \n- two"
assertThat(descriptionRichText(text)).isEqualTo(RichText(text))
}
@Test
fun `blocks and breaks become lines and whitespace collapses`() {
val html = "<div>Hello\n world</div><div>Second<br>Third</div>"
assertThat(parseHtml(html).text).isEqualTo("Hello world\nSecond\nThird")
}
@Test
fun `head, styles, scripts and comments are dropped`() {
val html = """
<html><head><meta charset="utf-8"><style>p { color: red; }</style></head>
<body><!-- converted from text --><script>alert(1)</script><p>Body</p></body></html>
""".trimIndent()
assertThat(parseHtml(html).text).isEqualTo("Body")
}
@Test
fun `an nbsp paragraph keeps one blank line, more collapse`() {
val html = "<p>One</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>Two</p>"
assertThat(parseHtml(html).text).isEqualTo("One\n\nTwo")
}
@Test
fun `entities are decoded`() {
assertThat(parseHtml("<p>Fish &amp; chips &lt;3 &#8364;5 &#x2013; caf&eacute; &bogus;</p>").text)
.isEqualTo("Fish & chips <3 €5 – café &bogus;")
}
@Test
fun `inline styles cover their text`() {
val rich = parseHtml("<p>A <b>bold</b> and <i>italic</i> word</p>")
assertThat(rich.text).isEqualTo("A bold and italic word")
assertThat(rich.styles).containsExactly(
StyleRange(2, 6, InlineStyle.Bold),
StyleRange(11, 17, InlineStyle.Italic),
)
}
@Test
fun `fonts and colours are not kept`() {
val rich = parseHtml("<span style=\"color:#000\"><font face=Calibri size=2>Text</font></span>")
assertThat(rich).isEqualTo(RichText("Text"))
}
@Test
fun `links keep their href, unsafe schemes are dropped`() {
val rich = parseHtml(
"<a href=\"https://teams.microsoft.com/l/x?a=1&amp;b=2\">Join</a> " +
"<a href='tel:+4930123,,456#'>Dial</a> <a href=\"javascript:alert(1)\">Bad</a>",
)
assertThat(rich.text).isEqualTo("Join Dial Bad")
assertThat(rich.links).containsExactly(
LinkRange(0, 4, "https://teams.microsoft.com/l/x?a=1&b=2"),
LinkRange(5, 9, "tel:+4930123,,456#"),
)
}
@Test
fun `lists get bullets and numbers`() {
val html = "<p>Agenda</p><ul><li>One</li><li>Two<ol><li>Sub</li></ol></li></ul><ol><li>A</li><li>B</li></ol>"
assertThat(parseHtml(html).text).isEqualTo("Agenda\n• One\n• Two\n 1. Sub\n1. A\n2. B")
}
@Test
fun `table rows become lines with cells side by side`() {
val html = "<table><tr><th>Time</th><th>Topic</th></tr><tr><td>9:00</td><td>Intro</td></tr>" +
"<tr><td>&nbsp;</td><td>Spacer row</td></tr></table>"
assertThat(parseHtml(html).text).isEqualTo("Time · Topic\n9:00 · Intro\nSpacer row")
}
@Test
fun `formatting on a dropped spacer cell doesn't leak onto the next`() {
val rich = parseHtml("<table><tr><td><b>&nbsp;</b></td><td>Plain</td></tr></table>")
assertThat(rich).isEqualTo(RichText("Plain"))
}
@Test
fun `pre keeps its whitespace`() {
assertThat(parseHtml("<pre>a b\n c</pre>").text).isEqualTo("a b\n c")
}
@Test
fun `spans shift with trimmed whitespace`() {
val rich = parseHtml("<p>&nbsp;</p><p> <b>Bold</b> </p>")
assertThat(rich.text).isEqualTo("Bold")
assertThat(rich.styles).containsExactly(StyleRange(0, 4, InlineStyle.Bold))
}
@Test
fun `broken markup stays text`() {
assertThat(parseHtml("<p>x < y and <b>z</p>").text).isEqualTo("x < y and z")
assertThat(parseHtml("<p>open <a href=\"https://a.b\">tail").links)
.containsExactly(LinkRange(5, 9, "https://a.b"))
}
@Test
fun `a paragraph inside a list item stays on the bullet's line`() {
assertThat(parseHtml("<ul><li><p>One</p></li><li><div>Two</div></li></ul>").text)
.isEqualTo("• One\n• Two")
}
@Test
fun `newlines are the lines when the markup sets none`() {
val rich = parseHtml("Agenda:\n1. Intro\n2. <a href=\"https://x.y\">Q&amp;A</a>")
assertThat(rich.text).isEqualTo("Agenda:\n1. Intro\n2. Q&A")
}
@Test
fun `newlines in markup with its own lines are just spaces`() {
assertThat(parseHtml("<p>wrapped\nsource line</p>").text).isEqualTo("wrapped source line")
}
@Test
fun `a link scheme is lower-cased`() {
assertThat(parseHtml("<a href=\"HTTPS://Teams.example/Join\">Join</a>").links.single().url)
.isEqualTo("https://Teams.example/Join")
}
@Test
fun `a query parameter is not read as an entity`() {
assertThat(parseHtml("<a href=\"https://x.example/?id=5&reg=eu&times=3\">x</a>").links.single().url)
.isEqualTo("https://x.example/?id=5&reg=eu&times=3")
}
@Test
fun `outlook cells with paragraphs still sit side by side`() {
val html = "<table><tr><td><p class=MsoNormal>9:00</p></td><td><p class=MsoNormal><b>Intro</b></p></td></tr>" +
"<tr><td><p>10:00</p></td><td><p>Talk</p></td></tr></table>"
val rich = parseHtml(html)
assertThat(rich.text).isEqualTo("9:00 · Intro\n10:00 · Talk")
assertThat(rich.styles).containsExactly(StyleRange(7, 12, InlineStyle.Bold))
}
@Test
fun `empty cells anywhere in a row leave no separator`() {
val html = "<table><tr><td>Time</td><td>&nbsp;</td><td>Topic</td><td></td></tr></table>"
assertThat(parseHtml(html).text).isEqualTo("Time · Topic")
}
@Test
fun `a trailing empty cell doesn't join the text after the table`() {
val html = "<table><tr><td>10:00</td><td>Demo</td><td></td></tr></table><p>After</p>"
assertThat(parseHtml(html).text).isEqualTo("10:00 · Demo\nAfter")
}
@Test
fun `an unclosed head or style doesn't swallow the description`() {
assertThat(parseHtml("<html><head><meta charset=utf-8><body><p>Agenda</p>").text).isEqualTo("Agenda")
assertThat(parseHtml("<style>p{}<p>Agenda</p>").text).contains("Agenda")
}
@Test
fun `a list in a later cell keeps its bullet clean`() {
val html = "<table><tr><td>Agenda</td><td><ul><li>Intro</li></ul></td></tr></table>"
assertThat(parseHtml(html).text).isEqualTo("Agenda\n• Intro")
}
@Test
fun `an empty trailing item doesn't pull the next paragraph into the list`() {
assertThat(parseHtml("<ul><li>A</li><li></li></ul><p>Next steps</p>").text).isEqualTo("• A\nNext steps")
}
@Test
fun `an ordered list honours its start`() {
assertThat(parseHtml("<ol start=\"3\"><li>C</li><li>D</li></ol>").text).isEqualTo("3. C\n4. D")
}
@Test
fun `every tag the renderer knows counts as html`() {
assertThat(looksLikeHtml("Run <code>make deploy</code>")).isTrue()
assertThat(looksLikeHtml("Price <del>50</del> 40")).isTrue()
}
@Test
fun `latin-1 entities decode`() {
assertThat(decodeEntities("Le&oacute;n, &iacute;ndice, &Aring;s, &yuml;")).isEqualTo("León, índice, Ås, ÿ")
}
@Test
fun `copied text keeps the addresses of its links`() {
val rich = parseHtml(
"<p><a href=\"https://teams.example/join\">Join</a> or <a href=\"https://x.y\">https://x.y</a>" +
" <a href=\"mailto:a@b.c\">Mail</a></p>",
)
assertThat(rich.copyText()).isEqualTo("Join (https://teams.example/join) or https://x.y Mail (a@b.c)")
}
}