mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge pull request #4197 from vitorpamplona/claude/serene-bohr-okvbkv
Add Decentralized Lists (kinds 9998, 39998, 9999, 39999) support
This commit is contained in:
@@ -4,7 +4,7 @@ Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexabl
|
||||
expression. **Update this file in the same PR as any change to the searchable set or to an
|
||||
`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` |
|
||||
|
||||
+45
@@ -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
|
||||
}
|
||||
}
|
||||
+51
@@ -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
|
||||
+34
@@ -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()
|
||||
+151
@@ -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.
|
||||
+97
@@ -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
|
||||
}
|
||||
}
|
||||
+45
@@ -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))
|
||||
+50
@@ -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)
|
||||
+117
@@ -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))
|
||||
}
|
||||
}
|
||||
+67
@@ -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 }
|
||||
}
|
||||
}
|
||||
+86
@@ -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()
|
||||
}
|
||||
+41
@@ -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())
|
||||
+70
@@ -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)
|
||||
}
|
||||
}
|
||||
+82
@@ -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 ?: "")
|
||||
}
|
||||
}
|
||||
+104
@@ -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,
|
||||
)
|
||||
+131
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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))
|
||||
+68
@@ -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)
|
||||
+66
@@ -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
|
||||
}
|
||||
}
|
||||
+64
@@ -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)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags
|
||||
|
||||
/** `["names", <singular>, <plural>]`. Required on every list header, e.g. `["names", "widget", "widgets"]`. */
|
||||
class NamesTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "names"
|
||||
|
||||
fun isTag(tag: Array<String>) = SingularPlural.isTag(tag, TAG_NAME)
|
||||
|
||||
fun parse(tag: Array<String>) = SingularPlural.parse(tag, TAG_NAME)
|
||||
|
||||
fun assemble(
|
||||
singular: String,
|
||||
plural: String,
|
||||
) = SingularPlural.assemble(TAG_NAME, singular, plural)
|
||||
|
||||
fun assemble(value: SingularPlural) = assemble(value.singular, value.plural)
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The value of the `names`, `titles` and `slugs` header tags: a singular form ("widget")
|
||||
* followed by a plural form ("widgets").
|
||||
*/
|
||||
@Immutable
|
||||
data class SingularPlural(
|
||||
val singular: String,
|
||||
val plural: String,
|
||||
) {
|
||||
companion object {
|
||||
// Both forms are required by the spec. A tag missing either one is malformed and
|
||||
// dropped rather than guessed at: pluralizing someone else's noun is not our call.
|
||||
fun isTag(
|
||||
tag: Array<String>,
|
||||
tagName: String,
|
||||
) = tag.has(2) && tag[0] == tagName && tag[1].isNotEmpty() && tag[2].isNotEmpty()
|
||||
|
||||
fun parse(
|
||||
tag: Array<String>,
|
||||
tagName: String,
|
||||
): SingularPlural? {
|
||||
ensure(tag.has(2)) { return null }
|
||||
ensure(tag[0] == tagName) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
ensure(tag[2].isNotEmpty()) { return null }
|
||||
return SingularPlural(tag[1], tag[2])
|
||||
}
|
||||
|
||||
fun assemble(
|
||||
tagName: String,
|
||||
singular: String,
|
||||
plural: String,
|
||||
) = arrayOf(tagName, singular, plural)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags
|
||||
|
||||
/** `["slugs", <singular>, <plural>]`. Optional URL-friendly identifier in singular and plural form. */
|
||||
class SlugsTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "slugs"
|
||||
|
||||
fun isTag(tag: Array<String>) = SingularPlural.isTag(tag, TAG_NAME)
|
||||
|
||||
fun parse(tag: Array<String>) = SingularPlural.parse(tag, TAG_NAME)
|
||||
|
||||
fun assemble(
|
||||
singular: String,
|
||||
plural: String,
|
||||
) = SingularPlural.assemble(TAG_NAME, singular, plural)
|
||||
|
||||
fun assemble(value: SingularPlural) = assemble(value.singular, value.plural)
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.arrayOfNotNull
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** How a list header constrains a tag on its children. The code is also the tag name. */
|
||||
enum class TagRuleType(
|
||||
val code: String,
|
||||
) {
|
||||
REQUIRED("required"),
|
||||
ALLOWED("allowed"),
|
||||
RECOMMENDED("recommended"),
|
||||
DISALLOWED("disallowed"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String) =
|
||||
when (code) {
|
||||
REQUIRED.code -> REQUIRED
|
||||
ALLOWED.code -> ALLOWED
|
||||
RECOMMENDED.code -> RECOMMENDED
|
||||
DISALLOWED.code -> DISALLOWED
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One constraint a list header places on its items: `["required", "p"]`, optionally with a
|
||||
* human-readable description of what the named tag represents:
|
||||
* `["required", "p", "Pubkey of the person being endorsed"]`.
|
||||
*
|
||||
* The spec requires one tag per constrained name — `["required", "foo"], ["required", "bar"]`,
|
||||
* never `["required", "foo", "bar"]`. That makes index 2 unambiguous: it is always the
|
||||
* description, never a second tag name. The description is informational only.
|
||||
*/
|
||||
@Immutable
|
||||
data class TagRule(
|
||||
val type: TagRuleType,
|
||||
val tagName: String,
|
||||
val description: String? = null,
|
||||
) {
|
||||
fun toTagArray() = TagRuleTag.assemble(type, tagName, description)
|
||||
}
|
||||
|
||||
class TagRuleTag {
|
||||
companion object {
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && TagRuleType.fromCode(tag[0]) != null && tag[1].isNotEmpty()
|
||||
|
||||
fun isTag(
|
||||
tag: Array<String>,
|
||||
type: TagRuleType,
|
||||
) = tag.has(1) && tag[0] == type.code && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): TagRule? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
val type = TagRuleType.fromCode(tag[0]) ?: return null
|
||||
return TagRule(type, tag[1], tag.getOrNull(2)?.ifEmpty { null })
|
||||
}
|
||||
|
||||
/** The constrained tag name when [tag] is a rule of [type]. Skips building the rule. */
|
||||
fun parseTagName(
|
||||
tag: Array<String>,
|
||||
type: TagRuleType,
|
||||
): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == type.code) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(
|
||||
type: TagRuleType,
|
||||
tagName: String,
|
||||
description: String? = null,
|
||||
) = arrayOfNotNull(type.code, tagName, description)
|
||||
|
||||
fun assemble(rule: TagRule) = assemble(rule.type, rule.tagName, rule.description)
|
||||
|
||||
fun assemble(rules: List<TagRule>) = rules.map { assemble(it) }
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags
|
||||
|
||||
/** `["titles", <singular>, <plural>]`. Optional display title in singular and plural form. */
|
||||
class TitlesTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "titles"
|
||||
|
||||
fun isTag(tag: Array<String>) = SingularPlural.isTag(tag, TAG_NAME)
|
||||
|
||||
fun parse(tag: Array<String>) = SingularPlural.parse(tag, TAG_NAME)
|
||||
|
||||
fun assemble(
|
||||
singular: String,
|
||||
plural: String,
|
||||
) = SingularPlural.assemble(TAG_NAME, singular, plural)
|
||||
|
||||
fun assemble(value: SingularPlural) = assemble(value.singular, value.plural)
|
||||
}
|
||||
}
|
||||
+207
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
@@ -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))
|
||||
+70
@@ -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)
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** `["comments", <text>]`. Free-text commentary from the contributor about the item. */
|
||||
class CommentsTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "comments"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(value: String) = arrayOf(TAG_NAME, value)
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists.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())
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** `["name", <text>]`. The item's name, e.g. `["name", "Derek Ross"]`. */
|
||||
class NameTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "name"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(value: String) = arrayOf(TAG_NAME, value)
|
||||
}
|
||||
}
|
||||
+133
@@ -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) }
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** `["slug", <text>]`. A URL-friendly identifier for the item. */
|
||||
class SlugTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "slug"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(value: String) = arrayOf(TAG_NAME, value)
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists.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())
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** `["title", <text>]`. The item's display title. */
|
||||
class TitleTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "title"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(value: String) = arrayOf(TAG_NAME, value)
|
||||
}
|
||||
}
|
||||
+198
@@ -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))
|
||||
}
|
||||
}
|
||||
+147
@@ -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))
|
||||
}
|
||||
}
|
||||
+48
@@ -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())
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists.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)
|
||||
+107
@@ -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)
|
||||
}
|
||||
+125
@@ -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))
|
||||
}
|
||||
}
|
||||
+64
@@ -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))
|
||||
}
|
||||
}
|
||||
+69
@@ -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)
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** `["description", <text>]`, carried by both list headers and list items. */
|
||||
class DescriptionTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "description"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(description: String) = arrayOf(TAG_NAME, description)
|
||||
}
|
||||
}
|
||||
+119
@@ -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)
|
||||
}
|
||||
}
|
||||
+48
@@ -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)
|
||||
|
||||
+120
@@ -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)
|
||||
}
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.AddressableListHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.ListHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.recommended
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.required
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRule
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRuleType
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.ListItemEvent
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemPubKey
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemString
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.name
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.parentListName
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The Tapestry Decentralized Lists tag layer against the spec's own examples
|
||||
* (https://github.com/nous-clawds4/tapestry/blob/main/protocols/nips/decentralized-lists.md).
|
||||
*/
|
||||
class DecentralizedListsTest {
|
||||
private val author = "dee45a23c4f1d93f3a2043650c5081e4ac14a778e0acbef03de3768e4f81ac7b"
|
||||
private val derek = "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"
|
||||
private val headerId = "9d1b6b9562e66f2ecf35eb0a3c2decc736c47fddb13d6fb8f87185a153ea3634"
|
||||
private val otherHeaderId = "1fc80cf813f1af33d5a435862b7ef7fb96b47e68a48f1abcadf8081f5a545550"
|
||||
|
||||
private fun event(
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
id: String = "a".repeat(64),
|
||||
): Event = EventFactory.create(id, author, 1719888496L, kind, tags, "", "")
|
||||
|
||||
@Test
|
||||
fun theFactoryBuildsEachKind() {
|
||||
assertIs<ListHeaderEvent>(event(9998, emptyArray()))
|
||||
assertIs<AddressableListHeaderEvent>(event(39998, emptyArray()))
|
||||
assertIs<ListItemEvent>(event(9999, emptyArray()))
|
||||
assertIs<AddressableListItemEvent>(event(39999, emptyArray()))
|
||||
}
|
||||
|
||||
// Example 1: a list of nostr developers.
|
||||
@Test
|
||||
fun theSpecsHeaderExampleParses() {
|
||||
val header =
|
||||
assertIs<ListHeaderEvent>(
|
||||
event(
|
||||
9998,
|
||||
arrayOf(
|
||||
arrayOf("names", "nostr developer", "nostr developers"),
|
||||
arrayOf("description", "This is a list of developers who build within the nostr ecosystem"),
|
||||
arrayOf("required", "p"),
|
||||
arrayOf("required", "name"),
|
||||
),
|
||||
id = headerId,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("nostr developer", header.names()?.singular)
|
||||
assertEquals("nostr developers", header.names()?.plural)
|
||||
assertEquals("This is a list of developers who build within the nostr ecosystem", header.description())
|
||||
assertEquals(listOf("p", "name"), header.requiredTags())
|
||||
assertEquals(emptyList(), header.allowedTags())
|
||||
assertEquals(headerId, header.listPointer())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ruleDescriptionsAreTheOptionalThirdElement() {
|
||||
val header =
|
||||
assertIs<ListHeaderEvent>(
|
||||
event(
|
||||
9998,
|
||||
arrayOf(
|
||||
arrayOf("names", "endorsement", "endorsements"),
|
||||
arrayOf("required", "p", "Pubkey of the person being endorsed"),
|
||||
arrayOf("allowed", "comments", "Optional textual reason"),
|
||||
arrayOf("disallowed", "e"),
|
||||
arrayOf("recommended", "title"),
|
||||
arrayOf("required", ""),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
TagRule(TagRuleType.REQUIRED, "p", "Pubkey of the person being endorsed"),
|
||||
TagRule(TagRuleType.ALLOWED, "comments", "Optional textual reason"),
|
||||
TagRule(TagRuleType.DISALLOWED, "e", null),
|
||||
TagRule(TagRuleType.RECOMMENDED, "title", null),
|
||||
),
|
||||
header.tagRules(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNamesTagMissingItsPluralIsDropped() {
|
||||
val header = assertIs<ListHeaderEvent>(event(9998, arrayOf(arrayOf("names", "dog"))))
|
||||
assertNull(header.names())
|
||||
}
|
||||
|
||||
// Example 1, item side, and Example 4 (one item on two lists).
|
||||
@Test
|
||||
fun theSpecsItemExampleParses() {
|
||||
val item =
|
||||
assertIs<ListItemEvent>(
|
||||
event(
|
||||
9999,
|
||||
arrayOf(
|
||||
arrayOf("z", headerId),
|
||||
arrayOf("z", otherHeaderId),
|
||||
arrayOf("name", "Derek Ross"),
|
||||
arrayOf("p", derek),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf(ParentList.EventId(headerId), ParentList.EventId(otherHeaderId)), item.parentLists())
|
||||
assertEquals("Derek Ross", item.name())
|
||||
assertEquals(listOf(derek), item.itemPubKeys().map { it.pubKey })
|
||||
assertTrue(headerId in item.linkedEventIds())
|
||||
assertFalse(item.declaresList())
|
||||
}
|
||||
|
||||
// Example 7: pointing at an editable (39998) header by coordinate.
|
||||
@Test
|
||||
fun zTagsPointingAtCoordinatesParseAsAddresses() {
|
||||
val coordinate = "39998:$author:dogs"
|
||||
val item = assertIs<ListItemEvent>(event(9999, arrayOf(arrayOf("z", coordinate), arrayOf("t", "Fido"))))
|
||||
|
||||
val parent = assertIs<ParentList.Coordinate>(item.parentLists().single())
|
||||
assertEquals(39998, parent.address.kind)
|
||||
assertEquals(author, parent.address.pubKeyHex)
|
||||
assertEquals("dogs", parent.address.dTag)
|
||||
assertEquals(coordinate, parent.value)
|
||||
assertEquals(listOf("Fido"), item.itemStrings())
|
||||
assertTrue(coordinate in item.linkedAddressIds())
|
||||
}
|
||||
|
||||
// Example 4, alternate form: undeclared lists named by their singular name.
|
||||
@Test
|
||||
fun zTagsWithPlainNamesParseAsNames() {
|
||||
val item =
|
||||
assertIs<ListItemEvent>(
|
||||
event(9999, arrayOf(arrayOf("z", "dog"), arrayOf("z", "animal"), arrayOf("t", "Fido"))),
|
||||
)
|
||||
|
||||
assertEquals(listOf(ParentList.Name("dog"), ParentList.Name("animal")), item.parentLists())
|
||||
assertEquals(listOf("dog", "animal"), item.parentListPointers())
|
||||
}
|
||||
|
||||
// Example 2: the spec puts an naddr in the item's `a` tag.
|
||||
@Test
|
||||
fun naddrItemsParseAsAddresses() {
|
||||
val naddr =
|
||||
"naddr1qvzqqqr4gupzq4rqjpyzsnf2z5wgma397sxr382z8mg90l80jf7m3z2k628z9wsrqythwumn8ghj7cnfw33k76twv4ezuum0vd5kzmp0qythwumn8ghj7ct5d3shxtnwdaehgu3wd3skuep0qq3kv6tpwskkxatjwfjkucme946xsefdwd5kcetwwskhg6tdv5khg6rfv4nqnxv6fx"
|
||||
val item =
|
||||
assertIs<ListItemEvent>(
|
||||
event(
|
||||
9999,
|
||||
arrayOf(
|
||||
arrayOf("z", headerId),
|
||||
arrayOf("a", naddr),
|
||||
arrayOf("title", "Fiat Currency: The Silent Time Thief"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(30023, item.itemAddresses().single().kind)
|
||||
assertEquals("Fiat Currency: The Silent Time Thief", item.title())
|
||||
}
|
||||
|
||||
// "Nonstandard methods to declare a list": a 9999 on the list of lists carries header tags.
|
||||
@Test
|
||||
fun anItemCanDeclareAListTheNonstandardWay() {
|
||||
val item =
|
||||
assertIs<ListItemEvent>(
|
||||
event(
|
||||
9999,
|
||||
arrayOf(
|
||||
arrayOf("z", "list"),
|
||||
arrayOf("names", "dog", "dogs"),
|
||||
arrayOf("description", "This is a list (by name) of individual dogs."),
|
||||
arrayOf("required", "t"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(item.declaresList())
|
||||
assertEquals("dogs", item.names()?.plural)
|
||||
assertEquals(listOf("t"), item.requiredTags())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theHeaderBuilderWritesTheSpecsShape() {
|
||||
val template =
|
||||
ListHeaderEvent.build("dog name", "dog names", "This is a list of commonly used dog names.") {
|
||||
required("t")
|
||||
recommended("comments", "Why this name")
|
||||
}
|
||||
|
||||
assertEquals(9998, template.kind)
|
||||
assertEquals(
|
||||
listOf(
|
||||
listOf("names", "dog name", "dog names"),
|
||||
listOf("description", "This is a list of commonly used dog names."),
|
||||
listOf("required", "t"),
|
||||
listOf("recommended", "comments", "Why this name"),
|
||||
),
|
||||
template.tags.map { it.toList() },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun itemsPointAtRegularHeadersByIdAndAddressableHeadersByCoordinate() {
|
||||
val header = assertIs<ListHeaderEvent>(event(9998, arrayOf(arrayOf("names", "dog", "dogs")), id = headerId))
|
||||
val byId = ListItemEvent.build(header) { itemString("Fido") }
|
||||
assertEquals(
|
||||
listOf(listOf("z", headerId), listOf("t", "Fido")),
|
||||
byId.tags.map { it.toList() },
|
||||
)
|
||||
|
||||
val editable =
|
||||
assertIs<AddressableListHeaderEvent>(
|
||||
event(39998, arrayOf(arrayOf("d", "dogs"), arrayOf("names", "dog", "dogs"))),
|
||||
)
|
||||
val byCoordinate = AddressableListItemEvent.build(editable, dTag = "fido") { itemString("Fido") }
|
||||
assertEquals(
|
||||
listOf(listOf("d", "fido"), listOf("z", "39998:$author:dogs"), listOf("t", "Fido")),
|
||||
byCoordinate.tags.map { it.toList() },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun itemBuilderAcceptsUndeclaredListsAndPubkeys() {
|
||||
val template =
|
||||
ListItemEvent.build(ParentList.Name("nostr developer")) {
|
||||
parentListName("nostr developer")
|
||||
name("Derek Ross")
|
||||
itemPubKey(derek)
|
||||
}
|
||||
|
||||
// the repeated parent is written once
|
||||
assertEquals(
|
||||
listOf(listOf("z", "nostr developer"), listOf("name", "Derek Ross"), listOf("p", derek)),
|
||||
template.tags.map { it.toList() },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchIndexesTheHumanTextAndBothPathsAgree() {
|
||||
val header =
|
||||
assertIs<ListHeaderEvent>(
|
||||
event(
|
||||
9998,
|
||||
arrayOf(
|
||||
arrayOf("names", "dog", "dogs"),
|
||||
arrayOf("titles", "Dog", "Dogs"),
|
||||
arrayOf("description", "Good boys."),
|
||||
arrayOf("required", "t"),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals("dog\ndogs\nDog\nDogs\nGood boys.", header.indexableContent())
|
||||
|
||||
val item =
|
||||
assertIs<ListItemEvent>(
|
||||
event(
|
||||
9999,
|
||||
arrayOf(arrayOf("z", headerId), arrayOf("name", "Fido"), arrayOf("comments", "Very good"), arrayOf("t", "Fido")),
|
||||
),
|
||||
)
|
||||
assertEquals("Fido\nVery good\nFido", item.indexableContent())
|
||||
|
||||
listOf<SearchableEvent>(header, item).forEach { event ->
|
||||
val fields = mutableListOf<String>()
|
||||
event.forEachIndexableField { field ->
|
||||
field?.let { fields.add(it) }
|
||||
true
|
||||
}
|
||||
assertEquals(event.indexableContent(), fields.joinToString(event.indexableSeparator()))
|
||||
}
|
||||
|
||||
assertEquals("", assertIs<ListItemEvent>(event(9999, emptyArray())).indexableContent())
|
||||
}
|
||||
}
|
||||
+340
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user