diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcher.kt index 3e005d88b2..59f1a9cdd3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcher.kt @@ -31,8 +31,14 @@ import com.vitorpamplona.quartz.nip01Core.core.fastAny * * ## What it matches * - * Terms are ANDed and each is a case-insensitive substring — of a tag value, or of the event's - * own indexable fields. Substring rather than token because that is what Amethyst's local search + * Terms are ANDed and each is a case-insensitive substring of the event's indexable fields — the + * same text a store's full-text index holds ([SearchableEvent.forEachIndexableField] is the read + * side of [SearchableEvent.indexableContent]), so local search and a store or relay agree on what + * an event can be found by. A tag value is searched only when the kind indexes it: a hashtag, an + * id, a URL or a data label the kind leaves out of its index is left out here too. A kind that is + * not a [SearchableEvent] has no indexable fields, so it falls back to its tag values and content. + * + * Substring rather than token because that is what Amethyst's local search * has always done (`content.contains(text, true)`), and switching to tokens would silently stop * matching mid-word queries; AND rather than one literal phrase because that is what a relay does * with the same string, and the point of routing local search through a `Filter` is that the two @@ -57,8 +63,9 @@ import com.vitorpamplona.quartz.nip01Core.core.fastAny class EventSearchMatcher( search: String?, /** - * Tag names whose values are not worth searching — `p`/`e`/`a` hold hex ids that only ever - * match by accident, `client` and `alt` hold text the author did not write. + * For a kind that is not a [SearchableEvent]: tag names whose values are not worth searching — + * `p`/`e`/`a` hold hex ids that only ever match by accident, `client` and `alt` hold text the + * author did not write. */ private val exceptTagNames: Set = DEFAULT_EXCLUDED_TAGS, ) { @@ -82,11 +89,9 @@ class EventSearchMatcher( event: Event, term: String, ): Boolean { - if (event.tags.fastAny { it.size > 1 && it[0] !in exceptTagNames && it[1].contains(term, true) }) { - return true - } - if (event !is SearchableEvent) return event.content.contains(term, true) - return visitor.matches(event, term) + if (event is SearchableEvent) return visitor.matches(event, term) + return event.content.contains(term, true) || + event.tags.fastAny { it.size > 1 && it[0] !in exceptTagNames && it[1].contains(term, true) } } /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcherTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcherTest.kt index cf35a7b7a7..fd6a049a3d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcherTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcherTest.kt @@ -66,18 +66,49 @@ class EventSearchMatcherTest { } @Test - fun tagValuesAreSearchedExceptTheOnesNobodyMeansToSearch() { + fun aSearchableKindMatchesWhatItIndexesAndNothingElse() { + // Kind 1 indexes its subject and content, as a store's full-text index does; a hashtag + // the kind leaves out of its index is left out here too, so local and store search agree. assertTrue(matches("subj", note("body", arrayOf(arrayOf("subject", "a subject"))))) - assertTrue(matches("bitcoin", note("body", arrayOf(arrayOf("t", "bitcoin"))))) - // `p`/`e`/`a` hold hex ids, `client` and `alt` hold text the author did not write. + assertFalse(matches("bitcoin", note("body", arrayOf(arrayOf("t", "bitcoin"))))) assertFalse(matches("amethyst", note("body", arrayOf(arrayOf("client", "amethyst"))))) assertFalse(matches("deadbeef", note("body", arrayOf(arrayOf("p", "deadbeef".repeat(8)))))) } + @Test + fun aDecentralizedListItemMatchesItsNaturalLanguageTagsButNotItsData() { + val book = + note( + "", + arrayOf( + arrayOf("d", "ol-ol1141875w"), + arrayOf("title", "Herfsttij der Middeleeuwen"), + arrayOf("author", "Johan Huizinga"), + arrayOf("subject", "Middle Ages"), + arrayOf("medium", "music"), + arrayOf("cover", "https://covers.openlibrary.org/b/id/7245546-L.jpg"), + ), + kind = 39999, + ) + assertTrue(matches("huizinga", book)) + assertTrue(matches("middle ages", book)) + assertFalse(matches("music", book)) + assertFalse(matches("openlibrary", book)) + assertFalse(matches("ol1141875w", book)) + } + + @Test + fun aKindWithNoIndexableFieldsFallsBackToItsTagsAndContent() { + assertTrue(matches("bitcoin", note("+", arrayOf(arrayOf("t", "bitcoin")), kind = 7))) + assertTrue(matches("+", note("+", kind = 7))) + // `p`/`e`/`a` hold hex ids, `client` and `alt` hold text the author did not write. + assertFalse(matches("amethyst", note("+", arrayOf(arrayOf("client", "amethyst")), kind = 7))) + assertFalse(matches("deadbeef", note("+", arrayOf(arrayOf("p", "deadbeef".repeat(8))), kind = 7))) + } + @Test fun indexableFieldsBeyondContentAreSearched() { - // A long-form title lives in a tag, but its summary reaches the matcher through the - // visitor as well — both paths must find it. + // A long-form title and summary live in tags; they reach the matcher through the visitor. val article = note("the body", arrayOf(arrayOf("title", "Lightning"), arrayOf("summary", "a summary")), kind = 30023) assertTrue(matches("Lightning", article)) assertTrue(matches("summary", article))