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&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:
@@ -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 .
|
||||
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<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®=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 <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> </p><p> </p><p> </p><p>Two</p>"
|
||||
assertThat(parseHtml(html).text).isEqualTo("One\n\nTwo")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entities are decoded`() {
|
||||
assertThat(parseHtml("<p>Fish & chips <3 €5 – café &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&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> </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> </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> </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&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®=eu×=3\">x</a>").links.single().url)
|
||||
.isEqualTo("https://x.example/?id=5®=eu×=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> </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ón, índice, Ås, ÿ")).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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user