diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFields.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFields.kt index d0e8720804..939914ce33 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFields.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFields.kt @@ -40,6 +40,25 @@ package com.vitorpamplona.quartz.nip50Search * once values are pre-joined a backend can't unmix them. Values produced by * [SearchFieldExtractor] are trimmed and non-empty; the types themselves do * not enforce it. + * + * ## PROFILE XOR TIERED — a contract, not an implementation detail + * + * A kind fills the profile roles or the content roles, NEVER both. The sealed + * type enforces it today, and weighted backends are entitled to depend on it: + * a ranker that scores the two role groups independently and SUMS them stays + * correct only while no document can answer from a naming column in each + * group. A shape that filled, say, [Profile.name] and [Tiered.primary] at + * once would claim the top band twice — in the store this extractor was + * built for, ~260 000 against the ~130 000 a whole-field title match earns, + * i.e. a document matching one word per column outranking one that IS the + * query. + * + * So a new kind that looks like both is a deliberate decision with a known + * downstream cost, not an accident of the extractor. Pick the shape the kind + * really has and route the rest through it — the way kind 31990 (an app + * handler, whose metadata is a kind-0 clone) returns [Profile] wholesale + * rather than a profile plus a tier. If a future shape genuinely must fill + * both, the backends that sum these groups have to be told. */ sealed interface IndexableFields { fun isEmpty(): Boolean diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt index c6ce826f23..b73326c311 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt @@ -27,25 +27,39 @@ import com.vitorpamplona.quartz.buzz.teams.TeamEvent import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent +import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent import com.vitorpamplona.quartz.experimental.trustedLists.TrustedListEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.feedDefinition.FeedDefinitionEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent +import com.vitorpamplona.quartz.nip15Marketplace.marketplace.MarketplaceEvent +import com.vitorpamplona.quartz.nip15Marketplace.product.ProductEvent +import com.vitorpamplona.quartz.nip15Marketplace.stall.StallEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent @@ -66,6 +80,7 @@ import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEven import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent @@ -78,6 +93,7 @@ import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent import com.vitorpamplona.quartz.nip5dNapplets.NappletSnapshotEvent import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip69P2pOrderEvents.P2POrderEvent import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -85,6 +101,7 @@ import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -92,6 +109,8 @@ import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent /** * Decomposes every [SearchableEvent] into [IndexableFields] by priority tier: @@ -148,8 +167,35 @@ object SearchFieldExtractor { tiers(event, event.title(), event.summary(), event.content) } + // NIP-15 kinds 30017/30018/30019/30020 -- the marketplace family + // keeps its name and description inside a JSON `content` blob, so + // the fallback dropped a stall/product/auction NAME into the body + // tier, where a title can never reach the title band. Decoding + // failures still reach the funnel, as they do for the buzz kinds. + is StallEvent -> { + event.stallData()?.let { tiers(event, it.name, it.description, null) } ?: tiers(event, null, null, null) + } + + // ProductEvent.categories() is `t` under another name, so the + // funnel already carries it in the hashtag role -- passing it + // again would index the same words twice. + is ProductEvent -> { + event.productData()?.let { tiers(event, it.name, it.description, null) } ?: tiers(event, null, null, null) + } + + is MarketplaceEvent -> { + event.marketplaceData()?.let { tiers(event, it.name, it.about, null) } ?: tiers(event, null, null, null) + } + + is AuctionEvent -> { + event.auctionData()?.let { tiers(event, it.name, it.description, null) } ?: tiers(event, null, null, null) + } + + // The clone URL is as much a repository's public address as its + // homepage is -- `github.com/owner/repo.git` is how most people + // would search for it -- so both fill the affiliation role. is GitRepositoryEvent -> { - tiers(event, listOf(event.name()), listOf(event.description()), event.content, websites = event.webs()) + tiers(event, listOf(event.name()), listOf(event.description()), event.content, websites = (event.webs() + event.clones()).distinct()) } is GitIssueEvent -> { @@ -238,8 +284,10 @@ object SearchFieldExtractor { tiers(event, event.title(), event.summary(), event.content) } + // endpoint() is the `streaming` tag -- the same role + // LiveActivitiesEvent.streaming() fills above. is MeetingSpaceEvent -> { - tiers(event, event.room(), event.summary(), event.content) + tiers(event, event.room(), event.summary(), event.content, website = event.endpoint()) } is MeetingRoomEvent -> { @@ -282,10 +330,29 @@ object SearchFieldExtractor { tiers(event, listOf(event.title()), listOf(event.description()), null, websites = event.websites()) } + // kinds 30054/30055 -- the Podcasting 2.0 pair carries the same + // title/description shape kind 54 does and was falling through: + // an episode title indexed as body text. topics() is hashtags() + // under another name, so the funnel carries it once. + is Podcasting20EpisodeEvent -> { + tiers(event, event.title(), event.description(), event.content) + } + + is Podcasting20TrailerEvent -> { + tiers(event, event.title(), null, event.content) + } + is GroupMetadataEvent -> { tiers(event, event.name(), event.about(), null) } + // kind 9002 edits the very metadata kind 39000 publishes, so it + // splits the same way -- it was the only half of the pair falling + // through. Its hashtags() is `t`, carried once by the funnel. + is EditMetadataEvent -> { + tiers(event, event.name(), event.about(), null) + } + is InterestSetEvent -> { tiers(event, event.title(), event.description(), null) } @@ -328,12 +395,14 @@ object SearchFieldExtractor { tiers(event, event.title(), event.description(), null, website = event.url()) } + // A site's `source` tag is the URL its files were published + // from -- the same affiliation role a repo's homepage fills. is NamedSiteEvent -> { - tiers(event, event.title(), event.description(), null) + tiers(event, event.title(), event.description(), null, website = event.source()) } is RootSiteEvent -> { - tiers(event, event.title(), event.description(), null) + tiers(event, event.title(), event.description(), null, website = event.source()) } is RootNappletEvent -> { @@ -412,6 +481,50 @@ object SearchFieldExtractor { tiers(event, null, event.summary(), event.content) } + // kinds 1065/1163 -- summary-only kinds. Their whole searchable + // text IS a summary, so it belongs in the summary tier, next to + // kind 1063's, rather than in the body tier the fallback gave it. + is FileStorageHeaderEvent -> { + tiers(event, null, event.summary(), null) + } + + is ProfileGalleryEntryEvent -> { + tiers(event, null, event.summary(), null) + } + + // kind 2473 -- a sighting IS its species, under both names: the + // scientific one from the `n` tag and the vernacular one + // commonName() parses out of the `alt`. Once that parse succeeds + // the alt is only Birdstar's boilerplate wrapper around the two + // ("Bird detection: ()"), so indexing it + // whole would repeat both names in a second role; the alt is + // carried only when it is NOT that shape and so holds text of its + // own. + is BirdDetectionEvent -> { + val common = event.commonName() + tiers(event, listOf(event.speciesName(), common), listOf(event.summary().takeIf { common == null }), null) + } + + // kind 12473 is a life LIST, not a sighting: its species names are + // an unbounded collection, so they sit in the secondary tier with + // a torrent's file names rather than claiming the title band once + // per bird. + is BirdexEvent -> { + tiers(event, emptyList(), listOf(event.summary()) + event.speciesNames(), null) + } + + // kind 38192 -- the save's title is a title; region and filename + // are the keywords beside it, like a torrent's file names. + is Ps1SaveEvent -> { + tiers(event, listOf(event.saveTitle()), listOf(event.summary(), event.region(), event.filename()), null) + } + + // kind 38383 -- an order is looked up by who is offering it; + // currency and payment methods are the keywords that qualify it. + is P2POrderEvent -> { + tiers(event, listOf(event.makerName()), listOf(event.currency()) + event.paymentMethods().orEmpty(), null) + } + is AudioTrackEvent -> { tiers(event, event.subject(), null, null) } @@ -458,6 +571,46 @@ object SearchFieldExtractor { } } + // kind 1010 -- `content` is the proposed replacement text and + // `summary` describes the edit, so they split the way kind 1063's + // summary and body do (indexableContent concatenates them in the + // other order; the roles, not the order, are what a weighted + // backend reads). + is TextNoteModificationEvent -> { + tiers(event, null, event.summary(), event.content) + } + + // kinds 1068/6969 -- the question is the body, the option labels + // are short answer-like values a searcher matches whole, so they + // sit in the secondary tier UNJOINED instead of being appended to + // the body the way indexableContent() has to. + is PollEvent -> { + tiers(event, emptyList(), event.options().map { it.label }, event.content) + } + + is ZapPollEvent -> { + tiers(event, emptyList(), event.pollOptionsArray().map { it.descriptor }, event.content) + } + + // kind 1985 -- the label values are the keywords of the event; + // the reasoning, if any, is the body. + is LabelEvent -> { + tiers(event, emptyList(), event.labels().map { it.label }, event.content) + } + + // kinds 1111/1311 -- body-only kinds whose indexableContent() + // concatenates the `t` tags INTO the body. The funnel already + // carries them in the hashtag role, so passing the body alone + // stops the same words being indexed twice (the ContactCard + // reasoning above, applied to the two chat/comment kinds). + is CommentEvent -> { + tiers(event, null, null, event.content) + } + + is LiveActivitiesChatMessageEvent -> { + tiers(event, null, null, event.content) + } + // kind 1 LAST among the explicit branches, defensively: a future // kind extending the text-note base must hit its own branch first. is TextNoteEvent -> { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt index dcebf5b636..227b8b531c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt @@ -21,16 +21,31 @@ package com.vitorpamplona.quartz.nip50Search import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip15Marketplace.product.ProductEvent +import com.vitorpamplona.quartz.nip15Marketplace.stall.StallEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.nip32Labeling.LabelEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip69P2pOrderEvents.P2POrderEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import kotlin.test.Test import kotlin.test.assertEquals @@ -219,4 +234,206 @@ class SearchFieldExtractorTest { val fields = SearchFieldExtractor.extract(MetadataEvent("9".repeat(64), alice, 1L, tags, "{}", "")) assertEquals(IndexableFields.None, fields) } + + @Test + fun marketplaceNamesReachTheTitleTierNotTheBody() { + // NIP-15 keeps the stall's name inside a JSON content blob. Falling + // through to the catch-all put that NAME in the body tier, where a + // weighted backend can never rank it as a title. + val content = """{"id":"s1","name":"Vitor's Coffee","description":"beans from Minas","currency":"BRL"}""" + val fields = SearchFieldExtractor.extract(StallEvent("20".repeat(32), alice, 1L, arrayOf(arrayOf("d", "s1")), content, "")) + assertEquals(IndexableFields.Tiered(primary = listOf("Vitor's Coffee"), secondary = listOf("beans from Minas")), fields) + } + + @Test + fun productCategoriesAreCarriedOnceAsHashtags() { + // categories() is `t` under another name: indexableContent() + // concatenates it into the flat blob, but the funnel already carries + // it in the hashtag role, so the branch must not pass it again. + val content = """{"id":"p1","stall_id":"s1","name":"Bag of Beans","description":"1kg","currency":"BRL","price":90.0}""" + val tags = arrayOf(arrayOf("d", "p1"), arrayOf("t", "coffee")) + val fields = SearchFieldExtractor.extract(ProductEvent("21".repeat(32), alice, 1L, tags, content, "")) + assertEquals( + IndexableFields.Tiered(primary = listOf("Bag of Beans"), secondary = listOf("1kg"), hashtags = listOf("coffee")), + fields, + ) + } + + @Test + fun unparseableMarketplaceContentStillIndexesItsHashtags() { + val fields = SearchFieldExtractor.extract(StallEvent("22".repeat(32), alice, 1L, arrayOf(arrayOf("t", "coffee")), "not json", "")) + assertEquals(IndexableFields.Tiered(hashtags = listOf("coffee")), fields) + } + + @Test + fun groupMetadataEditsSplitLikeTheMetadataTheyEdit() { + // kind 9002 edits what kind 39000 publishes; it was the only half of + // the pair without a branch. Its hashtags() is `t`: carried once. + val tags = arrayOf(arrayOf("h", "grp"), arrayOf("name", "Nostr Devs"), arrayOf("about", "we build"), arrayOf("t", "nostr")) + val fields = SearchFieldExtractor.extract(EditMetadataEvent("23".repeat(32), alice, 1L, tags, "", "")) + assertEquals( + IndexableFields.Tiered(primary = listOf("Nostr Devs"), secondary = listOf("we build"), hashtags = listOf("nostr")), + fields, + ) + } + + @Test + fun podcasting20EpisodesSplitLikeEveryOtherTitledKind() { + val tags = arrayOf(arrayOf("d", "ep1"), arrayOf("title", "Episode 42"), arrayOf("description", "on search"), arrayOf("t", "podcast")) + val fields = SearchFieldExtractor.extract(Podcasting20EpisodeEvent("24".repeat(32), alice, 1L, tags, "show notes", "")) + assertEquals( + IndexableFields.Tiered(primary = listOf("Episode 42"), secondary = listOf("on search"), text = "show notes", hashtags = listOf("podcast")), + fields, + ) + } + + @Test + fun summaryOnlyKindsUseTheSummaryTier() { + // kind 1065's whole searchable text IS a summary -- it belongs beside + // kind 1063's, not in the body tier. + val tags = arrayOf(arrayOf("summary", "the quarterly report")) + val fields = SearchFieldExtractor.extract(FileStorageHeaderEvent("25".repeat(32), alice, 1L, tags, "", "")) + assertEquals(IndexableFields.Tiered(secondary = listOf("the quarterly report")), fields) + } + + @Test + fun birdSightingsAreNamedByTheirSpeciesUnderBothNames() { + // Birdstar's `alt` is a boilerplate wrapper around the two names, so + // once commonName() has parsed it there is nothing left in it to + // index -- carrying it whole would repeat both names in a second role. + val tags = + arrayOf( + arrayOf("n", "Porphyrio martinica"), + arrayOf("alt", "Bird detection: Purple Gallinule (Porphyrio martinica)"), + ) + val fields = SearchFieldExtractor.extract(BirdDetectionEvent("26".repeat(32), alice, 1L, tags, "", "")) + assertEquals(IndexableFields.Tiered(primary = listOf("Porphyrio martinica", "Purple Gallinule")), fields) + } + + @Test + fun aBirdSightingWithAnUnrecognizedAltStillIndexesIt() { + // A publisher that words the alt differently keeps it: the summary is + // dropped only when commonName() proves it was the boilerplate. + val tags = arrayOf(arrayOf("n", "Ramphastos toco"), arrayOf("alt", "a toucan at the feeder")) + val fields = SearchFieldExtractor.extract(BirdDetectionEvent("3a".repeat(32), alice, 1L, tags, "", "")) + assertEquals( + IndexableFields.Tiered(primary = listOf("Ramphastos toco"), secondary = listOf("a toucan at the feeder")), + fields, + ) + } + + @Test + fun aLifeListKeepsItsSpeciesOutOfTheTitleTier() { + // kind 12473 is an unbounded collection, not a sighting: one title + // band per bird would dilute the tier the way a torrent's file list + // would. + val tags = arrayOf(arrayOf("n", "Ramphastos toco"), arrayOf("n", "Porphyrio martinica"), arrayOf("alt", "my life list")) + val fields = SearchFieldExtractor.extract(BirdexEvent("3b".repeat(32), alice, 1L, tags, "", "")) + assertEquals( + IndexableFields.Tiered(secondary = listOf("my life list", "Ramphastos toco", "Porphyrio martinica")), + fields, + ) + } + + @Test + fun saveTitlesAreTitlesAndTheRestAreKeywords() { + val tags = + arrayOf( + arrayOf("d", "save1"), + arrayOf("title", "Final Fantasy VII"), + arrayOf("filename", "BASCUS-94163"), + arrayOf("region", "NTSC-U"), + arrayOf("alt", "a memory card save"), + ) + val fields = SearchFieldExtractor.extract(Ps1SaveEvent("27".repeat(32), alice, 1L, tags, "", "")) + assertEquals( + IndexableFields.Tiered( + primary = listOf("Final Fantasy VII"), + secondary = listOf("a memory card save", "NTSC-U", "BASCUS-94163"), + ), + fields, + ) + } + + @Test + fun p2pOrdersAreNamedByTheirMaker() { + val tags = arrayOf(arrayOf("d", "o1"), arrayOf("name", "Satoshi"), arrayOf("f", "BRL"), arrayOf("pm", "pix", "wire")) + val fields = SearchFieldExtractor.extract(P2POrderEvent("28".repeat(32), alice, 1L, tags, "", "")) + assertEquals( + IndexableFields.Tiered(primary = listOf("Satoshi"), secondary = listOf("BRL", "pix", "wire")), + fields, + ) + } + + @Test + fun pollOptionsAreCarriedUnjoinedInTheSecondaryTier() { + // indexableContent() has to append the labels to the body; the roles + // keep them separate, so the backend chooses how to weight them. + val tags = arrayOf(arrayOf("option", "1", "Coffee"), arrayOf("option", "2", "Tea")) + val fields = SearchFieldExtractor.extract(PollEvent("29".repeat(32), alice, 1L, tags, "what should I drink?", "")) + assertEquals( + IndexableFields.Tiered(secondary = listOf("Coffee", "Tea"), text = "what should I drink?"), + fields, + ) + } + + @Test + fun labelValuesAreKeywordsNotBody() { + val tags = arrayOf(arrayOf("l", "spam", "report"), arrayOf("L", "report")) + val fields = SearchFieldExtractor.extract(LabelEvent("2b".repeat(32), alice, 1L, tags, "obvious bot", "")) + assertEquals(IndexableFields.Tiered(secondary = listOf("spam"), text = "obvious bot"), fields) + } + + @Test + fun commentHashtagsAreNotIndexedTwice() { + // indexableContent() concatenates the `t` tags INTO the body for kind + // 1111; the funnel already carries them in the hashtag role, so the + // branch passes the body alone. + val tags = arrayOf(arrayOf("t", "nostr")) + val fields = SearchFieldExtractor.extract(CommentEvent("2c".repeat(32), alice, 1L, tags, "good point", "")) + assertEquals(IndexableFields.Tiered(text = "good point", hashtags = listOf("nostr")), fields) + } + + @Test + fun repositoriesAreFindableByCloneUrlAndHomepage() { + val tags = + arrayOf( + arrayOf("d", "amethyst"), + arrayOf("name", "Amethyst"), + arrayOf("description", "a nostr client"), + arrayOf("web", "https://amethyst.social"), + arrayOf("clone", "https://github.com/vitorpamplona/amethyst.git"), + // A repo whose homepage IS its clone URL must not index it twice. + arrayOf("clone", "https://amethyst.social"), + ) + val fields = SearchFieldExtractor.extract(GitRepositoryEvent("2d".repeat(32), alice, 1L, tags, "", "")) + assertEquals( + IndexableFields.Tiered( + primary = listOf("Amethyst"), + secondary = listOf("a nostr client"), + websites = listOf("https://amethyst.social", "https://github.com/vitorpamplona/amethyst.git"), + ), + fields, + ) + } + + @Test + fun meetingSpacesCarryTheirStreamingUrlLikeLiveActivitiesDo() { + val tags = arrayOf(arrayOf("d", "room1"), arrayOf("title", "Design Sync"), arrayOf("streaming", "https://nests.example/room1")) + val fields = SearchFieldExtractor.extract(MeetingSpaceEvent("2e".repeat(32), alice, 1L, tags, "", "")) + assertEquals( + IndexableFields.Tiered(primary = listOf("Design Sync"), websites = listOf("https://nests.example/room1")), + fields, + ) + } + + @Test + fun staticSitesCarryTheirSourceUrl() { + val tags = arrayOf(arrayOf("d", "blog"), arrayOf("title", "My Blog"), arrayOf("source", "https://github.com/me/blog")) + val fields = SearchFieldExtractor.extract(NamedSiteEvent("2f".repeat(32), alice, 1L, tags, "", "")) + assertEquals( + IndexableFields.Tiered(primary = listOf("My Blog"), websites = listOf("https://github.com/me/blog")), + fields, + ) + } }