mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
fix(quartz): local search matches a searchable kind only by what it indexes
EventSearchMatcher substring-matched the first value of every tag (minus client/p/e/a/alt) before walking the event's indexable fields, so local search found what no full-text store would: a list item's `medium` "music", cover URLs, ids in `d`, hashtags a kind leaves out of its index. A SearchableEvent now matches through forEachIndexableField alone, the read side of the same indexableContent() the stores index, so local and store search agree. Kinds that are not SearchableEvent have no indexable fields and keep matching their tag values and content. On the recorded relay corpus (tools/search-parity) the match set is unchanged (56 of 64 before and after). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XJSf4ekFVuhSj5m8eeNsP
This commit is contained in:
+14
-9
@@ -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<String> = 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) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+36
-5
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user