From f7629f0d7f5823c412fa479b149155930a4a4498 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 8 Aug 2026 16:22:57 +0200 Subject: [PATCH] Rank search results by where they matched (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search ordered purely by date, so an event that matched only deep in its description outranked a title match that happened to be further off — the reported case was 'test' surfacing a holiday whose description contains 'protestantischer'. Matching is now token-wise: the query splits on whitespace and every token must appear in the title, location or description, in any order. EventSearchRanker scores each token by field (title > location > description) and by position (field start > word start > mid-word) and ranks an event by the weakest field the query needed, falling back to the old nearest-first date order. Mid-word matches still count so compounds keep working: 'termin' finds 'Zahnarzttermin'. The final comparison also moved in-memory so case folds beyond ASCII ('ärzte' now finds 'ÄRZTE'); SQL stays a pre-filter, substituting non-ASCII cased letters with LIKE's single-character wildcard to stay a superset. Hits carry their match spans and a description excerpt, which the row will render next. --- .../data/calendar/CalendarDataSource.kt | 74 ++++-- .../data/calendar/CalendarRepository.kt | 10 +- .../data/calendar/CalendarRepositoryImpl.kt | 5 +- .../calendula/data/calendar/Projections.kt | 4 + .../calendula/domain/EventSearch.kt | 221 ++++++++++++++++++ .../calendula/ui/search/SearchScreen.kt | 11 +- .../calendula/ui/search/SearchViewModel.kt | 25 +- .../calendar/CalendarRepositoryImplTest.kt | 9 +- .../data/calendar/FakeCalendarDataSource.kt | 5 +- .../calendula/domain/EventSearchRankerTest.kt | 187 +++++++++++++++ .../ui/search/SearchViewModelTest.kt | 13 +- 11 files changed, 509 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchRankerTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index cb33c9d..1cdf8f7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -27,8 +27,10 @@ import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.curatedForPicker import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.EventSearchRanker import de.jeanlucmakiola.calendula.domain.EventStatus 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.SpecialDateType import de.jeanlucmakiola.calendula.domain.ics.IcsEvent @@ -62,12 +64,14 @@ interface CalendarDataSource { fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? /** - * Master/one-off events whose title, description or location contains - * [query] (case-insensitive), across all calendars, newest first. Reads the - * Events table directly so the search is unbounded in time; exception rows - * are excluded (see [SearchProjection]). [query] is assumed non-blank. + * Master/one-off events that may match [query], across all calendars: every + * whitespace-separated token has to appear in the title, description or + * location. Reads the Events table directly so the search is unbounded in + * 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 + fun searchEvents(query: String): List /** * 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() } - override fun searchEvents(query: String): List { + override fun searchEvents(query: String): List { ensureObserversRegistered() - val trimmed = query.trim() - if (trimmed.isEmpty()) return emptyList() - // Escape the SQL LIKE wildcards so a literal % or _ in the query matches - // itself instead of acting as a wildcard. - val escaped = trimmed - .replace("\\", "\\\\") - .replace("%", "\\%") - .replace("_", "\\_") - val like = "%$escaped%" - val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " + - "${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " + - "${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'" + val tokens = EventSearchRanker.tokenize(query).take(MAX_SEARCH_TOKENS) + if (tokens.isEmpty()) return emptyList() + // Every token has to appear somewhere, so the groups are ANDed. This is + // only a pre-filter — EventSearchRanker re-checks each token with proper + // case folding, and re-checks the tokens dropped by the cap above. + val match = tokens.joinToString(" AND ") { + "(${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " + + "${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " + + "${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\')" + } + val args = tokens.flatMap { token -> List(3) { likePattern(token) } }.toTypedArray() val selection = "($match) AND " + "${CalendarContract.Events.DELETED} = 0 AND " + "${CalendarContract.Events.ORIGINAL_ID} IS NULL" @@ -606,17 +609,18 @@ class AndroidCalendarDataSource @Inject constructor( CalendarContract.Events.CONTENT_URI, SearchProjection.COLUMNS, selection, - arrayOf(like, like, like), + args, CalendarContract.Events.DTSTART + " DESC", )?.use { c -> val reader = CursorColumnReader(c) - val out = ArrayList(c.count) + val out = ArrayList(c.count) while (c.moveToNext()) { + val description = reader.getString(SearchProjection.IDX_DESCRIPTION) val base = reader.toSearchResult() ?: continue // A recurring master's DTSTART is the series start; show its // nearest occurrence instead so the date is the one the user // actually cares about (and sorting reflects it). - out += if (base.isRecurring) { + val event = if (base.isRecurring) { nearestOccurrenceMillis(base.eventId)?.let { (begin, end) -> base.copy( start = begin.toKotlinInstantFromEpochMillis(), @@ -626,11 +630,32 @@ class AndroidCalendarDataSource @Inject constructor( } else { base } + out += SearchCandidate(event = event, description = description) } out } ?: 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 * 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. */ 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 } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index f8c6212..a384292 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm 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.IcsImportSummary import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent @@ -17,11 +18,12 @@ interface CalendarRepository { suspend fun eventDetail(eventId: Long): EventDetail /** - * Events whose title, description or location contains [query], with hidden - * calendars removed and newest first. Empty when [query] is blank. Searches - * the whole history/future (see [CalendarDataSource.searchEvents]). + * Candidate matches for [query] with hidden calendars removed; empty when + * [query] is blank. Searches the whole history/future (see + * [CalendarDataSource.searchEvents]) and leaves ranking to + * [de.jeanlucmakiola.calendula.domain.EventSearchRanker]. */ - suspend fun searchEvents(query: String): List + suspend fun searchEvents(query: String): List /** * The event-colour palette a calendar's account publishes; empty when it diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 8d64cab..f7b677a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm 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.ParsedIcsEvent import kotlinx.coroutines.CoroutineDispatcher @@ -162,13 +163,13 @@ class CalendarRepositoryImpl @Inject constructor( ?: throw NoSuchEventException(eventId) } - override suspend fun searchEvents(query: String): List = withContext(io) { + override suspend fun searchEvents(query: String): List = withContext(io) { if (query.isBlank()) return@withContext emptyList() val excluded = prefs.hiddenCalendarIds.first() + prefs.pendingDisabledCalendarIds.first() + invisibleCalendarIds() 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 = diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index ac3e138..290b930 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -180,6 +180,9 @@ internal object SearchProjection { // display its nearest occurrence, not the series-start DTSTART. CalendarContract.Events.RRULE, 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 @@ -194,6 +197,7 @@ internal object SearchProjection { const val IDX_LOCATION = 9 const val IDX_RRULE = 10 const val IDX_RDATE = 11 + const val IDX_DESCRIPTION = 12 } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt new file mode 100644 index 0000000..6f0e372 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt @@ -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, +) + +/** + * 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 = emptyList(), + val locationSpans: List = 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 = + 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, + query: String, + now: Instant, + ): List { + val tokens = tokenize(query) + if (tokens.isEmpty()) return emptyList() + + val scored = candidates.mapNotNull { candidate -> score(candidate, tokens) } + return scored + .sortedWith( + compareByDescending { 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): 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): List { + val raw = mutableListOf() + 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): 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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 1e507ff..abd4642 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -392,7 +392,7 @@ private fun SearchResults( onEventClick: (EventInstance) -> Unit, onToggle: (Long) -> Unit, ) { - val events = results.events + val hits = results.hits LazyColumn( modifier = Modifier.fillMaxSize(), // No horizontal inset here: GroupedRow already insets itself, and adding @@ -400,13 +400,14 @@ private fun SearchResults( contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), ) { itemsIndexed( - items = events, - key = { _, event -> event.eventId }, - ) { index, event -> + items = hits, + key = { _, hit -> hit.event.eventId }, + ) { index, hit -> + val event = hit.event val deletable = results.isDeletable(event) SearchResultRow( event = event, - position = positionOf(index, events.size), + position = positionOf(index, hits.size), modifier = animateItemMotion(), selected = event.eventId in selection, // A read-only calendar's row can't join a batch, so in selection diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt index 63cc616..7f3428d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt @@ -6,7 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.EventSearchRanker import de.jeanlucmakiola.calendula.domain.RecurringWriteScope +import de.jeanlucmakiola.calendula.domain.SearchHit import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -39,9 +41,9 @@ sealed interface SearchUiState { /** A query ran but matched nothing. */ 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( - val events: List, + val hits: List, /** * Calendars among the results that can't be written to (WebCal, birthday * mirrors, …). Their rows are excluded from selection rather than failing @@ -49,6 +51,8 @@ sealed interface SearchUiState { */ val readOnlyCalendarIds: Set = emptySet(), ) : SearchUiState { + val events: List get() = hits.map { it.event } + fun isDeletable(event: EventInstance): Boolean = event.calendarId !in readOnlyCalendarIds } @@ -96,12 +100,16 @@ class SearchViewModel @Inject constructor( if (q.length < MIN_QUERY_LENGTH) { SearchUiState.Idle } else { - val results = repository.searchEvents(q) - if (results.isEmpty()) { + val hits = EventSearchRanker.rank( + candidates = repository.searchEvents(q), + query = q, + now = Clock.System.now(), + ) + if (hits.isEmpty()) { SearchUiState.Empty(q) } else { SearchUiState.Results( - events = sortNearestFirst(results), + hits = hits, readOnlyCalendarIds = readOnlyCalendarIds(), ) } @@ -206,11 +214,4 @@ class SearchViewModel @Inject constructor( .filterNot { it.canModifyContents } .map { it.id } .toSet() - - /** Soonest upcoming (and ongoing) first, then the most recent past. */ - private fun sortNearestFirst(events: List): List { - val now = Clock.System.now() - val (upcoming, past) = events.partition { it.end >= now } - return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start } - } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index 575b4ec..1e022a6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.SearchCandidate import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.datetime.LocalDate @@ -451,15 +452,15 @@ class CalendarRepositoryImplTest { calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false), makeCal(3L)) searchResult = { listOf( - makeEvent(10L, "Shown", calendarId = 1L), - makeEvent(11L, "Switched off", calendarId = 2L), - makeEvent(12L, "Hidden", calendarId = 3L), + SearchCandidate(makeEvent(10L, "Shown", calendarId = 1L)), + SearchCandidate(makeEvent(11L, "Switched off", calendarId = 2L)), + SearchCandidate(makeEvent(12L, "Hidden", calendarId = 3L)), ) } } 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 diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index 89f96ae..682f253 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.SearchCandidate import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent @@ -19,7 +20,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { var calendarsResult: List = emptyList() var instancesResult: (Long, Long) -> List = { _, _ -> emptyList() } - var searchResult: (String) -> List = { _ -> emptyList() } + var searchResult: (String) -> List = { _ -> emptyList() } var eventDetailResult: (Long) -> EventDetail? = { null } var eventColorPaletteResult: (Long) -> List = { emptyList() } var exportableEventsResult: List = emptyList() @@ -71,7 +72,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { } override fun instances(beginMillis: Long, endMillis: Long): List = instancesResult(beginMillis, endMillis) - override fun searchEvents(query: String): List = searchResult(query) + override fun searchEvents(query: String): List = searchResult(query) override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? = eventDetailResult(eventId) override fun eventColorPalette(calendarId: Long): List = diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchRankerTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchRankerTest.kt new file mode 100644 index 0000000..20346a2 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchRankerTest.kt @@ -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 = + 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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModelTest.kt index 20194ad..36b6a7f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModelTest.kt @@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.RecurringWriteScope +import de.jeanlucmakiola.calendula.domain.SearchCandidate import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -51,11 +52,13 @@ class SearchViewModelTest { calendarId: Long = 1L, recurring: Boolean = false, startMillis: Long = begin, - ) = EventInstance( - instanceId = id, eventId = id, calendarId = calendarId, title = "Standup $id", - start = Instant.fromEpochMilliseconds(startMillis), - end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L), - isAllDay = false, color = 0xFF000000.toInt(), location = null, isRecurring = recurring, + ) = SearchCandidate( + EventInstance( + instanceId = id, eventId = id, calendarId = calendarId, title = "Standup $id", + start = Instant.fromEpochMilliseconds(startMillis), + end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L), + isAllDay = false, color = 0xFF000000.toInt(), location = null, isRecurring = recurring, + ), ) private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): SearchViewModel {