diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a94e35..efadd073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt index 963fd82d..748a0e4b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/HtmlText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/HtmlText.kt new file mode 100644 index 00000000..87a44ce6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/HtmlText.kt @@ -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 ``, 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 = emptyList(), + val links: List = 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 = """]*)?/?>"""), + 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 + * `
` 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
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() + private val links = mutableListOf() + private val openStyles = mutableListOf>() + private var openLink: Pair? = null + private val lists = mutableListOf() + 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

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 + 4) + return if (end < 0) html.length else end + 3 + } + if (html.startsWith("', 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 is valid): just ignore the tag. + val closeAt = 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  . + 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 (  … ÿ) 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 = 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®=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)) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 84ec4375..952f70f8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -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, linkColor: Color, textDecoration: TextDecoration? = null, +): AnnotatedString = linkify(RichText(text), phoneLinks, linkColor, textDecoration) + +/** + * [linkify] for text rendered from HTML: its own formatting and `` + * links come along, and a URL or number already inside one isn't linked again. + */ +@Composable +private fun linkify( + text: RichText, + phoneLinks: List, + 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>() + 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt index f4a75739..18c278cf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index 8dcace17..72ad8b45 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -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 diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchTest.kt index 0ebae1fc..a6e494ee 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchTest.kt @@ -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 = "

Raum 4.12

" + 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 = "

Join Zoom Meeting

" + 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() diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/HtmlTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/HtmlTextTest.kt new file mode 100644 index 00000000..8c472d3e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/HtmlTextTest.kt @@ -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 ")).isFalse() + assertThat(looksLikeHtml("Bring ")).isFalse() + } + + @Test + fun `common tags are html`() { + assertThat(looksLikeHtml("Line one
Line two")).isTrue() + assertThat(looksLikeHtml("

Hi

")).isTrue() + assertThat(looksLikeHtml("See here")).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 = "
Hello\n world
Second
Third
" + assertThat(parseHtml(html).text).isEqualTo("Hello world\nSecond\nThird") + } + + @Test + fun `head, styles, scripts and comments are dropped`() { + val html = """ + +

Body

+ """.trimIndent() + assertThat(parseHtml(html).text).isEqualTo("Body") + } + + @Test + fun `an nbsp paragraph keeps one blank line, more collapse`() { + val html = "

One

 

 

 

Two

" + assertThat(parseHtml(html).text).isEqualTo("One\n\nTwo") + } + + @Test + fun `entities are decoded`() { + assertThat(parseHtml("

Fish & chips <3 €5 – café &bogus;

").text) + .isEqualTo("Fish & chips <3 €5 – café &bogus;") + } + + @Test + fun `inline styles cover their text`() { + val rich = parseHtml("

A bold and italic word

") + 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("Text") + assertThat(rich).isEqualTo(RichText("Text")) + } + + @Test + fun `links keep their href, unsafe schemes are dropped`() { + val rich = parseHtml( + "Join " + + "Dial Bad", + ) + 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 = "

Agenda

  1. A
  2. B
" + 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 = "" + + "
TimeTopic
9:00Intro
 Spacer row
" + 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("
 Plain
") + assertThat(rich).isEqualTo(RichText("Plain")) + } + + @Test + fun `pre keeps its whitespace`() { + assertThat(parseHtml("
a  b\n  c
").text).isEqualTo("a b\n c") + } + + @Test + fun `spans shift with trimmed whitespace`() { + val rich = parseHtml("

 

Bold

") + assertThat(rich.text).isEqualTo("Bold") + assertThat(rich.styles).containsExactly(StyleRange(0, 4, InlineStyle.Bold)) + } + + @Test + fun `broken markup stays text`() { + assertThat(parseHtml("

x < y and z

").text).isEqualTo("x < y and z") + assertThat(parseHtml("

open 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("

").text) + .isEqualTo("• One\n• Two") + } + + @Test + fun `newlines are the lines when the markup sets none`() { + val rich = parseHtml("Agenda:\n1. Intro\n2. Q&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("

wrapped\nsource line

").text).isEqualTo("wrapped source line") + } + + @Test + fun `a link scheme is lower-cased`() { + assertThat(parseHtml("Join").links.single().url) + .isEqualTo("https://Teams.example/Join") + } + + @Test + fun `a query parameter is not read as an entity`() { + assertThat(parseHtml("x").links.single().url) + .isEqualTo("https://x.example/?id=5®=eu×=3") + } + + @Test + fun `outlook cells with paragraphs still sit side by side`() { + val html = "" + + "

9:00

Intro

10:00

Talk

" + 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 = "
Time Topic
" + assertThat(parseHtml(html).text).isEqualTo("Time · Topic") + } + + @Test + fun `a trailing empty cell doesn't join the text after the table`() { + val html = "
10:00Demo

After

" + assertThat(parseHtml(html).text).isEqualTo("10:00 · Demo\nAfter") + } + + @Test + fun `an unclosed head or style doesn't swallow the description`() { + assertThat(parseHtml("

Agenda

").text).isEqualTo("Agenda") + assertThat(parseHtml("