diff --git a/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/LuceneSearchEngine.kt b/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/LuceneSearchEngine.kt index 77931295..bb21b9eb 100644 --- a/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/LuceneSearchEngine.kt +++ b/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/LuceneSearchEngine.kt @@ -267,7 +267,12 @@ class LuceneSearchEngine( val sources = withContext(Dispatchers.IO) { snippetSources(hits.map { LineSnippetInfo(it.lineId, it.bookId, it.lineIndex) }) } return hits.map { hit -> val source = sources[hit.lineId] ?: SnippetSource(hit.rawText, 0, hit.rawText.length) - hit.copy(snippet = SnippetBuilder.build(source.text, context.highlightPlan, source.lineStart, source.lineEnd), rawText = source.text) + hit.copy( + snippet = SnippetBuilder.build(source.text, context.highlightPlan, source.lineStart, source.lineEnd), + rawText = source.text, + lineStart = source.lineStart, + lineEnd = source.lineEnd + ) } } @@ -316,8 +321,13 @@ class LuceneSearchEngine( override fun highlightRanges(text: String, query: String): List = mergedRanges(text, planFor(query, DEFAULT_NEAR).highlights(text)) - override fun rangeSnippet(text: String, ranges: List): String = - SnippetBuilder.build(text, ranges.map { Occurrence(it.first, it.last + 1, 0, EvidenceLevel.LITERAL) }) + override fun rangeSnippet(text: String, ranges: List, lineStart: Int, lineEnd: Int): String = + SnippetBuilder.build( + text, + ranges.map { Occurrence(it.first, it.last + 1, 0, EvidenceLevel.LITERAL) }, + lineStart, + lineEnd + ) // The highlight plans of the latest queries: a results page and its preview ask for the same one many times private val planCache = object : LinkedHashMap(PLAN_CACHE_SIZE, 0.75f, true) { diff --git a/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchEngine.kt b/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchEngine.kt index d9ed4fe5..64cc5d0d 100644 --- a/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchEngine.kt +++ b/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchEngine.kt @@ -86,35 +86,36 @@ interface SearchEngine : Closeable { fun buildSnippet(rawText: String, query: String, near: Int): String /** - * The words that stand for [query] in [text] (plain, diacritics allowed), as ranges of [text]: the one rule every - * view highlights a search with (results' snippets, their preview, the reader's smart find). A query word counts - * as itself (prefixed, or inside a longer word from 4 letters) or as one of its inflections; consecutive matched - * words form one range. + * The words that stand for [query] in [text] (plain, diacritics allowed), as ranges of [text]: what a search + * highlights when it doesn't highlight by meaning (see [highlightsByMeaning]). A query word counts as itself + * (prefixed, or inside a longer word from 4 letters) or as one of its inflections; consecutive matched words form + * one range. */ fun highlightRanges(text: String, query: String): List = emptyList() /** - * What to highlight of [query] in found lines [texts] (plain): [highlightRanges] where the query's words show, - * else, for engines that search by meaning, the passage closest in meaning to the query (the whole line when it - * is a single clause). With [alwaysByMeaning] (a find by meaning), that passage shows beside the words in every - * line. One batch, as the meaning takes a model run. + * Whether a search for [query] highlights by meaning: the passage closest in meaning in the found line alone, + * none of the query's words. False for engines without a meaning search, and for an exact (quoted) query. + */ + fun highlightsByMeaning(query: String): Boolean = false + + /** + * What to highlight of [query] in found lines [texts] (plain): the one rule every view highlights a found line with + * (results' snippets, their preview, the reader's smart find). By meaning (see [highlightsByMeaning]), the passage + * closest in meaning (the whole line when it is a single clause), in one batch as it takes a model run; else, or + * when the meaning search fails, [highlightRanges]. */ suspend fun highlights( texts: List, - query: String, - alwaysByMeaning: Boolean = false + query: String ): List { - val words = texts.map { highlightRanges(it, query) } - val wanted = texts.indices.filter { (alwaysByMeaning || words[it].isEmpty()) && texts[it].isNotBlank() } - val passages = if (wanted.isEmpty()) null else passagesByMeaning(query, wanted.map { texts[it] }) - if (passages == null) return words.map { TextHighlights(it) } - val passageOf = wanted.indices.associate { wanted[it] to passages[it] } + val passages = if (highlightsByMeaning(query)) passagesByMeaning(query, texts) else null + if (passages == null) return texts.map { TextHighlights(highlightRanges(it, query)) } return texts.mapIndexed { i, text -> - if (i !in passageOf) return@mapIndexed TextHighlights(words[i]) // A single clause is the passage itself - val passage = passageOf[i] ?: text.trim() - val at = text.indexOf(passage) - if (at < 0) TextHighlights(words[i]) else TextHighlights(unionRanges(words[i] + listOf(at until at + passage.length)), byMeaning = true) + val passage = passages[i] ?: text.trim() + val at = if (passage.isEmpty()) -1 else text.indexOf(passage) + TextHighlights(if (at < 0) emptyList() else listOf(at until at + passage.length), byMeaning = true) } } @@ -124,8 +125,11 @@ interface SearchEngine : Closeable { */ suspend fun passagesByMeaning(query: String, texts: List): List? = null - /** A snippet of [text] (snippet text, see [SnippetSources.clean]) laid out around [ranges], bold. */ - fun rangeSnippet(text: String, ranges: List): String = text + /** + * A snippet of [text] (snippet text, see [SnippetSources.clean]) laid out around [ranges] (sorted), bold; + * [lineStart] until [lineEnd] is the hit line when [text] holds its neighbors (see [LineHit]). + */ + fun rangeSnippet(text: String, ranges: List, lineStart: Int = 0, lineEnd: Int = text.length): String = text /** * Fills `snippet` and `rawText` of [hits] fetched without snippets (`nextPage(limit, snippets = false)`), so diff --git a/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchSession.kt b/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchSession.kt index 511b9fd5..2226885b 100644 --- a/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchSession.kt +++ b/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchSession.kt @@ -76,6 +76,8 @@ data class SearchPage( * @property rawText Original unprocessed text content of the line * @property isBaseBook True if the line belongs to a base book (vs a commentary). Lets callers * correlate results: anchor each hit on its base line and nest matching commentaries under it. + * @property lineStart start of the hit line in [rawText], which may hold its neighbors as context (snippet source) + * @property lineEnd end (exclusive) of the hit line in [rawText] */ data class LineHit( val bookId: Long, @@ -85,7 +87,9 @@ data class LineHit( val snippet: String, val score: Float, val rawText: String, - val isBaseBook: Boolean = false + val isBaseBook: Boolean = false, + val lineStart: Int = 0, + val lineEnd: Int = rawText.length ) /** diff --git a/search/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/search/HighlightsTest.kt b/search/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/search/HighlightsTest.kt index 102d42ae..4cf74d8d 100644 --- a/search/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/search/HighlightsTest.kt +++ b/search/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/search/HighlightsTest.kt @@ -5,7 +5,7 @@ import kotlin.test.Test import kotlin.test.assertEquals class HighlightsTest { - // Words: "שבת" wherever it is; meaning: the clause after the comma + // Words: the query wherever it is; meaning (unless quoted): the clause after the comma private val engine = object : SearchEngine { override fun openSession( @@ -33,7 +33,9 @@ class HighlightsTest { ): SearchFacets? = null override fun highlightRanges(text: String, query: String): List = - Regex(query).findAll(text).map { it.range }.toList() + Regex(query.trim('"')).findAll(text).map { it.range }.toList() + + override fun highlightsByMeaning(query: String): Boolean = !query.startsWith("\"") override suspend fun passagesByMeaning(query: String, texts: List): List = texts.map { text -> text.substringAfter(", ", "").ifEmpty { null } } @@ -45,17 +47,17 @@ class HighlightsTest { mapIndexed { i, h -> h.byMeaning to h.ranges.map { lines[i].substring(it.first, it.last + 1) } } @Test - fun `the words where they show, else the passage closest in meaning`() = runBlocking { - val lines = listOf("שמור שבת, וזכור", "יום מנוחה, לנפש", "יום אחד") + fun `by meaning, the passage alone, the whole line when a single clause`() = runBlocking { + val lines = listOf("שמור שבת, וזכור", "יום מנוחה, לנפש", "יום אחד", "") assertEquals( - listOf(false to listOf("שבת"), true to listOf("לנפש"), true to listOf("יום אחד")), + listOf(true to listOf("וזכור"), true to listOf("לנפש"), true to listOf("יום אחד"), true to emptyList()), engine.highlights(lines, "שבת").texts(lines), ) } @Test - fun `a find by meaning shows the passage beside the words`() = runBlocking { + fun `a quoted query highlights its words`() = runBlocking { val lines = listOf("שמור שבת, וזכור שבת") - assertEquals(listOf(true to listOf("שבת", "וזכור שבת")), engine.highlights(lines, "שבת", alwaysByMeaning = true).texts(lines)) + assertEquals(listOf(false to listOf("שבת", "שבת")), engine.highlights(lines, "\"שבת\"").texts(lines)) } }