Merge pull request #4197 from vitorpamplona/claude/serene-bohr-okvbkv

Add Decentralized Lists (kinds 9998, 39998, 9999, 39999) support
This commit is contained in:
Vitor Pamplona
2026-09-25 12:26:19 -04:00
committed by GitHub
55 changed files with 4609 additions and 1 deletions
@@ -4,7 +4,7 @@ Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexabl
expression. **Update this file in the same PR as any change to the searchable set or to an
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-09-17.
Counts: 133 concrete classes covering 136 kind values (`GitStatusEvent` spans 4 kinds;
Counts: 137 concrete classes covering 140 kind values (`GitStatusEvent` spans 4 kinds;
kind 30063 has a collision — see the footnote). File paths are under
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`.
@@ -65,6 +65,8 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 9736 | Bolt12ZapEvent | nipB1Bolt12Zaps/zap | `content` |
| 9737 | Bolt12ZapIntentEvent | nipB1Bolt12Zaps/intent | `content` |
| 9802 | HighlightEvent | nip84Highlights | `listOfNotNull(comment(), context(), content)` NL |
| 9998 | ListHeaderEvent | experimental/decentralizedLists/header | `tags.searchableListContent()` NL — `names` (singular, plural), `titles` (singular, plural), `name`, `title`, `description`, `comments`, then every `t` value; ids/pubkeys/coordinates are left to tag filters |
| 9999 | ListItemEvent | experimental/decentralizedLists/item | same as 9998 |
| 10003 | BookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL |
| 10100 | AgentProfileEvent | buzz/agentProfiles | `profileOrNull()?.let { listOfNotNull(it.name, it.displayName).joinToString("\n") } ?: ""` |
| 10154 | PodcastMetadataEvent | nipF4Podcasts/metadata | `listOfNotNull(title(), description())` NL |
@@ -140,6 +142,8 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 39092 | MediaStarterPackEvent | nip51Lists/mediaStarterPack | `listOfNotNull(title(), description())` NL |
| 39307 | TextTrackEvent | nip71Video/textTrack | `content` (the WebVTT cue text) |
| 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL |
| 39998 | AddressableListHeaderEvent | experimental/decentralizedLists/header | same as 9998 |
| 39999 | AddressableListItemEvent | experimental/decentralizedLists/item | same as 9998 |
| 40002 | StreamMessageV2Event | buzz/stream | `content` |
| 40100 | CanvasEvent | buzz/stream | `content` |
| 45001 | ForumPostEvent | buzz/forum | `content` |
@@ -0,0 +1,45 @@
/*
* 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
/**
* A cheap, allocation-free check that a value has the `<kind>:<64-hex pubkey>:<d>` coordinate
* shape, done before anything reaches `AddressSerializer.parse`. That parser splits the string
* and logs a warning for every value it rejects — and in this family a rejected value is
* routine: `z` tags legitimately carry plain list names, which may be long and contain colons.
*/
internal object CoordinateShape {
fun matches(value: String): Boolean {
val firstColon = value.indexOf(':')
// kinds are at most 5 digits (0..65535)
if (firstColon !in 1..5) return false
for (i in 0 until firstColon) {
if (value[i] !in '0'..'9') return false
}
val pubKeyEnd = firstColon + 1 + 64
if (value.length <= pubKeyEnd || value[pubKeyEnd] != ':') return false
for (i in firstColon + 1 until pubKeyEnd) {
val c = value[i]
if (c !in '0'..'9' && c !in 'a'..'f' && c !in 'A'..'F') return false
}
return true
}
}
@@ -0,0 +1,51 @@
/*
* 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
/**
* The addressable kinds (39998, 39999): the only ones the Tapestry extensions — the `b`
* inherit-from tag, the `json` payload — are defined on, since those point at and resolve
* through `kind:pubkey:d` coordinates.
*/
interface AddressableDecentralizedListEvent : DecentralizedListEvent
@@ -0,0 +1,34 @@
/*
* 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.item.parentListPointers
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.parentLists
import com.vitorpamplona.quartz.nip01Core.core.Event
/**
* The lists an event of any kind claims to be on. Cross-NIP Compatibility lets a foreign event
* (a NIP-72 community, say) list itself by carrying `z` tags — the creator's voice, "I claim to
* be on this list" — next to its own NIP's tags. Such lists declare the kind with `item-kind`.
*/
fun Event.dListParents() = tags.parentLists()
fun Event.dListParentPointers() = tags.parentListPointers()
@@ -0,0 +1,151 @@
# 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.
## Tapestry extensions
The Tapestry drafts
([`protocols/drafts`](https://github.com/nous-clawds4/tapestry/tree/main/protocols/drafts))
add tags and conventions on these same four kinds. None of them adds a kind.
Concept addresses such as `39998:<assistant>:tag` or `nostr-user-tag` are
published by each deployment's own assistant key, and the drafts forbid
hardcoding them, so every builder and parser below takes them as parameters.
### Tags
| Tag | Draft | Kinds | Quartz |
|---|---|---|---|
| `["b", <kind:pk:d>, "pointer"\|"inherit"\|"inherit-items"]` | Inherit-From | 39998, 39999 | `tags.InheritFromTag`, `inheritFrom()` |
| `["b", "b-tag-deferred"]` | Shared Concepts | 39998, 39999 | `isDeliberatelyUnaffiliated()` |
| `["n", <kind:pk:d>]` / `["s", <kind:pk:d>]` | Class Thread Relationships | 39999 | `item.tags.ElementOfTag` / `SubsetOfTag` |
| `["json", "<json>"]` | Tapestry Concepts | 39998, 39999 | `tags.JsonTag`, `wordWrapper()` → `concepts.WordWrapper` |
| `["concept-graph", "39999:<pk>:<d>-concept-graph"]` | Tapestry Concepts | 39998 | `conceptGraph()`, falls back to the computed address |
| `["item-kind", <kind>, <description>?]` | Cross-NIP Compatibility | headers | `itemKinds()`, `acceptedItemKinds()` |
An unknown or missing `b` type reads as `pointer`, and code that acts on
deference must check for `inherit` / `inherit-items` explicitly.
Events of other NIPs can list themselves by carrying `z` tags (Cross-NIP
Compatibility "Method 3"); `Event.dListParents()` reads them on any kind.
### Taggings (`taggings/`)
All kind 39999 items, told apart by their `z` tags:
- `TagElement`: a tag ("Podcaster"), `d` = slug, JSON in `content`, plus
optional `tag-for-nostr-pubkey` / `tag-for-nostr-event` hint `z`s.
- `PubKeyTagging`: "Avi is a Podcaster", with the deterministic
`profile-tag-<slug>-<target8>-<asserter8>` `d`.
- `TagPin`: a viewer's pin with a `curation-method` JSON tag. Unpin with a
NIP-09 deletion.
- `TaggingHeader` + `EventTagging`: tagging events. The target sits in
`a`/`e`, so the tag is reached through a `z` to a per-tag header.
- Federation: every builder takes a collection of concept namespaces and
writes one concept `z` per namespace; every parser takes the set of
namespaces the reader honors. Pass them all: a concept `z` the reader didn't
list would be taken for something else, e.g. a tagging header.
- `polarity()`: no tag means apply, `≥ 0.5` applied, `≤ -0.5` disputed, and
anything in between is not counted in v1.
### Assistant designation (`assistant/`)
- Kind 10040 entries next to NIP-85's: the blanket
`["39998:dlist-header", <assistant>, <relay>]` and one
`["<kind>:<d>", <assistant>, <relay>]` per curated list. Read with
`dListAssistant()` / `dListCurations()`; write with the `replace…` /
`remove…` helpers, which keep every other tag verbatim. The first
occurrence wins on duplicates.
- `CurationCopy`: the assistant's copy of an accepted item, with
`d` = `copy-<sha256(header + "\n" + original ref)>`, two `q` tags back to
the original, and only the tags the spec lists. `buildRemoval` is the
matching NIP-09 deletion.
- `HeaderResolution`: which header governs a user's concept. Their own
header wins, then their assistant's, and recency never decides.
@@ -0,0 +1,97 @@
/*
* 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.tags.NamesTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.SingularPlural
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TitlesTag
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.TitleTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.DescriptionTag
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 {
// The read path runs once per event per keystroke, so this is two allocation-free passes
// instead of one full scan (and one parsed object) per field. The first pass only remembers
// the first well-formed tag of each field; the fixed visiting order is applied afterwards.
var names: Array<String>? = null
var titles: Array<String>? = null
var name: String? = null
var title: String? = null
var description: String? = null
var comments: String? = null
fastForEach { tag ->
if (tag.size < 2 || tag[1].isEmpty()) return@fastForEach
when (tag[0]) {
NamesTag.TAG_NAME -> if (names == null && SingularPlural.isTag(tag, NamesTag.TAG_NAME)) names = tag
TitlesTag.TAG_NAME -> if (titles == null && SingularPlural.isTag(tag, TitlesTag.TAG_NAME)) titles = tag
NameTag.TAG_NAME -> if (name == null) name = tag[1]
TitleTag.TAG_NAME -> if (title == null) title = tag[1]
DescriptionTag.TAG_NAME -> if (description == null) description = tag[1]
CommentsTag.TAG_NAME -> if (comments == null) comments = tag[1]
}
}
names?.let {
if (!visitor.visit(it[1])) return false
if (!visitor.visit(it[2])) return false
}
titles?.let {
if (!visitor.visit(it[1])) return false
if (!visitor.visit(it[2])) return false
}
name?.let { if (!visitor.visit(it)) return false }
title?.let { if (!visitor.visit(it)) return false }
description?.let { if (!visitor.visit(it)) return false }
comments?.let { if (!visitor.visit(it)) return false }
fastForEach { tag ->
HashtagTag.parse(tag)?.let { if (!visitor.visit(it)) return false }
}
return true
}
/** The write-path join of [forEachSearchableListField]: one field per line. */
fun TagArray.searchableListContent() =
buildString {
forEachSearchableListField { field ->
if (field != null) {
if (isNotEmpty()) append('\n')
append(field)
}
true
}
}
@@ -0,0 +1,45 @@
/*
* 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.experimental.decentralizedLists.tags.InheritFromTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritType
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.JsonTag
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun <T : DecentralizedListEvent> TagArrayBuilder<T>.description(description: String) = addUnique(DescriptionTag.assemble(description))
fun <T : AddressableDecentralizedListEvent> TagArrayBuilder<T>.inheritFrom(
target: Address,
type: InheritType,
) = add(InheritFromTag.assemble(target, type))
fun <T : AddressableDecentralizedListEvent> TagArrayBuilder<T>.inheritFrom(
target: String,
type: InheritType,
) = add(InheritFromTag.assemble(target, type))
/** Writes `["b", "b-tag-deferred"]`: considered a shared twin and chose none. */
fun <T : AddressableDecentralizedListEvent> TagArrayBuilder<T>.deliberatelyUnaffiliated() = addUniqueValueIfNew(InheritFromTag.assembleUnaffiliated())
fun <T : AddressableDecentralizedListEvent> TagArrayBuilder<T>.json(json: String) = addUnique(JsonTag.assemble(json))
@@ -0,0 +1,50 @@
/*
* 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.concepts.WordWrapper
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.DescriptionTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritFromTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritType
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.JsonTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
fun TagArray.description() = fastFirstNotNullOfOrNull(DescriptionTag::parse)
/** Every `b` link, in tag order. Excludes the `b-tag-deferred` marker. */
fun TagArray.inheritFrom() = mapNotNull(InheritFromTag::parse)
/**
* The targets of `b` tags of exactly [type], in tag order. For [InheritType.INHERIT] the order
* is load-bearing: the first-listed parent wins a field both ancestors state.
*/
fun TagArray.inheritFromTargets(type: InheritType) = mapNotNull { InheritFromTag.parseTarget(it, type) }
/** True when the event carries `["b", "b-tag-deferred"]`: deliberately affiliated with nothing. */
fun TagArray.isDeliberatelyUnaffiliated() = fastAny(InheritFromTag::isUnaffiliatedMarker)
/** The raw `json` tag. */
fun TagArray.json() = fastFirstNotNullOfOrNull(JsonTag::parse)
/** The `json` tag parsed as a JSON object, or null when absent or malformed. */
fun TagArray.wordWrapper() = json()?.let(WordWrapper::parse)
@@ -0,0 +1,117 @@
/*
* 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.assistant
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
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.experimental.decentralizedLists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
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.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.sha256.sha256
/**
* Tapestry Assistant Designation, *curation copies*: how an empowered assistant fills the list
* it curates. It files its own kind 39999 copy of each item it accepts under the curated
* header, and deletes the copies it stops accepting.
*
* A copy's tags are exactly its `d`, one `z` (the curated header), its `q` pointers back to the
* original, and the original's `name`/`title`/`slug`/`description`/`comments` and item tags
* (`p`/`e`/`t`/`a`) verbatim, plus the original's `content`. Nothing else is carried — not
* `json`, `n` or `s`, which describe the original's place in its author's own graph — and the
* copy is never stamped with the original list's `z`s.
*/
object CurationCopy {
const val Q_TAG = "q"
/**
* `copy-` + hex SHA-256 of `<header address>\n<original ref>`, where the ref is the
* original's `39999:<author>:<d>` address for a 39999, else its event id. Copying the same
* original into the same list again therefore replaces the copy.
*/
fun dTag(
curatedHeader: Address,
original: Event,
) = "copy-" + sha256((curatedHeader.toValue() + "\n" + originalRef(original)).encodeToByteArray()).toHexKey()
fun originalRef(original: Event) = if (original is AddressableListItemEvent) original.addressTag() else original.id
private val CARRIED =
setOf(
NameTag.TAG_NAME,
TitleTag.TAG_NAME,
SlugTag.TAG_NAME,
DescriptionTag.TAG_NAME,
CommentsTag.TAG_NAME,
PTag.TAG_NAME,
ETag.TAG_NAME,
HashtagTag.TAG_NAME,
ATag.TAG_NAME,
)
/**
* @param curatedHeader the assistant's curated header, `<kind>:<assistant>:<d>`.
* @param relay where the original can be fetched; written into the `q` tags.
*/
fun build(
curatedHeader: Address,
original: Event,
relay: NormalizedRelayUrl?,
createdAt: Long = TimeUtils.now(),
) = AddressableListItemEvent.build(
parent = ParentListTag.classify(curatedHeader.toValue()),
dTag = dTag(curatedHeader, original),
createdAt = createdAt,
content = original.content,
) {
val relayUrl = relay?.url ?: ""
// the stable address, surviving the author's edits
if (original is AddressableListItemEvent) add(arrayOf(Q_TAG, original.addressTag(), relayUrl))
// the exact version copied, NIP-18 style, naming the author here rather than in a `p`
add(arrayOf(Q_TAG, original.id, relayUrl, original.pubKey))
original.tags.forEach { tag ->
if (tag.isNotEmpty() && tag[0] in CARRIED) add(tag)
}
}
/** The NIP-09 removal of a copy: by address, by event id and by kind. */
fun buildRemoval(
copy: AddressableListItemEvent,
createdAt: Long = TimeUtils.now(),
) = eventTemplate<DeletionEvent>(DeletionEvent.KIND, "", createdAt) {
add(ATag.assemble(copy.address(), null))
add(ETag.assemble(copy.id, null, null))
add(KindTag.assemble(AddressableListItemEvent.KIND))
}
}
@@ -0,0 +1,67 @@
/*
* 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.assistant
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.AddressableListHeaderEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
/**
* Tapestry Assistant Designation, dual-author precedence: which header governs a user's
* concept `slug` when both the user and their assistant may have written one.
*
* 1. the user's own `39998:<user>:<slug>`, if it exists;
* 2. else their assistant's `39998:<assistant>:<slug>`, the assistant found through the user's
* kind 10040 `39998:dlist-header` entry;
* 3. else none.
*
* Never by recency: a newer assistant header does not shadow a personal one. A user who wants
* the assistant's to govern says so with `["b", <assistant header>, "inherit"]` on their own.
*/
object HeaderResolution {
/** The candidate addresses, in precedence order. [treasureMap] is the user's 10040 tags. */
fun candidates(
user: HexKey,
slug: String,
treasureMap: TagArray?,
): List<Address> {
val personal = Address(AddressableListHeaderEvent.KIND, user, slug)
val assistant = treasureMap?.dListAssistant()?.assistant ?: return listOf(personal)
if (assistant == user) return listOf(personal)
return listOf(personal, Address(AddressableListHeaderEvent.KIND, assistant, slug))
}
/**
* Picks the governing header among the fetched [headers]: the first candidate that has one.
* Headers signed by anyone else, or for another slug, are ignored.
*/
fun governing(
user: HexKey,
slug: String,
treasureMap: TagArray?,
headers: Collection<AddressableListHeaderEvent>,
): AddressableListHeaderEvent? =
candidates(user, slug, treasureMap).firstNotNullOfOrNull { candidate ->
// several versions of one addressable header may be at hand: the newest is the header
headers.filter { it.pubKey == candidate.pubKeyHex && it.dTag() == candidate.dTag }.maxByOrNull { it.createdAt }
}
}
@@ -0,0 +1,86 @@
/*
* 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.assistant
import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.tags.AssistantDesignation
import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.tags.AssistantDesignationTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.tags.DListCuration
import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.tags.DListCurationTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
// Both entry families ride NIP-85's kind 10040 but are bounded away from it: NIP-85 only reads
// 30382-30385 keys, and these parsers only read 39998/39999 ones.
/** The blanket assistant designation. On duplicates the first occurrence wins. */
fun TagArray.dListAssistant() = fastFirstNotNullOfOrNull(AssistantDesignationTag::parse)
/** Every per-list curation entry, first occurrence per (kind, d-tag). */
fun TagArray.dListCurations() = mapNotNull(DListCurationTag::parse).distinctBy { it.kind to it.dTag }
fun TagArray.dListCuration(
kind: Int,
dTag: String,
) = fastFirstNotNullOfOrNull { tag -> DListCurationTag.parse(tag)?.takeIf { it.kind == kind && it.dTag == dTag } }
/**
* Sets the blanket designation in place, keeping every other tag verbatim — 10040 is
* replaceable, so whatever is dropped here is gone from the Map. Duplicates collapse onto the
* first position.
*/
fun TagArray.replaceDListAssistant(designation: AssistantDesignation): TagArray = replaceFirstMatch(designation.toTagArray()) { AssistantDesignationTag.isTag(it) }
fun TagArray.removeDListAssistant(): TagArray = filterNot { AssistantDesignationTag.isTag(it) }.toTypedArray()
/** Adds or replaces the entry for the curation's (kind, d-tag), as [replaceDListAssistant]. */
fun TagArray.replaceDListCuration(entry: DListCuration): TagArray =
replaceFirstMatch(entry.toTagArray()) { tag ->
DListCurationTag.parse(tag)?.let { it.kind == entry.kind && it.dTag == entry.dTag } ?: false
}
/** Revokes the empowerment. The header and its `b` stay on relays. */
fun TagArray.removeDListCuration(
kind: Int,
dTag: String,
): TagArray =
filterNot { tag ->
DListCurationTag.parse(tag)?.let { it.kind == kind && it.dTag == dTag } ?: false
}.toTypedArray()
private inline fun TagArray.replaceFirstMatch(
replacement: Array<String>,
matches: (Array<String>) -> Boolean,
): TagArray {
var replaced = false
val out = ArrayList<Array<String>>(size + 1)
forEach { tag ->
if (matches(tag)) {
if (!replaced) {
out.add(replacement)
replaced = true
}
} else {
out.add(tag)
}
}
if (!replaced) out.add(replacement)
return out.toTypedArray()
}
@@ -0,0 +1,41 @@
/*
* 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.assistant
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
/** The public half alone: no signer, so no private entries. */
fun TrustProviderListEvent.publicDListAssistant() = tags.dListAssistant()
/** The public half alone: no signer, so no private entries. */
fun TrustProviderListEvent.publicDListCurations() = tags.dListCurations()
/**
* Both halves of the Map. With anyone else's signer, or a private half that will not decrypt,
* this sees the public half alone. Public tags come first, so a public entry wins a duplicate.
*/
suspend fun TrustProviderListEvent.dListAssistant(signer: NostrSigner) = mergedTags(signer).dListAssistant()
suspend fun TrustProviderListEvent.dListCurations(signer: NostrSigner) = mergedTags(signer).dListCurations()
private suspend fun TrustProviderListEvent.mergedTags(signer: NostrSigner): TagArray = tags + (privateTags(signer) ?: emptyArray())
@@ -0,0 +1,70 @@
/*
* 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.assistant.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.ensure
/**
* Tapestry Assistant Designation, the blanket entry on a user's kind 10040:
* `["39998:dlist-header", <assistant pubkey>, <relay>]` — "this pubkey authors my concept /
* list headers on my behalf".
*
* Signed by the user, so it is a delegation the user granted; revoked by republishing the Map
* without it. The relay may be the empty string when the writer had none configured, and the
* designation stands without it.
*/
@Immutable
data class AssistantDesignation(
val assistant: HexKey,
val relay: NormalizedRelayUrl? = null,
) {
fun toTagArray() = AssistantDesignationTag.assemble(assistant, relay)
}
class AssistantDesignationTag {
companion object {
const val RESERVED_D_TAG = "dlist-header"
const val KEY = "39998:$RESERVED_D_TAG"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == KEY && isPubKey(tag[1])
fun parse(tag: Array<String>): AssistantDesignation? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == KEY) { return null }
ensure(isPubKey(tag[1])) { return null }
return AssistantDesignation(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) })
}
// Always three elements: an empty relay keeps the shape the spec fixes.
fun assemble(
assistant: HexKey,
relay: NormalizedRelayUrl?,
) = arrayOf(KEY, assistant, relay?.url ?: "")
internal fun isPubKey(value: String) = value.length == 64 && Hex.isHex64(value)
}
}
@@ -0,0 +1,82 @@
/*
* 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.assistant.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.ensure
/**
* Tapestry Assistant Designation, a per-list entry on a user's kind 10040:
* `["<kind>:<d-tag>", <assistant pubkey>, <relay>]` — "my assistant curates this list for me,
* with the header at `<kind>:<assistant>:<d-tag>`".
*
* The d-tag is everything after the **first** colon and may itself contain colons. The word
* `dlist-header` is reserved for [AssistantDesignationTag] and is never a per-list d-tag.
*/
@Immutable
data class DListCuration(
val kind: Int,
val dTag: String,
val assistant: HexKey,
val relay: NormalizedRelayUrl? = null,
) {
/** The curated header: authorization is read from the Map, composition from this header. */
fun headerAddress() = Address(kind, assistant, dTag)
fun toTagArray() = DListCurationTag.assemble(this)
}
class DListCurationTag {
companion object {
val KINDS = setOf(39998, 39999)
fun key(
kind: Int,
dTag: String,
) = "$kind:$dTag"
fun isTag(tag: Array<String>) = parse(tag) != null
fun parse(tag: Array<String>): DListCuration? {
ensure(tag.has(1)) { return null }
ensure(AssistantDesignationTag.isPubKey(tag[1])) { return null }
val divider = tag[0].indexOf(':')
ensure(divider > 0) { return null }
val kind = tag[0].substring(0, divider).toIntOrNull() ?: return null
ensure(kind in KINDS) { return null }
val dTag = tag[0].substring(divider + 1)
ensure(dTag.isNotEmpty()) { return null }
ensure(dTag != AssistantDesignationTag.RESERVED_D_TAG) { return null }
return DListCuration(kind, dTag, tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) })
}
fun assemble(entry: DListCuration) = arrayOf(key(entry.kind, entry.dTag), entry.assistant, entry.relay?.url ?: "")
}
}
@@ -0,0 +1,104 @@
/*
* 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.concepts
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
/**
* The parsed `json` tag of a Tapestry node: a JSON object keyed by section. Every node in the
* word-wrapper layout has a [word] section plus one or more role sections (`conceptHeader`,
* `superset`, `set`, `property`, `graph`, `conceptGraph`, …), readable raw via [section].
* Plain elements use the same object, keyed by concept slug (`{"dog": {...}}`).
*/
@Immutable
class WordWrapper(
val root: JsonObject,
) {
/** One section as raw JSON, or null when absent or not an object. */
fun section(key: String) = root[key] as? JsonObject
fun word(): WordSection? = decode(WORD)
fun conceptHeader(): ConceptHeaderSection? = decode(CONCEPT_HEADER)
private inline fun <reified T> decode(key: String): T? {
val element = section(key) ?: return null
return runCatching { JsonMapper.jsonInstance.decodeFromJsonElement<T>(element) }.getOrNull()
}
companion object {
const val WORD = "word"
const val CONCEPT_HEADER = "conceptHeader"
/** Null for anything that is not a JSON object: the tag is authored data, not trusted. */
fun parse(json: String): WordWrapper? = runCatching { WordWrapper(JsonMapper.jsonInstance.parseToJsonElement(json).jsonObject) }.getOrNull()
}
}
/** A reference to another node inside a word-wrapper payload. `uuid` carries its a-tag address. */
@Immutable
@Serializable
data class NodeRef(
val slug: String? = null,
val uuid: String? = null,
)
/** The universal `word` section. */
@Immutable
@Serializable
data class WordSection(
val slug: String? = null,
val name: String? = null,
val title: String? = null,
// Nullable: this is authored JSON, and an explicit `null` must read as "absent" rather
// than fail the whole section. Use [types] / [memberOf] for the non-null view.
val wordTypes: List<String>? = null,
/** Set on a concept's core nodes, pointing back at the concept; omitted by the header itself. */
val coreMemberOf: List<NodeRef>? = null,
) {
fun types() = wordTypes.orEmpty()
fun memberOf() = coreMemberOf.orEmpty()
}
@Immutable
@Serializable
data class SingularPluralJson(
val singular: String? = null,
val plural: String? = null,
)
/** The `conceptHeader` section of a Concept Header node. */
@Immutable
@Serializable
data class ConceptHeaderSection(
val description: String? = null,
val oNames: SingularPluralJson? = null,
val oSlugs: SingularPluralJson? = null,
val oKeys: SingularPluralJson? = null,
val oTitles: SingularPluralJson? = null,
val oLabels: SingularPluralJson? = null,
)
@@ -0,0 +1,131 @@
/*
* 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.AddressableDecentralizedListEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.description
import com.vitorpamplona.quartz.experimental.decentralizedLists.forEachSearchableListField
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.acceptedItemKinds
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.conceptGraph
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.itemKinds
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.ConceptGraphTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.inheritFrom
import com.vitorpamplona.quartz.experimental.decentralizedLists.inheritFromTargets
import com.vitorpamplona.quartz.experimental.decentralizedLists.isDeliberatelyUnaffiliated
import com.vitorpamplona.quartz.experimental.decentralizedLists.json
import com.vitorpamplona.quartz.experimental.decentralizedLists.searchableListContent
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritType
import com.vitorpamplona.quartz.experimental.decentralizedLists.wordWrapper
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),
AddressableDecentralizedListEvent,
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()
fun itemKinds() = tags.itemKinds()
fun acceptedItemKinds() = tags.acceptedItemKinds()
fun inheritFrom() = tags.inheritFrom()
fun inheritFromTargets(type: InheritType) = tags.inheritFromTargets(type)
fun isDeliberatelyUnaffiliated() = tags.isDeliberatelyUnaffiliated()
fun json() = tags.json()
fun wordWrapper() = tags.wordWrapper()
/**
* The Concept Graph core node of this concept: the `concept-graph` tag if present, else
* computed from this header's pubkey and `d`, as the resolution contract requires.
*/
fun conceptGraph() = tags.conceptGraph() ?: ConceptGraphTag.compute(pubKey, dTag())
companion object {
const val KIND = 39998
@OptIn(ExperimentalUuidApi::class)
fun build(
singularName: String,
pluralName: String,
description: String? = null,
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AddressableListHeaderEvent>.() -> Unit = {},
) = eventTemplate<AddressableListHeaderEvent>(KIND, "", createdAt) {
dTag(dTag)
names(singularName, pluralName)
description?.let { this.description(it) }
initializer()
}
}
}
@@ -0,0 +1,102 @@
/*
* 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.header.acceptedItemKinds
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.itemKinds
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()
fun itemKinds() = tags.itemKinds()
fun acceptedItemKinds() = tags.acceptedItemKinds()
companion object {
const val KIND = 9998
fun build(
singularName: String,
pluralName: String,
description: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ListHeaderEvent>.() -> Unit = {},
) = eventTemplate<ListHeaderEvent>(KIND, "", createdAt) {
names(singularName, pluralName)
description?.let { this.description(it) }
initializer()
}
}
}
@@ -0,0 +1,90 @@
/*
* 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.AddressableDecentralizedListEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.ConceptGraphTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.ItemKindTag
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.HexKey
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))
fun <T : DecentralizedListEvent> TagArrayBuilder<T>.itemKind(
kind: Int,
description: String? = null,
) = add(ItemKindTag.assemble(kind, description))
/**
* The header's own Concept Graph pointer. Takes the signing pubkey and `d` because the value
* is derived from them; a template has no pubkey until it is signed.
*/
fun <T : AddressableDecentralizedListEvent> TagArrayBuilder<T>.conceptGraph(
pubKey: HexKey,
dTag: String,
) = addUnique(ConceptGraphTag.assemble(pubKey, dTag))
@@ -0,0 +1,68 @@
/*
* 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.ConceptGraphTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.ItemKindTag
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
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
fun TagArray.names() = fastFirstNotNullOfOrNull(NamesTag::parse)
fun TagArray.titles() = fastFirstNotNullOfOrNull(TitlesTag::parse)
fun TagArray.slugs() = fastFirstNotNullOfOrNull(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() = fastAny(NamesTag::isTag)
/** The `concept-graph` pointer when present. See [ConceptGraphTag.compute] for the fallback. */
fun TagArray.conceptGraph() = fastFirstNotNullOfOrNull(ConceptGraphTag::parse)
/** The `item-kind` declarations, in tag order. */
fun TagArray.itemKinds() = mapNotNull(ItemKindTag::parse)
/** The kinds to query for this list's items: the declared `item-kind`s, else 9999 and 39999. */
fun TagArray.acceptedItemKinds(): List<Int> = itemKinds().map { it.kind }.distinct().ifEmpty { STANDARD_ITEM_KINDS }
val STANDARD_ITEM_KINDS = listOf(9999, 39999)
@@ -0,0 +1,66 @@
/*
* 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 com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Tapestry Concepts: `["concept-graph", "39999:<pubkey>:<d>-concept-graph"]` on a kind 39998
* concept header, pointing at the concept's Concept Graph core node.
*
* The value is computed from the header's own pubkey and `d`, never looked up, so it is right
* even before that node exists — and a reader facing a header without the tag computes the
* same address ([compute]).
*/
class ConceptGraphTag {
companion object {
const val TAG_NAME = "concept-graph"
const val SUFFIX = "-concept-graph"
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]
}
/** The Concept Graph address of the concept whose header is `39998:<pubKey>:<dTag>`. */
fun compute(
pubKey: HexKey,
dTag: String,
) = Address.assemble(CONCEPT_GRAPH_NODE_KIND, pubKey, dTag + SUFFIX)
fun assemble(address: String) = arrayOf(TAG_NAME, address)
fun assemble(
pubKey: HexKey,
dTag: String,
) = assemble(compute(pubKey, dTag))
/** Core nodes are kind 39999 items. */
const val CONCEPT_GRAPH_NODE_KIND = 39999
}
}
@@ -0,0 +1,64 @@
/*
* 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
/**
* Decentralized Lists Cross-NIP Compatibility: `["item-kind", "<kind>", <description>?]` on a
* list header, one per accepted kind. It widens the items a list accepts beyond 9999/39999 to
* events of other NIPs (e.g. 34550 NIP-72 communities) that carry `z` tags themselves, or that
* a curator's 9999/39999 references with an `a` tag.
*
* A header without any `item-kind` accepts only 9999/39999. Its `required`/`allowed` rules
* apply on top of whatever the foreign kind's own NIP requires, never instead of it.
*/
@Immutable
data class ItemKind(
val kind: Int,
val description: String? = null,
)
class ItemKindTag {
companion object {
const val TAG_NAME = "item-kind"
fun isTag(tag: Array<String>) = parse(tag) != null
fun parse(tag: Array<String>): ItemKind? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val kind = tag[1].toIntOrNull() ?: return null
ensure(kind >= 0) { return null }
return ItemKind(kind, tag.getOrNull(2)?.ifEmpty { null })
}
fun assemble(
kind: Int,
description: String? = null,
) = arrayOfNotNull(TAG_NAME, kind.toString(), description)
fun assemble(itemKind: ItemKind) = assemble(itemKind.kind, itemKind.description)
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags
/** `["names", <singular>, <plural>]`. Required on every list header, e.g. `["names", "widget", "widgets"]`. */
class NamesTag {
companion object {
const val TAG_NAME = "names"
fun isTag(tag: Array<String>) = SingularPlural.isTag(tag, TAG_NAME)
fun parse(tag: Array<String>) = SingularPlural.parse(tag, TAG_NAME)
fun assemble(
singular: String,
plural: String,
) = SingularPlural.assemble(TAG_NAME, singular, plural)
fun assemble(value: SingularPlural) = assemble(value.singular, value.plural)
}
}
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The value of the `names`, `titles` and `slugs` header tags: a singular form ("widget")
* followed by a plural form ("widgets").
*/
@Immutable
data class SingularPlural(
val singular: String,
val plural: String,
) {
companion object {
// Both forms are required by the spec. A tag missing either one is malformed and
// dropped rather than guessed at: pluralizing someone else's noun is not our call.
fun isTag(
tag: Array<String>,
tagName: String,
) = tag.has(2) && tag[0] == tagName && tag[1].isNotEmpty() && tag[2].isNotEmpty()
fun parse(
tag: Array<String>,
tagName: String,
): SingularPlural? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == tagName) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(tag[2].isNotEmpty()) { return null }
return SingularPlural(tag[1], tag[2])
}
fun assemble(
tagName: String,
singular: String,
plural: String,
) = arrayOf(tagName, singular, plural)
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags
/** `["slugs", <singular>, <plural>]`. Optional URL-friendly identifier in singular and plural form. */
class SlugsTag {
companion object {
const val TAG_NAME = "slugs"
fun isTag(tag: Array<String>) = SingularPlural.isTag(tag, TAG_NAME)
fun parse(tag: Array<String>) = SingularPlural.parse(tag, TAG_NAME)
fun assemble(
singular: String,
plural: String,
) = SingularPlural.assemble(TAG_NAME, singular, plural)
fun assemble(value: SingularPlural) = assemble(value.singular, value.plural)
}
}
@@ -0,0 +1,105 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
/** How a list header constrains a tag on its children. The code is also the tag name. */
enum class TagRuleType(
val code: String,
) {
REQUIRED("required"),
ALLOWED("allowed"),
RECOMMENDED("recommended"),
DISALLOWED("disallowed"),
;
companion object {
fun fromCode(code: String) =
when (code) {
REQUIRED.code -> REQUIRED
ALLOWED.code -> ALLOWED
RECOMMENDED.code -> RECOMMENDED
DISALLOWED.code -> DISALLOWED
else -> null
}
}
}
/**
* One constraint a list header places on its items: `["required", "p"]`, optionally with a
* human-readable description of what the named tag represents:
* `["required", "p", "Pubkey of the person being endorsed"]`.
*
* The spec requires one tag per constrained name — `["required", "foo"], ["required", "bar"]`,
* never `["required", "foo", "bar"]`. That makes index 2 unambiguous: it is always the
* description, never a second tag name. The description is informational only.
*/
@Immutable
data class TagRule(
val type: TagRuleType,
val tagName: String,
val description: String? = null,
) {
fun toTagArray() = TagRuleTag.assemble(type, tagName, description)
}
class TagRuleTag {
companion object {
fun isTag(tag: Array<String>) = tag.has(1) && TagRuleType.fromCode(tag[0]) != null && tag[1].isNotEmpty()
fun isTag(
tag: Array<String>,
type: TagRuleType,
) = tag.has(1) && tag[0] == type.code && tag[1].isNotEmpty()
fun parse(tag: Array<String>): TagRule? {
ensure(tag.has(1)) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val type = TagRuleType.fromCode(tag[0]) ?: return null
return TagRule(type, tag[1], tag.getOrNull(2)?.ifEmpty { null })
}
/** The constrained tag name when [tag] is a rule of [type]. Skips building the rule. */
fun parseTagName(
tag: Array<String>,
type: TagRuleType,
): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == type.code) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(
type: TagRuleType,
tagName: String,
description: String? = null,
) = arrayOfNotNull(type.code, tagName, description)
fun assemble(rule: TagRule) = assemble(rule.type, rule.tagName, rule.description)
fun assemble(rules: List<TagRule>) = rules.map { assemble(it) }
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags
/** `["titles", <singular>, <plural>]`. Optional display title in singular and plural form. */
class TitlesTag {
companion object {
const val TAG_NAME = "titles"
fun isTag(tag: Array<String>) = SingularPlural.isTag(tag, TAG_NAME)
fun parse(tag: Array<String>) = SingularPlural.parse(tag, TAG_NAME)
fun assemble(
singular: String,
plural: String,
) = SingularPlural.assemble(TAG_NAME, singular, plural)
fun assemble(value: SingularPlural) = assemble(value.singular, value.plural)
}
}
@@ -0,0 +1,207 @@
/*
* 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.AddressableDecentralizedListEvent
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.acceptedItemKinds
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.itemKinds
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.inheritFrom
import com.vitorpamplona.quartz.experimental.decentralizedLists.inheritFromTargets
import com.vitorpamplona.quartz.experimental.decentralizedLists.isDeliberatelyUnaffiliated
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.json
import com.vitorpamplona.quartz.experimental.decentralizedLists.searchableListContent
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritType
import com.vitorpamplona.quartz.experimental.decentralizedLists.wordWrapper
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.core.fastForEach
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,
AddressableDecentralizedListEvent,
EventHintProvider,
AddressHintProvider,
PubKeyHintProvider,
SearchableEvent {
override fun listPointer() = addressTag()
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint)
/** Event items plus any parent lists referenced by id, in one pass. */
override fun linkedEventIds(): List<HexKey> {
val out = ArrayList<HexKey>()
tags.fastForEach { tag -> (ETag.parseId(tag) ?: ParentListTag.parseEventId(tag))?.let { out.add(it) } }
return out
}
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
/** Addressable items plus any parent lists referenced by coordinate, in one pass. */
override fun linkedAddressIds(): List<String> {
val out = ArrayList<String>()
tags.fastForEach { tag -> (ATag.parseValidAddress(tag) ?: ParentListTag.parseCoordinate(tag))?.let { out.add(it) } }
return out
}
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()
fun itemKinds() = tags.itemKinds()
fun acceptedItemKinds() = tags.acceptedItemKinds()
fun inheritFrom() = tags.inheritFrom()
fun inheritFromTargets(type: InheritType) = tags.inheritFromTargets(type)
fun isDeliberatelyUnaffiliated() = tags.isDeliberatelyUnaffiliated()
fun json() = tags.json()
fun wordWrapper() = tags.wordWrapper()
fun elementOf() = tags.elementOf()
fun subsetOf() = tags.subsetOf()
companion object {
const val KIND = 39999
@OptIn(ExperimentalUuidApi::class)
fun build(
parent: DecentralizedListEvent,
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
content: String = "",
initializer: TagArrayBuilder<AddressableListItemEvent>.() -> Unit = {},
) = build(ParentListTag.classify(parent.listPointer()), dTag, createdAt, content, initializer)
@OptIn(ExperimentalUuidApi::class)
fun build(
parent: ParentList,
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
content: String = "",
initializer: TagArrayBuilder<AddressableListItemEvent>.() -> Unit = {},
) = eventTemplate<AddressableListItemEvent>(KIND, content, createdAt) {
dTag(dTag)
parentList(parent)
initializer()
}
}
}
@@ -0,0 +1,182 @@
/*
* 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.acceptedItemKinds
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.itemKinds
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.core.fastForEach
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, in one pass. */
override fun linkedEventIds(): List<HexKey> {
val out = ArrayList<HexKey>()
tags.fastForEach { tag -> (ETag.parseId(tag) ?: ParentListTag.parseEventId(tag))?.let { out.add(it) } }
return out
}
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
/** Addressable items plus any parent lists referenced by coordinate, in one pass. */
override fun linkedAddressIds(): List<String> {
val out = ArrayList<String>()
tags.fastForEach { tag -> (ATag.parseValidAddress(tag) ?: ParentListTag.parseCoordinate(tag))?.let { out.add(it) } }
return out
}
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()
fun itemKinds() = tags.itemKinds()
fun acceptedItemKinds() = tags.acceptedItemKinds()
companion object {
const val KIND = 9999
fun build(
parent: DecentralizedListEvent,
createdAt: Long = TimeUtils.now(),
content: String = "",
initializer: TagArrayBuilder<ListItemEvent>.() -> Unit = {},
) = build(ParentListTag.classify(parent.listPointer()), createdAt, content, initializer)
fun build(
parent: ParentList,
createdAt: Long = TimeUtils.now(),
content: String = "",
initializer: TagArrayBuilder<ListItemEvent>.() -> Unit = {},
) = eventTemplate<ListItemEvent>(KIND, content, createdAt) {
parentList(parent)
initializer()
}
}
}
@@ -0,0 +1,86 @@
/*
* 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.ElementOfTag
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.SubsetOfTag
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))
// `n` and `s` are defined for kind 39999 only.
fun TagArrayBuilder<AddressableListItemEvent>.elementOf(parent: Address) = addUniqueValueIfNew(ElementOfTag.assemble(parent))
fun TagArrayBuilder<AddressableListItemEvent>.subsetOf(parent: Address) = addUniqueValueIfNew(SubsetOfTag.assemble(parent))
@@ -0,0 +1,70 @@
/*
* 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.ElementOfTag
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.SubsetOfTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.TitleTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
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() = fastFirstNotNullOfOrNull(NameTag::parse)
fun TagArray.title() = fastFirstNotNullOfOrNull(TitleTag::parse)
fun TagArray.slug() = fastFirstNotNullOfOrNull(SlugTag::parse)
fun TagArray.comments() = fastFirstNotNullOfOrNull(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)
/** The sets/supersets this item claims to be an element of (`n`). */
fun TagArray.elementOf() = mapNotNull(ElementOfTag::parse)
/** The supersets this set claims to be a subset of (`s`). */
fun TagArray.subsetOf() = mapNotNull(SubsetOfTag::parse)
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** `["comments", <text>]`. Free-text commentary from the contributor about the item. */
class CommentsTag {
companion object {
const val TAG_NAME = "comments"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(value: String) = arrayOf(TAG_NAME, value)
}
}
@@ -0,0 +1,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.item.tags
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Class Thread Relationships: `["n", <set kind:pubkey:d>]` — this item is an element of that set or superset. A consumer derives `(parent)-[HAS_ELEMENT]->(this)`.
*
* Defined for kind 39999 only, child-claims-parent, several allowed. Consumers must attribute
* the derived relationship to a curator's graph only when the event is signed by that curator:
* anyone can publish a tag naming someone else's node.
*/
class ElementOfTag {
companion object {
const val TAG_NAME = "n"
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 parseAddress(tag: Array<String>): Address? = parse(tag)?.let { Address.parse(it) }
fun assemble(parent: String) = arrayOf(TAG_NAME, parent)
fun assemble(parent: Address) = assemble(parent.toValue())
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** `["name", <text>]`. The item's name, e.g. `["name", "Derek Ross"]`. */
class NameTag {
companion object {
const val TAG_NAME = "name"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(value: String) = arrayOf(TAG_NAME, value)
}
}
@@ -0,0 +1,133 @@
/*
* 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.CoordinateShape
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
}
/** The raw `kind:pubkey:d` value when [tag] points at a coordinate. No parsing, no allocation. */
fun parseCoordinate(tag: Array<String>): String? {
val value = parseValue(tag) ?: return null
return if (looksLikeAddress(value)) 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) = CoordinateShape.matches(value)
fun assemble(pointer: String) = arrayOf(TAG_NAME, pointer)
fun assemble(parent: ParentList) = assemble(parent.value)
fun assemble(parent: DecentralizedListEvent) = assemble(parent.listPointer())
fun assemble(parents: List<ParentList>) = parents.map { assemble(it) }
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** `["slug", <text>]`. A URL-friendly identifier for the item. */
class SlugTag {
companion object {
const val TAG_NAME = "slug"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(value: String) = arrayOf(TAG_NAME, value)
}
}
@@ -0,0 +1,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.item.tags
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Class Thread Relationships: `["s", <superset kind:pubkey:d>]` — this set or superset is a subset of that superset. A consumer derives `(parent)-[IS_A_SUPERSET_OF]->(this)`.
*
* Defined for kind 39999 only, child-claims-parent, several allowed. Consumers must attribute
* the derived relationship to a curator's graph only when the event is signed by that curator:
* anyone can publish a tag naming someone else's node.
*/
class SubsetOfTag {
companion object {
const val TAG_NAME = "s"
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 parseAddress(tag: Array<String>): Address? = parse(tag)?.let { Address.parse(it) }
fun assemble(parent: String) = arrayOf(TAG_NAME, parent)
fun assemble(parent: Address) = assemble(parent.toValue())
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** `["title", <text>]`. The item's display title. */
class TitleTag {
companion object {
const val TAG_NAME = "title"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(value: String) = arrayOf(TAG_NAME, value)
}
}
@@ -0,0 +1,198 @@
/*
* 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.taggings
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decentralizedLists.description
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.names
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemAddress
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.parentList
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Polarity
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.utils.TimeUtils
/** What an event tagging is about: an addressable event (`a`) or a plain one (`e`). */
@Immutable
sealed interface TaggingTarget {
/** The first 8 characters used in the assertion `d`. */
val prefix: String
@Immutable
data class ByAddress(
val address: Address,
) : TaggingTarget {
// the author segment of the coordinate, not its kind
override val prefix get() = address.pubKeyHex.take(8)
}
@Immutable
data class ByEventId(
val eventId: HexKey,
) : TaggingTarget {
override val prefix get() = eventId.take(8)
}
}
/**
* Event Taggings: the per-tag *tagging header* — "taggings of events as an Awesome Tag".
*
* A kind 39999 that is at once a list header (`names`, `description`) and an item of the
* deployment's `tagging-with-specific-tag` list, naming its tag with a direct `a`. Assertions
* point at it with a `z`, because their own `a`/`e` slot is taken by the target.
*/
object TaggingHeader {
fun dTag(tagSlug: String) = "tagging:$tagSlug-tagging"
/**
* @param taggingWithSpecificTagConcept the deployment's `tagging-with-specific-tag` concept.
*/
fun build(
taggingWithSpecificTagConcepts: Collection<String>,
tagElement: Address,
singularName: String,
pluralName: String,
description: String? = null,
createdAt: Long = TimeUtils.now(),
) = AddressableListItemEvent.build(
parent = taggingWithSpecificTagConcepts.firstNamespace(),
dTag = dTag(tagElement.dTag),
createdAt = createdAt,
) {
conceptNamespaces(taggingWithSpecificTagConcepts)
names(singularName, pluralName)
description?.let { this.description(it) }
itemAddress(tagElement)
}
fun build(
taggingWithSpecificTagConcept: String,
tagElement: Address,
singularName: String,
pluralName: String,
description: String? = null,
createdAt: Long = TimeUtils.now(),
) = build(listOf(taggingWithSpecificTagConcept), tagElement, singularName, pluralName, description, createdAt)
}
/**
* Event Taggings: "this note is an Awesome Tag" — a kind 39999 carrying the target in `a`/`e`
* and two `z`s, one to the deployment's `nostr-event-tag` list and one to the per-tag
* [TaggingHeader]:
*
* ```
* ["d", "event-tag-<tagSlug>-<target8>-<asserter8>"]
* ["a", <target coordinate>] or ["e", <target id>]
* ["z", <nostr-event-tag concept>]
* ["z", <tagging header coordinate>]
* ["polarity", "1" | "-1"]
* ```
*
* An `a`/`e` reference without such a `z` is not a tagging and must not be read as one.
*/
@Immutable
data class EventTagging(
val target: TaggingTarget,
/** The `z`s other than the `nostr-event-tag` one: candidate tagging headers to resolve. */
val taggingHeaders: List<String>,
val polarity: Polarity,
) {
companion object {
fun dTag(
tagSlug: String,
target: TaggingTarget,
asserter: HexKey,
) = "event-tag-$tagSlug-${target.prefix}-${asserter.take(8)}"
/**
* @param nostrEventTagConcepts the `nostr-event-tag` concept of each authority namespace
* to join: the deployment's own, plus any shared one it federates with.
* @param taggingHeader the per-tag tagging header's coordinate.
* @param tagSlug the applied tag's slug, for the deterministic `d`.
* @param asserter the pubkey that will sign, for the deterministic `d`.
*/
fun build(
nostrEventTagConcepts: Collection<String>,
taggingHeader: Address,
tagSlug: String,
target: TaggingTarget,
asserter: HexKey,
apply: Boolean = true,
createdAt: Long = TimeUtils.now(),
) = AddressableListItemEvent.build(
parent = nostrEventTagConcepts.firstNamespace(),
dTag = dTag(tagSlug, target, asserter),
createdAt = createdAt,
) {
conceptNamespaces(nostrEventTagConcepts)
parentList(ParentListTag.classify(taggingHeader.toValue()))
when (target) {
is TaggingTarget.ByAddress -> itemAddress(target.address)
is TaggingTarget.ByEventId -> itemEvent(target.eventId)
}
polarity(apply)
}
fun build(
nostrEventTagConcept: String,
taggingHeader: Address,
tagSlug: String,
target: TaggingTarget,
asserter: HexKey,
apply: Boolean = true,
createdAt: Long = TimeUtils.now(),
) = build(listOf(nostrEventTagConcept), taggingHeader, tagSlug, target, asserter, apply, createdAt)
/**
* Null unless [event] joins one of the [honoredNamespaces]' `nostr-event-tag` concepts,
* names a tagging header and a target. Every namespace the reader honors must be passed:
* a federated tagging carries one concept `z` per namespace it joined, and any concept
* `z` the reader did not list would otherwise be taken for a tagging header.
*/
fun parse(
event: AddressableListItemEvent,
honoredNamespaces: Set<String>,
): EventTagging? {
val pointers = event.parentListPointers()
if (pointers.none { it in honoredNamespaces }) return null
val headers = pointers.filter { it !in honoredNamespaces }
if (headers.isEmpty()) return null
val target =
event.tags.fastFirstNotNullOfOrNull(ATag::parseAddress)?.let { TaggingTarget.ByAddress(it) }
?: event.tags.fastFirstNotNullOfOrNull(ETag::parseId)?.let { TaggingTarget.ByEventId(it) }
?: return null
return EventTagging(target, headers, event.tags.polarity())
}
fun parse(
event: AddressableListItemEvent,
nostrEventTagConcept: String,
) = parse(event, setOf(nostrEventTagConcept))
}
}
@@ -0,0 +1,147 @@
/*
* 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.taggings
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemAddress
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemPubKey
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Polarity
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
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.utils.TimeUtils
import kotlinx.serialization.Serializable
@Immutable
@Serializable
data class NostrUserTagInfo(
val taggedPubkey: HexKey,
val tagEventId: HexKey? = null,
)
/** The `content` mirror of a pubkey tagging: `{"nostrUserTag": {...}}`. */
@Immutable
@Serializable
data class NostrUserTagContent(
val nostrUserTag: NostrUserTagInfo,
)
/**
* Tags & Taggings: "Avi is a Podcaster" — a kind 39999 item asserting that a pubkey belongs to
* a tag, or disputing it.
*
* ```
* ["d", "profile-tag-<tagSlug>-<target[0:8]>-<asserter[0:8]>"]
* ["p", <target>]
* ["a", "39999:<tagAuthor>:<slug>"] the tag applied: stable identity, scan this
* ["e", <tagEventId>] the tag's version at apply time: provenance only
* ["z", <nostr-user-tag concept>]
* ["polarity", "1" | "-1"]
* ```
*
* The deterministic `d` gives each asserter one live stance per (target, tag): republishing
* replaces it, including a flip between apply and dispute.
*/
@Immutable
data class PubKeyTagging(
val target: HexKey,
val tag: Address?,
val tagEventId: HexKey?,
val polarity: Polarity,
) {
companion object {
fun dTag(
tagSlug: String,
target: HexKey,
asserter: HexKey,
) = "profile-tag-$tagSlug-${target.take(8)}-${asserter.take(8)}"
/**
* @param userTagConcepts the `nostr-user-tag` concept of each authority namespace to join.
* @param asserter the pubkey that will sign, needed for the deterministic `d`.
*/
fun build(
userTagConcepts: Collection<String>,
tagElement: Address,
tagEventId: HexKey?,
target: HexKey,
asserter: HexKey,
apply: Boolean = true,
createdAt: Long = TimeUtils.now(),
) = AddressableListItemEvent.build(
parent = userTagConcepts.firstNamespace(),
dTag = dTag(tagElement.dTag, target, asserter),
createdAt = createdAt,
content = JsonMapper.toJson(NostrUserTagContent(NostrUserTagInfo(target, tagEventId))),
) {
conceptNamespaces(userTagConcepts)
itemPubKey(target)
itemAddress(tagElement)
tagEventId?.let { itemEvent(it) }
polarity(apply)
}
fun build(
userTagConcept: String,
tagElement: AddressableListItemEvent,
target: HexKey,
asserter: HexKey,
apply: Boolean = true,
createdAt: Long = TimeUtils.now(),
) = build(listOf(userTagConcept), tagElement.address(), tagElement.id, target, asserter, apply, createdAt)
fun build(
userTagConcept: String,
tagElement: Address,
tagEventId: HexKey?,
target: HexKey,
asserter: HexKey,
apply: Boolean = true,
createdAt: Long = TimeUtils.now(),
) = build(listOf(userTagConcept), tagElement, tagEventId, target, asserter, apply, createdAt)
/** Null unless [event] joins one of the [honoredNamespaces] and names a target pubkey. */
fun parse(
event: AddressableListItemEvent,
honoredNamespaces: Set<String>,
): PubKeyTagging? {
if (event.parentListPointers().none { it in honoredNamespaces }) return null
val target = event.tags.fastFirstNotNullOfOrNull(PTag::parseKey) ?: return null
return PubKeyTagging(
target = target,
tag = event.tags.fastFirstNotNullOfOrNull(ATag::parseAddress),
tagEventId = event.tags.fastFirstNotNullOfOrNull(ETag::parseId),
polarity = event.tags.polarity(),
)
}
fun parse(
event: AddressableListItemEvent,
userTagConcept: String,
) = parse(event, setOf(userTagConcept))
}
}
@@ -0,0 +1,48 @@
/*
* 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.taggings
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.CurationMethod
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.CurationMethodTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.PolarityTag
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun TagArrayBuilder<AddressableListItemEvent>.polarity(apply: Boolean) = addUnique(PolarityTag.assemble(apply))
fun TagArrayBuilder<AddressableListItemEvent>.curationMethod(method: CurationMethod) = addUnique(CurationMethodTag.assemble(method))
/** Rides alongside the concept-membership `z`, never instead of it. */
fun TagArrayBuilder<AddressableListItemEvent>.applicabilityHint(hint: TagApplicabilityHint) = addUniqueValueIfNew(ParentListTag.assemble(hint.code))
/**
* Federation: one concept `z` per authority namespace the publisher joins (e.g. a shared
* canonical `nostr-event-tag` and the deployment's own). Joining several is opt-in; a reader
* scanning any one of them finds the event.
*/
fun TagArrayBuilder<AddressableListItemEvent>.conceptNamespaces(concepts: Collection<String>) = concepts.forEach { addUniqueValueIfNew(ParentListTag.assemble(it)) }
internal fun Collection<String>.firstNamespace(): ParentList {
require(isNotEmpty()) { "At least one concept namespace is required" }
return ParentListTag.classify(first())
}
@@ -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.taggings
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.parentListPointers
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.CurationMethodTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Polarity
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.PolarityTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip01Core.core.fastFirstOrNull
/**
* How a v1 reader counts this tagging. No `polarity` tag means apply; a tag whose value is
* not a number is not counted, rather than guessed.
*/
fun TagArray.polarity(): Polarity {
val tag = fastFirstOrNull(PolarityTag::isTag) ?: return Polarity.APPLIED
val value = PolarityTag.parseValue(tag) ?: return Polarity.UNCOUNTED
return PolarityTag.bucket(value)
}
fun TagArray.curationMethod() = fastFirstNotNullOfOrNull(CurationMethodTag::parse)
/** The applicability hints a tag-element's author recorded. Hints, never gates. */
fun TagArray.applicabilityHints() = parentListPointers().mapNotNull(TagApplicabilityHint::fromCode)
@@ -0,0 +1,107 @@
/*
* 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.taggings
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.Serializable
/**
* The context a tag-element was created for, recorded as a pubkey-free `z` value alongside
* its concept membership. The author's intent only: a reader must not require a hint for a tag
* to work in a context, nor read its absence as "not applicable".
*/
enum class TagApplicabilityHint(
val code: String,
) {
PUBKEY("tag-for-nostr-pubkey"),
EVENT("tag-for-nostr-event"),
;
companion object {
fun fromCode(code: String) = entries.firstOrNull { it.code == code }
}
}
@Immutable
@Serializable
data class TagInfo(
val slug: String,
val name: String? = null,
val description: String? = null,
)
/** The `content` of a tag-element: `{"tag": {"slug", "name", "description"}}`. */
@Immutable
@Serializable
data class TagElementContent(
val tag: TagInfo,
) {
fun toContent() = JsonMapper.toJson(this)
companion object {
fun parse(content: String): TagElementContent? {
if (content.isBlank()) return null
return runCatching { JsonMapper.fromJson<TagElementContent>(content) }.getOrNull()
}
}
}
/**
* Tags & Taggings: a *tag* ("Podcaster is a tag") is a kind 39999 item joining the deployment's
* `tag` concept, with `d` = its slug. It is addressed at `39999:<author>:<slug>`; tags by
* different authors with the same slug are different tags.
*/
object TagElement {
/**
* @param tagConcept the deployment's `tag` concept address (`39998:<assistant>:tag`).
* Deployment-specific: never hardcode it.
*/
fun build(
tagConcepts: Collection<String>,
slug: String,
name: String,
description: String? = null,
hints: Set<TagApplicabilityHint> = emptySet(),
createdAt: Long = TimeUtils.now(),
) = AddressableListItemEvent.build(
parent = tagConcepts.firstNamespace(),
dTag = slug,
createdAt = createdAt,
content = TagElementContent(TagInfo(slug, name, description)).toContent(),
) {
conceptNamespaces(tagConcepts)
hints.forEach { applicabilityHint(it) }
}
fun build(
tagConcept: String,
slug: String,
name: String,
description: String? = null,
hints: Set<TagApplicabilityHint> = emptySet(),
createdAt: Long = TimeUtils.now(),
) = build(listOf(tagConcept), slug, name, description, hints, createdAt)
fun content(event: AddressableListItemEvent) = TagElementContent.parse(event.content)
}
@@ -0,0 +1,125 @@
/*
* 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.taggings
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemAddress
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.CurationMethod
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.Serializable
@Immutable
@Serializable
data class TagPinningInfo(
val tagEventId: HexKey? = null,
val curationMethod: CurationMethod? = null,
)
/** The `content` mirror of a pin: `{"tagPinning": {...}}`. */
@Immutable
@Serializable
data class TagPinningContent(
val tagPinning: TagPinningInfo,
)
/**
* Tags & Taggings: a viewer opting a tag into their personal curated set — a kind 39999 item.
*
* ```
* ["d", "tag-pin-<tagSlug>-<tagAuthor[0:8]>-<viewer[0:8]>"]
* ["e", <tagEventId>] the version pinned
* ["a", "39999:<tagAuthor>:<slug>"] survives the tag author's later edits
* ["z", <tag-pinning concept>]
* ["curation-method", "<json>"]
* ```
*
* Unpinning is a plain NIP-09 deletion of the pin; a live pin event means pinned.
*/
@Immutable
data class TagPin(
val tag: Address?,
val tagEventId: HexKey?,
val curationMethod: CurationMethod?,
) {
companion object {
fun dTag(
tagSlug: String,
tagAuthor: HexKey,
viewer: HexKey,
) = "tag-pin-$tagSlug-${tagAuthor.take(8)}-${viewer.take(8)}"
/**
* @param tagPinningConcept the deployment's `tag-pinning` concept address.
* @param viewer the pubkey that will sign, needed for the deterministic `d`.
*/
fun build(
tagPinningConcepts: Collection<String>,
tagElement: AddressableListItemEvent,
viewer: HexKey,
curationMethod: CurationMethod,
createdAt: Long = TimeUtils.now(),
) = AddressableListItemEvent.build(
parent = tagPinningConcepts.firstNamespace(),
dTag = dTag(tagElement.dTag(), tagElement.pubKey, viewer),
createdAt = createdAt,
content = JsonMapper.toJson(TagPinningContent(TagPinningInfo(tagElement.id, curationMethod))),
) {
conceptNamespaces(tagPinningConcepts)
itemEvent(tagElement.id)
itemAddress(tagElement.address())
curationMethod(curationMethod)
}
fun build(
tagPinningConcept: String,
tagElement: AddressableListItemEvent,
viewer: HexKey,
curationMethod: CurationMethod,
createdAt: Long = TimeUtils.now(),
) = build(listOf(tagPinningConcept), tagElement, viewer, curationMethod, createdAt)
/** Null unless [event] joins one of the [honoredNamespaces]. */
fun parse(
event: AddressableListItemEvent,
honoredNamespaces: Set<String>,
): TagPin? {
if (event.parentListPointers().none { it in honoredNamespaces }) return null
return TagPin(
tag = event.tags.fastFirstNotNullOfOrNull(ATag::parseAddress),
tagEventId = event.tags.fastFirstNotNullOfOrNull(ETag::parseId),
curationMethod = event.tags.curationMethod(),
)
}
fun parse(
event: AddressableListItemEvent,
tagPinningConcept: String,
) = parse(event, setOf(tagPinningConcept))
}
}
@@ -0,0 +1,64 @@
/*
* 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.taggings.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
import kotlinx.serialization.Serializable
/**
* A pin's curation intent: whose point of view ranks the tag's members ([observer]), by which
* method (e.g. `nip85:rank`), the rank [cutoff], and whether derived scores go into the
* Trusted Lists published from it. Further method identifiers come by convention.
*/
@Immutable
@Serializable
data class CurationMethod(
val observer: HexKey? = null,
val method: String? = null,
val cutoff: Int? = null,
val includeScoreInTL: Boolean? = null,
) {
companion object {
const val NIP85_RANK = "nip85:rank"
}
}
/** Tags & Taggings: `["curation-method", "<stringified CurationMethod JSON>"]` on a pin. */
class CurationMethodTag {
companion object {
const val TAG_NAME = "curation-method"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): CurationMethod? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return runCatching { JsonMapper.fromJson<CurationMethod>(tag[1]) }.getOrNull()
}
fun assemble(method: CurationMethod) = arrayOf(TAG_NAME, JsonMapper.toJson(method))
}
}
@@ -0,0 +1,69 @@
/*
* 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.taggings.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** How a v1 reader counts a tagging. */
enum class Polarity {
/** `≥ 0.5`, or no `polarity` tag at all. */
APPLIED,
/** `≤ -0.5`. */
DISPUTED,
/**
* Strictly between the two, or unreadable. Reserved for a future graded-valence design and
* not counted either way in v1.
*/
UNCOUNTED,
}
/**
* Tags & Taggings: `["polarity", "1" | "-1"]` — apply or dispute. An absent tag means apply.
* It is multi-letter, so relays cannot filter on it; excluding disputes is a read-time step.
*/
class PolarityTag {
companion object {
const val TAG_NAME = "polarity"
const val APPLY = "1"
const val DISPUTE = "-1"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
/** The raw value, or null when the tag is not a polarity tag or does not hold a number. */
fun parseValue(tag: Array<String>): Double? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return tag[1].toDoubleOrNull()?.takeIf { it.isFinite() }
}
fun bucket(value: Double) =
when {
value >= 0.5 -> Polarity.APPLIED
value <= -0.5 -> Polarity.DISPUTED
else -> Polarity.UNCOUNTED
}
fun assemble(apply: Boolean) = arrayOf(TAG_NAME, if (apply) APPLY else DISPUTE)
}
}
@@ -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)
}
}
@@ -0,0 +1,119 @@
/*
* 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 androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decentralizedLists.CoordinateShape
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* What a `b` tag claims about its target. A closed registry: anything else, including an
* absent third element, reads as [POINTER] — the least-commitment reading, so that an
* underspecified tag never grants live deference. Code that acts on deference must gate on
* [INHERIT] / [INHERIT_ITEMS] explicitly, never on "not pointer".
*/
enum class InheritType(
val code: String,
) {
/** "My object corresponds to that one." No deference, no resolution semantics. */
POINTER("pointer"),
/** "My definition is this parent's, unless I state otherwise." Live, followed on read. */
INHERIT("inherit"),
/** "My list's items are this parent's items, plus my own." Additive; no definition fields. */
INHERIT_ITEMS("inherit-items"),
;
companion object {
fun fromCode(code: String?) =
when (code) {
INHERIT.code -> INHERIT
INHERIT_ITEMS.code -> INHERIT_ITEMS
else -> POINTER
}
}
}
/**
* Tapestry Inherit-From: `["b", <target kind:pubkey:d>, <type>]`, on kinds 39998 and 39999.
*
* Child-claims-parent, and unlike the `n`/`s` class-thread tags it is **not** flipped: the
* derived relationship points child → target for every type.
*
* Order matters only among inherit-typed tags (the first-listed parent wins a field conflict);
* pointer and inherit-items tags carry no meaning in their position.
*/
@Immutable
data class InheritFromTag(
val target: String,
val type: InheritType = InheritType.POINTER,
) {
fun targetAddress() = Address.parse(target)
fun toTagArray() = assemble(target, type)
companion object {
const val TAG_NAME = "b"
/**
* The one reserved non-address value: `["b", "b-tag-deferred"]` marks its carrier as
* *deliberately* unaffiliated ("I considered a shared twin and chose none"). It derives
* nothing — no edge, no target — so [parse] never returns it as a target.
*/
const val UNAFFILIATED = "b-tag-deferred"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && CoordinateShape.matches(tag[1])
fun isUnaffiliatedMarker(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == UNAFFILIATED
fun parse(tag: Array<String>): InheritFromTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
// The value form is closed: a coordinate, or the reserved marker (which is not a
// target). Anything else derives nothing rather than becoming a bogus parent.
ensure(CoordinateShape.matches(tag[1])) { return null }
return InheritFromTag(tag[1], InheritType.fromCode(tag.getOrNull(2)))
}
/** The target when [tag] is a `b` of exactly [type]. */
fun parseTarget(
tag: Array<String>,
type: InheritType,
): String? = parse(tag)?.takeIf { it.type == type }?.target
// The type is always written out, pointer included: readers treat a missing one as
// pointer anyway, and an explicit value says the author meant it.
fun assemble(
target: String,
type: InheritType,
) = arrayOf(TAG_NAME, target, type.code)
fun assemble(
target: Address,
type: InheritType,
) = assemble(target.toValue(), type)
fun assembleUnaffiliated() = arrayOf(TAG_NAME, UNAFFILIATED)
}
}
@@ -0,0 +1,48 @@
/*
* 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
/**
* Tapestry Concepts: `["json", "<json object>"]`, the element's structured data.
*
* The object is namespaced by concept slug, so one element can carry data for several concepts
* at once (`{"dog": {...}, "animal": {...}}`). Core nodes use the word-wrapper layout; see
* `concepts.WordWrapper`. `content` stays free for human-readable text.
*/
class JsonTag {
companion object {
const val TAG_NAME = "json"
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(json: String) = arrayOf(TAG_NAME, json)
}
}
@@ -35,6 +35,10 @@ import com.vitorpamplona.quartz.experimental.citations.PromptCitationEvent
import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent
import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent
import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.AddressableListHeaderEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.ListHeaderEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.ListItemEvent
import com.vitorpamplona.quartz.experimental.decoupling.setup.EncryptionKeyListEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
@@ -644,6 +648,10 @@ object KindNames {
EventTrustedListEvent.KIND to KindName("Trusted List of Events", null),
AddressableTrustedListEvent.KIND to KindName("Trusted List of Addressables", null),
ExternalIdTrustedListEvent.KIND to KindName("Trusted List of External IDs", null),
ListHeaderEvent.KIND to KindName("Decentralized List", null),
AddressableListHeaderEvent.KIND to KindName("Editable Decentralized List", null),
ListItemEvent.KIND to KindName("Decentralized List Item", null),
AddressableListItemEvent.KIND to KindName("Editable Decentralized List Item", null),
KeyPackageEvent.KIND to KindName("MLS KeyPackage", null),
GitRepositoryStateEvent.KIND to KindName("Git Repo State", "34"),
FeedDefinitionEvent.KIND to KindName("Feed Definition", null),
@@ -113,6 +113,8 @@ object SearchableKinds {
9736, // Bolt12ZapEvent
9737, // Bolt12ZapIntentEvent
9802, // HighlightEvent
9998, // ListHeaderEvent
9999, // ListItemEvent
10003, // BookmarkListEvent
10100, // AgentProfileEvent
10154, // PodcastMetadataEvent
@@ -194,6 +196,8 @@ object SearchableKinds {
39092, // MediaStarterPackEvent
39307, // TextTrackEvent
39701, // WebBookmarkEvent
39998, // AddressableListHeaderEvent
39999, // AddressableListItemEvent
40002, // StreamMessageV2Event
40100, // CanvasEvent
45001, // ForumPostEvent
@@ -124,6 +124,10 @@ import com.vitorpamplona.quartz.experimental.citations.PromptCitationEvent
import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent
import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent
import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.AddressableListHeaderEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.ListHeaderEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.ListItemEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
@@ -842,6 +846,10 @@ class EventFactory {
EventTrustedListEvent.KIND -> EventTrustedListEvent(id, pubKey, createdAt, tags, content, sig)
AddressableTrustedListEvent.KIND -> AddressableTrustedListEvent(id, pubKey, createdAt, tags, content, sig)
ExternalIdTrustedListEvent.KIND -> ExternalIdTrustedListEvent(id, pubKey, createdAt, tags, content, sig)
ListHeaderEvent.KIND -> ListHeaderEvent(id, pubKey, createdAt, tags, content, sig)
AddressableListHeaderEvent.KIND -> AddressableListHeaderEvent(id, pubKey, createdAt, tags, content, sig)
ListItemEvent.KIND -> ListItemEvent(id, pubKey, createdAt, tags, content, sig)
AddressableListItemEvent.KIND -> AddressableListItemEvent(id, pubKey, createdAt, tags, content, sig)
RelayAddMemberEvent.KIND -> RelayAddMemberEvent(id, pubKey, createdAt, tags, content, sig)
RelayRemoveMemberEvent.KIND -> RelayRemoveMemberEvent(id, pubKey, createdAt, tags, content, sig)
RelayMembershipListEvent.KIND -> RelayMembershipListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,120 @@
/*
* 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.assistant.HeaderResolution
import com.vitorpamplona.quartz.experimental.decentralizedLists.concepts.WordWrapper
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.AddressableListHeaderEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.itemKinds
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.ItemKindTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.EventTagging
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.TaggingTarget
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritType
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
/** Regressions found in the audit: each failed before its fix. */
class AuditRegressionTest {
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
private val ta = "d".repeat(64)
private val other = "f".repeat(64)
private fun item(tags: Array<Array<String>>) = assertIs<AddressableListItemEvent>(EventFactory.create("e".repeat(64), alice, 1L, 39999, tags, "", ""))
// Event Taggings § "Concept namespaces & federation": one nostr-event-tag `z` per namespace.
@Test
fun federatedTaggingsDoNotMistakeOtherNamespacesForTaggingHeaders() {
val tagging =
item(
arrayOf(
arrayOf("d", "event-tag-awesome-tag-cccccccc-aaaaaaaa"),
arrayOf("a", "39999:$bob:good-tag"),
arrayOf("z", "39998:$ta:nostr-event-tag"),
arrayOf("z", "39998:$other:nostr-event-tag"),
arrayOf("z", "39999:$bob:tagging:awesome-tag-tagging"),
),
)
val parsed = EventTagging.parse(tagging, setOf("39998:$ta:nostr-event-tag", "39998:$other:nostr-event-tag"))!!
assertEquals(listOf("39999:$bob:tagging:awesome-tag-tagging"), parsed.taggingHeaders)
}
@Test
fun federatedTaggingsCanBeBuiltForSeveralNamespaces() {
val template =
EventTagging.build(
nostrEventTagConcepts = listOf("39998:$ta:nostr-event-tag", "39998:$other:nostr-event-tag"),
taggingHeader = Address(39999, bob, "tagging:awesome-tag-tagging"),
tagSlug = "awesome-tag",
target = TaggingTarget.ByEventId("1".repeat(64)),
asserter = alice,
)
assertEquals(
listOf("39998:$ta:nostr-event-tag", "39998:$other:nostr-event-tag", "39999:$bob:tagging:awesome-tag-tagging"),
template.tags.filter { it[0] == "z" }.map { it[1] },
)
}
// Two versions of the same personal header must resolve to the newest, not whichever came first.
@Test
fun governingHeaderIsTheNewestVersionOfTheWinningAuthor() {
val old = assertIs<AddressableListHeaderEvent>(EventFactory.create("1".repeat(64), alice, 1L, 39998, arrayOf(arrayOf("d", "dogs")), "", ""))
val new = assertIs<AddressableListHeaderEvent>(EventFactory.create("2".repeat(64), alice, 5L, 39998, arrayOf(arrayOf("d", "dogs")), "", ""))
assertEquals(new, HeaderResolution.governing(alice, "dogs", null, listOf(old, new)))
}
// Inherit-From: element 2 is an a-tag; the value form is closed.
@Test
fun bTagsWhoseTargetIsNotACoordinateAreIgnored() {
val node = item(arrayOf(arrayOf("d", "x"), arrayOf("b", "hello", "inherit"), arrayOf("b", "39998:$bob:dogs", "inherit")))
assertEquals(listOf("39998:$bob:dogs"), node.inheritFromTargets(InheritType.INHERIT))
}
@Test
fun itemKindIsTagAgreesWithParse() {
val tag = arrayOf("item-kind", "-1")
assertEquals(ItemKindTag.parse(tag) != null, ItemKindTag.isTag(tag))
assertEquals(emptyList(), arrayOf(tag).itemKinds())
}
// A long list name containing a colon is still a name, and never reaches the address parser.
@Test
fun longNamesWithColonsStayNames() {
val name = "a list of: " + "x".repeat(70)
val node = item(arrayOf(arrayOf("d", "x"), arrayOf("z", name)))
assertIs<ParentList.Name>(node.parentLists().single())
assertFalse(name in node.linkedAddressIds())
}
// Authored JSON: an explicit null in a list field must not make the whole section unreadable.
@Test
fun explicitNullListsInTheWordSectionAreTolerated() {
val wrapper = WordWrapper.parse("""{"word":{"slug":"dogs","wordTypes":null,"coreMemberOf":null}}""")!!
assertEquals("dogs", wrapper.word()?.slug)
}
}
@@ -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())
}
}
@@ -0,0 +1,340 @@
/*
* 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.assistant.CurationCopy
import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.HeaderResolution
import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.dListAssistant
import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.dListCuration
import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.dListCurations
import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.replaceDListCuration
import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.tags.DListCuration
import com.vitorpamplona.quartz.experimental.decentralizedLists.concepts.WordWrapper
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.AddressableListHeaderEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.ItemKind
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.elementOf
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.subsetOf
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.EventTagging
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.PubKeyTagging
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.TagApplicabilityHint
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.TagElement
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.TaggingHeader
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.TaggingTarget
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.applicabilityHints
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.polarity
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Polarity
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritFromTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritType
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.EventFactory
import com.vitorpamplona.quartz.utils.sha256.sha256
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 drafts layered on Decentralized Lists, against their own examples. */
class TapestryExtensionsTest {
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
private val assistant = "c".repeat(64)
private val ta = "d".repeat(64)
private fun event(
kind: Int,
tags: Array<Array<String>>,
pubKey: String = alice,
content: String = "",
id: String = "e".repeat(64),
): Event = EventFactory.create(id, pubKey, 1L, kind, tags, content, "")
private fun tagsOf(tags: Array<Array<String>>) = tags.map { it.toList() }
// Inherit-From
@Test
fun bTagTypesAndTheirFailSafeDefault() {
val header =
assertIs<AddressableListHeaderEvent>(
event(
39998,
arrayOf(
arrayOf("d", "dogs"),
arrayOf("b", "39998:$bob:dogs", "inherit"),
arrayOf("b", "39998:$ta:dogs", "inherit-items"),
arrayOf("b", "39998:$ta:canines"),
arrayOf("b", "39998:$ta:hounds", "something-new"),
),
),
)
assertEquals(
listOf(
InheritFromTag("39998:$bob:dogs", InheritType.INHERIT),
InheritFromTag("39998:$ta:dogs", InheritType.INHERIT_ITEMS),
InheritFromTag("39998:$ta:canines", InheritType.POINTER),
InheritFromTag("39998:$ta:hounds", InheritType.POINTER),
),
header.inheritFrom(),
)
assertEquals(listOf("39998:$bob:dogs"), header.inheritFromTargets(InheritType.INHERIT))
assertFalse(header.isDeliberatelyUnaffiliated())
}
@Test
fun theDeferredSentinelIsAMarkerNotATarget() {
val header = assertIs<AddressableListHeaderEvent>(event(39998, arrayOf(arrayOf("d", "dogs"), arrayOf("b", "b-tag-deferred"))))
assertTrue(header.isDeliberatelyUnaffiliated())
assertEquals(emptyList(), header.inheritFrom())
}
// Tapestry Concepts
@Test
fun conceptGraphIsReadOrComputed() {
val without = assertIs<AddressableListHeaderEvent>(event(39998, arrayOf(arrayOf("d", "dogs"))))
assertEquals("39999:$alice:dogs-concept-graph", without.conceptGraph())
val with = assertIs<AddressableListHeaderEvent>(event(39998, arrayOf(arrayOf("d", "dogs"), arrayOf("concept-graph", "39999:$bob:elsewhere"))))
assertEquals("39999:$bob:elsewhere", with.conceptGraph())
}
@Test
fun wordWrapperJsonParses() {
val json =
"""{"word":{"slug":"concept-header-for-the-concept-of-dogs","name":"concept header for the concept of dogs","wordTypes":["word","conceptHeader"]},""" +
""""conceptHeader":{"description":"Dog is a concept.","oNames":{"singular":"dog","plural":"dogs"}}}"""
val item = assertIs<AddressableListItemEvent>(event(39999, arrayOf(arrayOf("d", "dogs"), arrayOf("json", json))))
val wrapper = item.wordWrapper()!!
assertEquals("concept-header-for-the-concept-of-dogs", wrapper.word()?.slug)
assertEquals(listOf("word", "conceptHeader"), wrapper.word()?.wordTypes)
assertEquals("dogs", wrapper.conceptHeader()?.oNames?.plural)
assertNull(wrapper.section("superset"))
assertNull(WordWrapper.parse("[1,2]"))
assertNull(WordWrapper.parse("not json"))
}
// Class Thread Relationships
@Test
fun classThreadTagsRoundTrip() {
val set = Address(39999, alice, "the-set-of-paid-nostr-relays")
val superset = Address(39999, alice, "superset-of-relays")
val template =
AddressableListItemEvent.build(ParentListTag.classify(set.toValue()), dTag = "relay-x") {
elementOf(set)
subsetOf(superset)
}
val signed = assertIs<AddressableListItemEvent>(event(39999, template.tags))
assertEquals(listOf(set.toValue()), signed.elementOf())
assertEquals(listOf(superset.toValue()), signed.subsetOf())
}
// Cross-NIP Compatibility
@Test
fun itemKindsWidenTheAcceptedKinds() {
val plain = assertIs<AddressableListHeaderEvent>(event(39998, arrayOf(arrayOf("d", "communities"))))
assertEquals(listOf(9999, 39999), plain.acceptedItemKinds())
val widened =
assertIs<AddressableListHeaderEvent>(
event(
39998,
arrayOf(
arrayOf("d", "communities"),
arrayOf("item-kind", "39999"),
arrayOf("item-kind", "34550", "NIP-72 community-definition events"),
arrayOf("item-kind", "not-a-number"),
),
),
)
assertEquals(listOf(ItemKind(39999), ItemKind(34550, "NIP-72 community-definition events")), widened.itemKinds())
assertEquals(listOf(39999, 34550), widened.acceptedItemKinds())
}
@Test
fun foreignKindsCanListThemselves() {
val community = event(34550, arrayOf(arrayOf("d", "lfo"), arrayOf("z", "39998:$ta:communities")))
assertEquals(listOf("39998:$ta:communities"), community.dListParentPointers())
}
// Tags & Taggings
@Test
fun tagElementsCarryTheirJsonAndHints() {
val template = TagElement.build("39998:$ta:tag", "podcaster", "Podcaster", "Makes podcasts", setOf(TagApplicabilityHint.PUBKEY))
assertEquals(
listOf(listOf("d", "podcaster"), listOf("z", "39998:$ta:tag"), listOf("z", "tag-for-nostr-pubkey")),
tagsOf(template.tags),
)
val signed = assertIs<AddressableListItemEvent>(event(39999, template.tags, content = template.content))
assertEquals("Podcaster", TagElement.content(signed)?.tag?.name)
assertEquals(listOf(TagApplicabilityHint.PUBKEY), signed.tags.applicabilityHints())
}
@Test
fun pubKeyTaggingsUseTheDeterministicShape() {
val tag = Address(39999, bob, "podcaster")
val template = PubKeyTagging.build("39998:$ta:nostr-user-tag", tag, "f".repeat(64), target = assistant, asserter = alice, apply = false)
assertEquals(
listOf(
listOf("d", "profile-tag-podcaster-cccccccc-aaaaaaaa"),
listOf("z", "39998:$ta:nostr-user-tag"),
listOf("p", assistant),
listOf("a", "39999:$bob:podcaster"),
listOf("e", "f".repeat(64)),
listOf("polarity", "-1"),
),
tagsOf(template.tags),
)
val signed = assertIs<AddressableListItemEvent>(event(39999, template.tags))
val parsed = PubKeyTagging.parse(signed, "39998:$ta:nostr-user-tag")!!
assertEquals(assistant, parsed.target)
assertEquals(tag, parsed.tag)
assertEquals(Polarity.DISPUTED, parsed.polarity)
assertNull(PubKeyTagging.parse(signed, "39998:$ta:some-other-concept"))
}
@Test
fun polarityBuckets() {
fun p(vararg tags: Array<String>) = arrayOf(*tags).polarity()
assertEquals(Polarity.APPLIED, p())
assertEquals(Polarity.APPLIED, p(arrayOf("polarity", "1")))
assertEquals(Polarity.DISPUTED, p(arrayOf("polarity", "-1")))
assertEquals(Polarity.UNCOUNTED, p(arrayOf("polarity", "0.2")))
assertEquals(Polarity.UNCOUNTED, p(arrayOf("polarity", "yes")))
}
// Event Taggings: the spec's worked example
@Test
fun eventTaggingsFollowTheWorkedExample() {
val awesomeTag = Address(39999, bob, "awesome-tag")
val header = TaggingHeader.build("39998:$ta:tagging-with-specific-tag", awesomeTag, "Tagging of an event as an Awesome Tag", "Taggings of events as Awesome Tags")
assertEquals("tagging:awesome-tag-tagging", header.tags.first { it[0] == "d" }[1])
val headerAddress = Address(39999, bob, "tagging:awesome-tag-tagging")
val goodTag = Address(39999, assistant, "good-tag")
val assertion = EventTagging.build("39998:$ta:nostr-event-tag", headerAddress, "awesome-tag", TaggingTarget.ByAddress(goodTag), asserter = alice)
assertEquals(
listOf(
listOf("d", "event-tag-awesome-tag-cccccccc-aaaaaaaa"),
listOf("z", "39998:$ta:nostr-event-tag"),
listOf("z", "39999:$bob:tagging:awesome-tag-tagging"),
listOf("a", "39999:$assistant:good-tag"),
listOf("polarity", "1"),
),
tagsOf(assertion.tags),
)
val signed = assertIs<AddressableListItemEvent>(event(39999, assertion.tags))
val parsed = EventTagging.parse(signed, "39998:$ta:nostr-event-tag")!!
assertEquals(TaggingTarget.ByAddress(goodTag), parsed.target)
assertEquals(listOf("39999:$bob:tagging:awesome-tag-tagging"), parsed.taggingHeaders)
}
// Assistant Designation
@Test
fun treasureMapEntriesParseAndStayOutOfEachOther() {
val map =
arrayOf(
arrayOf("30382:rank", ta, "wss://nip85.brainstorm.world"),
arrayOf("39998:dlist-header", assistant, ""),
arrayOf("39998:dogs", assistant, "wss://dcosl.brainstorm.world"),
arrayOf("39999:tagging:with:colons", assistant, "wss://dcosl.brainstorm.world"),
arrayOf("39999:dlist-header", assistant, ""),
)
assertEquals(assistant, map.dListAssistant()?.assistant)
assertNull(map.dListAssistant()?.relay)
assertEquals(listOf("dogs", "tagging:with:colons"), map.dListCurations().map { it.dTag })
assertEquals(Address(39998, assistant, "dogs"), map.dListCuration(39998, "dogs")?.headerAddress())
val updated = map.replaceDListCuration(DListCuration(39998, "dogs", bob))
assertEquals(map.size, updated.size)
assertEquals(bob, updated.dListCuration(39998, "dogs")?.assistant)
assertEquals(listOf("30382:rank", ta, "wss://nip85.brainstorm.world"), updated[0].toList())
}
@Test
fun curationCopiesCarryOnlyWhatTheSpecLists() {
val original =
assertIs<AddressableListItemEvent>(
event(
39999,
arrayOf(
arrayOf("d", "fido"),
arrayOf("z", "39998:$ta:dogs"),
arrayOf("name", "Fido"),
arrayOf("t", "Fido"),
arrayOf("n", "39999:$bob:good-dogs"),
arrayOf("json", "{}"),
),
pubKey = bob,
content = "a good dog",
),
)
val header = Address(39998, assistant, "dogs")
val copy = CurationCopy.build(header, original, null)
val expectedD = "copy-" + sha256("39998:$assistant:dogs\n39999:$bob:fido".encodeToByteArray()).toHexKey()
assertEquals(
listOf(
listOf("d", expectedD),
listOf("z", "39998:$assistant:dogs"),
listOf("q", "39999:$bob:fido", ""),
listOf("q", original.id, "", bob),
listOf("name", "Fido"),
listOf("t", "Fido"),
),
tagsOf(copy.tags),
)
assertEquals("a good dog", copy.content)
}
@Test
fun personalHeadersWinOverTheAssistantsRegardlessOfAge() {
val map = arrayOf(arrayOf("39998:dlist-header", assistant, ""))
assertEquals(
listOf(Address(39998, alice, "dogs"), Address(39998, assistant, "dogs")),
HeaderResolution.candidates(alice, "dogs", map),
)
val personal = assertIs<AddressableListHeaderEvent>(EventFactory.create("1".repeat(64), alice, 1L, 39998, arrayOf(arrayOf("d", "dogs")), "", ""))
val newerByAssistant = assertIs<AddressableListHeaderEvent>(EventFactory.create("2".repeat(64), assistant, 99L, 39998, arrayOf(arrayOf("d", "dogs")), "", ""))
assertEquals(personal, HeaderResolution.governing(alice, "dogs", map, listOf(newerByAssistant, personal)))
assertEquals(newerByAssistant, HeaderResolution.governing(alice, "dogs", map, listOf(newerByAssistant)))
assertNull(HeaderResolution.governing(alice, "dogs", null, listOf(newerByAssistant)))
}
}
@@ -58,6 +58,8 @@
9736 The content body.
9737 The content body.
9802 The Comment\nThe Context\nThe content body.
9998 The Name\nThe Title\nThe Description\nhashtag1\nhashtag2
9999 The Name\nThe Title\nThe Description\nhashtag1\nhashtag2
10003 The Title
10100
10154 The Title\nThe Description
@@ -139,6 +141,8 @@
39092 The Title\nThe Description
39307 The content body.
39701 The Title\nThe content body.
39998 The Name\nThe Title\nThe Description\nhashtag1\nhashtag2
39999 The Name\nThe Title\nThe Description\nhashtag1\nhashtag2
40002 The content body.
40100 The content body.
45001 The content body.