diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index 08f00d54ad..2a3d427b70 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -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>, 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt index b4d0d59f93..be1db07883 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt index f3208477b2..6395940cf2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt index d2f4bc68dd..a250490e7f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt @@ -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 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt index 6ad7ae2d25..ebcc4081b7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt @@ -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 + } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt index 3678d795c4..2d1c8013df 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt @@ -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( + // An event whose kind has no SearchableEvent mapping must not be indexed. + val nonSearchable = + signer.sign( 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