Compare commits
3 Commits
11adfd66bc
...
a9c5bc1038
| Author | SHA1 | Date | |
|---|---|---|---|
| a9c5bc1038 | |||
| e8aaa7d333 | |||
| f7629f0d7f |
@@ -27,8 +27,10 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
|||||||
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventSearchRanker
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
@@ -62,12 +64,14 @@ interface CalendarDataSource {
|
|||||||
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master/one-off events whose title, description or location contains
|
* Master/one-off events that may match [query], across all calendars: every
|
||||||
* [query] (case-insensitive), across all calendars, newest first. Reads the
|
* whitespace-separated token has to appear in the title, description or
|
||||||
* Events table directly so the search is unbounded in time; exception rows
|
* location. Reads the Events table directly so the search is unbounded in
|
||||||
* are excluded (see [SearchProjection]). [query] is assumed non-blank.
|
* time; exception rows are excluded (see [SearchProjection]). A deliberate
|
||||||
|
* superset — [EventSearchRanker] makes the final call and orders the hits.
|
||||||
|
* [query] is assumed non-blank.
|
||||||
*/
|
*/
|
||||||
fun searchEvents(query: String): List<EventInstance>
|
fun searchEvents(query: String): List<SearchCandidate>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette the calendar's account publishes
|
* The event-colour palette the calendar's account publishes
|
||||||
@@ -585,20 +589,19 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchEvents(query: String): List<EventInstance> {
|
override fun searchEvents(query: String): List<SearchCandidate> {
|
||||||
ensureObserversRegistered()
|
ensureObserversRegistered()
|
||||||
val trimmed = query.trim()
|
val tokens = EventSearchRanker.tokenize(query).take(MAX_SEARCH_TOKENS)
|
||||||
if (trimmed.isEmpty()) return emptyList()
|
if (tokens.isEmpty()) return emptyList()
|
||||||
// Escape the SQL LIKE wildcards so a literal % or _ in the query matches
|
// Every token has to appear somewhere, so the groups are ANDed. This is
|
||||||
// itself instead of acting as a wildcard.
|
// only a pre-filter — EventSearchRanker re-checks each token with proper
|
||||||
val escaped = trimmed
|
// case folding, and re-checks the tokens dropped by the cap above.
|
||||||
.replace("\\", "\\\\")
|
val match = tokens.joinToString(" AND ") {
|
||||||
.replace("%", "\\%")
|
"(${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
||||||
.replace("_", "\\_")
|
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
||||||
val like = "%$escaped%"
|
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\')"
|
||||||
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
}
|
||||||
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
val args = tokens.flatMap { token -> List(3) { likePattern(token) } }.toTypedArray()
|
||||||
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
|
||||||
val selection = "($match) AND " +
|
val selection = "($match) AND " +
|
||||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
||||||
@@ -606,17 +609,18 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Events.CONTENT_URI,
|
CalendarContract.Events.CONTENT_URI,
|
||||||
SearchProjection.COLUMNS,
|
SearchProjection.COLUMNS,
|
||||||
selection,
|
selection,
|
||||||
arrayOf(like, like, like),
|
args,
|
||||||
CalendarContract.Events.DTSTART + " DESC",
|
CalendarContract.Events.DTSTART + " DESC",
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
val reader = CursorColumnReader(c)
|
val reader = CursorColumnReader(c)
|
||||||
val out = ArrayList<EventInstance>(c.count)
|
val out = ArrayList<SearchCandidate>(c.count)
|
||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) {
|
||||||
|
val description = reader.getString(SearchProjection.IDX_DESCRIPTION)
|
||||||
val base = reader.toSearchResult() ?: continue
|
val base = reader.toSearchResult() ?: continue
|
||||||
// A recurring master's DTSTART is the series start; show its
|
// A recurring master's DTSTART is the series start; show its
|
||||||
// nearest occurrence instead so the date is the one the user
|
// nearest occurrence instead so the date is the one the user
|
||||||
// actually cares about (and sorting reflects it).
|
// actually cares about (and sorting reflects it).
|
||||||
out += if (base.isRecurring) {
|
val event = if (base.isRecurring) {
|
||||||
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
||||||
base.copy(
|
base.copy(
|
||||||
start = begin.toKotlinInstantFromEpochMillis(),
|
start = begin.toKotlinInstantFromEpochMillis(),
|
||||||
@@ -626,11 +630,32 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
base
|
base
|
||||||
}
|
}
|
||||||
|
out += SearchCandidate(event = event, description = description)
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One token as a `LIKE` pattern. SQLite folds case for ASCII only, so a
|
||||||
|
* non-ASCII cased letter is substituted with the single-character wildcard
|
||||||
|
* `_` — "ärzte" queries as `%_rzte%` and so still reaches "Ärzte". That
|
||||||
|
* matches more than it should on purpose; [EventSearchRanker] does the real
|
||||||
|
* comparison. Uncased scripts stay literal, keeping the filter selective.
|
||||||
|
*/
|
||||||
|
private fun likePattern(token: String): String {
|
||||||
|
val sb = StringBuilder("%")
|
||||||
|
for (c in token) {
|
||||||
|
when {
|
||||||
|
c.code > 127 && (c.isUpperCase() || c.isLowerCase()) -> sb.append('_')
|
||||||
|
// A literal wildcard from the query matches itself.
|
||||||
|
c == '%' || c == '_' || c == '\\' -> sb.append('\\').append(c)
|
||||||
|
else -> sb.append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.append('%').toString()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The occurrence of [eventId] nearest to now: the soonest upcoming one
|
* The occurrence of [eventId] nearest to now: the soonest upcoming one
|
||||||
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
|
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
|
||||||
@@ -1591,5 +1616,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
* next fires beyond it falls back to its series-start date.
|
* next fires beyond it falls back to its series-start date.
|
||||||
*/
|
*/
|
||||||
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
|
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens the SQL pre-filter is built from. Bounds the statement for a
|
||||||
|
* pasted paragraph; the ranker still requires every token, so a capped
|
||||||
|
* search returns fewer rows to check, never more hits.
|
||||||
|
*/
|
||||||
|
const val MAX_SEARCH_TOKENS = 8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
@@ -17,11 +18,12 @@ interface CalendarRepository {
|
|||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Events whose title, description or location contains [query], with hidden
|
* Candidate matches for [query] with hidden calendars removed; empty when
|
||||||
* calendars removed and newest first. Empty when [query] is blank. Searches
|
* [query] is blank. Searches the whole history/future (see
|
||||||
* the whole history/future (see [CalendarDataSource.searchEvents]).
|
* [CalendarDataSource.searchEvents]) and leaves ranking to
|
||||||
|
* [de.jeanlucmakiola.calendula.domain.EventSearchRanker].
|
||||||
*/
|
*/
|
||||||
suspend fun searchEvents(query: String): List<EventInstance>
|
suspend fun searchEvents(query: String): List<SearchCandidate>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette a calendar's account publishes; empty when it
|
* The event-colour palette a calendar's account publishes; empty when it
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -162,13 +163,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
?: throw NoSuchEventException(eventId)
|
?: throw NoSuchEventException(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
|
||||||
if (query.isBlank()) return@withContext emptyList()
|
if (query.isBlank()) return@withContext emptyList()
|
||||||
val excluded = prefs.hiddenCalendarIds.first() +
|
val excluded = prefs.hiddenCalendarIds.first() +
|
||||||
prefs.pendingDisabledCalendarIds.first() +
|
prefs.pendingDisabledCalendarIds.first() +
|
||||||
invisibleCalendarIds()
|
invisibleCalendarIds()
|
||||||
dataSource.searchEvents(query)
|
dataSource.searchEvents(query)
|
||||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
|
|||||||
@@ -180,6 +180,9 @@ internal object SearchProjection {
|
|||||||
// display its nearest occurrence, not the series-start DTSTART.
|
// display its nearest occurrence, not the series-start DTSTART.
|
||||||
CalendarContract.Events.RRULE,
|
CalendarContract.Events.RRULE,
|
||||||
CalendarContract.Events.RDATE,
|
CalendarContract.Events.RDATE,
|
||||||
|
// Ranked and excerpted, not just filtered on: a description-only hit has
|
||||||
|
// to be able to show what it matched.
|
||||||
|
CalendarContract.Events.DESCRIPTION,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_ID = 0
|
const val IDX_ID = 0
|
||||||
@@ -194,6 +197,7 @@ internal object SearchProjection {
|
|||||||
const val IDX_LOCATION = 9
|
const val IDX_LOCATION = 9
|
||||||
const val IDX_RRULE = 10
|
const val IDX_RRULE = 10
|
||||||
const val IDX_RDATE = 11
|
const val IDX_RDATE = 11
|
||||||
|
const val IDX_DESCRIPTION = 12
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** An event with the free text the search may have matched it on. */
|
||||||
|
data class SearchCandidate(
|
||||||
|
val event: EventInstance,
|
||||||
|
val description: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A matched run inside one of a hit's texts, for highlighting. */
|
||||||
|
data class MatchSpan(val start: Int, val end: Int)
|
||||||
|
|
||||||
|
/** The part of a description a token matched, elided to what fits a row. */
|
||||||
|
data class DescriptionSnippet(
|
||||||
|
val text: String,
|
||||||
|
val spans: List<MatchSpan>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One ranked search result: the event, plus where the query matched so the row
|
||||||
|
* can show why it is in the list.
|
||||||
|
*/
|
||||||
|
data class SearchHit(
|
||||||
|
val event: EventInstance,
|
||||||
|
val titleSpans: List<MatchSpan> = emptyList(),
|
||||||
|
val locationSpans: List<MatchSpan> = emptyList(),
|
||||||
|
val descriptionSnippet: DescriptionSnippet? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turning a typed query into ranked hits.
|
||||||
|
*
|
||||||
|
* Matching is token-wise: the query splits on whitespace and an event has to
|
||||||
|
* satisfy *every* token, each in any of title / location / description. Case is
|
||||||
|
* folded through Kotlin's [String.indexOf] rather than SQL's `LIKE`, which folds
|
||||||
|
* ASCII only — "ärzte" has to find "Ärzte".
|
||||||
|
*
|
||||||
|
* Ranking answers the complaint behind #80's follow-up: a description hit used
|
||||||
|
* to outrank a title hit purely by being sooner. A token scores by the field it
|
||||||
|
* matched ([FIELD_TITLE] > [FIELD_LOCATION] > [FIELD_DESCRIPTION]) and by how it
|
||||||
|
* sat in that field (whole-field start > word start > mid-word), and an event
|
||||||
|
* ranks by the *weakest* field the query needed — one token findable only in the
|
||||||
|
* description drags the whole event below the title matches, however soon it is.
|
||||||
|
*/
|
||||||
|
object EventSearchRanker {
|
||||||
|
|
||||||
|
private const val FIELD_TITLE = 3
|
||||||
|
private const val FIELD_LOCATION = 2
|
||||||
|
private const val FIELD_DESCRIPTION = 1
|
||||||
|
|
||||||
|
private const val KIND_MID_WORD = 1
|
||||||
|
private const val KIND_WORD_START = 2
|
||||||
|
private const val KIND_FIELD_START = 3
|
||||||
|
|
||||||
|
/** Characters of description kept around the match in a row's snippet. */
|
||||||
|
private const val SNIPPET_LENGTH = 96
|
||||||
|
|
||||||
|
/** Characters of lead-in shown before the match, when there is room. */
|
||||||
|
private const val SNIPPET_LEAD = 24
|
||||||
|
|
||||||
|
private val WHITESPACE = Regex("\\s+")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The query as match tokens: whitespace-separated, blanks dropped. Shared
|
||||||
|
* with the data layer so its SQL pre-filter tokenises identically.
|
||||||
|
*/
|
||||||
|
fun tokenize(query: String): List<String> =
|
||||||
|
query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rank [candidates] against [query], dropping any that don't carry every
|
||||||
|
* token. Ties fall back to the previous ordering — soonest upcoming (and
|
||||||
|
* ongoing) first, then the most recent past — measured against [now].
|
||||||
|
*/
|
||||||
|
fun rank(
|
||||||
|
candidates: List<SearchCandidate>,
|
||||||
|
query: String,
|
||||||
|
now: Instant,
|
||||||
|
): List<SearchHit> {
|
||||||
|
val tokens = tokenize(query)
|
||||||
|
if (tokens.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val scored = candidates.mapNotNull { candidate -> score(candidate, tokens) }
|
||||||
|
return scored
|
||||||
|
.sortedWith(
|
||||||
|
compareByDescending<Scored> { it.weakest }
|
||||||
|
.thenByDescending { it.total }
|
||||||
|
.thenBy { if (it.hit.event.end >= now) 0 else 1 }
|
||||||
|
.thenComparator { a, b ->
|
||||||
|
val aStart = a.hit.event.start
|
||||||
|
val bStart = b.hit.event.start
|
||||||
|
// Same side of now by the key above: upcoming reads
|
||||||
|
// forwards, the past backwards from today.
|
||||||
|
if (a.hit.event.end >= now) {
|
||||||
|
aStart.compareTo(bStart)
|
||||||
|
} else {
|
||||||
|
bStart.compareTo(aStart)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map { it.hit }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun score(candidate: SearchCandidate, tokens: List<String>): Scored? {
|
||||||
|
val title = candidate.event.title
|
||||||
|
val location = candidate.event.location?.takeIf { it.isNotBlank() }
|
||||||
|
// Collapsed once so a multi-line description matches and snippets the
|
||||||
|
// same way it will be drawn.
|
||||||
|
val description = candidate.description
|
||||||
|
?.replace(WHITESPACE, " ")
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
var weakest = Int.MAX_VALUE
|
||||||
|
var total = 0
|
||||||
|
for (token in tokens) {
|
||||||
|
val best = bestScore(token, title, location, description) ?: return null
|
||||||
|
weakest = minOf(weakest, best)
|
||||||
|
total += best
|
||||||
|
}
|
||||||
|
|
||||||
|
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
|
||||||
|
return Scored(
|
||||||
|
hit = SearchHit(
|
||||||
|
event = candidate.event,
|
||||||
|
titleSpans = spansIn(title, tokens),
|
||||||
|
locationSpans = location?.let { spansIn(it, tokens) }.orEmpty(),
|
||||||
|
descriptionSnippet = description
|
||||||
|
?.takeIf { descriptionSpans.isNotEmpty() }
|
||||||
|
?.let { snippet(it, descriptionSpans) },
|
||||||
|
),
|
||||||
|
weakest = weakest,
|
||||||
|
total = total,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The best a single token does across the fields, or null if it is absent. */
|
||||||
|
private fun bestScore(
|
||||||
|
token: String,
|
||||||
|
title: String,
|
||||||
|
location: String?,
|
||||||
|
description: String?,
|
||||||
|
): Int? {
|
||||||
|
val scores = listOfNotNull(
|
||||||
|
kindIn(title, token)?.let { FIELD_TITLE * 10 + it },
|
||||||
|
location?.let { kindIn(it, token) }?.let { FIELD_LOCATION * 10 + it },
|
||||||
|
description?.let { kindIn(it, token) }?.let { FIELD_DESCRIPTION * 10 + it },
|
||||||
|
)
|
||||||
|
return scores.maxOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How well [token] sits in [text]: at its very start, at the start of a word,
|
||||||
|
* or inside one. Mid-word still counts — German compounds mean "termin" has
|
||||||
|
* to keep finding "Zahnarzttermin" — it just ranks last.
|
||||||
|
*/
|
||||||
|
private fun kindIn(text: String, token: String): Int? {
|
||||||
|
var best: Int? = null
|
||||||
|
var index = text.indexOf(token, startIndex = 0, ignoreCase = true)
|
||||||
|
while (index >= 0) {
|
||||||
|
val kind = when {
|
||||||
|
index == 0 -> KIND_FIELD_START
|
||||||
|
!text[index - 1].isLetterOrDigit() -> KIND_WORD_START
|
||||||
|
else -> KIND_MID_WORD
|
||||||
|
}
|
||||||
|
if (kind == KIND_FIELD_START) return KIND_FIELD_START
|
||||||
|
if (best == null || kind > best) best = kind
|
||||||
|
index = text.indexOf(token, startIndex = index + 1, ignoreCase = true)
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every occurrence of every token in [text], merged where they overlap. */
|
||||||
|
private fun spansIn(text: String, tokens: List<String>): List<MatchSpan> {
|
||||||
|
val raw = mutableListOf<MatchSpan>()
|
||||||
|
for (token in tokens) {
|
||||||
|
var index = text.indexOf(token, startIndex = 0, ignoreCase = true)
|
||||||
|
while (index >= 0) {
|
||||||
|
raw += MatchSpan(index, index + token.length)
|
||||||
|
index = text.indexOf(token, startIndex = index + 1, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (raw.isEmpty()) return emptyList()
|
||||||
|
val sorted = raw.sortedBy { it.start }
|
||||||
|
val merged = mutableListOf(sorted.first())
|
||||||
|
for (span in sorted.drop(1)) {
|
||||||
|
val last = merged.last()
|
||||||
|
if (span.start <= last.end) {
|
||||||
|
merged[merged.lastIndex] = MatchSpan(last.start, maxOf(last.end, span.end))
|
||||||
|
} else {
|
||||||
|
merged += span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A window of [description] around its first match, elided at both ends, with
|
||||||
|
* the spans rebased onto the window. Anything that falls outside is dropped —
|
||||||
|
* the row shows one excerpt, not the whole note.
|
||||||
|
*/
|
||||||
|
private fun snippet(description: String, spans: List<MatchSpan>): DescriptionSnippet {
|
||||||
|
if (description.length <= SNIPPET_LENGTH) {
|
||||||
|
return DescriptionSnippet(description, spans)
|
||||||
|
}
|
||||||
|
val first = spans.first().start
|
||||||
|
val start = (first - SNIPPET_LEAD).coerceIn(0, (description.length - SNIPPET_LENGTH))
|
||||||
|
val end = (start + SNIPPET_LENGTH).coerceAtMost(description.length)
|
||||||
|
val prefix = if (start > 0) "…" else ""
|
||||||
|
val suffix = if (end < description.length) "…" else ""
|
||||||
|
val text = prefix + description.substring(start, end) + suffix
|
||||||
|
val shift = prefix.length - start
|
||||||
|
val rebased = spans
|
||||||
|
.filter { it.start >= start && it.end <= end }
|
||||||
|
.map { MatchSpan(it.start + shift, it.end + shift) }
|
||||||
|
return DescriptionSnippet(text, rebased)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Scored(val hit: SearchHit, val weakest: Int, val total: Int)
|
||||||
|
}
|
||||||
@@ -56,11 +56,16 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -70,7 +75,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.MatchSpan
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
@@ -392,7 +399,7 @@ private fun SearchResults(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onToggle: (Long) -> Unit,
|
onToggle: (Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
val events = results.events
|
val hits = results.hits
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
// No horizontal inset here: GroupedRow already insets itself, and adding
|
// No horizontal inset here: GroupedRow already insets itself, and adding
|
||||||
@@ -400,13 +407,14 @@ private fun SearchResults(
|
|||||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = events,
|
items = hits,
|
||||||
key = { _, event -> event.eventId },
|
key = { _, hit -> hit.event.eventId },
|
||||||
) { index, event ->
|
) { index, hit ->
|
||||||
|
val event = hit.event
|
||||||
val deletable = results.isDeletable(event)
|
val deletable = results.isDeletable(event)
|
||||||
SearchResultRow(
|
SearchResultRow(
|
||||||
event = event,
|
hit = hit,
|
||||||
position = positionOf(index, events.size),
|
position = positionOf(index, hits.size),
|
||||||
modifier = animateItemMotion(),
|
modifier = animateItemMotion(),
|
||||||
selected = event.eventId in selection,
|
selected = event.eventId in selection,
|
||||||
// A read-only calendar's row can't join a batch, so in selection
|
// A read-only calendar's row can't join a batch, so in selection
|
||||||
@@ -426,7 +434,7 @@ private fun SearchResults(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchResultRow(
|
private fun SearchResultRow(
|
||||||
event: EventInstance,
|
hit: SearchHit,
|
||||||
position: Position,
|
position: Position,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
selected: Boolean = false,
|
selected: Boolean = false,
|
||||||
@@ -435,12 +443,19 @@ private fun SearchResultRow(
|
|||||||
onClick: (() -> Unit)?,
|
onClick: (() -> Unit)?,
|
||||||
onLongClick: (() -> Unit)? = null,
|
onLongClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
val event = hit.event
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
|
// On a picked row the headline is already recoloured for the secondary
|
||||||
|
// container, so the match is carried by weight alone there.
|
||||||
|
val highlight = SpanStyle(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (selected) Color.Unspecified else MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
title = event.title,
|
title = marked(event.title, hit.titleSpans, highlight),
|
||||||
summary = searchSummary(event),
|
summary = searchSummary(hit, highlight),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
selected = selected,
|
selected = selected,
|
||||||
@@ -516,9 +531,26 @@ private fun DeleteOutcomeChip(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
|
/** [text] with every matched run emphasised. */
|
||||||
|
private fun marked(text: String, spans: List<MatchSpan>, style: SpanStyle): AnnotatedString =
|
||||||
|
if (spans.isEmpty()) {
|
||||||
|
AnnotatedString(text)
|
||||||
|
} else {
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(text)
|
||||||
|
spans.forEach { addStyle(style, it.start, it.end) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then
|
||||||
|
* location, with a second line excerpting the description when that is where the
|
||||||
|
* query matched. Without it a description-only hit reads as though it arrived
|
||||||
|
* from nowhere.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun searchSummary(event: EventInstance): String {
|
private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString {
|
||||||
|
val event = hit.event
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val zone = remember { ZoneId.systemDefault() }
|
val zone = remember { ZoneId.systemDefault() }
|
||||||
val start = remember(event.start, zone) {
|
val start = remember(event.start, zone) {
|
||||||
@@ -537,8 +569,23 @@ private fun searchSummary(event: EventInstance): String {
|
|||||||
} else {
|
} else {
|
||||||
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||||
}
|
}
|
||||||
val base = "$dateText · $timeText"
|
return buildAnnotatedString {
|
||||||
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
append("$dateText · $timeText")
|
||||||
|
event.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||||
|
append(" · ")
|
||||||
|
val offset = length
|
||||||
|
append(location)
|
||||||
|
hit.locationSpans.forEach {
|
||||||
|
addStyle(highlight, offset + it.start, offset + it.end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hit.descriptionSnippet?.let { snippet ->
|
||||||
|
append("\n")
|
||||||
|
val offset = length
|
||||||
|
append(snippet.text)
|
||||||
|
snippet.spans.forEach { addStyle(highlight, offset + it.start, offset + it.end) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventSearchRanker
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@@ -39,9 +41,9 @@ sealed interface SearchUiState {
|
|||||||
/** A query ran but matched nothing. */
|
/** A query ran but matched nothing. */
|
||||||
data class Empty(val query: String) : SearchUiState
|
data class Empty(val query: String) : SearchUiState
|
||||||
|
|
||||||
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
|
/** Matches, best first — see [de.jeanlucmakiola.calendula.domain.EventSearchRanker]. */
|
||||||
data class Results(
|
data class Results(
|
||||||
val events: List<EventInstance>,
|
val hits: List<SearchHit>,
|
||||||
/**
|
/**
|
||||||
* Calendars among the results that can't be written to (WebCal, birthday
|
* Calendars among the results that can't be written to (WebCal, birthday
|
||||||
* mirrors, …). Their rows are excluded from selection rather than failing
|
* mirrors, …). Their rows are excluded from selection rather than failing
|
||||||
@@ -49,6 +51,8 @@ sealed interface SearchUiState {
|
|||||||
*/
|
*/
|
||||||
val readOnlyCalendarIds: Set<Long> = emptySet(),
|
val readOnlyCalendarIds: Set<Long> = emptySet(),
|
||||||
) : SearchUiState {
|
) : SearchUiState {
|
||||||
|
val events: List<EventInstance> get() = hits.map { it.event }
|
||||||
|
|
||||||
fun isDeletable(event: EventInstance): Boolean =
|
fun isDeletable(event: EventInstance): Boolean =
|
||||||
event.calendarId !in readOnlyCalendarIds
|
event.calendarId !in readOnlyCalendarIds
|
||||||
}
|
}
|
||||||
@@ -96,12 +100,16 @@ class SearchViewModel @Inject constructor(
|
|||||||
if (q.length < MIN_QUERY_LENGTH) {
|
if (q.length < MIN_QUERY_LENGTH) {
|
||||||
SearchUiState.Idle
|
SearchUiState.Idle
|
||||||
} else {
|
} else {
|
||||||
val results = repository.searchEvents(q)
|
val hits = EventSearchRanker.rank(
|
||||||
if (results.isEmpty()) {
|
candidates = repository.searchEvents(q),
|
||||||
|
query = q,
|
||||||
|
now = Clock.System.now(),
|
||||||
|
)
|
||||||
|
if (hits.isEmpty()) {
|
||||||
SearchUiState.Empty(q)
|
SearchUiState.Empty(q)
|
||||||
} else {
|
} else {
|
||||||
SearchUiState.Results(
|
SearchUiState.Results(
|
||||||
events = sortNearestFirst(results),
|
hits = hits,
|
||||||
readOnlyCalendarIds = readOnlyCalendarIds(),
|
readOnlyCalendarIds = readOnlyCalendarIds(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -206,11 +214,4 @@ class SearchViewModel @Inject constructor(
|
|||||||
.filterNot { it.canModifyContents }
|
.filterNot { it.canModifyContents }
|
||||||
.map { it.id }
|
.map { it.id }
|
||||||
.toSet()
|
.toSet()
|
||||||
|
|
||||||
/** Soonest upcoming (and ongoing) first, then the most recent past. */
|
|
||||||
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
|
|
||||||
val now = Clock.System.now()
|
|
||||||
val (upcoming, past) = events.partition { it.end >= now }
|
|
||||||
return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -451,15 +452,15 @@ class CalendarRepositoryImplTest {
|
|||||||
calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false), makeCal(3L))
|
calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false), makeCal(3L))
|
||||||
searchResult = {
|
searchResult = {
|
||||||
listOf(
|
listOf(
|
||||||
makeEvent(10L, "Shown", calendarId = 1L),
|
SearchCandidate(makeEvent(10L, "Shown", calendarId = 1L)),
|
||||||
makeEvent(11L, "Switched off", calendarId = 2L),
|
SearchCandidate(makeEvent(11L, "Switched off", calendarId = 2L)),
|
||||||
makeEvent(12L, "Hidden", calendarId = 3L),
|
SearchCandidate(makeEvent(12L, "Hidden", calendarId = 3L)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
assertThat(repo.searchEvents("e").map { it.title }).containsExactly("Shown")
|
assertThat(repo.searchEvents("e").map { it.event.title }).containsExactly("Shown")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
@@ -19,7 +20,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
|
|
||||||
var calendarsResult: List<CalendarSource> = emptyList()
|
var calendarsResult: List<CalendarSource> = emptyList()
|
||||||
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
||||||
var searchResult: (String) -> List<EventInstance> = { _ -> emptyList() }
|
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
|
||||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||||
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||||
var exportableEventsResult: List<IcsEvent> = emptyList()
|
var exportableEventsResult: List<IcsEvent> = emptyList()
|
||||||
@@ -71,7 +72,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
}
|
}
|
||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||||
instancesResult(beginMillis, endMillis)
|
instancesResult(beginMillis, endMillis)
|
||||||
override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
|
override fun searchEvents(query: String): List<SearchCandidate> = searchResult(query)
|
||||||
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? =
|
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? =
|
||||||
eventDetailResult(eventId)
|
eventDetailResult(eventId)
|
||||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ranking and matching for search (#80 follow-up): where a query matched decides
|
||||||
|
* the order, and matching folds case and splits on whitespace.
|
||||||
|
*/
|
||||||
|
class EventSearchRankerTest {
|
||||||
|
|
||||||
|
private val now = Instant.fromEpochMilliseconds(1_800_000_000_000L)
|
||||||
|
|
||||||
|
private fun candidate(
|
||||||
|
id: Long,
|
||||||
|
title: String,
|
||||||
|
location: String? = null,
|
||||||
|
description: String? = null,
|
||||||
|
startMillis: Long = now.toEpochMilliseconds() + 86_400_000L,
|
||||||
|
) = SearchCandidate(
|
||||||
|
event = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = title,
|
||||||
|
start = Instant.fromEpochMilliseconds(startMillis),
|
||||||
|
end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L),
|
||||||
|
isAllDay = false,
|
||||||
|
color = 0,
|
||||||
|
location = location,
|
||||||
|
),
|
||||||
|
description = description,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun titlesFor(vararg candidates: SearchCandidate, query: String): List<String> =
|
||||||
|
EventSearchRanker.rank(candidates.toList(), query, now).map { it.event.title }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a title match outranks a description match even when it is further off`() {
|
||||||
|
// The reported case: "protestantischer" in a holiday's description put it
|
||||||
|
// above the events actually called Test, because it was sooner.
|
||||||
|
val holiday = candidate(
|
||||||
|
id = 1L,
|
||||||
|
title = "Reformationstag",
|
||||||
|
description = "Feiertag der protestantischer Kirchen",
|
||||||
|
startMillis = now.toEpochMilliseconds() + 86_400_000L,
|
||||||
|
)
|
||||||
|
val real = candidate(
|
||||||
|
id = 2L,
|
||||||
|
title = "Test",
|
||||||
|
startMillis = now.toEpochMilliseconds() + 30 * 86_400_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(titlesFor(holiday, real, query = "test"))
|
||||||
|
.containsExactly("Test", "Reformationstag")
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a word start outranks a mid-word match inside the same field`() {
|
||||||
|
val midWord = candidate(1L, "Zahnarzttermin")
|
||||||
|
val wordStart = candidate(2L, "Neuer Termin beim Amt")
|
||||||
|
|
||||||
|
assertThat(titlesFor(midWord, wordStart, query = "termin"))
|
||||||
|
.containsExactly("Neuer Termin beim Amt", "Zahnarzttermin")
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a mid-word match is still found, so compounds keep working`() {
|
||||||
|
val compound = candidate(1L, "Zahnarzttermin")
|
||||||
|
|
||||||
|
assertThat(titlesFor(compound, query = "termin")).containsExactly("Zahnarzttermin")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the field ranking runs title over location over description`() {
|
||||||
|
val inDescription = candidate(1L, "Frühstück", description = "im Büro besprechen")
|
||||||
|
val inLocation = candidate(2L, "Frühstück", location = "Büro")
|
||||||
|
val inTitle = candidate(3L, "Büro aufräumen")
|
||||||
|
|
||||||
|
assertThat(titlesFor(inDescription, inLocation, inTitle, query = "büro"))
|
||||||
|
.containsExactly("Büro aufräumen", "Frühstück", "Frühstück")
|
||||||
|
.inOrder()
|
||||||
|
// The location hit ranks above the description one.
|
||||||
|
val ranked = EventSearchRanker.rank(
|
||||||
|
listOf(inDescription, inLocation, inTitle),
|
||||||
|
"büro",
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
assertThat(ranked.map { it.event.eventId }).containsExactly(3L, 2L, 1L).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `case folds beyond ASCII`() {
|
||||||
|
val upper = candidate(1L, "ÄRZTE Termin")
|
||||||
|
|
||||||
|
assertThat(titlesFor(upper, query = "ärzte")).containsExactly("ÄRZTE Termin")
|
||||||
|
assertThat(titlesFor(candidate(2L, "ärzte"), query = "ÄRZTE")).containsExactly("ärzte")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every token has to match, in any field and any order`() {
|
||||||
|
val both = candidate(1L, "Zahnarzt", description = "Termin bestätigen")
|
||||||
|
val onlyOne = candidate(2L, "Zahnarzt")
|
||||||
|
|
||||||
|
val ranked = EventSearchRanker.rank(listOf(both, onlyOne), "termin zahnarzt", now)
|
||||||
|
|
||||||
|
assertThat(ranked.map { it.event.eventId }).containsExactly(1L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event ranks by the weakest field the query needed`() {
|
||||||
|
// Both tokens in the title beats one token stranded in the description.
|
||||||
|
val allInTitle = candidate(1L, "Zahnarzt Termin")
|
||||||
|
val split = candidate(2L, "Zahnarzt", description = "Termin bestätigen")
|
||||||
|
|
||||||
|
assertThat(titlesFor(split, allInTitle, query = "zahnarzt termin"))
|
||||||
|
.containsExactly("Zahnarzt Termin", "Zahnarzt")
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ties keep the nearest-first order, upcoming before past`() {
|
||||||
|
val soon = candidate(1L, "Test", startMillis = now.toEpochMilliseconds() + 86_400_000L)
|
||||||
|
val later = candidate(2L, "Test", startMillis = now.toEpochMilliseconds() + 5 * 86_400_000L)
|
||||||
|
val recentPast =
|
||||||
|
candidate(3L, "Test", startMillis = now.toEpochMilliseconds() - 86_400_000L)
|
||||||
|
val longAgo =
|
||||||
|
candidate(4L, "Test", startMillis = now.toEpochMilliseconds() - 90 * 86_400_000L)
|
||||||
|
|
||||||
|
val ranked = EventSearchRanker.rank(listOf(longAgo, later, recentPast, soon), "test", now)
|
||||||
|
|
||||||
|
assertThat(ranked.map { it.event.eventId }).containsExactly(1L, 2L, 3L, 4L).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the title carries the spans to highlight, merged where they overlap`() {
|
||||||
|
val event = candidate(1L, "Test the tester")
|
||||||
|
|
||||||
|
val hit = EventSearchRanker.rank(listOf(event), "test", now).single()
|
||||||
|
|
||||||
|
assertThat(hit.titleSpans).containsExactly(MatchSpan(0, 4), MatchSpan(9, 13)).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a description snippet is excerpted around its match`() {
|
||||||
|
val long = "x".repeat(300) + " geheimwort " + "y".repeat(300)
|
||||||
|
val event = candidate(1L, "Notiz", description = long)
|
||||||
|
|
||||||
|
val snippet = EventSearchRanker.rank(listOf(event), "geheimwort", now)
|
||||||
|
.single()
|
||||||
|
.descriptionSnippet
|
||||||
|
|
||||||
|
requireNotNull(snippet)
|
||||||
|
assertThat(snippet.text).contains("geheimwort")
|
||||||
|
assertThat(snippet.text.length).isLessThan(long.length)
|
||||||
|
assertThat(snippet.text).startsWith("…")
|
||||||
|
// The span points at the match inside the excerpt, not the full text.
|
||||||
|
val span = snippet.spans.single()
|
||||||
|
assertThat(snippet.text.substring(span.start, span.end)).isEqualTo("geheimwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a title-only match carries no description snippet`() {
|
||||||
|
val event = candidate(1L, "Test", description = "nothing relevant here")
|
||||||
|
|
||||||
|
assertThat(EventSearchRanker.rank(listOf(event), "test", now).single().descriptionSnippet)
|
||||||
|
.isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a multi-line description matches and snippets as one line`() {
|
||||||
|
val event = candidate(1L, "Notiz", description = "erste Zeile\n\n zweite Zeile")
|
||||||
|
|
||||||
|
val snippet = EventSearchRanker.rank(listOf(event), "zweite", now).single().descriptionSnippet
|
||||||
|
|
||||||
|
requireNotNull(snippet)
|
||||||
|
assertThat(snippet.text).isEqualTo("erste Zeile zweite Zeile")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a blank query matches nothing`() {
|
||||||
|
assertThat(EventSearchRanker.rank(listOf(candidate(1L, "Test")), " ", now)).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -51,11 +52,13 @@ class SearchViewModelTest {
|
|||||||
calendarId: Long = 1L,
|
calendarId: Long = 1L,
|
||||||
recurring: Boolean = false,
|
recurring: Boolean = false,
|
||||||
startMillis: Long = begin,
|
startMillis: Long = begin,
|
||||||
) = EventInstance(
|
) = SearchCandidate(
|
||||||
instanceId = id, eventId = id, calendarId = calendarId, title = "Standup $id",
|
EventInstance(
|
||||||
start = Instant.fromEpochMilliseconds(startMillis),
|
instanceId = id, eventId = id, calendarId = calendarId, title = "Standup $id",
|
||||||
end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L),
|
start = Instant.fromEpochMilliseconds(startMillis),
|
||||||
isAllDay = false, color = 0xFF000000.toInt(), location = null, isRecurring = recurring,
|
end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L),
|
||||||
|
isAllDay = false, color = 0xFF000000.toInt(), location = null, isRecurring = recurring,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): SearchViewModel {
|
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): SearchViewModel {
|
||||||
|
|||||||
Submodule floret-kit updated: b9475688a8...ea860ed781
Reference in New Issue
Block a user