feat: index parsed JSON fields of profile/channel/app-handler events

Kinds 0 (profile), 40/41 (channel create/metadata) and 31990 (app handler)
store their data as JSON in content. Implement SearchableEvent on them by
parsing the JSON (via the existing UserMetadata/ChannelData/AppMetadata
accessors) and indexing only the meaningful fields — names, bio/about, and
the addresses people search by: nip05 email, lightning addresses
(lud06/lud16), and website/picture/banner URLs. This avoids indexing the
JSON keys and structural punctuation that raw-content indexing would add.

Updates the FsSearchTest "non-searchable" case to use an unknown kind, since
MetadataEvent is now searchable, and adds SearchTest coverage for profile
and channel JSON fields (name, about, email, lightning, URL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
This commit is contained in:
Claude
2026-06-18 19:33:10 +00:00
parent a689d5cb19
commit e400a48dbf
6 changed files with 107 additions and 11 deletions
@@ -48,6 +48,7 @@ import com.vitorpamplona.quartz.nip39ExtIdentities.githubClaim
import com.vitorpamplona.quartz.nip39ExtIdentities.mastodonClaim
import com.vitorpamplona.quartz.nip39ExtIdentities.replaceClaims
import com.vitorpamplona.quartz.nip39ExtIdentities.twitterClaim
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.text
@@ -64,9 +65,29 @@ class MetadataEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
SearchableEvent {
override fun isContentEncoded() = true
// Profile content is JSON, so we parse it and index only the
// human-meaningful fields: names, bio, plus the addresses people search
// by (nip05 email, lightning addresses, website/picture/banner URLs).
// Mirrors the in-memory searchable set in UserMetadata.anyPropertyContains.
override fun indexableContent() =
contactMetaData()?.let {
listOfNotNull(
it.name,
it.displayName,
it.about,
it.nip05,
it.lud06,
it.lud16,
it.website,
it.picture,
it.banner,
).joinToString(" ")
} ?: ""
fun contactMetadataJson() =
try {
Json.parseToJsonElement(content) as JsonObject
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData
import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
@@ -45,7 +46,10 @@ class ChannelCreateEvent(
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider {
EventHintProvider,
SearchableEvent {
override fun indexableContent() = channelInfo().let { listOfNotNull(it.name, it.about, it.picture).joinToString(" ") }
@kotlinx.serialization.Transient
@kotlin.jvm.Transient
var cache: ChannelDataNorm? = null
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData
import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm
import com.vitorpamplona.quartz.nip28PublicChat.base.channel
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
@@ -48,7 +49,10 @@ class ChannelMetadataEvent(
content: String,
sig: HexKey,
) : BasePublicChatEvent(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider {
EventHintProvider,
SearchableEvent {
override fun indexableContent() = channelInfo().let { listOfNotNull(it.name, it.about, it.picture).joinToString(" ") }
@kotlinx.serialization.Transient
@kotlin.jvm.Transient
var cache: ChannelDataNorm? = null
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType
import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.PlatformLinkTag
import com.vitorpamplona.quartz.utils.Log
@@ -50,7 +51,27 @@ class AppDefinitionEvent(
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PublishedAtProvider {
PublishedAtProvider,
SearchableEvent {
// App-handler content is JSON; parse it and index the human-meaningful
// fields plus the addresses/URLs people search by.
override fun indexableContent() =
appMetaData()?.let {
listOfNotNull(
it.name,
it.username,
it.displayName,
it.about,
it.nip05,
it.lud06,
it.lud16,
it.website,
it.picture,
it.banner,
it.image,
).joinToString(" ")
} ?: ""
@kotlinx.serialization.Transient
@kotlin.jvm.Transient
private var cachedMetadata: AppMetadata? = null
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -165,4 +166,48 @@ class SearchTest : BaseDBTest() {
db.assertQuery(repo, Filter(search = "uniqdesc"))
db.assertQuery(repo, Filter(kinds = listOf(GitRepositoryEvent.KIND), search = "uniqdesc"))
}
@Test
fun testProfileJsonFieldsAreSearchable() =
forEachDB { db ->
// Kind-0 content is JSON; we parse it and index names, bio, and the
// addresses people search by (nip05 email, lightning address, URLs).
val p =
signer.sign(
MetadataEvent.createNew(
name = "uniqalice",
about = "loves uniqbio and coffee",
nip05 = "alice@uniqmail.example",
lnAddress = "alice@uniqln.example",
website = "https://uniqsite.example",
),
)
db.store.insertEvent(p)
db.assertQuery(p, Filter(search = "uniqalice")) // name
db.assertQuery(p, Filter(search = "uniqbio")) // about
db.assertQuery(p, Filter(search = "uniqmail")) // nip05 email
db.assertQuery(p, Filter(search = "uniqln")) // lightning address
db.assertQuery(p, Filter(search = "uniqsite")) // website URL
db.assertQuery(p, Filter(kinds = listOf(MetadataEvent.KIND), search = "uniqalice"))
}
@Test
fun testChannelJsonFieldsAreSearchable() =
forEachDB { db ->
val chan =
signer.sign(
ChannelCreateEvent.build(
name = "uniqchan",
about = "a uniqtopic discussion",
picture = "https://uniqpic.example/c.jpg",
relays = null,
),
)
db.store.insertEvent(chan)
db.assertQuery(chan, Filter(search = "uniqchan")) // name
db.assertQuery(chan, Filter(search = "uniqtopic")) // about
db.assertQuery(chan, Filter(search = "uniqpic")) // picture URL
}
}
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.fs
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -117,17 +117,18 @@ class FsSearchTest {
@Test
fun `non-searchable event does not produce fts entries`() =
runBlocking {
val meta =
signer.sign<MetadataEvent>(
// An event whose kind has no SearchableEvent mapping must not be indexed.
val nonSearchable =
signer.sign<Event>(
createdAt = 1,
kind = MetadataEvent.KIND,
kind = 9999,
tags = emptyArray(),
content = "{\"name\":\"vitor\"}",
content = "this text must not be indexed",
)
store.insert(meta)
store.insert(nonSearchable)
val ftsRoot = root.resolve("idx/fts")
assertEquals(0, ftsRoot.listDirectoryEntries().size, "MetadataEvent is not SearchableEvent")
assertEquals(0, ftsRoot.listDirectoryEntries().size, "unknown kind is not SearchableEvent")
}
@Test