feat(quartz): add Tapestry Decentralized Lists (kinds 9998/39998/9999/39999)

Implements the Decentralized Lists draft from Tapestry under
experimental/decentralizedLists, laid out like nip88Polls: a header/
sub-package for list declarations and an item/ sub-package for community
contributed items, each with events, TagArrayExt readers, builder
extensions and tags/.

- Headers: names/titles/slugs (singular + plural), description, and
  required/allowed/recommended/disallowed rules with the optional
  third-element description.
- Items: z parent pointers classified as event id, coordinate or plain
  list name; p/e/t/a item tags (t kept case-sensitive, a accepts naddr);
  name/title/slug/description/comments; hint providers.
- Nonstandard method: items expose header accessors and declaresList().
- Registered in EventFactory, KindNames and SearchableKinds; all four
  kinds are searchable over their human-authored tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2NXYQuDhvx4cmH1Nq5Zxq
This commit is contained in:
Claude
2026-09-25 14:33:37 +00:00
parent 32beaeece8
commit 97bc4baffc
29 changed files with 2010 additions and 1 deletions
@@ -4,7 +4,7 @@ Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexabl
expression. **Update this file in the same PR as any change to the searchable set or to an
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-09-17.
Counts: 133 concrete classes covering 136 kind values (`GitStatusEvent` spans 4 kinds;
Counts: 137 concrete classes covering 140 kind values (`GitStatusEvent` spans 4 kinds;
kind 30063 has a collision — see the footnote). File paths are under
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`.
@@ -65,6 +65,8 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 9736 | Bolt12ZapEvent | nipB1Bolt12Zaps/zap | `content` |
| 9737 | Bolt12ZapIntentEvent | nipB1Bolt12Zaps/intent | `content` |
| 9802 | HighlightEvent | nip84Highlights | `listOfNotNull(comment(), context(), content)` NL |
| 9998 | ListHeaderEvent | experimental/decentralizedLists/header | `tags.searchableListContent()` NL — `names` (singular, plural), `titles` (singular, plural), `name`, `title`, `description`, `comments`, then every `t` value; ids/pubkeys/coordinates are left to tag filters |
| 9999 | ListItemEvent | experimental/decentralizedLists/item | same as 9998 |
| 10003 | BookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL |
| 10100 | AgentProfileEvent | buzz/agentProfiles | `profileOrNull()?.let { listOfNotNull(it.name, it.displayName).joinToString("\n") } ?: ""` |
| 10154 | PodcastMetadataEvent | nipF4Podcasts/metadata | `listOfNotNull(title(), description())` NL |
@@ -140,6 +142,8 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 39092 | MediaStarterPackEvent | nip51Lists/mediaStarterPack | `listOfNotNull(title(), description())` NL |
| 39307 | TextTrackEvent | nip71Video/textTrack | `content` (the WebVTT cue text) |
| 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL |
| 39998 | AddressableListHeaderEvent | experimental/decentralizedLists/header | same as 9998 |
| 39999 | AddressableListItemEvent | experimental/decentralizedLists/item | same as 9998 |
| 40002 | StreamMessageV2Event | buzz/stream | `content` |
| 40100 | CanvasEvent | buzz/stream | `content` |
| 45001 | ForumPostEvent | buzz/forum | `content` |
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists
import com.vitorpamplona.quartz.nip01Core.core.IEvent
/**
* Common face of the four Decentralized Lists kinds (9998, 39998, 9999, 39999).
*
* Any of them can be the parent of a list item: headers are the standard parents, and the
* spec's "nonstandard" method declares a list with a 9999/39999 item whose own parent is a
* list of lists. So every kind knows how children must point at it.
*/
interface DecentralizedListEvent : IEvent {
/**
* The value a child item writes in its `z` tag to point at this event: the event id for
* the regular kinds, the `kind:pubkey:d` coordinate for the addressable ones.
*/
fun listPointer(): String
}
/**
* The item kinds (9999, 39999). Items point at their parent list with one or more `z` tags and
* carry the item itself in `p` / `e` / `t` / `a` tags.
*/
interface DecentralizedListItem : DecentralizedListEvent
@@ -0,0 +1,90 @@
# Decentralized Lists (kinds 9998, 39998, 9999, 39999)
Lists that anyone can add to. Where a NIP-51 list is fully controlled by its
author, a Decentralized List only has its *header* controlled by its author;
the *items* are contributed by the community, one event each, and curation is
left to the reader's trust metric (typically fed by NIP-25 `+`/`-` reactions
to the item events).
This is a pre-NIP wire format drafted by Tapestry
([spec](https://github.com/nous-clawds4/tapestry/blob/main/protocols/nips/decentralized-lists.md)).
The package mirrors `nip88Polls`: one sub-package per role, each with its
events, `TagArrayExt` (readers), `TagArrayBuilderExt` (writers) and `tags/`.
| Kind | Role | Editable | Quartz class |
|---|---|---|---|
| 9998 | list header | no | `header.ListHeaderEvent` |
| 39998 | list header | yes (addressable) | `header.AddressableListHeaderEvent` |
| 9999 | list item | no | `item.ListItemEvent` |
| 39999 | list item | yes (addressable) | `item.AddressableListItemEvent` |
All four implement `DecentralizedListEvent`, whose `listPointer()` is what a
child writes in its `z` tag: the event id for 9998/9999, the
`kind:pubkey:d` coordinate for 39998/39999.
## Header tags
- `["names", <singular>, <plural>]` — required. `titles` and `slugs` have the
same two-form shape (`SingularPlural`); a tag missing either form is dropped.
- `["required" | "allowed" | "recommended" | "disallowed", <tag name>, <description>?]`
— one tag per constrained name, with an optional human-readable description
at index 2 (`TagRule`).
- `["description", <text>]`.
## Item tags
- `["z", <pointer>]` — required, one per parent list. `ParentListTag.classify`
tells the three allowed shapes apart by form: a 64-hex event id
(`ParentList.EventId`), a `kind:pubkey:d` coordinate
(`ParentList.Coordinate`), or anything else, the singular name of an
undeclared list such as `"dog"` (`ParentList.Name`).
- The items themselves: `p` (pubkeys), `e` (events), `t` (strings, kept in
their original case — they are values like "Fido", not hashtags) and `a`
(addressables; the spec's own example uses an `naddr1…`, which parses too).
- Optional `name`, `title`, `slug`, `description`, `comments`.
## The nonstandard method
The spec also allows declaring a list with an *item*: a 9999 on the list of
lists (`["z", "list"]` or `["z", <id of the list of lists>]`) that carries the
header tags. Item events therefore expose the header accessors too
(`names()`, `requiredTags()`, …), and `declaresList()` says whether one is
doing so. The header builder extensions are typed on the whole family for the
same reason.
## Building
```kotlin
val header =
ListHeaderEvent.build("dog name", "dog names", "Commonly used dog names.") {
required("t")
allowed("comments", "Why this name")
}
// once `header` is signed:
val item =
ListItemEvent.build(signedHeader) {
itemString("Fido")
comments("A classic")
}
```
## Retrieval
```kotlin
Filter(kinds = listOf(ListHeaderEvent.KIND, AddressableListHeaderEvent.KIND))
// every item on a list, across redundant declarations of it
Filter(
kinds = listOf(ListItemEvent.KIND, AddressableListItemEvent.KIND),
tags = mapOf("z" to listOf("dog", header.listPointer())),
)
```
## Search
All four kinds are `SearchableEvent`s sharing one walk
(`forEachSearchableListField`): `names` and `titles` (singular then plural),
`name`, `title`, `description`, `comments`, then every `t` item value.
`content` is not part of the spec, and ids, pubkeys and coordinates are
served by `#p`/`#e`/`#a`/`#z` filters, so none of them are indexed.
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.names
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.titles
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.comments
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.name
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.title
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
/**
* The human-authored text of any kind in the family, in a fixed order: the header's `names`
* and `titles` (singular, then plural), the item's `name` and `title`, `description`,
* `comments`, then each `t` item value. Headers and items share one walk because the spec's
* nonstandard method lets an item carry header tags; each kind simply has fewer of them set.
*
* `content` is not part of the spec and ids/pubkeys/coordinates are served by tag filters,
* so neither is indexed.
*
* @return false when the visitor stopped the walk.
*/
fun TagArray.forEachSearchableListField(visitor: IndexableFieldVisitor): Boolean {
names()?.let {
if (!visitor.visit(it.singular)) return false
if (!visitor.visit(it.plural)) return false
}
titles()?.let {
if (!visitor.visit(it.singular)) return false
if (!visitor.visit(it.plural)) return false
}
name()?.let { if (!visitor.visit(it)) return false }
title()?.let { if (!visitor.visit(it)) return false }
description()?.let { if (!visitor.visit(it)) return false }
comments()?.let { if (!visitor.visit(it)) return false }
fastForEach { tag ->
HashtagTag.parse(tag)?.let { if (!visitor.visit(it)) return false }
}
return true
}
/** The write-path join of [forEachSearchableListField]: one field per line. */
fun TagArray.searchableListContent() =
buildString {
forEachSearchableListField { field ->
if (field != null) {
if (isNotEmpty()) append('\n')
append(field)
}
true
}
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun <T : DecentralizedListEvent> TagArrayBuilder<T>.description(description: String) = addUnique(DescriptionTag.assemble(description))
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse)
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.header
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.description
import com.vitorpamplona.quartz.experimental.decentralizedLists.forEachSearchableListField
import com.vitorpamplona.quartz.experimental.decentralizedLists.searchableListContent
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Decentralized Lists: an editable list declaration, a.k.a. list header (kind 39998).
*
* Same tags as [ListHeaderEvent], but addressable: the author can revise it in place, and
* items point at it with a `z` tag holding its `39998:<pubkey>:<d>` coordinate, since its
* event id changes with every edit.
*/
@Immutable
class AddressableListHeaderEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DecentralizedListEvent,
SearchableEvent {
override fun listPointer() = addressTag()
override fun indexableContent() = tags.searchableListContent()
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
tags.forEachSearchableListField(visitor)
}
fun names() = tags.names()
fun titles() = tags.titles()
fun slugs() = tags.slugs()
fun description() = tags.description()
fun tagRules() = tags.tagRules()
fun requiredTags() = tags.requiredTags()
fun allowedTags() = tags.allowedTags()
fun recommendedTags() = tags.recommendedTags()
fun disallowedTags() = tags.disallowedTags()
companion object {
const val KIND = 39998
@OptIn(ExperimentalUuidApi::class)
fun build(
singularName: String,
pluralName: String,
description: String? = null,
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AddressableListHeaderEvent>.() -> Unit = {},
) = eventTemplate<AddressableListHeaderEvent>(KIND, "", createdAt) {
dTag(dTag)
names(singularName, pluralName)
description?.let { this.description(it) }
initializer()
}
}
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.header
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.description
import com.vitorpamplona.quartz.experimental.decentralizedLists.forEachSearchableListField
import com.vitorpamplona.quartz.experimental.decentralizedLists.searchableListContent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Decentralized Lists: an immutable list declaration, a.k.a. list header (kind 9998).
*
* The author declares *what* the list is — its `names`, and which tags its items must, may, or
* must not carry — but not its members: anyone contributes items with kind 9999/39999 events
* whose `z` tag holds this event's id. Use [AddressableListHeaderEvent] (39998) for an editable
* header.
*/
@Immutable
class ListHeaderEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
DecentralizedListEvent,
SearchableEvent {
override fun listPointer() = id
override fun indexableContent() = tags.searchableListContent()
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
tags.forEachSearchableListField(visitor)
}
fun names() = tags.names()
fun titles() = tags.titles()
fun slugs() = tags.slugs()
fun description() = tags.description()
fun tagRules() = tags.tagRules()
fun requiredTags() = tags.requiredTags()
fun allowedTags() = tags.allowedTags()
fun recommendedTags() = tags.recommendedTags()
fun disallowedTags() = tags.disallowedTags()
companion object {
const val KIND = 9998
fun build(
singularName: String,
pluralName: String,
description: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ListHeaderEvent>.() -> Unit = {},
) = eventTemplate<ListHeaderEvent>(KIND, "", createdAt) {
names(singularName, pluralName)
description?.let { this.description(it) }
initializer()
}
}
}
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.header
import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.NamesTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.SlugsTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRule
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRuleTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRuleType
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TitlesTag
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
// Typed on the whole family, not just the header kinds: the spec's nonstandard method
// declares a list with a 9999/39999 item, which then carries these same tags.
fun <T : DecentralizedListEvent> TagArrayBuilder<T>.names(
singular: String,
plural: String,
) = addUnique(NamesTag.assemble(singular, plural))
fun <T : DecentralizedListEvent> TagArrayBuilder<T>.titles(
singular: String,
plural: String,
) = addUnique(TitlesTag.assemble(singular, plural))
fun <T : DecentralizedListEvent> TagArrayBuilder<T>.slugs(
singular: String,
plural: String,
) = addUnique(SlugsTag.assemble(singular, plural))
fun <T : DecentralizedListEvent> TagArrayBuilder<T>.tagRule(rule: TagRule) = add(TagRuleTag.assemble(rule))
fun <T : DecentralizedListEvent> TagArrayBuilder<T>.tagRules(rules: List<TagRule>) = addAll(TagRuleTag.assemble(rules))
fun <T : DecentralizedListEvent> TagArrayBuilder<T>.required(
tagName: String,
description: String? = null,
) = add(TagRuleTag.assemble(TagRuleType.REQUIRED, tagName, description))
fun <T : DecentralizedListEvent> TagArrayBuilder<T>.allowed(
tagName: String,
description: String? = null,
) = add(TagRuleTag.assemble(TagRuleType.ALLOWED, tagName, description))
fun <T : DecentralizedListEvent> TagArrayBuilder<T>.recommended(
tagName: String,
description: String? = null,
) = add(TagRuleTag.assemble(TagRuleType.RECOMMENDED, tagName, description))
fun <T : DecentralizedListEvent> TagArrayBuilder<T>.disallowed(
tagName: String,
description: String? = null,
) = add(TagRuleTag.assemble(TagRuleType.DISALLOWED, tagName, description))
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.header
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.NamesTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.SlugsTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRuleTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRuleType
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TitlesTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
fun TagArray.names() = firstNotNullOfOrNull(NamesTag::parse)
fun TagArray.titles() = firstNotNullOfOrNull(TitlesTag::parse)
fun TagArray.slugs() = firstNotNullOfOrNull(SlugsTag::parse)
/** Every `required` / `allowed` / `recommended` / `disallowed` rule, in tag order. */
fun TagArray.tagRules() = mapNotNull(TagRuleTag::parse)
fun TagArray.tagRuleNames(type: TagRuleType) = mapNotNull { TagRuleTag.parseTagName(it, type) }
fun TagArray.requiredTags() = tagRuleNames(TagRuleType.REQUIRED)
fun TagArray.allowedTags() = tagRuleNames(TagRuleType.ALLOWED)
fun TagArray.recommendedTags() = tagRuleNames(TagRuleType.RECOMMENDED)
fun TagArray.disallowedTags() = tagRuleNames(TagRuleType.DISALLOWED)
/**
* True when the tags carry a list declaration. Standard headers (9998/39998) must; an item
* (9999/39999) that does is using the spec's "nonstandard" method to declare a list.
*/
fun TagArray.declaresList() = any(NamesTag::isTag)
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags
/** `["names", <singular>, <plural>]`. Required on every list header, e.g. `["names", "widget", "widgets"]`. */
class NamesTag {
companion object {
const val TAG_NAME = "names"
fun isTag(tag: Array<String>) = SingularPlural.isTag(tag, TAG_NAME)
fun parse(tag: Array<String>) = SingularPlural.parse(tag, TAG_NAME)
fun assemble(
singular: String,
plural: String,
) = SingularPlural.assemble(TAG_NAME, singular, plural)
fun assemble(value: SingularPlural) = assemble(value.singular, value.plural)
}
}
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The value of the `names`, `titles` and `slugs` header tags: a singular form ("widget")
* followed by a plural form ("widgets").
*/
@Immutable
data class SingularPlural(
val singular: String,
val plural: String,
) {
companion object {
// Both forms are required by the spec. A tag missing either one is malformed and
// dropped rather than guessed at: pluralizing someone else's noun is not our call.
fun isTag(
tag: Array<String>,
tagName: String,
) = tag.has(2) && tag[0] == tagName && tag[1].isNotEmpty() && tag[2].isNotEmpty()
fun parse(
tag: Array<String>,
tagName: String,
): SingularPlural? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == tagName) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(tag[2].isNotEmpty()) { return null }
return SingularPlural(tag[1], tag[2])
}
fun assemble(
tagName: String,
singular: String,
plural: String,
) = arrayOf(tagName, singular, plural)
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags
/** `["slugs", <singular>, <plural>]`. Optional URL-friendly identifier in singular and plural form. */
class SlugsTag {
companion object {
const val TAG_NAME = "slugs"
fun isTag(tag: Array<String>) = SingularPlural.isTag(tag, TAG_NAME)
fun parse(tag: Array<String>) = SingularPlural.parse(tag, TAG_NAME)
fun assemble(
singular: String,
plural: String,
) = SingularPlural.assemble(TAG_NAME, singular, plural)
fun assemble(value: SingularPlural) = assemble(value.singular, value.plural)
}
}
@@ -0,0 +1,105 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
/** How a list header constrains a tag on its children. The code is also the tag name. */
enum class TagRuleType(
val code: String,
) {
REQUIRED("required"),
ALLOWED("allowed"),
RECOMMENDED("recommended"),
DISALLOWED("disallowed"),
;
companion object {
fun fromCode(code: String) =
when (code) {
REQUIRED.code -> REQUIRED
ALLOWED.code -> ALLOWED
RECOMMENDED.code -> RECOMMENDED
DISALLOWED.code -> DISALLOWED
else -> null
}
}
}
/**
* One constraint a list header places on its items: `["required", "p"]`, optionally with a
* human-readable description of what the named tag represents:
* `["required", "p", "Pubkey of the person being endorsed"]`.
*
* The spec requires one tag per constrained name — `["required", "foo"], ["required", "bar"]`,
* never `["required", "foo", "bar"]`. That makes index 2 unambiguous: it is always the
* description, never a second tag name. The description is informational only.
*/
@Immutable
data class TagRule(
val type: TagRuleType,
val tagName: String,
val description: String? = null,
) {
fun toTagArray() = TagRuleTag.assemble(type, tagName, description)
}
class TagRuleTag {
companion object {
fun isTag(tag: Array<String>) = tag.has(1) && TagRuleType.fromCode(tag[0]) != null && tag[1].isNotEmpty()
fun isTag(
tag: Array<String>,
type: TagRuleType,
) = tag.has(1) && tag[0] == type.code && tag[1].isNotEmpty()
fun parse(tag: Array<String>): TagRule? {
ensure(tag.has(1)) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val type = TagRuleType.fromCode(tag[0]) ?: return null
return TagRule(type, tag[1], tag.getOrNull(2)?.ifEmpty { null })
}
/** The constrained tag name when [tag] is a rule of [type]. Skips building the rule. */
fun parseTagName(
tag: Array<String>,
type: TagRuleType,
): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == type.code) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(
type: TagRuleType,
tagName: String,
description: String? = null,
) = arrayOfNotNull(type.code, tagName, description)
fun assemble(rule: TagRule) = assemble(rule.type, rule.tagName, rule.description)
fun assemble(rules: List<TagRule>) = rules.map { assemble(it) }
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags
/** `["titles", <singular>, <plural>]`. Optional display title in singular and plural form. */
class TitlesTag {
companion object {
const val TAG_NAME = "titles"
fun isTag(tag: Array<String>) = SingularPlural.isTag(tag, TAG_NAME)
fun parse(tag: Array<String>) = SingularPlural.parse(tag, TAG_NAME)
fun assemble(
singular: String,
plural: String,
) = SingularPlural.assemble(TAG_NAME, singular, plural)
fun assemble(value: SingularPlural) = assemble(value.singular, value.plural)
}
}
@@ -0,0 +1,168 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.item
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListItem
import com.vitorpamplona.quartz.experimental.decentralizedLists.description
import com.vitorpamplona.quartz.experimental.decentralizedLists.forEachSearchableListField
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.allowedTags
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.declaresList
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.disallowedTags
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.names
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.recommendedTags
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.requiredTags
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.slugs
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tagRules
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.titles
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.searchableListContent
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Decentralized Lists: an editable list item declaration (kind 39999).
*
* Same tags as [ListItemEvent], plus the `d` tag that makes it addressable, so the contributor
* can revise the item in place. When it declares a list through the nonstandard method, its
* children point at its `39999:pubkey:d` coordinate.
*/
@Immutable
class AddressableListItemEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DecentralizedListItem,
EventHintProvider,
AddressHintProvider,
PubKeyHintProvider,
SearchableEvent {
override fun listPointer() = addressTag()
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint)
/** Event items plus any parent lists referenced by id. */
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(ParentListTag::parseEventId)
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
/** Addressable items plus any parent lists referenced by coordinate. */
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseValidAddress) + tags.mapNotNull { ParentListTag.parseAddress(it)?.toValue() }
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
override fun indexableContent() = tags.searchableListContent()
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
tags.forEachSearchableListField(visitor)
}
fun parentLists() = tags.parentLists()
fun parentListPointers() = tags.parentListPointers()
fun name() = tags.name()
fun title() = tags.title()
fun slug() = tags.slug()
fun description() = tags.description()
fun comments() = tags.comments()
fun itemPubKeys() = tags.itemPubKeys()
fun itemEvents() = tags.itemEvents()
fun itemStrings() = tags.itemStrings()
fun itemAddresses() = tags.itemAddresses()
/**
* True when this item uses the spec's nonstandard method to declare a list: it carries a
* `names` tag and sits on a list of lists. Its header fields then read the same way as a
* 9998's, and children point at it through [listPointer].
*/
fun declaresList() = tags.declaresList()
fun names() = tags.names()
fun titles() = tags.titles()
fun slugs() = tags.slugs()
fun tagRules() = tags.tagRules()
fun requiredTags() = tags.requiredTags()
fun allowedTags() = tags.allowedTags()
fun recommendedTags() = tags.recommendedTags()
fun disallowedTags() = tags.disallowedTags()
companion object {
const val KIND = 39999
@OptIn(ExperimentalUuidApi::class)
fun build(
parent: DecentralizedListEvent,
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AddressableListItemEvent>.() -> Unit = {},
) = build(ParentListTag.classify(parent.listPointer()), dTag, createdAt, initializer)
@OptIn(ExperimentalUuidApi::class)
fun build(
parent: ParentList,
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AddressableListItemEvent>.() -> Unit = {},
) = eventTemplate<AddressableListItemEvent>(KIND, "", createdAt) {
dTag(dTag)
parentList(parent)
initializer()
}
}
}
@@ -0,0 +1,165 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.item
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListItem
import com.vitorpamplona.quartz.experimental.decentralizedLists.description
import com.vitorpamplona.quartz.experimental.decentralizedLists.forEachSearchableListField
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.allowedTags
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.declaresList
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.disallowedTags
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.names
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.recommendedTags
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.requiredTags
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.slugs
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tagRules
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.titles
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.searchableListContent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Decentralized Lists: an immutable list item declaration (kind 9999).
*
* Anyone can publish one. Its `z` tags name the list(s) it belongs to — a header's event id, a
* header's `39998:pubkey:d` coordinate, or just a list name for undeclared lists — and its
* `p` / `e` / `t` / `a` tags carry the item(s) themselves, as the parent header's `required`
* and `allowed` rules ask. One `z` and one item tag per event is recommended, but several of
* each are valid. Use [AddressableListItemEvent] (39999) for an editable item.
*
* Curation is out of scope for the spec: whether an item is accepted is up to the reader's
* trust metric, typically fed by NIP-25 `+`/`-` reactions to this event.
*/
@Immutable
class ListItemEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
DecentralizedListItem,
EventHintProvider,
AddressHintProvider,
PubKeyHintProvider,
SearchableEvent {
override fun listPointer() = id
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint)
/** Event items plus any parent lists referenced by id. */
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(ParentListTag::parseEventId)
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
/** Addressable items plus any parent lists referenced by coordinate. */
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseValidAddress) + tags.mapNotNull { ParentListTag.parseAddress(it)?.toValue() }
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
override fun indexableContent() = tags.searchableListContent()
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
tags.forEachSearchableListField(visitor)
}
fun parentLists() = tags.parentLists()
fun parentListPointers() = tags.parentListPointers()
fun name() = tags.name()
fun title() = tags.title()
fun slug() = tags.slug()
fun description() = tags.description()
fun comments() = tags.comments()
fun itemPubKeys() = tags.itemPubKeys()
fun itemEvents() = tags.itemEvents()
fun itemStrings() = tags.itemStrings()
fun itemAddresses() = tags.itemAddresses()
/**
* True when this item uses the spec's nonstandard method to declare a list: it carries a
* `names` tag and sits on a list of lists. Its header fields then read the same way as a
* 9998's, and children point at it through [listPointer].
*/
fun declaresList() = tags.declaresList()
fun names() = tags.names()
fun titles() = tags.titles()
fun slugs() = tags.slugs()
fun tagRules() = tags.tagRules()
fun requiredTags() = tags.requiredTags()
fun allowedTags() = tags.allowedTags()
fun recommendedTags() = tags.recommendedTags()
fun disallowedTags() = tags.disallowedTags()
companion object {
const val KIND = 9999
fun build(
parent: DecentralizedListEvent,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ListItemEvent>.() -> Unit = {},
) = build(ParentListTag.classify(parent.listPointer()), createdAt, initializer)
fun build(
parent: ParentList,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ListItemEvent>.() -> Unit = {},
) = eventTemplate<ListItemEvent>(KIND, "", createdAt) {
parentList(parent)
initializer()
}
}
}
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.item
import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListItem
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.CommentsTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.NameTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.SlugTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.TitleTag
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.utils.arrayOfNotNull
fun <T : DecentralizedListItem> TagArrayBuilder<T>.parentList(parent: DecentralizedListEvent) = addUniqueValueIfNew(ParentListTag.assemble(parent))
fun <T : DecentralizedListItem> TagArrayBuilder<T>.parentList(parent: ParentList) = addUniqueValueIfNew(ParentListTag.assemble(parent))
/** Points at a list that was never formally declared, by its singular name (e.g. "dog"). */
fun <T : DecentralizedListItem> TagArrayBuilder<T>.parentListName(name: String) = addUniqueValueIfNew(ParentListTag.assemble(name))
fun <T : DecentralizedListItem> TagArrayBuilder<T>.parentLists(parents: List<ParentList>) = addAllUniqueValueIfNew(ParentListTag.assemble(parents))
fun <T : DecentralizedListItem> TagArrayBuilder<T>.name(name: String) = addUnique(NameTag.assemble(name))
fun <T : DecentralizedListItem> TagArrayBuilder<T>.title(title: String) = addUnique(TitleTag.assemble(title))
fun <T : DecentralizedListItem> TagArrayBuilder<T>.slug(slug: String) = addUnique(SlugTag.assemble(slug))
fun <T : DecentralizedListItem> TagArrayBuilder<T>.comments(comments: String) = addUnique(CommentsTag.assemble(comments))
fun <T : DecentralizedListItem> TagArrayBuilder<T>.itemPubKey(
pubKey: HexKey,
relayHint: NormalizedRelayUrl? = null,
) = add(PTag.assemble(pubKey, relayHint))
fun <T : DecentralizedListItem> TagArrayBuilder<T>.itemEvent(
eventId: HexKey,
relayHint: NormalizedRelayUrl? = null,
author: HexKey? = null,
) = add(
// Pads the relay slot when only the author is known, so the author stays at index 3.
arrayOfNotNull(ETag.TAG_NAME, eventId, relayHint?.url ?: author?.let { "" }, author),
)
/** A string item, written verbatim: unlike a hashtag it is not duplicated in lowercase. */
fun <T : DecentralizedListItem> TagArrayBuilder<T>.itemString(value: String) = add(HashtagTag.assemble(value))
fun <T : DecentralizedListItem> TagArrayBuilder<T>.itemAddress(
address: Address,
relayHint: NormalizedRelayUrl? = null,
) = add(ATag.assemble(address, relayHint))
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.item
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.CommentsTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.NameTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.SlugTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.TitleTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
/** Every list this item belongs to. One `z` tag per list; an item may sit on several. */
fun TagArray.parentLists() = mapNotNull(ParentListTag::parse)
/** The raw `z` values, as they would go into a `#z` filter. */
fun TagArray.parentListPointers() = mapNotNull(ParentListTag::parseValue)
fun TagArray.name() = firstNotNullOfOrNull(NameTag::parse)
fun TagArray.title() = firstNotNullOfOrNull(TitleTag::parse)
fun TagArray.slug() = firstNotNullOfOrNull(SlugTag::parse)
fun TagArray.comments() = firstNotNullOfOrNull(CommentsTag::parse)
/** Pubkeys declared as items (`p`). */
fun TagArray.itemPubKeys() = mapNotNull(PTag::parse)
/** Events declared as items (`e`). */
fun TagArray.itemEvents() = mapNotNull(ETag::parse)
/**
* Strings declared as items (`t`), with their case preserved. These are list values like
* "Switzerland" or "Fido", not hashtags, so they are not lowercased the way hashtag readers do.
*/
fun TagArray.itemStrings() = mapNotNull(HashtagTag::parse)
/** Addressable events declared as items (`a`). Accepts both `kind:pubkey:d` and `naddr1…`. */
fun TagArray.itemAddresses() = mapNotNull(ATag::parse)
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** `["comments", <text>]`. Free-text commentary from the contributor about the item. */
class CommentsTag {
companion object {
const val TAG_NAME = "comments"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(value: String) = arrayOf(TAG_NAME, value)
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** `["name", <text>]`. The item's name, e.g. `["name", "Derek Ross"]`. */
class NameTag {
companion object {
const val TAG_NAME = "name"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(value: String) = arrayOf(TAG_NAME, value)
}
}
@@ -0,0 +1,126 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.ensure
/**
* What a `z` tag points at. The spec allows three shapes in the same slot, told apart by form:
*
* - [EventId]: a 64-char hex id, for a kind 9998 header (or a 9999 used as a nonstandard header);
* - [Coordinate]: `kind:pubkey:d`, for a kind 39998 header, whose id changes on every edit;
* - [Name]: anything else — the singular name of a list that was never formally declared,
* e.g. `["z", "dog"]`. The spec allows it but prefers a declared header.
*/
@Immutable
sealed interface ParentList {
/** The raw value to write back in the `z` tag. */
val value: String
@Immutable
data class EventId(
val eventId: HexKey,
) : ParentList {
override val value get() = eventId
}
@Immutable
data class Coordinate(
val address: Address,
) : ParentList {
override val value get() = address.toValue()
}
@Immutable
data class Name(
val name: String,
) : ParentList {
override val value get() = name
}
}
/** `["z", <header event id | header coordinate | list name>]`: the list an item belongs to. */
class ParentListTag {
companion object {
const val TAG_NAME = "z"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun isTagged(
tag: Array<String>,
pointer: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == pointer
fun isTagged(
tag: Array<String>,
pointers: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in pointers
/** The raw pointer, without classifying it. */
fun parseValue(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun parse(tag: Array<String>): ParentList? = parseValue(tag)?.let(::classify)
fun parseEventId(tag: Array<String>): HexKey? {
val value = parseValue(tag) ?: return null
return if (isEventId(value)) value else null
}
fun parseAddress(tag: Array<String>): Address? {
val value = parseValue(tag) ?: return null
return if (looksLikeAddress(value)) AddressSerializer.parse(value) else null
}
fun classify(value: String): ParentList {
if (isEventId(value)) return ParentList.EventId(value)
if (looksLikeAddress(value)) {
AddressSerializer.parse(value)?.let { return ParentList.Coordinate(it) }
}
return ParentList.Name(value)
}
private fun isEventId(value: String) = value.length == 64 && Hex.isHex64(value)
// Only coordinate-shaped values reach the address parser: it logs a warning for
// everything it rejects, and a plain list name such as "dog" is not an error.
private fun looksLikeAddress(value: String) = value.length >= 66 && value.contains(':')
fun assemble(pointer: String) = arrayOf(TAG_NAME, pointer)
fun assemble(parent: ParentList) = assemble(parent.value)
fun assemble(parent: DecentralizedListEvent) = assemble(parent.listPointer())
fun assemble(parents: List<ParentList>) = parents.map { assemble(it) }
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** `["slug", <text>]`. A URL-friendly identifier for the item. */
class SlugTag {
companion object {
const val TAG_NAME = "slug"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(value: String) = arrayOf(TAG_NAME, value)
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** `["title", <text>]`. The item's display title. */
class TitleTag {
companion object {
const val TAG_NAME = "title"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(value: String) = arrayOf(TAG_NAME, value)
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** `["description", <text>]`, carried by both list headers and list items. */
class DescriptionTag {
companion object {
const val TAG_NAME = "description"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(description: String) = arrayOf(TAG_NAME, description)
}
}
@@ -35,6 +35,10 @@ import com.vitorpamplona.quartz.experimental.citations.PromptCitationEvent
import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent
import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent
import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.AddressableListHeaderEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.ListHeaderEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.ListItemEvent
import com.vitorpamplona.quartz.experimental.decoupling.setup.EncryptionKeyListEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
@@ -644,6 +648,10 @@ object KindNames {
EventTrustedListEvent.KIND to KindName("Trusted List of Events", null),
AddressableTrustedListEvent.KIND to KindName("Trusted List of Addressables", null),
ExternalIdTrustedListEvent.KIND to KindName("Trusted List of External IDs", null),
ListHeaderEvent.KIND to KindName("Decentralized List", null),
AddressableListHeaderEvent.KIND to KindName("Editable Decentralized List", null),
ListItemEvent.KIND to KindName("Decentralized List Item", null),
AddressableListItemEvent.KIND to KindName("Editable Decentralized List Item", null),
KeyPackageEvent.KIND to KindName("MLS KeyPackage", null),
GitRepositoryStateEvent.KIND to KindName("Git Repo State", "34"),
FeedDefinitionEvent.KIND to KindName("Feed Definition", null),
@@ -113,6 +113,8 @@ object SearchableKinds {
9736, // Bolt12ZapEvent
9737, // Bolt12ZapIntentEvent
9802, // HighlightEvent
9998, // ListHeaderEvent
9999, // ListItemEvent
10003, // BookmarkListEvent
10100, // AgentProfileEvent
10154, // PodcastMetadataEvent
@@ -194,6 +196,8 @@ object SearchableKinds {
39092, // MediaStarterPackEvent
39307, // TextTrackEvent
39701, // WebBookmarkEvent
39998, // AddressableListHeaderEvent
39999, // AddressableListItemEvent
40002, // StreamMessageV2Event
40100, // CanvasEvent
45001, // ForumPostEvent
@@ -124,6 +124,10 @@ import com.vitorpamplona.quartz.experimental.citations.PromptCitationEvent
import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent
import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent
import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.AddressableListHeaderEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.ListHeaderEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.ListItemEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
@@ -842,6 +846,10 @@ class EventFactory {
EventTrustedListEvent.KIND -> EventTrustedListEvent(id, pubKey, createdAt, tags, content, sig)
AddressableTrustedListEvent.KIND -> AddressableTrustedListEvent(id, pubKey, createdAt, tags, content, sig)
ExternalIdTrustedListEvent.KIND -> ExternalIdTrustedListEvent(id, pubKey, createdAt, tags, content, sig)
ListHeaderEvent.KIND -> ListHeaderEvent(id, pubKey, createdAt, tags, content, sig)
AddressableListHeaderEvent.KIND -> AddressableListHeaderEvent(id, pubKey, createdAt, tags, content, sig)
ListItemEvent.KIND -> ListItemEvent(id, pubKey, createdAt, tags, content, sig)
AddressableListItemEvent.KIND -> AddressableListItemEvent(id, pubKey, createdAt, tags, content, sig)
RelayAddMemberEvent.KIND -> RelayAddMemberEvent(id, pubKey, createdAt, tags, content, sig)
RelayRemoveMemberEvent.KIND -> RelayRemoveMemberEvent(id, pubKey, createdAt, tags, content, sig)
RelayMembershipListEvent.KIND -> RelayMembershipListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,313 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.AddressableListHeaderEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.ListHeaderEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.recommended
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.required
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRule
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRuleType
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.ListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemPubKey
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemString
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.name
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.parentListName
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The Tapestry Decentralized Lists tag layer against the spec's own examples
* (https://github.com/nous-clawds4/tapestry/blob/main/protocols/nips/decentralized-lists.md).
*/
class DecentralizedListsTest {
private val author = "dee45a23c4f1d93f3a2043650c5081e4ac14a778e0acbef03de3768e4f81ac7b"
private val derek = "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"
private val headerId = "9d1b6b9562e66f2ecf35eb0a3c2decc736c47fddb13d6fb8f87185a153ea3634"
private val otherHeaderId = "1fc80cf813f1af33d5a435862b7ef7fb96b47e68a48f1abcadf8081f5a545550"
private fun event(
kind: Int,
tags: Array<Array<String>>,
id: String = "a".repeat(64),
): Event = EventFactory.create(id, author, 1719888496L, kind, tags, "", "")
@Test
fun theFactoryBuildsEachKind() {
assertIs<ListHeaderEvent>(event(9998, emptyArray()))
assertIs<AddressableListHeaderEvent>(event(39998, emptyArray()))
assertIs<ListItemEvent>(event(9999, emptyArray()))
assertIs<AddressableListItemEvent>(event(39999, emptyArray()))
}
// Example 1: a list of nostr developers.
@Test
fun theSpecsHeaderExampleParses() {
val header =
assertIs<ListHeaderEvent>(
event(
9998,
arrayOf(
arrayOf("names", "nostr developer", "nostr developers"),
arrayOf("description", "This is a list of developers who build within the nostr ecosystem"),
arrayOf("required", "p"),
arrayOf("required", "name"),
),
id = headerId,
),
)
assertEquals("nostr developer", header.names()?.singular)
assertEquals("nostr developers", header.names()?.plural)
assertEquals("This is a list of developers who build within the nostr ecosystem", header.description())
assertEquals(listOf("p", "name"), header.requiredTags())
assertEquals(emptyList(), header.allowedTags())
assertEquals(headerId, header.listPointer())
}
@Test
fun ruleDescriptionsAreTheOptionalThirdElement() {
val header =
assertIs<ListHeaderEvent>(
event(
9998,
arrayOf(
arrayOf("names", "endorsement", "endorsements"),
arrayOf("required", "p", "Pubkey of the person being endorsed"),
arrayOf("allowed", "comments", "Optional textual reason"),
arrayOf("disallowed", "e"),
arrayOf("recommended", "title"),
arrayOf("required", ""),
),
),
)
assertEquals(
listOf(
TagRule(TagRuleType.REQUIRED, "p", "Pubkey of the person being endorsed"),
TagRule(TagRuleType.ALLOWED, "comments", "Optional textual reason"),
TagRule(TagRuleType.DISALLOWED, "e", null),
TagRule(TagRuleType.RECOMMENDED, "title", null),
),
header.tagRules(),
)
}
@Test
fun aNamesTagMissingItsPluralIsDropped() {
val header = assertIs<ListHeaderEvent>(event(9998, arrayOf(arrayOf("names", "dog"))))
assertNull(header.names())
}
// Example 1, item side, and Example 4 (one item on two lists).
@Test
fun theSpecsItemExampleParses() {
val item =
assertIs<ListItemEvent>(
event(
9999,
arrayOf(
arrayOf("z", headerId),
arrayOf("z", otherHeaderId),
arrayOf("name", "Derek Ross"),
arrayOf("p", derek),
),
),
)
assertEquals(listOf(ParentList.EventId(headerId), ParentList.EventId(otherHeaderId)), item.parentLists())
assertEquals("Derek Ross", item.name())
assertEquals(listOf(derek), item.itemPubKeys().map { it.pubKey })
assertTrue(headerId in item.linkedEventIds())
assertFalse(item.declaresList())
}
// Example 7: pointing at an editable (39998) header by coordinate.
@Test
fun zTagsPointingAtCoordinatesParseAsAddresses() {
val coordinate = "39998:$author:dogs"
val item = assertIs<ListItemEvent>(event(9999, arrayOf(arrayOf("z", coordinate), arrayOf("t", "Fido"))))
val parent = assertIs<ParentList.Coordinate>(item.parentLists().single())
assertEquals(39998, parent.address.kind)
assertEquals(author, parent.address.pubKeyHex)
assertEquals("dogs", parent.address.dTag)
assertEquals(coordinate, parent.value)
assertEquals(listOf("Fido"), item.itemStrings())
assertTrue(coordinate in item.linkedAddressIds())
}
// Example 4, alternate form: undeclared lists named by their singular name.
@Test
fun zTagsWithPlainNamesParseAsNames() {
val item =
assertIs<ListItemEvent>(
event(9999, arrayOf(arrayOf("z", "dog"), arrayOf("z", "animal"), arrayOf("t", "Fido"))),
)
assertEquals(listOf(ParentList.Name("dog"), ParentList.Name("animal")), item.parentLists())
assertEquals(listOf("dog", "animal"), item.parentListPointers())
}
// Example 2: the spec puts an naddr in the item's `a` tag.
@Test
fun naddrItemsParseAsAddresses() {
val naddr =
"naddr1qvzqqqr4gupzq4rqjpyzsnf2z5wgma397sxr382z8mg90l80jf7m3z2k628z9wsrqythwumn8ghj7cnfw33k76twv4ezuum0vd5kzmp0qythwumn8ghj7ct5d3shxtnwdaehgu3wd3skuep0qq3kv6tpwskkxatjwfjkucme946xsefdwd5kcetwwskhg6tdv5khg6rfv4nqnxv6fx"
val item =
assertIs<ListItemEvent>(
event(
9999,
arrayOf(
arrayOf("z", headerId),
arrayOf("a", naddr),
arrayOf("title", "Fiat Currency: The Silent Time Thief"),
),
),
)
assertEquals(30023, item.itemAddresses().single().kind)
assertEquals("Fiat Currency: The Silent Time Thief", item.title())
}
// "Nonstandard methods to declare a list": a 9999 on the list of lists carries header tags.
@Test
fun anItemCanDeclareAListTheNonstandardWay() {
val item =
assertIs<ListItemEvent>(
event(
9999,
arrayOf(
arrayOf("z", "list"),
arrayOf("names", "dog", "dogs"),
arrayOf("description", "This is a list (by name) of individual dogs."),
arrayOf("required", "t"),
),
),
)
assertTrue(item.declaresList())
assertEquals("dogs", item.names()?.plural)
assertEquals(listOf("t"), item.requiredTags())
}
@Test
fun theHeaderBuilderWritesTheSpecsShape() {
val template =
ListHeaderEvent.build("dog name", "dog names", "This is a list of commonly used dog names.") {
required("t")
recommended("comments", "Why this name")
}
assertEquals(9998, template.kind)
assertEquals(
listOf(
listOf("names", "dog name", "dog names"),
listOf("description", "This is a list of commonly used dog names."),
listOf("required", "t"),
listOf("recommended", "comments", "Why this name"),
),
template.tags.map { it.toList() },
)
}
@Test
fun itemsPointAtRegularHeadersByIdAndAddressableHeadersByCoordinate() {
val header = assertIs<ListHeaderEvent>(event(9998, arrayOf(arrayOf("names", "dog", "dogs")), id = headerId))
val byId = ListItemEvent.build(header) { itemString("Fido") }
assertEquals(
listOf(listOf("z", headerId), listOf("t", "Fido")),
byId.tags.map { it.toList() },
)
val editable =
assertIs<AddressableListHeaderEvent>(
event(39998, arrayOf(arrayOf("d", "dogs"), arrayOf("names", "dog", "dogs"))),
)
val byCoordinate = AddressableListItemEvent.build(editable, dTag = "fido") { itemString("Fido") }
assertEquals(
listOf(listOf("d", "fido"), listOf("z", "39998:$author:dogs"), listOf("t", "Fido")),
byCoordinate.tags.map { it.toList() },
)
}
@Test
fun itemBuilderAcceptsUndeclaredListsAndPubkeys() {
val template =
ListItemEvent.build(ParentList.Name("nostr developer")) {
parentListName("nostr developer")
name("Derek Ross")
itemPubKey(derek)
}
// the repeated parent is written once
assertEquals(
listOf(listOf("z", "nostr developer"), listOf("name", "Derek Ross"), listOf("p", derek)),
template.tags.map { it.toList() },
)
}
@Test
fun searchIndexesTheHumanTextAndBothPathsAgree() {
val header =
assertIs<ListHeaderEvent>(
event(
9998,
arrayOf(
arrayOf("names", "dog", "dogs"),
arrayOf("titles", "Dog", "Dogs"),
arrayOf("description", "Good boys."),
arrayOf("required", "t"),
),
),
)
assertEquals("dog\ndogs\nDog\nDogs\nGood boys.", header.indexableContent())
val item =
assertIs<ListItemEvent>(
event(
9999,
arrayOf(arrayOf("z", headerId), arrayOf("name", "Fido"), arrayOf("comments", "Very good"), arrayOf("t", "Fido")),
),
)
assertEquals("Fido\nVery good\nFido", item.indexableContent())
listOf<SearchableEvent>(header, item).forEach { event ->
val fields = mutableListOf<String>()
event.forEachIndexableField { field ->
field?.let { fields.add(it) }
true
}
assertEquals(event.indexableContent(), fields.joinToString(event.indexableSeparator()))
}
assertEquals("", assertIs<ListItemEvent>(event(9999, emptyArray())).indexableContent())
}
}