Compare commits

...

3 Commits

Author SHA1 Message Date
a9c5bc1038 Show what a search result matched (#80)
Results were plain rows, so a hit that matched on its description looked
unexplained. The matched runs are now emphasised in the title and location,
and a description match adds a short excerpt of it as a second summary line —
elided around the match rather than dumping the note.
2026-08-08 16:27:16 +02:00
e8aaa7d333 Bump floret-kit for the styled grouped row (#80) 2026-08-08 16:24:59 +02:00
f7629f0d7f Rank search results by where they matched (#80)
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.
2026-08-08 16:22:57 +02:00
12 changed files with 564 additions and 64 deletions

View File

@@ -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<EventInstance>
fun searchEvents(query: String): List<SearchCandidate>
/**
* 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<EventInstance> {
override fun searchEvents(query: String): List<SearchCandidate> {
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 " +
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 '\\'"
"${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<EventInstance>(c.count)
val out = ArrayList<SearchCandidate>(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
}
}

View File

@@ -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<EventInstance>
suspend fun searchEvents(query: String): List<SearchCandidate>
/**
* The event-colour palette a calendar's account publishes; empty when it

View File

@@ -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<EventInstance> = withContext(io) {
override suspend fun searchEvents(query: String): List<SearchCandidate> = 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<EventColorOption> =

View File

@@ -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
}
/**

View File

@@ -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)
}

View File

@@ -56,11 +56,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.pluralStringResource
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.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
@@ -70,7 +75,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.MatchSpan
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack
@@ -392,7 +399,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 +407,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),
hit = hit,
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
@@ -426,7 +434,7 @@ private fun SearchResults(
@Composable
private fun SearchResultRow(
event: EventInstance,
hit: SearchHit,
position: Position,
modifier: Modifier = Modifier,
selected: Boolean = false,
@@ -435,12 +443,19 @@ private fun SearchResultRow(
onClick: (() -> Unit)?,
onLongClick: (() -> Unit)? = null,
) {
val event = hit.event
val dark = isSystemInDarkTheme()
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(
modifier = modifier,
title = event.title,
summary = searchSummary(event),
title = marked(event.title, hit.titleSpans, highlight),
summary = searchSummary(hit, highlight),
position = position,
minHeight = 64.dp,
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
private fun searchSummary(event: EventInstance): String {
private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString {
val event = hit.event
val locale = currentLocale()
val zone = remember { ZoneId.systemDefault() }
val start = remember(event.start, zone) {
@@ -537,8 +569,23 @@ private fun searchSummary(event: EventInstance): String {
} else {
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
}
val base = "$dateText · $timeText"
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
return buildAnnotatedString {
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

View File

@@ -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<EventInstance>,
val hits: List<SearchHit>,
/**
* 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<Long> = emptySet(),
) : SearchUiState {
val events: List<EventInstance> 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<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 }
}
}

View File

@@ -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

View File

@@ -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<CalendarSource> = 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 eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = emptyList()
@@ -71,7 +72,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
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? =
eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =

View File

@@ -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()
}
}

View File

@@ -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(
) = 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 {