diff --git a/.claude/skills/searchable-events/references/searchable-kinds.md b/.claude/skills/searchable-events/references/searchable-kinds.md index c8cab211a5..b2e3348127 100644 --- a/.claude/skills/searchable-events/references/searchable-kinds.md +++ b/.claude/skills/searchable-events/references/searchable-kinds.md @@ -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` | diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListEvent.kt new file mode 100644 index 0000000000..604bdeea42 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListEvent.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md new file mode 100644 index 0000000000..5e29e512b7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md @@ -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", , ]` — required. `titles` and `slugs` have the + same two-form shape (`SingularPlural`); a tag missing either form is dropped. +- `["required" | "allowed" | "recommended" | "disallowed", , ?]` + — one tag per constrained name, with an optional human-readable description + at index 2 (`TagRule`). +- `["description", ]`. + +## Item tags + +- `["z", ]` — 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", ]`) 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. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/SearchExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/SearchExt.kt new file mode 100644 index 0000000000..8433037d25 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/SearchExt.kt @@ -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 + } + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..014a15e840 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayBuilderExt.kt @@ -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 TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt new file mode 100644 index 0000000000..40f4a295f2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/AddressableListHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/AddressableListHeaderEvent.kt new file mode 100644 index 0000000000..3c1857989d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/AddressableListHeaderEvent.kt @@ -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::` coordinate, since its + * event id changes with every edit. + */ +@Immutable +class AddressableListHeaderEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + 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.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag(dTag) + names(singularName, pluralName) + description?.let { this.description(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/ListHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/ListHeaderEvent.kt new file mode 100644 index 0000000000..00eb0d61fd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/ListHeaderEvent.kt @@ -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>, + 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.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + names(singularName, pluralName) + description?.let { this.description(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..c1e73b4e0c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayBuilderExt.kt @@ -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 TagArrayBuilder.names( + singular: String, + plural: String, +) = addUnique(NamesTag.assemble(singular, plural)) + +fun TagArrayBuilder.titles( + singular: String, + plural: String, +) = addUnique(TitlesTag.assemble(singular, plural)) + +fun TagArrayBuilder.slugs( + singular: String, + plural: String, +) = addUnique(SlugsTag.assemble(singular, plural)) + +fun TagArrayBuilder.tagRule(rule: TagRule) = add(TagRuleTag.assemble(rule)) + +fun TagArrayBuilder.tagRules(rules: List) = addAll(TagRuleTag.assemble(rules)) + +fun TagArrayBuilder.required( + tagName: String, + description: String? = null, +) = add(TagRuleTag.assemble(TagRuleType.REQUIRED, tagName, description)) + +fun TagArrayBuilder.allowed( + tagName: String, + description: String? = null, +) = add(TagRuleTag.assemble(TagRuleType.ALLOWED, tagName, description)) + +fun TagArrayBuilder.recommended( + tagName: String, + description: String? = null, +) = add(TagRuleTag.assemble(TagRuleType.RECOMMENDED, tagName, description)) + +fun TagArrayBuilder.disallowed( + tagName: String, + description: String? = null, +) = add(TagRuleTag.assemble(TagRuleType.DISALLOWED, tagName, description)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt new file mode 100644 index 0000000000..a674e96e2b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/NamesTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/NamesTag.kt new file mode 100644 index 0000000000..6e5164314d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/NamesTag.kt @@ -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", , ]`. Required on every list header, e.g. `["names", "widget", "widgets"]`. */ +class NamesTag { + companion object { + const val TAG_NAME = "names" + + fun isTag(tag: Array) = SingularPlural.isTag(tag, TAG_NAME) + + fun parse(tag: Array) = 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/SingularPlural.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/SingularPlural.kt new file mode 100644 index 0000000000..cf25129482 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/SingularPlural.kt @@ -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, + tagName: String, + ) = tag.has(2) && tag[0] == tagName && tag[1].isNotEmpty() && tag[2].isNotEmpty() + + fun parse( + tag: Array, + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/SlugsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/SlugsTag.kt new file mode 100644 index 0000000000..0050b83235 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/SlugsTag.kt @@ -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", , ]`. Optional URL-friendly identifier in singular and plural form. */ +class SlugsTag { + companion object { + const val TAG_NAME = "slugs" + + fun isTag(tag: Array) = SingularPlural.isTag(tag, TAG_NAME) + + fun parse(tag: Array) = 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/TagRuleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/TagRuleTag.kt new file mode 100644 index 0000000000..84060fd5c6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/TagRuleTag.kt @@ -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) = tag.has(1) && TagRuleType.fromCode(tag[0]) != null && tag[1].isNotEmpty() + + fun isTag( + tag: Array, + type: TagRuleType, + ) = tag.has(1) && tag[0] == type.code && tag[1].isNotEmpty() + + fun parse(tag: Array): 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, + 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) = rules.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/TitlesTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/TitlesTag.kt new file mode 100644 index 0000000000..98ceff1af7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/TitlesTag.kt @@ -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", , ]`. Optional display title in singular and plural form. */ +class TitlesTag { + companion object { + const val TAG_NAME = "titles" + + fun isTag(tag: Array) = SingularPlural.isTag(tag, TAG_NAME) + + fun parse(tag: Array) = 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt new file mode 100644 index 0000000000..f39088b793 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt @@ -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>, + 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.() -> 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.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag(dTag) + parentList(parent) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt new file mode 100644 index 0000000000..e03d0905d8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt @@ -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>, + 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.() -> Unit = {}, + ) = build(ParentListTag.classify(parent.listPointer()), createdAt, initializer) + + fun build( + parent: ParentList, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + parentList(parent) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..ed0b69b72a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayBuilderExt.kt @@ -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 TagArrayBuilder.parentList(parent: DecentralizedListEvent) = addUniqueValueIfNew(ParentListTag.assemble(parent)) + +fun TagArrayBuilder.parentList(parent: ParentList) = addUniqueValueIfNew(ParentListTag.assemble(parent)) + +/** Points at a list that was never formally declared, by its singular name (e.g. "dog"). */ +fun TagArrayBuilder.parentListName(name: String) = addUniqueValueIfNew(ParentListTag.assemble(name)) + +fun TagArrayBuilder.parentLists(parents: List) = addAllUniqueValueIfNew(ParentListTag.assemble(parents)) + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.slug(slug: String) = addUnique(SlugTag.assemble(slug)) + +fun TagArrayBuilder.comments(comments: String) = addUnique(CommentsTag.assemble(comments)) + +fun TagArrayBuilder.itemPubKey( + pubKey: HexKey, + relayHint: NormalizedRelayUrl? = null, +) = add(PTag.assemble(pubKey, relayHint)) + +fun TagArrayBuilder.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 TagArrayBuilder.itemString(value: String) = add(HashtagTag.assemble(value)) + +fun TagArrayBuilder.itemAddress( + address: Address, + relayHint: NormalizedRelayUrl? = null, +) = add(ATag.assemble(address, relayHint)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt new file mode 100644 index 0000000000..144163a6d4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/CommentsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/CommentsTag.kt new file mode 100644 index 0000000000..1fca4dd2fb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/CommentsTag.kt @@ -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", ]`. Free-text commentary from the contributor about the item. */ +class CommentsTag { + companion object { + const val TAG_NAME = "comments" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/NameTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/NameTag.kt new file mode 100644 index 0000000000..b94f914b6e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/NameTag.kt @@ -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", ]`. The item's name, e.g. `["name", "Derek Ross"]`. */ +class NameTag { + companion object { + const val TAG_NAME = "name" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ParentListTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ParentListTag.kt new file mode 100644 index 0000000000..7db4355938 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ParentListTag.kt @@ -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",
]`: the list an item belongs to. */ +class ParentListTag { + companion object { + const val TAG_NAME = "z" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun isTagged( + tag: Array, + pointer: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == pointer + + fun isTagged( + tag: Array, + pointers: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in pointers + + /** The raw pointer, without classifying it. */ + fun parseValue(tag: Array): 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): ParentList? = parseValue(tag)?.let(::classify) + + fun parseEventId(tag: Array): HexKey? { + val value = parseValue(tag) ?: return null + return if (isEventId(value)) value else null + } + + fun parseAddress(tag: Array): 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) = parents.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/SlugTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/SlugTag.kt new file mode 100644 index 0000000000..06ecbae8ff --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/SlugTag.kt @@ -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", ]`. A URL-friendly identifier for the item. */ +class SlugTag { + companion object { + const val TAG_NAME = "slug" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/TitleTag.kt new file mode 100644 index 0000000000..5809c759de --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/TitleTag.kt @@ -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", ]`. The item's display title. */ +class TitleTag { + companion object { + const val TAG_NAME = "title" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/DescriptionTag.kt new file mode 100644 index 0000000000..0f55a54e2b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/DescriptionTag.kt @@ -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", ]`, carried by both list headers and list items. */ +class DescriptionTag { + companion object { + const val TAG_NAME = "description" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 0a1191cf14..b1ff7a9483 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -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), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt index 278c3b8e44..7c7eb0e954 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index d597a994b0..76257ca74d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListsTest.kt new file mode 100644 index 0000000000..4a0e5511f0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListsTest.kt @@ -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>, + id: String = "a".repeat(64), + ): Event = EventFactory.create(id, author, 1719888496L, kind, tags, "", "") + + @Test + fun theFactoryBuildsEachKind() { + assertIs(event(9998, emptyArray())) + assertIs(event(39998, emptyArray())) + assertIs(event(9999, emptyArray())) + assertIs(event(39999, emptyArray())) + } + + // Example 1: a list of nostr developers. + @Test + fun theSpecsHeaderExampleParses() { + val header = + assertIs( + 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( + 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(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( + 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(event(9999, arrayOf(arrayOf("z", coordinate), arrayOf("t", "Fido")))) + + val parent = assertIs(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( + 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( + 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( + 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(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( + 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( + 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( + event( + 9999, + arrayOf(arrayOf("z", headerId), arrayOf("name", "Fido"), arrayOf("comments", "Very good"), arrayOf("t", "Fido")), + ), + ) + assertEquals("Fido\nVery good\nFido", item.indexableContent()) + + listOf(header, item).forEach { event -> + val fields = mutableListOf() + event.forEachIndexableField { field -> + field?.let { fields.add(it) } + true + } + assertEquals(event.indexableContent(), fields.joinToString(event.indexableSeparator())) + } + + assertEquals("", assertIs(event(9999, emptyArray())).indexableContent()) + } +}