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