From 97bc4baffc38aca46d49a1237a1558cd0a1c0cae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:33:37 +0000 Subject: [PATCH 1/3] feat(quartz): add Tapestry Decentralized Lists (kinds 9998/39998/9999/39999) Implements the Decentralized Lists draft from Tapestry under experimental/decentralizedLists, laid out like nip88Polls: a header/ sub-package for list declarations and an item/ sub-package for community contributed items, each with events, TagArrayExt readers, builder extensions and tags/. - Headers: names/titles/slugs (singular + plural), description, and required/allowed/recommended/disallowed rules with the optional third-element description. - Items: z parent pointers classified as event id, coordinate or plain list name; p/e/t/a item tags (t kept case-sensitive, a accepts naddr); name/title/slug/description/comments; hint providers. - Nonstandard method: items expose header accessors and declaresList(). - Registered in EventFactory, KindNames and SearchableKinds; all four kinds are searchable over their human-authored tags. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2NXYQuDhvx4cmH1Nq5Zxq --- .../references/searchable-kinds.md | 6 +- .../DecentralizedListEvent.kt | 44 +++ .../experimental/decentralizedLists/README.md | 90 +++++ .../decentralizedLists/SearchExt.kt | 73 ++++ .../decentralizedLists/TagArrayBuilderExt.kt | 26 ++ .../decentralizedLists/TagArrayExt.kt | 26 ++ .../header/AddressableListHeaderEvent.kt | 101 ++++++ .../header/ListHeaderEvent.kt | 96 ++++++ .../header/TagArrayBuilderExt.kt | 72 ++++ .../decentralizedLists/header/TagArrayExt.kt | 53 +++ .../header/tags/NamesTag.kt | 39 +++ .../header/tags/SingularPlural.kt | 61 ++++ .../header/tags/SlugsTag.kt | 39 +++ .../header/tags/TagRuleTag.kt | 105 ++++++ .../header/tags/TitlesTag.kt | 39 +++ .../item/AddressableListItemEvent.kt | 168 ++++++++++ .../decentralizedLists/item/ListItemEvent.kt | 165 +++++++++ .../item/TagArrayBuilderExt.kt | 78 +++++ .../decentralizedLists/item/TagArrayExt.kt | 61 ++++ .../item/tags/CommentsTag.kt | 42 +++ .../decentralizedLists/item/tags/NameTag.kt | 42 +++ .../item/tags/ParentListTag.kt | 126 +++++++ .../decentralizedLists/item/tags/SlugTag.kt | 42 +++ .../decentralizedLists/item/tags/TitleTag.kt | 42 +++ .../decentralizedLists/tags/DescriptionTag.kt | 42 +++ .../vitorpamplona/quartz/kinds/KindNames.kt | 8 + .../quartz/nip50Search/SearchableKinds.kt | 4 + .../quartz/utils/EventFactory.kt | 8 + .../DecentralizedListsTest.kt | 313 ++++++++++++++++++ 29 files changed, 2010 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/SearchExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/AddressableListHeaderEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/ListHeaderEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/NamesTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/SingularPlural.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/SlugsTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/TagRuleTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/TitlesTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/CommentsTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/NameTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ParentListTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/SlugTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/TitleTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/DescriptionTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListsTest.kt diff --git a/.claude/skills/searchable-events/references/searchable-kinds.md b/.claude/skills/searchable-events/references/searchable-kinds.md index c8cab211a5..b2e3348127 100644 --- a/.claude/skills/searchable-events/references/searchable-kinds.md +++ b/.claude/skills/searchable-events/references/searchable-kinds.md @@ -4,7 +4,7 @@ Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexabl expression. **Update this file in the same PR as any change to the searchable set or to an `indexableContent()` body** (see SKILL.md). Verified against the code 2026-09-17. -Counts: 133 concrete classes covering 136 kind values (`GitStatusEvent` spans 4 kinds; +Counts: 137 concrete classes covering 140 kind values (`GitStatusEvent` spans 4 kinds; kind 30063 has a collision — see the footnote). File paths are under `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`. @@ -65,6 +65,8 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. | 9736 | Bolt12ZapEvent | nipB1Bolt12Zaps/zap | `content` | | 9737 | Bolt12ZapIntentEvent | nipB1Bolt12Zaps/intent | `content` | | 9802 | HighlightEvent | nip84Highlights | `listOfNotNull(comment(), context(), content)` NL | +| 9998 | ListHeaderEvent | experimental/decentralizedLists/header | `tags.searchableListContent()` NL — `names` (singular, plural), `titles` (singular, plural), `name`, `title`, `description`, `comments`, then every `t` value; ids/pubkeys/coordinates are left to tag filters | +| 9999 | ListItemEvent | experimental/decentralizedLists/item | same as 9998 | | 10003 | BookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL | | 10100 | AgentProfileEvent | buzz/agentProfiles | `profileOrNull()?.let { listOfNotNull(it.name, it.displayName).joinToString("\n") } ?: ""` | | 10154 | PodcastMetadataEvent | nipF4Podcasts/metadata | `listOfNotNull(title(), description())` NL | @@ -140,6 +142,8 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. | 39092 | MediaStarterPackEvent | nip51Lists/mediaStarterPack | `listOfNotNull(title(), description())` NL | | 39307 | TextTrackEvent | nip71Video/textTrack | `content` (the WebVTT cue text) | | 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL | +| 39998 | AddressableListHeaderEvent | experimental/decentralizedLists/header | same as 9998 | +| 39999 | AddressableListItemEvent | experimental/decentralizedLists/item | same as 9998 | | 40002 | StreamMessageV2Event | buzz/stream | `content` | | 40100 | CanvasEvent | buzz/stream | `content` | | 45001 | ForumPostEvent | buzz/forum | `content` | diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListEvent.kt new file mode 100644 index 0000000000..604bdeea42 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListEvent.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists + +import com.vitorpamplona.quartz.nip01Core.core.IEvent + +/** + * Common face of the four Decentralized Lists kinds (9998, 39998, 9999, 39999). + * + * Any of them can be the parent of a list item: headers are the standard parents, and the + * spec's "nonstandard" method declares a list with a 9999/39999 item whose own parent is a + * list of lists. So every kind knows how children must point at it. + */ +interface DecentralizedListEvent : IEvent { + /** + * The value a child item writes in its `z` tag to point at this event: the event id for + * the regular kinds, the `kind:pubkey:d` coordinate for the addressable ones. + */ + fun listPointer(): String +} + +/** + * The item kinds (9999, 39999). Items point at their parent list with one or more `z` tags and + * carry the item itself in `p` / `e` / `t` / `a` tags. + */ +interface DecentralizedListItem : DecentralizedListEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md new file mode 100644 index 0000000000..5e29e512b7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md @@ -0,0 +1,90 @@ +# Decentralized Lists (kinds 9998, 39998, 9999, 39999) + +Lists that anyone can add to. Where a NIP-51 list is fully controlled by its +author, a Decentralized List only has its *header* controlled by its author; +the *items* are contributed by the community, one event each, and curation is +left to the reader's trust metric (typically fed by NIP-25 `+`/`-` reactions +to the item events). + +This is a pre-NIP wire format drafted by Tapestry +([spec](https://github.com/nous-clawds4/tapestry/blob/main/protocols/nips/decentralized-lists.md)). +The package mirrors `nip88Polls`: one sub-package per role, each with its +events, `TagArrayExt` (readers), `TagArrayBuilderExt` (writers) and `tags/`. + +| Kind | Role | Editable | Quartz class | +|---|---|---|---| +| 9998 | list header | no | `header.ListHeaderEvent` | +| 39998 | list header | yes (addressable) | `header.AddressableListHeaderEvent` | +| 9999 | list item | no | `item.ListItemEvent` | +| 39999 | list item | yes (addressable) | `item.AddressableListItemEvent` | + +All four implement `DecentralizedListEvent`, whose `listPointer()` is what a +child writes in its `z` tag: the event id for 9998/9999, the +`kind:pubkey:d` coordinate for 39998/39999. + +## Header tags + +- `["names", , ]` — required. `titles` and `slugs` have the + same two-form shape (`SingularPlural`); a tag missing either form is dropped. +- `["required" | "allowed" | "recommended" | "disallowed", , ?]` + — one tag per constrained name, with an optional human-readable description + at index 2 (`TagRule`). +- `["description", ]`. + +## Item tags + +- `["z", ]` — required, one per parent list. `ParentListTag.classify` + tells the three allowed shapes apart by form: a 64-hex event id + (`ParentList.EventId`), a `kind:pubkey:d` coordinate + (`ParentList.Coordinate`), or anything else, the singular name of an + undeclared list such as `"dog"` (`ParentList.Name`). +- The items themselves: `p` (pubkeys), `e` (events), `t` (strings, kept in + their original case — they are values like "Fido", not hashtags) and `a` + (addressables; the spec's own example uses an `naddr1…`, which parses too). +- Optional `name`, `title`, `slug`, `description`, `comments`. + +## The nonstandard method + +The spec also allows declaring a list with an *item*: a 9999 on the list of +lists (`["z", "list"]` or `["z", ]`) that carries the +header tags. Item events therefore expose the header accessors too +(`names()`, `requiredTags()`, …), and `declaresList()` says whether one is +doing so. The header builder extensions are typed on the whole family for the +same reason. + +## Building + +```kotlin +val header = + ListHeaderEvent.build("dog name", "dog names", "Commonly used dog names.") { + required("t") + allowed("comments", "Why this name") + } + +// once `header` is signed: +val item = + ListItemEvent.build(signedHeader) { + itemString("Fido") + comments("A classic") + } +``` + +## Retrieval + +```kotlin +Filter(kinds = listOf(ListHeaderEvent.KIND, AddressableListHeaderEvent.KIND)) + +// every item on a list, across redundant declarations of it +Filter( + kinds = listOf(ListItemEvent.KIND, AddressableListItemEvent.KIND), + tags = mapOf("z" to listOf("dog", header.listPointer())), +) +``` + +## Search + +All four kinds are `SearchableEvent`s sharing one walk +(`forEachSearchableListField`): `names` and `titles` (singular then plural), +`name`, `title`, `description`, `comments`, then every `t` item value. +`content` is not part of the spec, and ids, pubkeys and coordinates are +served by `#p`/`#e`/`#a`/`#z` filters, so none of them are indexed. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/SearchExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/SearchExt.kt new file mode 100644 index 0000000000..8433037d25 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/SearchExt.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists + +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.names +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.titles +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.comments +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.name +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.title +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastForEach +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor + +/** + * The human-authored text of any kind in the family, in a fixed order: the header's `names` + * and `titles` (singular, then plural), the item's `name` and `title`, `description`, + * `comments`, then each `t` item value. Headers and items share one walk because the spec's + * nonstandard method lets an item carry header tags; each kind simply has fewer of them set. + * + * `content` is not part of the spec and ids/pubkeys/coordinates are served by tag filters, + * so neither is indexed. + * + * @return false when the visitor stopped the walk. + */ +fun TagArray.forEachSearchableListField(visitor: IndexableFieldVisitor): Boolean { + names()?.let { + if (!visitor.visit(it.singular)) return false + if (!visitor.visit(it.plural)) return false + } + titles()?.let { + if (!visitor.visit(it.singular)) return false + if (!visitor.visit(it.plural)) return false + } + name()?.let { if (!visitor.visit(it)) return false } + title()?.let { if (!visitor.visit(it)) return false } + description()?.let { if (!visitor.visit(it)) return false } + comments()?.let { if (!visitor.visit(it)) return false } + fastForEach { tag -> + HashtagTag.parse(tag)?.let { if (!visitor.visit(it)) return false } + } + return true +} + +/** The write-path join of [forEachSearchableListField]: one field per line. */ +fun TagArray.searchableListContent() = + buildString { + forEachSearchableListField { field -> + if (field != null) { + if (isNotEmpty()) append('\n') + append(field) + } + true + } + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..014a15e840 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists + +import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt new file mode 100644 index 0000000000..40f4a295f2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists + +import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/AddressableListHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/AddressableListHeaderEvent.kt new file mode 100644 index 0000000000..3c1857989d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/AddressableListHeaderEvent.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.header + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.description +import com.vitorpamplona.quartz.experimental.decentralizedLists.forEachSearchableListField +import com.vitorpamplona.quartz.experimental.decentralizedLists.searchableListContent +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Decentralized Lists: an editable list declaration, a.k.a. list header (kind 39998). + * + * Same tags as [ListHeaderEvent], but addressable: the author can revise it in place, and + * items point at it with a `z` tag holding its `39998::` coordinate, since its + * event id changes with every edit. + */ +@Immutable +class AddressableListHeaderEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DecentralizedListEvent, + SearchableEvent { + override fun listPointer() = addressTag() + + override fun indexableContent() = tags.searchableListContent() + + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + tags.forEachSearchableListField(visitor) + } + + fun names() = tags.names() + + fun titles() = tags.titles() + + fun slugs() = tags.slugs() + + fun description() = tags.description() + + fun tagRules() = tags.tagRules() + + fun requiredTags() = tags.requiredTags() + + fun allowedTags() = tags.allowedTags() + + fun recommendedTags() = tags.recommendedTags() + + fun disallowedTags() = tags.disallowedTags() + + companion object { + const val KIND = 39998 + + @OptIn(ExperimentalUuidApi::class) + fun build( + singularName: String, + pluralName: String, + description: String? = null, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag(dTag) + names(singularName, pluralName) + description?.let { this.description(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/ListHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/ListHeaderEvent.kt new file mode 100644 index 0000000000..00eb0d61fd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/ListHeaderEvent.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.header + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.description +import com.vitorpamplona.quartz.experimental.decentralizedLists.forEachSearchableListField +import com.vitorpamplona.quartz.experimental.decentralizedLists.searchableListContent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Decentralized Lists: an immutable list declaration, a.k.a. list header (kind 9998). + * + * The author declares *what* the list is — its `names`, and which tags its items must, may, or + * must not carry — but not its members: anyone contributes items with kind 9999/39999 events + * whose `z` tag holds this event's id. Use [AddressableListHeaderEvent] (39998) for an editable + * header. + */ +@Immutable +class ListHeaderEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + DecentralizedListEvent, + SearchableEvent { + override fun listPointer() = id + + override fun indexableContent() = tags.searchableListContent() + + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + tags.forEachSearchableListField(visitor) + } + + fun names() = tags.names() + + fun titles() = tags.titles() + + fun slugs() = tags.slugs() + + fun description() = tags.description() + + fun tagRules() = tags.tagRules() + + fun requiredTags() = tags.requiredTags() + + fun allowedTags() = tags.allowedTags() + + fun recommendedTags() = tags.recommendedTags() + + fun disallowedTags() = tags.disallowedTags() + + companion object { + const val KIND = 9998 + + fun build( + singularName: String, + pluralName: String, + description: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + names(singularName, pluralName) + description?.let { this.description(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..c1e73b4e0c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayBuilderExt.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.header + +import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.NamesTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.SlugsTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRule +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRuleTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRuleType +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TitlesTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +// Typed on the whole family, not just the header kinds: the spec's nonstandard method +// declares a list with a 9999/39999 item, which then carries these same tags. + +fun TagArrayBuilder.names( + singular: String, + plural: String, +) = addUnique(NamesTag.assemble(singular, plural)) + +fun TagArrayBuilder.titles( + singular: String, + plural: String, +) = addUnique(TitlesTag.assemble(singular, plural)) + +fun TagArrayBuilder.slugs( + singular: String, + plural: String, +) = addUnique(SlugsTag.assemble(singular, plural)) + +fun TagArrayBuilder.tagRule(rule: TagRule) = add(TagRuleTag.assemble(rule)) + +fun TagArrayBuilder.tagRules(rules: List) = addAll(TagRuleTag.assemble(rules)) + +fun TagArrayBuilder.required( + tagName: String, + description: String? = null, +) = add(TagRuleTag.assemble(TagRuleType.REQUIRED, tagName, description)) + +fun TagArrayBuilder.allowed( + tagName: String, + description: String? = null, +) = add(TagRuleTag.assemble(TagRuleType.ALLOWED, tagName, description)) + +fun TagArrayBuilder.recommended( + tagName: String, + description: String? = null, +) = add(TagRuleTag.assemble(TagRuleType.RECOMMENDED, tagName, description)) + +fun TagArrayBuilder.disallowed( + tagName: String, + description: String? = null, +) = add(TagRuleTag.assemble(TagRuleType.DISALLOWED, tagName, description)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt new file mode 100644 index 0000000000..a674e96e2b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.header + +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.NamesTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.SlugsTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRuleTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRuleType +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TitlesTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.names() = firstNotNullOfOrNull(NamesTag::parse) + +fun TagArray.titles() = firstNotNullOfOrNull(TitlesTag::parse) + +fun TagArray.slugs() = firstNotNullOfOrNull(SlugsTag::parse) + +/** Every `required` / `allowed` / `recommended` / `disallowed` rule, in tag order. */ +fun TagArray.tagRules() = mapNotNull(TagRuleTag::parse) + +fun TagArray.tagRuleNames(type: TagRuleType) = mapNotNull { TagRuleTag.parseTagName(it, type) } + +fun TagArray.requiredTags() = tagRuleNames(TagRuleType.REQUIRED) + +fun TagArray.allowedTags() = tagRuleNames(TagRuleType.ALLOWED) + +fun TagArray.recommendedTags() = tagRuleNames(TagRuleType.RECOMMENDED) + +fun TagArray.disallowedTags() = tagRuleNames(TagRuleType.DISALLOWED) + +/** + * True when the tags carry a list declaration. Standard headers (9998/39998) must; an item + * (9999/39999) that does is using the spec's "nonstandard" method to declare a list. + */ +fun TagArray.declaresList() = any(NamesTag::isTag) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/NamesTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/NamesTag.kt new file mode 100644 index 0000000000..6e5164314d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/NamesTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags + +/** `["names", , ]`. Required on every list header, e.g. `["names", "widget", "widgets"]`. */ +class NamesTag { + companion object { + const val TAG_NAME = "names" + + fun isTag(tag: Array) = SingularPlural.isTag(tag, TAG_NAME) + + fun parse(tag: Array) = SingularPlural.parse(tag, TAG_NAME) + + fun assemble( + singular: String, + plural: String, + ) = SingularPlural.assemble(TAG_NAME, singular, plural) + + fun assemble(value: SingularPlural) = assemble(value.singular, value.plural) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/SingularPlural.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/SingularPlural.kt new file mode 100644 index 0000000000..cf25129482 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/SingularPlural.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The value of the `names`, `titles` and `slugs` header tags: a singular form ("widget") + * followed by a plural form ("widgets"). + */ +@Immutable +data class SingularPlural( + val singular: String, + val plural: String, +) { + companion object { + // Both forms are required by the spec. A tag missing either one is malformed and + // dropped rather than guessed at: pluralizing someone else's noun is not our call. + fun isTag( + tag: Array, + tagName: String, + ) = tag.has(2) && tag[0] == tagName && tag[1].isNotEmpty() && tag[2].isNotEmpty() + + fun parse( + tag: Array, + tagName: String, + ): SingularPlural? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == tagName) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[2].isNotEmpty()) { return null } + return SingularPlural(tag[1], tag[2]) + } + + fun assemble( + tagName: String, + singular: String, + plural: String, + ) = arrayOf(tagName, singular, plural) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/SlugsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/SlugsTag.kt new file mode 100644 index 0000000000..0050b83235 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/SlugsTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags + +/** `["slugs", , ]`. Optional URL-friendly identifier in singular and plural form. */ +class SlugsTag { + companion object { + const val TAG_NAME = "slugs" + + fun isTag(tag: Array) = SingularPlural.isTag(tag, TAG_NAME) + + fun parse(tag: Array) = SingularPlural.parse(tag, TAG_NAME) + + fun assemble( + singular: String, + plural: String, + ) = SingularPlural.assemble(TAG_NAME, singular, plural) + + fun assemble(value: SingularPlural) = assemble(value.singular, value.plural) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/TagRuleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/TagRuleTag.kt new file mode 100644 index 0000000000..84060fd5c6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/TagRuleTag.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +/** How a list header constrains a tag on its children. The code is also the tag name. */ +enum class TagRuleType( + val code: String, +) { + REQUIRED("required"), + ALLOWED("allowed"), + RECOMMENDED("recommended"), + DISALLOWED("disallowed"), + ; + + companion object { + fun fromCode(code: String) = + when (code) { + REQUIRED.code -> REQUIRED + ALLOWED.code -> ALLOWED + RECOMMENDED.code -> RECOMMENDED + DISALLOWED.code -> DISALLOWED + else -> null + } + } +} + +/** + * One constraint a list header places on its items: `["required", "p"]`, optionally with a + * human-readable description of what the named tag represents: + * `["required", "p", "Pubkey of the person being endorsed"]`. + * + * The spec requires one tag per constrained name — `["required", "foo"], ["required", "bar"]`, + * never `["required", "foo", "bar"]`. That makes index 2 unambiguous: it is always the + * description, never a second tag name. The description is informational only. + */ +@Immutable +data class TagRule( + val type: TagRuleType, + val tagName: String, + val description: String? = null, +) { + fun toTagArray() = TagRuleTag.assemble(type, tagName, description) +} + +class TagRuleTag { + companion object { + fun isTag(tag: Array) = tag.has(1) && TagRuleType.fromCode(tag[0]) != null && tag[1].isNotEmpty() + + fun isTag( + tag: Array, + type: TagRuleType, + ) = tag.has(1) && tag[0] == type.code && tag[1].isNotEmpty() + + fun parse(tag: Array): TagRule? { + ensure(tag.has(1)) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val type = TagRuleType.fromCode(tag[0]) ?: return null + return TagRule(type, tag[1], tag.getOrNull(2)?.ifEmpty { null }) + } + + /** The constrained tag name when [tag] is a rule of [type]. Skips building the rule. */ + fun parseTagName( + tag: Array, + type: TagRuleType, + ): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == type.code) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble( + type: TagRuleType, + tagName: String, + description: String? = null, + ) = arrayOfNotNull(type.code, tagName, description) + + fun assemble(rule: TagRule) = assemble(rule.type, rule.tagName, rule.description) + + fun assemble(rules: List) = rules.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/TitlesTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/TitlesTag.kt new file mode 100644 index 0000000000..98ceff1af7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/TitlesTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags + +/** `["titles", , ]`. Optional display title in singular and plural form. */ +class TitlesTag { + companion object { + const val TAG_NAME = "titles" + + fun isTag(tag: Array) = SingularPlural.isTag(tag, TAG_NAME) + + fun parse(tag: Array) = SingularPlural.parse(tag, TAG_NAME) + + fun assemble( + singular: String, + plural: String, + ) = SingularPlural.assemble(TAG_NAME, singular, plural) + + fun assemble(value: SingularPlural) = assemble(value.singular, value.plural) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt new file mode 100644 index 0000000000..f39088b793 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.item + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListItem +import com.vitorpamplona.quartz.experimental.decentralizedLists.description +import com.vitorpamplona.quartz.experimental.decentralizedLists.forEachSearchableListField +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.allowedTags +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.declaresList +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.disallowedTags +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.names +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.recommendedTags +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.requiredTags +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.slugs +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tagRules +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.titles +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.searchableListContent +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Decentralized Lists: an editable list item declaration (kind 39999). + * + * Same tags as [ListItemEvent], plus the `d` tag that makes it addressable, so the contributor + * can revise the item in place. When it declares a list through the nonstandard method, its + * children point at its `39999:pubkey:d` coordinate. + */ +@Immutable +class AddressableListItemEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DecentralizedListItem, + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider, + SearchableEvent { + override fun listPointer() = addressTag() + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + /** Event items plus any parent lists referenced by id. */ + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(ParentListTag::parseEventId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + /** Addressable items plus any parent lists referenced by coordinate. */ + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseValidAddress) + tags.mapNotNull { ParentListTag.parseAddress(it)?.toValue() } + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun indexableContent() = tags.searchableListContent() + + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + tags.forEachSearchableListField(visitor) + } + + fun parentLists() = tags.parentLists() + + fun parentListPointers() = tags.parentListPointers() + + fun name() = tags.name() + + fun title() = tags.title() + + fun slug() = tags.slug() + + fun description() = tags.description() + + fun comments() = tags.comments() + + fun itemPubKeys() = tags.itemPubKeys() + + fun itemEvents() = tags.itemEvents() + + fun itemStrings() = tags.itemStrings() + + fun itemAddresses() = tags.itemAddresses() + + /** + * True when this item uses the spec's nonstandard method to declare a list: it carries a + * `names` tag and sits on a list of lists. Its header fields then read the same way as a + * 9998's, and children point at it through [listPointer]. + */ + fun declaresList() = tags.declaresList() + + fun names() = tags.names() + + fun titles() = tags.titles() + + fun slugs() = tags.slugs() + + fun tagRules() = tags.tagRules() + + fun requiredTags() = tags.requiredTags() + + fun allowedTags() = tags.allowedTags() + + fun recommendedTags() = tags.recommendedTags() + + fun disallowedTags() = tags.disallowedTags() + + companion object { + const val KIND = 39999 + + @OptIn(ExperimentalUuidApi::class) + fun build( + parent: DecentralizedListEvent, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(ParentListTag.classify(parent.listPointer()), dTag, createdAt, initializer) + + @OptIn(ExperimentalUuidApi::class) + fun build( + parent: ParentList, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag(dTag) + parentList(parent) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt new file mode 100644 index 0000000000..e03d0905d8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.item + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListItem +import com.vitorpamplona.quartz.experimental.decentralizedLists.description +import com.vitorpamplona.quartz.experimental.decentralizedLists.forEachSearchableListField +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.allowedTags +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.declaresList +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.disallowedTags +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.names +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.recommendedTags +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.requiredTags +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.slugs +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tagRules +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.titles +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.searchableListContent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Decentralized Lists: an immutable list item declaration (kind 9999). + * + * Anyone can publish one. Its `z` tags name the list(s) it belongs to — a header's event id, a + * header's `39998:pubkey:d` coordinate, or just a list name for undeclared lists — and its + * `p` / `e` / `t` / `a` tags carry the item(s) themselves, as the parent header's `required` + * and `allowed` rules ask. One `z` and one item tag per event is recommended, but several of + * each are valid. Use [AddressableListItemEvent] (39999) for an editable item. + * + * Curation is out of scope for the spec: whether an item is accepted is up to the reader's + * trust metric, typically fed by NIP-25 `+`/`-` reactions to this event. + */ +@Immutable +class ListItemEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + DecentralizedListItem, + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider, + SearchableEvent { + override fun listPointer() = id + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + /** Event items plus any parent lists referenced by id. */ + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(ParentListTag::parseEventId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + /** Addressable items plus any parent lists referenced by coordinate. */ + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseValidAddress) + tags.mapNotNull { ParentListTag.parseAddress(it)?.toValue() } + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun indexableContent() = tags.searchableListContent() + + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + tags.forEachSearchableListField(visitor) + } + + fun parentLists() = tags.parentLists() + + fun parentListPointers() = tags.parentListPointers() + + fun name() = tags.name() + + fun title() = tags.title() + + fun slug() = tags.slug() + + fun description() = tags.description() + + fun comments() = tags.comments() + + fun itemPubKeys() = tags.itemPubKeys() + + fun itemEvents() = tags.itemEvents() + + fun itemStrings() = tags.itemStrings() + + fun itemAddresses() = tags.itemAddresses() + + /** + * True when this item uses the spec's nonstandard method to declare a list: it carries a + * `names` tag and sits on a list of lists. Its header fields then read the same way as a + * 9998's, and children point at it through [listPointer]. + */ + fun declaresList() = tags.declaresList() + + fun names() = tags.names() + + fun titles() = tags.titles() + + fun slugs() = tags.slugs() + + fun tagRules() = tags.tagRules() + + fun requiredTags() = tags.requiredTags() + + fun allowedTags() = tags.allowedTags() + + fun recommendedTags() = tags.recommendedTags() + + fun disallowedTags() = tags.disallowedTags() + + companion object { + const val KIND = 9999 + + fun build( + parent: DecentralizedListEvent, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(ParentListTag.classify(parent.listPointer()), createdAt, initializer) + + fun build( + parent: ParentList, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + parentList(parent) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..ed0b69b72a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayBuilderExt.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.item + +import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListItem +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.CommentsTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.NameTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.SlugTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.TitleTag +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +fun TagArrayBuilder.parentList(parent: DecentralizedListEvent) = addUniqueValueIfNew(ParentListTag.assemble(parent)) + +fun TagArrayBuilder.parentList(parent: ParentList) = addUniqueValueIfNew(ParentListTag.assemble(parent)) + +/** Points at a list that was never formally declared, by its singular name (e.g. "dog"). */ +fun TagArrayBuilder.parentListName(name: String) = addUniqueValueIfNew(ParentListTag.assemble(name)) + +fun TagArrayBuilder.parentLists(parents: List) = addAllUniqueValueIfNew(ParentListTag.assemble(parents)) + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.slug(slug: String) = addUnique(SlugTag.assemble(slug)) + +fun TagArrayBuilder.comments(comments: String) = addUnique(CommentsTag.assemble(comments)) + +fun TagArrayBuilder.itemPubKey( + pubKey: HexKey, + relayHint: NormalizedRelayUrl? = null, +) = add(PTag.assemble(pubKey, relayHint)) + +fun TagArrayBuilder.itemEvent( + eventId: HexKey, + relayHint: NormalizedRelayUrl? = null, + author: HexKey? = null, +) = add( + // Pads the relay slot when only the author is known, so the author stays at index 3. + arrayOfNotNull(ETag.TAG_NAME, eventId, relayHint?.url ?: author?.let { "" }, author), +) + +/** A string item, written verbatim: unlike a hashtag it is not duplicated in lowercase. */ +fun TagArrayBuilder.itemString(value: String) = add(HashtagTag.assemble(value)) + +fun TagArrayBuilder.itemAddress( + address: Address, + relayHint: NormalizedRelayUrl? = null, +) = add(ATag.assemble(address, relayHint)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt new file mode 100644 index 0000000000..144163a6d4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.item + +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.CommentsTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.NameTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.SlugTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.TitleTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag + +/** Every list this item belongs to. One `z` tag per list; an item may sit on several. */ +fun TagArray.parentLists() = mapNotNull(ParentListTag::parse) + +/** The raw `z` values, as they would go into a `#z` filter. */ +fun TagArray.parentListPointers() = mapNotNull(ParentListTag::parseValue) + +fun TagArray.name() = firstNotNullOfOrNull(NameTag::parse) + +fun TagArray.title() = firstNotNullOfOrNull(TitleTag::parse) + +fun TagArray.slug() = firstNotNullOfOrNull(SlugTag::parse) + +fun TagArray.comments() = firstNotNullOfOrNull(CommentsTag::parse) + +/** Pubkeys declared as items (`p`). */ +fun TagArray.itemPubKeys() = mapNotNull(PTag::parse) + +/** Events declared as items (`e`). */ +fun TagArray.itemEvents() = mapNotNull(ETag::parse) + +/** + * Strings declared as items (`t`), with their case preserved. These are list values like + * "Switzerland" or "Fido", not hashtags, so they are not lowercased the way hashtag readers do. + */ +fun TagArray.itemStrings() = mapNotNull(HashtagTag::parse) + +/** Addressable events declared as items (`a`). Accepts both `kind:pubkey:d` and `naddr1…`. */ +fun TagArray.itemAddresses() = mapNotNull(ATag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/CommentsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/CommentsTag.kt new file mode 100644 index 0000000000..1fca4dd2fb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/CommentsTag.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** `["comments", ]`. Free-text commentary from the contributor about the item. */ +class CommentsTag { + companion object { + const val TAG_NAME = "comments" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(value: String) = arrayOf(TAG_NAME, value) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/NameTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/NameTag.kt new file mode 100644 index 0000000000..b94f914b6e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/NameTag.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** `["name", ]`. The item's name, e.g. `["name", "Derek Ross"]`. */ +class NameTag { + companion object { + const val TAG_NAME = "name" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(value: String) = arrayOf(TAG_NAME, value) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ParentListTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ParentListTag.kt new file mode 100644 index 0000000000..7db4355938 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ParentListTag.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.ensure + +/** + * What a `z` tag points at. The spec allows three shapes in the same slot, told apart by form: + * + * - [EventId]: a 64-char hex id, for a kind 9998 header (or a 9999 used as a nonstandard header); + * - [Coordinate]: `kind:pubkey:d`, for a kind 39998 header, whose id changes on every edit; + * - [Name]: anything else — the singular name of a list that was never formally declared, + * e.g. `["z", "dog"]`. The spec allows it but prefers a declared header. + */ +@Immutable +sealed interface ParentList { + /** The raw value to write back in the `z` tag. */ + val value: String + + @Immutable + data class EventId( + val eventId: HexKey, + ) : ParentList { + override val value get() = eventId + } + + @Immutable + data class Coordinate( + val address: Address, + ) : ParentList { + override val value get() = address.toValue() + } + + @Immutable + data class Name( + val name: String, + ) : ParentList { + override val value get() = name + } +} + +/** `["z",
]`: the list an item belongs to. */ +class ParentListTag { + companion object { + const val TAG_NAME = "z" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun isTagged( + tag: Array, + pointer: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == pointer + + fun isTagged( + tag: Array, + pointers: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in pointers + + /** The raw pointer, without classifying it. */ + fun parseValue(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun parse(tag: Array): ParentList? = parseValue(tag)?.let(::classify) + + fun parseEventId(tag: Array): HexKey? { + val value = parseValue(tag) ?: return null + return if (isEventId(value)) value else null + } + + fun parseAddress(tag: Array): Address? { + val value = parseValue(tag) ?: return null + return if (looksLikeAddress(value)) AddressSerializer.parse(value) else null + } + + fun classify(value: String): ParentList { + if (isEventId(value)) return ParentList.EventId(value) + if (looksLikeAddress(value)) { + AddressSerializer.parse(value)?.let { return ParentList.Coordinate(it) } + } + return ParentList.Name(value) + } + + private fun isEventId(value: String) = value.length == 64 && Hex.isHex64(value) + + // Only coordinate-shaped values reach the address parser: it logs a warning for + // everything it rejects, and a plain list name such as "dog" is not an error. + private fun looksLikeAddress(value: String) = value.length >= 66 && value.contains(':') + + fun assemble(pointer: String) = arrayOf(TAG_NAME, pointer) + + fun assemble(parent: ParentList) = assemble(parent.value) + + fun assemble(parent: DecentralizedListEvent) = assemble(parent.listPointer()) + + fun assemble(parents: List) = parents.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/SlugTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/SlugTag.kt new file mode 100644 index 0000000000..06ecbae8ff --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/SlugTag.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** `["slug", ]`. A URL-friendly identifier for the item. */ +class SlugTag { + companion object { + const val TAG_NAME = "slug" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(value: String) = arrayOf(TAG_NAME, value) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/TitleTag.kt new file mode 100644 index 0000000000..5809c759de --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/TitleTag.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** `["title", ]`. The item's display title. */ +class TitleTag { + companion object { + const val TAG_NAME = "title" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(value: String) = arrayOf(TAG_NAME, value) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/DescriptionTag.kt new file mode 100644 index 0000000000..0f55a54e2b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/DescriptionTag.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** `["description", ]`, carried by both list headers and list items. */ +class DescriptionTag { + companion object { + const val TAG_NAME = "description" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(description: String) = arrayOf(TAG_NAME, description) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 0a1191cf14..b1ff7a9483 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -35,6 +35,10 @@ import com.vitorpamplona.quartz.experimental.citations.PromptCitationEvent import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.AddressableListHeaderEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.ListHeaderEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.ListItemEvent import com.vitorpamplona.quartz.experimental.decoupling.setup.EncryptionKeyListEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent @@ -644,6 +648,10 @@ object KindNames { EventTrustedListEvent.KIND to KindName("Trusted List of Events", null), AddressableTrustedListEvent.KIND to KindName("Trusted List of Addressables", null), ExternalIdTrustedListEvent.KIND to KindName("Trusted List of External IDs", null), + ListHeaderEvent.KIND to KindName("Decentralized List", null), + AddressableListHeaderEvent.KIND to KindName("Editable Decentralized List", null), + ListItemEvent.KIND to KindName("Decentralized List Item", null), + AddressableListItemEvent.KIND to KindName("Editable Decentralized List Item", null), KeyPackageEvent.KIND to KindName("MLS KeyPackage", null), GitRepositoryStateEvent.KIND to KindName("Git Repo State", "34"), FeedDefinitionEvent.KIND to KindName("Feed Definition", null), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt index 278c3b8e44..7c7eb0e954 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt @@ -113,6 +113,8 @@ object SearchableKinds { 9736, // Bolt12ZapEvent 9737, // Bolt12ZapIntentEvent 9802, // HighlightEvent + 9998, // ListHeaderEvent + 9999, // ListItemEvent 10003, // BookmarkListEvent 10100, // AgentProfileEvent 10154, // PodcastMetadataEvent @@ -194,6 +196,8 @@ object SearchableKinds { 39092, // MediaStarterPackEvent 39307, // TextTrackEvent 39701, // WebBookmarkEvent + 39998, // AddressableListHeaderEvent + 39999, // AddressableListItemEvent 40002, // StreamMessageV2Event 40100, // CanvasEvent 45001, // ForumPostEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index d597a994b0..76257ca74d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -124,6 +124,10 @@ import com.vitorpamplona.quartz.experimental.citations.PromptCitationEvent import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.AddressableListHeaderEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.ListHeaderEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.ListItemEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent @@ -842,6 +846,10 @@ class EventFactory { EventTrustedListEvent.KIND -> EventTrustedListEvent(id, pubKey, createdAt, tags, content, sig) AddressableTrustedListEvent.KIND -> AddressableTrustedListEvent(id, pubKey, createdAt, tags, content, sig) ExternalIdTrustedListEvent.KIND -> ExternalIdTrustedListEvent(id, pubKey, createdAt, tags, content, sig) + ListHeaderEvent.KIND -> ListHeaderEvent(id, pubKey, createdAt, tags, content, sig) + AddressableListHeaderEvent.KIND -> AddressableListHeaderEvent(id, pubKey, createdAt, tags, content, sig) + ListItemEvent.KIND -> ListItemEvent(id, pubKey, createdAt, tags, content, sig) + AddressableListItemEvent.KIND -> AddressableListItemEvent(id, pubKey, createdAt, tags, content, sig) RelayAddMemberEvent.KIND -> RelayAddMemberEvent(id, pubKey, createdAt, tags, content, sig) RelayRemoveMemberEvent.KIND -> RelayRemoveMemberEvent(id, pubKey, createdAt, tags, content, sig) RelayMembershipListEvent.KIND -> RelayMembershipListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListsTest.kt new file mode 100644 index 0000000000..4a0e5511f0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListsTest.kt @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists + +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.AddressableListHeaderEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.ListHeaderEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.recommended +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.required +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRule +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRuleType +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.ListItemEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemPubKey +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemString +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.name +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.parentListName +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The Tapestry Decentralized Lists tag layer against the spec's own examples + * (https://github.com/nous-clawds4/tapestry/blob/main/protocols/nips/decentralized-lists.md). + */ +class DecentralizedListsTest { + private val author = "dee45a23c4f1d93f3a2043650c5081e4ac14a778e0acbef03de3768e4f81ac7b" + private val derek = "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + private val headerId = "9d1b6b9562e66f2ecf35eb0a3c2decc736c47fddb13d6fb8f87185a153ea3634" + private val otherHeaderId = "1fc80cf813f1af33d5a435862b7ef7fb96b47e68a48f1abcadf8081f5a545550" + + private fun event( + kind: Int, + tags: Array>, + id: String = "a".repeat(64), + ): Event = EventFactory.create(id, author, 1719888496L, kind, tags, "", "") + + @Test + fun theFactoryBuildsEachKind() { + assertIs(event(9998, emptyArray())) + assertIs(event(39998, emptyArray())) + assertIs(event(9999, emptyArray())) + assertIs(event(39999, emptyArray())) + } + + // Example 1: a list of nostr developers. + @Test + fun theSpecsHeaderExampleParses() { + val header = + assertIs( + event( + 9998, + arrayOf( + arrayOf("names", "nostr developer", "nostr developers"), + arrayOf("description", "This is a list of developers who build within the nostr ecosystem"), + arrayOf("required", "p"), + arrayOf("required", "name"), + ), + id = headerId, + ), + ) + + assertEquals("nostr developer", header.names()?.singular) + assertEquals("nostr developers", header.names()?.plural) + assertEquals("This is a list of developers who build within the nostr ecosystem", header.description()) + assertEquals(listOf("p", "name"), header.requiredTags()) + assertEquals(emptyList(), header.allowedTags()) + assertEquals(headerId, header.listPointer()) + } + + @Test + fun ruleDescriptionsAreTheOptionalThirdElement() { + val header = + assertIs( + event( + 9998, + arrayOf( + arrayOf("names", "endorsement", "endorsements"), + arrayOf("required", "p", "Pubkey of the person being endorsed"), + arrayOf("allowed", "comments", "Optional textual reason"), + arrayOf("disallowed", "e"), + arrayOf("recommended", "title"), + arrayOf("required", ""), + ), + ), + ) + + assertEquals( + listOf( + TagRule(TagRuleType.REQUIRED, "p", "Pubkey of the person being endorsed"), + TagRule(TagRuleType.ALLOWED, "comments", "Optional textual reason"), + TagRule(TagRuleType.DISALLOWED, "e", null), + TagRule(TagRuleType.RECOMMENDED, "title", null), + ), + header.tagRules(), + ) + } + + @Test + fun aNamesTagMissingItsPluralIsDropped() { + val header = assertIs(event(9998, arrayOf(arrayOf("names", "dog")))) + assertNull(header.names()) + } + + // Example 1, item side, and Example 4 (one item on two lists). + @Test + fun theSpecsItemExampleParses() { + val item = + assertIs( + event( + 9999, + arrayOf( + arrayOf("z", headerId), + arrayOf("z", otherHeaderId), + arrayOf("name", "Derek Ross"), + arrayOf("p", derek), + ), + ), + ) + + assertEquals(listOf(ParentList.EventId(headerId), ParentList.EventId(otherHeaderId)), item.parentLists()) + assertEquals("Derek Ross", item.name()) + assertEquals(listOf(derek), item.itemPubKeys().map { it.pubKey }) + assertTrue(headerId in item.linkedEventIds()) + assertFalse(item.declaresList()) + } + + // Example 7: pointing at an editable (39998) header by coordinate. + @Test + fun zTagsPointingAtCoordinatesParseAsAddresses() { + val coordinate = "39998:$author:dogs" + val item = assertIs(event(9999, arrayOf(arrayOf("z", coordinate), arrayOf("t", "Fido")))) + + val parent = assertIs(item.parentLists().single()) + assertEquals(39998, parent.address.kind) + assertEquals(author, parent.address.pubKeyHex) + assertEquals("dogs", parent.address.dTag) + assertEquals(coordinate, parent.value) + assertEquals(listOf("Fido"), item.itemStrings()) + assertTrue(coordinate in item.linkedAddressIds()) + } + + // Example 4, alternate form: undeclared lists named by their singular name. + @Test + fun zTagsWithPlainNamesParseAsNames() { + val item = + assertIs( + event(9999, arrayOf(arrayOf("z", "dog"), arrayOf("z", "animal"), arrayOf("t", "Fido"))), + ) + + assertEquals(listOf(ParentList.Name("dog"), ParentList.Name("animal")), item.parentLists()) + assertEquals(listOf("dog", "animal"), item.parentListPointers()) + } + + // Example 2: the spec puts an naddr in the item's `a` tag. + @Test + fun naddrItemsParseAsAddresses() { + val naddr = + "naddr1qvzqqqr4gupzq4rqjpyzsnf2z5wgma397sxr382z8mg90l80jf7m3z2k628z9wsrqythwumn8ghj7cnfw33k76twv4ezuum0vd5kzmp0qythwumn8ghj7ct5d3shxtnwdaehgu3wd3skuep0qq3kv6tpwskkxatjwfjkucme946xsefdwd5kcetwwskhg6tdv5khg6rfv4nqnxv6fx" + val item = + assertIs( + event( + 9999, + arrayOf( + arrayOf("z", headerId), + arrayOf("a", naddr), + arrayOf("title", "Fiat Currency: The Silent Time Thief"), + ), + ), + ) + + assertEquals(30023, item.itemAddresses().single().kind) + assertEquals("Fiat Currency: The Silent Time Thief", item.title()) + } + + // "Nonstandard methods to declare a list": a 9999 on the list of lists carries header tags. + @Test + fun anItemCanDeclareAListTheNonstandardWay() { + val item = + assertIs( + event( + 9999, + arrayOf( + arrayOf("z", "list"), + arrayOf("names", "dog", "dogs"), + arrayOf("description", "This is a list (by name) of individual dogs."), + arrayOf("required", "t"), + ), + ), + ) + + assertTrue(item.declaresList()) + assertEquals("dogs", item.names()?.plural) + assertEquals(listOf("t"), item.requiredTags()) + } + + @Test + fun theHeaderBuilderWritesTheSpecsShape() { + val template = + ListHeaderEvent.build("dog name", "dog names", "This is a list of commonly used dog names.") { + required("t") + recommended("comments", "Why this name") + } + + assertEquals(9998, template.kind) + assertEquals( + listOf( + listOf("names", "dog name", "dog names"), + listOf("description", "This is a list of commonly used dog names."), + listOf("required", "t"), + listOf("recommended", "comments", "Why this name"), + ), + template.tags.map { it.toList() }, + ) + } + + @Test + fun itemsPointAtRegularHeadersByIdAndAddressableHeadersByCoordinate() { + val header = assertIs(event(9998, arrayOf(arrayOf("names", "dog", "dogs")), id = headerId)) + val byId = ListItemEvent.build(header) { itemString("Fido") } + assertEquals( + listOf(listOf("z", headerId), listOf("t", "Fido")), + byId.tags.map { it.toList() }, + ) + + val editable = + assertIs( + event(39998, arrayOf(arrayOf("d", "dogs"), arrayOf("names", "dog", "dogs"))), + ) + val byCoordinate = AddressableListItemEvent.build(editable, dTag = "fido") { itemString("Fido") } + assertEquals( + listOf(listOf("d", "fido"), listOf("z", "39998:$author:dogs"), listOf("t", "Fido")), + byCoordinate.tags.map { it.toList() }, + ) + } + + @Test + fun itemBuilderAcceptsUndeclaredListsAndPubkeys() { + val template = + ListItemEvent.build(ParentList.Name("nostr developer")) { + parentListName("nostr developer") + name("Derek Ross") + itemPubKey(derek) + } + + // the repeated parent is written once + assertEquals( + listOf(listOf("z", "nostr developer"), listOf("name", "Derek Ross"), listOf("p", derek)), + template.tags.map { it.toList() }, + ) + } + + @Test + fun searchIndexesTheHumanTextAndBothPathsAgree() { + val header = + assertIs( + event( + 9998, + arrayOf( + arrayOf("names", "dog", "dogs"), + arrayOf("titles", "Dog", "Dogs"), + arrayOf("description", "Good boys."), + arrayOf("required", "t"), + ), + ), + ) + assertEquals("dog\ndogs\nDog\nDogs\nGood boys.", header.indexableContent()) + + val item = + assertIs( + event( + 9999, + arrayOf(arrayOf("z", headerId), arrayOf("name", "Fido"), arrayOf("comments", "Very good"), arrayOf("t", "Fido")), + ), + ) + assertEquals("Fido\nVery good\nFido", item.indexableContent()) + + listOf(header, item).forEach { event -> + val fields = mutableListOf() + event.forEachIndexableField { field -> + field?.let { fields.add(it) } + true + } + assertEquals(event.indexableContent(), fields.joinToString(event.indexableSeparator())) + } + + assertEquals("", assertIs(event(9999, emptyArray())).indexableContent()) + } +} From 20920efb3e7929d2422399d6a8ce15d4218d4168 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:17:31 +0000 Subject: [PATCH 2/3] feat(quartz): add Tapestry draft extensions to Decentralized Lists Implements the wire formats of the Tapestry pre-NIP drafts that layer on kinds 9998/39998/9999/39999: - Inherit-From: the `b` tag with its closed pointer/inherit/inherit-items registry (unknown or absent reads as pointer) and the `b-tag-deferred` "deliberately unaffiliated" marker. - Class Thread Relationships: `n` and `s` on kind 39999. - Tapestry Concepts: the `json` tag with word-wrapper models, and the `concept-graph` header tag with its computed fallback. - Cross-NIP Compatibility: `item-kind` header tags and z-tag readers for events of any kind. - Tags & Taggings / Event Taggings: tag elements, pubkey taggings, pins, per-tag tagging headers and event-tagging assertions, with their deterministic d-tags and v1 polarity buckets. Deployment concept addresses are always parameters, never hardcoded. - Assistant Designation: the kind-10040 blanket and per-list curation entries (bounded away from NIP-85's), curation copies with their sha256 d-tag and q back-pointers, and dual-author header precedence. Also records the four new searchable kinds in the indexable-content golden file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2NXYQuDhvx4cmH1Nq5Zxq --- .../DecentralizedListEvent.kt | 7 + .../decentralizedLists/EventExt.kt | 34 ++ .../experimental/decentralizedLists/README.md | 57 +++ .../decentralizedLists/TagArrayBuilderExt.kt | 19 + .../decentralizedLists/TagArrayExt.kt | 22 ++ .../assistant/CurationCopy.kt | 117 ++++++ .../assistant/HeaderResolution.kt | 66 ++++ .../assistant/TagArrayExt.kt | 85 +++++ .../assistant/TrustProviderListEventExt.kt | 41 +++ .../assistant/tags/AssistantDesignationTag.kt | 70 ++++ .../assistant/tags/DListCurationTag.kt | 82 +++++ .../concepts/WordWrapper.kt | 98 +++++ .../header/AddressableListHeaderEvent.kt | 34 +- .../header/ListHeaderEvent.kt | 6 + .../header/TagArrayBuilderExt.kt | 18 + .../decentralizedLists/header/TagArrayExt.kt | 13 + .../header/tags/ConceptGraphTag.kt | 66 ++++ .../header/tags/ItemKindTag.kt | 64 ++++ .../item/AddressableListItemEvent.kt | 34 +- .../decentralizedLists/item/ListItemEvent.kt | 12 +- .../item/TagArrayBuilderExt.kt | 8 + .../decentralizedLists/item/TagArrayExt.kt | 8 + .../item/tags/ElementOfTag.kt | 53 +++ .../item/tags/SubsetOfTag.kt | 53 +++ .../taggings/EventTagging.kt | 165 +++++++++ .../taggings/PubKeyTagging.kt | 131 +++++++ .../taggings/TagArrayBuilderExt.kt | 35 ++ .../taggings/TagArrayExt.kt | 42 +++ .../decentralizedLists/taggings/TagElement.kt | 98 +++++ .../decentralizedLists/taggings/TagPin.kt | 111 ++++++ .../taggings/tags/CurationMethodTag.kt | 64 ++++ .../taggings/tags/PolarityTag.kt | 69 ++++ .../decentralizedLists/tags/InheritFromTag.kt | 117 ++++++ .../decentralizedLists/tags/JsonTag.kt | 48 +++ .../TapestryExtensionsTest.kt | 340 ++++++++++++++++++ .../resources/indexable-content.golden | 4 + 36 files changed, 2285 insertions(+), 6 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/EventExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/CurationCopy.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/HeaderResolution.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TrustProviderListEventExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/tags/AssistantDesignationTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/tags/DListCurationTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/concepts/WordWrapper.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ConceptGraphTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ItemKindTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ElementOfTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/SubsetOfTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/PubKeyTagging.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagElement.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagPin.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/tags/CurationMethodTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/tags/PolarityTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/InheritFromTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/JsonTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TapestryExtensionsTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListEvent.kt index 604bdeea42..090afc28e8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/DecentralizedListEvent.kt @@ -42,3 +42,10 @@ interface DecentralizedListEvent : IEvent { * 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/EventExt.kt new file mode 100644 index 0000000000..bedb1f0233 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/EventExt.kt @@ -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() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md index 5e29e512b7..91beb7be2d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md @@ -88,3 +88,60 @@ All four kinds are `SearchableEvent`s sharing one walk `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::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", , "pointer"\|"inherit"\|"inherit-items"]` | Inherit-From | 39998, 39999 | `tags.InheritFromTag`, `inheritFrom()` | +| `["b", "b-tag-deferred"]` | Shared Concepts | 39998, 39999 | `isDeliberatelyUnaffiliated()` | +| `["n", ]` / `["s", ]` | Class Thread Relationships | 39999 | `item.tags.ElementOfTag` / `SubsetOfTag` | +| `["json", ""]` | Tapestry Concepts | 39998, 39999 | `tags.JsonTag`, `wordWrapper()` → `concepts.WordWrapper` | +| `["concept-graph", "39999::-concept-graph"]` | Tapestry Concepts | 39998 | `conceptGraph()`, falls back to the computed address | +| `["item-kind", , ?]` | 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---` `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. +- `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", , ]` and one + `[":", , ]` 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-`, 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. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayBuilderExt.kt index 014a15e840..aec7d12de8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayBuilderExt.kt @@ -21,6 +21,25 @@ 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 TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.inheritFrom( + target: Address, + type: InheritType, +) = add(InheritFromTag.assemble(target, type)) + +fun TagArrayBuilder.inheritFrom( + target: String, + type: InheritType, +) = add(InheritFromTag.assemble(target, type)) + +/** Writes `["b", "b-tag-deferred"]`: considered a shared twin and chose none. */ +fun TagArrayBuilder.deliberatelyUnaffiliated() = addUniqueValueIfNew(InheritFromTag.assembleUnaffiliated()) + +fun TagArrayBuilder.json(json: String) = addUnique(JsonTag.assemble(json)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt index 40f4a295f2..01c6bb3fbb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt @@ -20,7 +20,29 @@ */ 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 fun TagArray.description() = firstNotNullOfOrNull(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() = any(InheritFromTag::isUnaffiliatedMarker) + +/** The raw `json` tag. */ +fun TagArray.json() = firstNotNullOfOrNull(JsonTag::parse) + +/** The `json` tag parsed as a JSON object, or null when absent or malformed. */ +fun TagArray.wordWrapper() = json()?.let(WordWrapper::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/CurationCopy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/CurationCopy.kt new file mode 100644 index 0000000000..bb6d37a238 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/CurationCopy.kt @@ -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 `
\n`, where the ref is the + * original's `39999::` 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, `::`. + * @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.KIND, "", createdAt) { + add(ATag.assemble(copy.address(), null)) + add(ETag.assemble(copy.id, null, null)) + add(KindTag.assemble(AddressableListItemEvent.KIND)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/HeaderResolution.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/HeaderResolution.kt new file mode 100644 index 0000000000..b4842783bf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/HeaderResolution.kt @@ -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.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::`, if it exists; + * 2. else their assistant's `39998::`, 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", , "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
{ + 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? = + candidates(user, slug, treasureMap).firstNotNullOfOrNull { candidate -> + headers.firstOrNull { it.pubKey == candidate.pubKeyHex && it.dTag() == candidate.dTag } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TagArrayExt.kt new file mode 100644 index 0000000000..1ea8169845 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TagArrayExt.kt @@ -0,0 +1,85 @@ +/* + * 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 + +// 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() = firstNotNullOfOrNull(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, +) = firstNotNullOfOrNull { 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, + matches: (Array) -> Boolean, +): TagArray { + var replaced = false + val out = ArrayList>(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() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TrustProviderListEventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TrustProviderListEventExt.kt new file mode 100644 index 0000000000..def53d26df --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TrustProviderListEventExt.kt @@ -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()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/tags/AssistantDesignationTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/tags/AssistantDesignationTag.kt new file mode 100644 index 0000000000..180d408017 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/tags/AssistantDesignationTag.kt @@ -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", , ]` — "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) = tag.has(1) && tag[0] == KEY && isPubKey(tag[1]) + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/tags/DListCurationTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/tags/DListCurationTag.kt new file mode 100644 index 0000000000..0cf6e693b9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/tags/DListCurationTag.kt @@ -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: + * `[":", , ]` — "my assistant curates this list for me, + * with the header at `::`". + * + * 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) = parse(tag) != null + + fun parse(tag: Array): 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 ?: "") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/concepts/WordWrapper.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/concepts/WordWrapper.kt new file mode 100644 index 0000000000..42811878c9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/concepts/WordWrapper.kt @@ -0,0 +1,98 @@ +/* + * 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 decode(key: String): T? { + val element = section(key) ?: return null + return runCatching { JsonMapper.jsonInstance.decodeFromJsonElement(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, + val wordTypes: List = emptyList(), + /** Set on a concept's core nodes, pointing back at the concept; omitted by the header itself. */ + val coreMemberOf: List = emptyList(), +) + +@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, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/AddressableListHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/AddressableListHeaderEvent.kt index 3c1857989d..97b91311b4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/AddressableListHeaderEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/AddressableListHeaderEvent.kt @@ -21,10 +21,20 @@ 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.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 @@ -52,7 +62,7 @@ class AddressableListHeaderEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), - DecentralizedListEvent, + AddressableDecentralizedListEvent, SearchableEvent { override fun listPointer() = addressTag() @@ -80,6 +90,26 @@ class AddressableListHeaderEvent( 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/ListHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/ListHeaderEvent.kt index 00eb0d61fd..cf3b8e6e97 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/ListHeaderEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/ListHeaderEvent.kt @@ -24,6 +24,8 @@ 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 @@ -78,6 +80,10 @@ class ListHeaderEvent( fun disallowedTags() = tags.disallowedTags() + fun itemKinds() = tags.itemKinds() + + fun acceptedItemKinds() = tags.acceptedItemKinds() + companion object { const val KIND = 9998 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayBuilderExt.kt index c1e73b4e0c..b1338126c5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayBuilderExt.kt @@ -20,13 +20,17 @@ */ 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 @@ -70,3 +74,17 @@ fun TagArrayBuilder.disallowed( tagName: String, description: String? = null, ) = add(TagRuleTag.assemble(TagRuleType.DISALLOWED, tagName, description)) + +fun TagArrayBuilder.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 TagArrayBuilder.conceptGraph( + pubKey: HexKey, + dTag: String, +) = addUnique(ConceptGraphTag.assemble(pubKey, dTag)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt index a674e96e2b..52de07f8ea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt @@ -20,6 +20,8 @@ */ 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 @@ -51,3 +53,14 @@ fun TagArray.disallowedTags() = tagRuleNames(TagRuleType.DISALLOWED) * (9999/39999) that does is using the spec's "nonstandard" method to declare a list. */ fun TagArray.declaresList() = any(NamesTag::isTag) + +/** The `concept-graph` pointer when present. See [ConceptGraphTag.compute] for the fallback. */ +fun TagArray.conceptGraph() = firstNotNullOfOrNull(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 = itemKinds().map { it.kind }.distinct().ifEmpty { STANDARD_ITEM_KINDS } + +val STANDARD_ITEM_KINDS = listOf(9999, 39999) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ConceptGraphTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ConceptGraphTag.kt new file mode 100644 index 0000000000..45a1a639be --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ConceptGraphTag.kt @@ -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::-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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + /** The Concept Graph address of the concept whose header is `39998::`. */ + 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 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ItemKindTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ItemKindTag.kt new file mode 100644 index 0000000000..60ae269125 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ItemKindTag.kt @@ -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", "", ?]` 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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].toIntOrNull() != null + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt index f39088b793..f8dcfcf213 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt @@ -21,22 +21,31 @@ 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 @@ -71,6 +80,7 @@ class AddressableListItemEvent( sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), DecentralizedListItem, + AddressableDecentralizedListEvent, EventHintProvider, AddressHintProvider, PubKeyHintProvider, @@ -142,6 +152,24 @@ class AddressableListItemEvent( 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 @@ -150,16 +178,18 @@ class AddressableListItemEvent( parent: DecentralizedListEvent, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), + content: String = "", initializer: TagArrayBuilder.() -> Unit = {}, - ) = build(ParentListTag.classify(parent.listPointer()), dTag, createdAt, initializer) + ) = 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.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { + ) = eventTemplate(KIND, content, createdAt) { dTag(dTag) parentList(parent) initializer() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt index e03d0905d8..37ca19be52 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt @@ -25,9 +25,11 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedLis 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 @@ -144,20 +146,26 @@ class ListItemEvent( 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.() -> Unit = {}, - ) = build(ParentListTag.classify(parent.listPointer()), createdAt, initializer) + ) = build(ParentListTag.classify(parent.listPointer()), createdAt, content, initializer) fun build( parent: ParentList, createdAt: Long = TimeUtils.now(), + content: String = "", initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { + ) = eventTemplate(KIND, content, createdAt) { parentList(parent) initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayBuilderExt.kt index ed0b69b72a..430a10c3a3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayBuilderExt.kt @@ -23,10 +23,12 @@ 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 @@ -76,3 +78,9 @@ fun TagArrayBuilder.itemAddress( address: Address, relayHint: NormalizedRelayUrl? = null, ) = add(ATag.assemble(address, relayHint)) + +// `n` and `s` are defined for kind 39999 only. + +fun TagArrayBuilder.elementOf(parent: Address) = addUniqueValueIfNew(ElementOfTag.assemble(parent)) + +fun TagArrayBuilder.subsetOf(parent: Address) = addUniqueValueIfNew(SubsetOfTag.assemble(parent)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt index 144163a6d4..c97708fcfe 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt @@ -21,9 +21,11 @@ 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.tags.aTag.ATag @@ -59,3 +61,9 @@ 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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ElementOfTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ElementOfTag.kt new file mode 100644 index 0000000000..9e209e0f9c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ElementOfTag.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.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", ]` — 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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun parseAddress(tag: Array): Address? = parse(tag)?.let { Address.parse(it) } + + fun assemble(parent: String) = arrayOf(TAG_NAME, parent) + + fun assemble(parent: Address) = assemble(parent.toValue()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/SubsetOfTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/SubsetOfTag.kt new file mode 100644 index 0000000000..bfb58b3c2c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/SubsetOfTag.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.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", ]` — 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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun parseAddress(tag: Array): Address? = parse(tag)?.let { Address.parse(it) } + + fun assemble(parent: String) = arrayOf(TAG_NAME, parent) + + fun assemble(parent: Address) = assemble(parent.toValue()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt new file mode 100644 index 0000000000..20130201ee --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.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.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( + taggingWithSpecificTagConcept: String, + tagElement: Address, + singularName: String, + pluralName: String, + description: String? = null, + createdAt: Long = TimeUtils.now(), + ) = AddressableListItemEvent.build( + parent = ParentListTag.classify(taggingWithSpecificTagConcept), + dTag = dTag(tagElement.dTag), + createdAt = createdAt, + ) { + names(singularName, pluralName) + description?.let { this.description(it) } + itemAddress(tagElement) + } +} + +/** + * 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---"] + * ["a", ] or ["e", ] + * ["z", ] + * ["z", ] + * ["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, + val polarity: Polarity, +) { + companion object { + fun dTag( + tagSlug: String, + target: TaggingTarget, + asserter: HexKey, + ) = "event-tag-$tagSlug-${target.prefix}-${asserter.take(8)}" + + /** + * @param nostrEventTagConcept the deployment's `nostr-event-tag` concept address. + * @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( + nostrEventTagConcept: String, + taggingHeader: Address, + tagSlug: String, + target: TaggingTarget, + asserter: HexKey, + apply: Boolean = true, + createdAt: Long = TimeUtils.now(), + ) = AddressableListItemEvent.build( + parent = ParentListTag.classify(nostrEventTagConcept), + dTag = dTag(tagSlug, target, asserter), + createdAt = createdAt, + ) { + parentList(ParentListTag.classify(taggingHeader.toValue())) + when (target) { + is TaggingTarget.ByAddress -> itemAddress(target.address) + is TaggingTarget.ByEventId -> itemEvent(target.eventId) + } + polarity(apply) + } + + /** Null unless [event] joins [nostrEventTagConcept], names a tagging header and a target. */ + fun parse( + event: AddressableListItemEvent, + nostrEventTagConcept: String, + ): EventTagging? { + val pointers = event.parentListPointers() + if (nostrEventTagConcept !in pointers) return null + val headers = pointers.filter { it != nostrEventTagConcept } + if (headers.isEmpty()) return null + + val target = + event.tags.firstNotNullOfOrNull(ATag::parseAddress)?.let { TaggingTarget.ByAddress(it) } + ?: event.tags.firstNotNullOfOrNull(ETag::parseId)?.let { TaggingTarget.ByEventId(it) } + ?: return null + + return EventTagging(target, headers, event.tags.polarity()) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/PubKeyTagging.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/PubKeyTagging.kt new file mode 100644 index 0000000000..8dd33ef46e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/PubKeyTagging.kt @@ -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.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.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.JsonMapper +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---"] + * ["p", ] + * ["a", "39999::"] the tag applied: stable identity, scan this + * ["e", ] the tag's version at apply time: provenance only + * ["z", ] + * ["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 userTagConcept the deployment's `nostr-user-tag` concept address. + * @param asserter the pubkey that will sign, needed for the deterministic `d`. + */ + fun build( + userTagConcept: String, + tagElement: Address, + tagEventId: HexKey?, + target: HexKey, + asserter: HexKey, + apply: Boolean = true, + createdAt: Long = TimeUtils.now(), + ) = AddressableListItemEvent.build( + parent = ParentListTag.classify(userTagConcept), + dTag = dTag(tagElement.dTag, target, asserter), + createdAt = createdAt, + content = JsonMapper.toJson(NostrUserTagContent(NostrUserTagInfo(target, tagEventId))), + ) { + 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(userTagConcept, tagElement.address(), tagElement.id, target, asserter, apply, createdAt) + + /** Null unless [event] joins [userTagConcept] and names a target pubkey. */ + fun parse( + event: AddressableListItemEvent, + userTagConcept: String, + ): PubKeyTagging? { + if (userTagConcept !in event.parentListPointers()) return null + val target = event.tags.firstNotNullOfOrNull(PTag::parseKey) ?: return null + return PubKeyTagging( + target = target, + tag = event.tags.firstNotNullOfOrNull(ATag::parseAddress), + tagEventId = event.tags.firstNotNullOfOrNull(ETag::parseId), + polarity = event.tags.polarity(), + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..950eb80cb2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayBuilderExt.kt @@ -0,0 +1,35 @@ +/* + * 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.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.polarity(apply: Boolean) = addUnique(PolarityTag.assemble(apply)) + +fun TagArrayBuilder.curationMethod(method: CurationMethod) = addUnique(CurationMethodTag.assemble(method)) + +/** Rides alongside the concept-membership `z`, never instead of it. */ +fun TagArrayBuilder.applicabilityHint(hint: TagApplicabilityHint) = addUniqueValueIfNew(ParentListTag.assemble(hint.code)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayExt.kt new file mode 100644 index 0000000000..6d3aeb3eab --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayExt.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.decentralizedLists.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 + +/** + * 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 = firstOrNull(PolarityTag::isTag) ?: return Polarity.APPLIED + val value = PolarityTag.parseValue(tag) ?: return Polarity.UNCOUNTED + return PolarityTag.bucket(value) +} + +fun TagArray.curationMethod() = firstNotNullOfOrNull(CurationMethodTag::parse) + +/** The applicability hints a tag-element's author recorded. Hints, never gates. */ +fun TagArray.applicabilityHints() = parentListPointers().mapNotNull(TagApplicabilityHint::fromCode) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagElement.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagElement.kt new file mode 100644 index 0000000000..4d53f9d492 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagElement.kt @@ -0,0 +1,98 @@ +/* + * 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.tags.ParentListTag +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(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::`; tags by + * different authors with the same slug are different tags. + */ +object TagElement { + /** + * @param tagConcept the deployment's `tag` concept address (`39998::tag`). + * Deployment-specific: never hardcode it. + */ + fun build( + tagConcept: String, + slug: String, + name: String, + description: String? = null, + hints: Set = emptySet(), + createdAt: Long = TimeUtils.now(), + ) = AddressableListItemEvent.build( + parent = ParentListTag.classify(tagConcept), + dTag = slug, + createdAt = createdAt, + content = TagElementContent(TagInfo(slug, name, description)).toContent(), + ) { + hints.forEach { applicabilityHint(it) } + } + + fun content(event: AddressableListItemEvent) = TagElementContent.parse(event.content) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagPin.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagPin.kt new file mode 100644 index 0000000000..ec871fba8a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagPin.kt @@ -0,0 +1,111 @@ +/* + * 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.tags.ParentListTag +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.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---"] + * ["e", ] the version pinned + * ["a", "39999::"] survives the tag author's later edits + * ["z", ] + * ["curation-method", ""] + * ``` + * + * 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( + tagPinningConcept: String, + tagElement: AddressableListItemEvent, + viewer: HexKey, + curationMethod: CurationMethod, + createdAt: Long = TimeUtils.now(), + ) = AddressableListItemEvent.build( + parent = ParentListTag.classify(tagPinningConcept), + dTag = dTag(tagElement.dTag(), tagElement.pubKey, viewer), + createdAt = createdAt, + content = JsonMapper.toJson(TagPinningContent(TagPinningInfo(tagElement.id, curationMethod))), + ) { + itemEvent(tagElement.id) + itemAddress(tagElement.address()) + curationMethod(curationMethod) + } + + /** Null unless [event] joins [tagPinningConcept]. */ + fun parse( + event: AddressableListItemEvent, + tagPinningConcept: String, + ): TagPin? { + if (tagPinningConcept !in event.parentListPointers()) return null + return TagPin( + tag = event.tags.firstNotNullOfOrNull(ATag::parseAddress), + tagEventId = event.tags.firstNotNullOfOrNull(ETag::parseId), + curationMethod = event.tags.curationMethod(), + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/tags/CurationMethodTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/tags/CurationMethodTag.kt new file mode 100644 index 0000000000..b73bd5ea52 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/tags/CurationMethodTag.kt @@ -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", ""]` on a pin. */ +class CurationMethodTag { + companion object { + const val TAG_NAME = "curation-method" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): CurationMethod? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return runCatching { JsonMapper.fromJson(tag[1]) }.getOrNull() + } + + fun assemble(method: CurationMethod) = arrayOf(TAG_NAME, JsonMapper.toJson(method)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/tags/PolarityTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/tags/PolarityTag.kt new file mode 100644 index 0000000000..b05d573775 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/tags/PolarityTag.kt @@ -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) = 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): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/InheritFromTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/InheritFromTag.kt new file mode 100644 index 0000000000..ec948c0a6a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/InheritFromTag.kt @@ -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.tags + +import androidx.compose.runtime.Immutable +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", , ]`, 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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[1] != UNAFFILIATED + + fun isUnaffiliatedMarker(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == UNAFFILIATED + + fun parse(tag: Array): InheritFromTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1] != UNAFFILIATED) { 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, + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/JsonTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/JsonTag.kt new file mode 100644 index 0000000000..9d11362a2b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/JsonTag.kt @@ -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", ""]`, 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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(json: String) = arrayOf(TAG_NAME, json) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TapestryExtensionsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TapestryExtensionsTest.kt new file mode 100644 index 0000000000..8e4b8a72a6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TapestryExtensionsTest.kt @@ -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>, + pubKey: String = alice, + content: String = "", + id: String = "e".repeat(64), + ): Event = EventFactory.create(id, pubKey, 1L, kind, tags, content, "") + + private fun tagsOf(tags: Array>) = tags.map { it.toList() } + + // Inherit-From + + @Test + fun bTagTypesAndTheirFailSafeDefault() { + val header = + assertIs( + 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(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(event(39998, arrayOf(arrayOf("d", "dogs")))) + assertEquals("39999:$alice:dogs-concept-graph", without.conceptGraph()) + + val with = assertIs(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(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(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(event(39998, arrayOf(arrayOf("d", "communities")))) + assertEquals(listOf(9999, 39999), plain.acceptedItemKinds()) + + val widened = + assertIs( + 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(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(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) = 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(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( + 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(EventFactory.create("1".repeat(64), alice, 1L, 39998, arrayOf(arrayOf("d", "dogs")), "", "")) + val newerByAssistant = assertIs(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))) + } +} diff --git a/quartz/src/jvmTest/resources/indexable-content.golden b/quartz/src/jvmTest/resources/indexable-content.golden index a74713c458..ba9a7f11a5 100644 --- a/quartz/src/jvmTest/resources/indexable-content.golden +++ b/quartz/src/jvmTest/resources/indexable-content.golden @@ -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. From c84e08cd323df8ca29ce075fba9940d7cbb73f9d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:24:27 +0000 Subject: [PATCH 3/3] fix(quartz): audit fixes and hot-path tuning for Decentralized Lists Bugs, each reproduced by a failing test in AuditRegressionTest first: - Event taggings could not federate: parse took a single namespace and read any other `nostr-event-tag` z as a tagging header. Every tagging builder now takes a collection of concept namespaces and every parser the set the reader honors (single-namespace overloads kept). - HeaderResolution picked whichever version of a header came first instead of the newest. - `b` tags accepted any string as a target; the value form is closed, so only coordinates are read now. - ItemKindTag.isTag accepted negative kinds that parse rejected. - An explicit null in word-section lists made the whole section unreadable. Performance: - The NIP-50 read path walks the tags in two allocation-free passes instead of one full scan and one parsed object per field. - Hint providers are single pass, and z coordinates skip the address parser; a shared CoordinateShape check also keeps plain list names (which may contain colons) from logging parser warnings. - Single-value readers use the iterator-free fast* tag operators. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2NXYQuDhvx4cmH1Nq5Zxq --- .../decentralizedLists/CoordinateShape.kt | 45 +++++++ .../experimental/decentralizedLists/README.md | 4 + .../decentralizedLists/SearchExt.kt | 54 +++++--- .../decentralizedLists/TagArrayExt.kt | 8 +- .../assistant/HeaderResolution.kt | 3 +- .../assistant/TagArrayExt.kt | 5 +- .../concepts/WordWrapper.kt | 12 +- .../decentralizedLists/header/TagArrayExt.kt | 12 +- .../header/tags/ItemKindTag.kt | 2 +- .../item/AddressableListItemEvent.kt | 17 ++- .../decentralizedLists/item/ListItemEvent.kt | 17 ++- .../decentralizedLists/item/TagArrayExt.kt | 9 +- .../item/tags/ParentListTag.kt | 9 +- .../taggings/EventTagging.kt | 55 ++++++-- .../taggings/PubKeyTagging.kt | 38 ++++-- .../taggings/TagArrayBuilderExt.kt | 13 ++ .../taggings/TagArrayExt.kt | 6 +- .../decentralizedLists/taggings/TagElement.kt | 15 ++- .../decentralizedLists/taggings/TagPin.kt | 30 +++-- .../decentralizedLists/tags/InheritFromTag.kt | 8 +- .../decentralizedLists/AuditRegressionTest.kt | 120 ++++++++++++++++++ 21 files changed, 401 insertions(+), 81 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/CoordinateShape.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/CoordinateShape.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/CoordinateShape.kt new file mode 100644 index 0000000000..e311973efb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/CoordinateShape.kt @@ -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 `:<64-hex pubkey>:` 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 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md index 91beb7be2d..060f22cfb4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md @@ -128,6 +128,10 @@ All kind 39999 items, told apart by their `z` tags: 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. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/SearchExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/SearchExt.kt index 8433037d25..e866f645b9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/SearchExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/SearchExt.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.quartz.experimental.decentralizedLists -import com.vitorpamplona.quartz.experimental.decentralizedLists.header.names -import com.vitorpamplona.quartz.experimental.decentralizedLists.header.titles -import com.vitorpamplona.quartz.experimental.decentralizedLists.item.comments -import com.vitorpamplona.quartz.experimental.decentralizedLists.item.name -import com.vitorpamplona.quartz.experimental.decentralizedLists.item.title +import com.vitorpamplona.quartz.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 @@ -42,18 +44,40 @@ import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor * @return false when the visitor stopped the walk. */ fun TagArray.forEachSearchableListField(visitor: IndexableFieldVisitor): Boolean { - names()?.let { - if (!visitor.visit(it.singular)) return false - if (!visitor.visit(it.plural)) return false + // 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? = null + var titles: Array? = 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] + } } - titles()?.let { - if (!visitor.visit(it.singular)) return false - if (!visitor.visit(it.plural)) return false + + names?.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 } + 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 } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt index 01c6bb3fbb..6eaa5f03bf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt @@ -26,8 +26,10 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritFrom 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() = firstNotNullOfOrNull(DescriptionTag::parse) +fun TagArray.description() = fastFirstNotNullOfOrNull(DescriptionTag::parse) /** Every `b` link, in tag order. Excludes the `b-tag-deferred` marker. */ fun TagArray.inheritFrom() = mapNotNull(InheritFromTag::parse) @@ -39,10 +41,10 @@ fun TagArray.inheritFrom() = mapNotNull(InheritFromTag::parse) 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() = any(InheritFromTag::isUnaffiliatedMarker) +fun TagArray.isDeliberatelyUnaffiliated() = fastAny(InheritFromTag::isUnaffiliatedMarker) /** The raw `json` tag. */ -fun TagArray.json() = firstNotNullOfOrNull(JsonTag::parse) +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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/HeaderResolution.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/HeaderResolution.kt index b4842783bf..e659137211 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/HeaderResolution.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/HeaderResolution.kt @@ -61,6 +61,7 @@ object HeaderResolution { headers: Collection, ): AddressableListHeaderEvent? = candidates(user, slug, treasureMap).firstNotNullOfOrNull { candidate -> - headers.firstOrNull { it.pubKey == candidate.pubKeyHex && it.dTag() == candidate.dTag } + // 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 } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TagArrayExt.kt index 1ea8169845..fe0172e3da 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TagArrayExt.kt @@ -25,12 +25,13 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.tags.A 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() = firstNotNullOfOrNull(AssistantDesignationTag::parse) +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 } @@ -38,7 +39,7 @@ fun TagArray.dListCurations() = mapNotNull(DListCurationTag::parse).distinctBy { fun TagArray.dListCuration( kind: Int, dTag: String, -) = firstNotNullOfOrNull { tag -> DListCurationTag.parse(tag)?.takeIf { it.kind == kind && it.dTag == dTag } } +) = 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/concepts/WordWrapper.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/concepts/WordWrapper.kt index 42811878c9..4bdae9b55d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/concepts/WordWrapper.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/concepts/WordWrapper.kt @@ -73,10 +73,16 @@ data class WordSection( val slug: String? = null, val name: String? = null, val title: String? = null, - val wordTypes: List = emptyList(), + // 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? = null, /** Set on a concept's core nodes, pointing back at the concept; omitted by the header itself. */ - val coreMemberOf: List = emptyList(), -) + val coreMemberOf: List? = null, +) { + fun types() = wordTypes.orEmpty() + + fun memberOf() = coreMemberOf.orEmpty() +} @Immutable @Serializable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt index 52de07f8ea..27d2e7318b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt @@ -28,12 +28,14 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagR 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() = firstNotNullOfOrNull(NamesTag::parse) +fun TagArray.names() = fastFirstNotNullOfOrNull(NamesTag::parse) -fun TagArray.titles() = firstNotNullOfOrNull(TitlesTag::parse) +fun TagArray.titles() = fastFirstNotNullOfOrNull(TitlesTag::parse) -fun TagArray.slugs() = firstNotNullOfOrNull(SlugsTag::parse) +fun TagArray.slugs() = fastFirstNotNullOfOrNull(SlugsTag::parse) /** Every `required` / `allowed` / `recommended` / `disallowed` rule, in tag order. */ fun TagArray.tagRules() = mapNotNull(TagRuleTag::parse) @@ -52,10 +54,10 @@ fun TagArray.disallowedTags() = tagRuleNames(TagRuleType.DISALLOWED) * True when the tags carry a list declaration. Standard headers (9998/39998) must; an item * (9999/39999) that does is using the spec's "nonstandard" method to declare a list. */ -fun TagArray.declaresList() = any(NamesTag::isTag) +fun TagArray.declaresList() = fastAny(NamesTag::isTag) /** The `concept-graph` pointer when present. See [ConceptGraphTag.compute] for the fallback. */ -fun TagArray.conceptGraph() = firstNotNullOfOrNull(ConceptGraphTag::parse) +fun TagArray.conceptGraph() = fastFirstNotNullOfOrNull(ConceptGraphTag::parse) /** The `item-kind` declarations, in tag order. */ fun TagArray.itemKinds() = mapNotNull(ItemKindTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ItemKindTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ItemKindTag.kt index 60ae269125..39cbee0a47 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ItemKindTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ItemKindTag.kt @@ -44,7 +44,7 @@ class ItemKindTag { companion object { const val TAG_NAME = "item-kind" - fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].toIntOrNull() != null + fun isTag(tag: Array) = parse(tag) != null fun parse(tag: Array): ItemKind? { ensure(tag.has(1)) { return null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt index f8dcfcf213..cdc356d13b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt @@ -49,6 +49,7 @@ 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 @@ -89,13 +90,21 @@ class AddressableListItemEvent( override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) - /** Event items plus any parent lists referenced by id. */ - override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(ParentListTag::parseEventId) + /** Event items plus any parent lists referenced by id, in one pass. */ + override fun linkedEventIds(): List { + val out = ArrayList() + 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. */ - override fun linkedAddressIds() = tags.mapNotNull(ATag::parseValidAddress) + tags.mapNotNull { ParentListTag.parseAddress(it)?.toValue() } + /** Addressable items plus any parent lists referenced by coordinate, in one pass. */ + override fun linkedAddressIds(): List { + val out = ArrayList() + tags.fastForEach { tag -> (ATag.parseValidAddress(tag) ?: ParentListTag.parseCoordinate(tag))?.let { out.add(it) } } + return out + } override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt index 37ca19be52..ba838382d3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.searchableListCo 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 @@ -83,13 +84,21 @@ class ListItemEvent( override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) - /** Event items plus any parent lists referenced by id. */ - override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(ParentListTag::parseEventId) + /** Event items plus any parent lists referenced by id, in one pass. */ + override fun linkedEventIds(): List { + val out = ArrayList() + 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. */ - override fun linkedAddressIds() = tags.mapNotNull(ATag::parseValidAddress) + tags.mapNotNull { ParentListTag.parseAddress(it)?.toValue() } + /** Addressable items plus any parent lists referenced by coordinate, in one pass. */ + override fun linkedAddressIds(): List { + val out = ArrayList() + tags.fastForEach { tag -> (ATag.parseValidAddress(tag) ?: ParentListTag.parseCoordinate(tag))?.let { out.add(it) } } + return out + } override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt index c97708fcfe..1ffb8c4897 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.SlugTa 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 @@ -39,13 +40,13 @@ fun TagArray.parentLists() = mapNotNull(ParentListTag::parse) /** The raw `z` values, as they would go into a `#z` filter. */ fun TagArray.parentListPointers() = mapNotNull(ParentListTag::parseValue) -fun TagArray.name() = firstNotNullOfOrNull(NameTag::parse) +fun TagArray.name() = fastFirstNotNullOfOrNull(NameTag::parse) -fun TagArray.title() = firstNotNullOfOrNull(TitleTag::parse) +fun TagArray.title() = fastFirstNotNullOfOrNull(TitleTag::parse) -fun TagArray.slug() = firstNotNullOfOrNull(SlugTag::parse) +fun TagArray.slug() = fastFirstNotNullOfOrNull(SlugTag::parse) -fun TagArray.comments() = firstNotNullOfOrNull(CommentsTag::parse) +fun TagArray.comments() = fastFirstNotNullOfOrNull(CommentsTag::parse) /** Pubkeys declared as items (`p`). */ fun TagArray.itemPubKeys() = mapNotNull(PTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ParentListTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ParentListTag.kt index 7db4355938..19753a4e7b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ParentListTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ParentListTag.kt @@ -21,6 +21,7 @@ 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 @@ -101,6 +102,12 @@ class ParentListTag { 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? { + 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)) { @@ -113,7 +120,7 @@ class ParentListTag { // Only coordinate-shaped values reach the address parser: it logs a warning for // everything it rejects, and a plain list name such as "dog" is not an error. - private fun looksLikeAddress(value: String) = value.length >= 66 && value.contains(':') + private fun looksLikeAddress(value: String) = CoordinateShape.matches(value) fun assemble(pointer: String) = arrayOf(TAG_NAME, pointer) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt index 20130201ee..1e157b23e6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.Parent 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 @@ -71,21 +72,31 @@ object TaggingHeader { * @param taggingWithSpecificTagConcept the deployment's `tagging-with-specific-tag` concept. */ fun build( - taggingWithSpecificTagConcept: String, + taggingWithSpecificTagConcepts: Collection, tagElement: Address, singularName: String, pluralName: String, description: String? = null, createdAt: Long = TimeUtils.now(), ) = AddressableListItemEvent.build( - parent = ParentListTag.classify(taggingWithSpecificTagConcept), + 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) } /** @@ -118,13 +129,14 @@ data class EventTagging( ) = "event-tag-$tagSlug-${target.prefix}-${asserter.take(8)}" /** - * @param nostrEventTagConcept the deployment's `nostr-event-tag` concept address. + * @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( - nostrEventTagConcept: String, + nostrEventTagConcepts: Collection, taggingHeader: Address, tagSlug: String, target: TaggingTarget, @@ -132,10 +144,11 @@ data class EventTagging( apply: Boolean = true, createdAt: Long = TimeUtils.now(), ) = AddressableListItemEvent.build( - parent = ParentListTag.classify(nostrEventTagConcept), + 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) @@ -144,22 +157,42 @@ data class EventTagging( polarity(apply) } - /** Null unless [event] joins [nostrEventTagConcept], names a tagging header and a target. */ + 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, - nostrEventTagConcept: String, + honoredNamespaces: Set, ): EventTagging? { val pointers = event.parentListPointers() - if (nostrEventTagConcept !in pointers) return null - val headers = pointers.filter { it != nostrEventTagConcept } + if (pointers.none { it in honoredNamespaces }) return null + val headers = pointers.filter { it !in honoredNamespaces } if (headers.isEmpty()) return null val target = - event.tags.firstNotNullOfOrNull(ATag::parseAddress)?.let { TaggingTarget.ByAddress(it) } - ?: event.tags.firstNotNullOfOrNull(ETag::parseId)?.let { TaggingTarget.ByEventId(it) } + 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)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/PubKeyTagging.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/PubKeyTagging.kt index 8dd33ef46e..4654adcaf4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/PubKeyTagging.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/PubKeyTagging.kt @@ -25,11 +25,11 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.item.Addressable 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.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.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 @@ -81,11 +81,11 @@ data class PubKeyTagging( ) = "profile-tag-$tagSlug-${target.take(8)}-${asserter.take(8)}" /** - * @param userTagConcept the deployment's `nostr-user-tag` concept address. + * @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( - userTagConcept: String, + userTagConcepts: Collection, tagElement: Address, tagEventId: HexKey?, target: HexKey, @@ -93,11 +93,12 @@ data class PubKeyTagging( apply: Boolean = true, createdAt: Long = TimeUtils.now(), ) = AddressableListItemEvent.build( - parent = ParentListTag.classify(userTagConcept), + 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) } @@ -111,21 +112,36 @@ data class PubKeyTagging( asserter: HexKey, apply: Boolean = true, createdAt: Long = TimeUtils.now(), - ) = build(userTagConcept, tagElement.address(), tagElement.id, target, asserter, apply, createdAt) + ) = build(listOf(userTagConcept), tagElement.address(), tagElement.id, target, asserter, apply, createdAt) - /** Null unless [event] joins [userTagConcept] and names a target pubkey. */ + 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, - userTagConcept: String, + honoredNamespaces: Set, ): PubKeyTagging? { - if (userTagConcept !in event.parentListPointers()) return null - val target = event.tags.firstNotNullOfOrNull(PTag::parseKey) ?: return null + 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.firstNotNullOfOrNull(ATag::parseAddress), - tagEventId = event.tags.firstNotNullOfOrNull(ETag::parseId), + 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)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayBuilderExt.kt index 950eb80cb2..e4a2295e90 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayBuilderExt.kt @@ -21,6 +21,7 @@ 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 @@ -33,3 +34,15 @@ fun TagArrayBuilder.curationMethod(method: CurationMet /** Rides alongside the concept-membership `z`, never instead of it. */ fun TagArrayBuilder.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.conceptNamespaces(concepts: Collection) = concepts.forEach { addUniqueValueIfNew(ParentListTag.assemble(it)) } + +internal fun Collection.firstNamespace(): ParentList { + require(isNotEmpty()) { "At least one concept namespace is required" } + return ParentListTag.classify(first()) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayExt.kt index 6d3aeb3eab..5d4028203c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayExt.kt @@ -25,18 +25,20 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Cu 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 = firstOrNull(PolarityTag::isTag) ?: return Polarity.APPLIED + val tag = fastFirstOrNull(PolarityTag::isTag) ?: return Polarity.APPLIED val value = PolarityTag.parseValue(tag) ?: return Polarity.UNCOUNTED return PolarityTag.bucket(value) } -fun TagArray.curationMethod() = firstNotNullOfOrNull(CurationMethodTag::parse) +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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagElement.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagElement.kt index 4d53f9d492..d5daa4b8bd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagElement.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagElement.kt @@ -22,7 +22,6 @@ 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.tags.ParentListTag import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.serialization.Serializable @@ -79,20 +78,30 @@ object TagElement { * Deployment-specific: never hardcode it. */ fun build( - tagConcept: String, + tagConcepts: Collection, slug: String, name: String, description: String? = null, hints: Set = emptySet(), createdAt: Long = TimeUtils.now(), ) = AddressableListItemEvent.build( - parent = ParentListTag.classify(tagConcept), + 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 = emptySet(), + createdAt: Long = TimeUtils.now(), + ) = build(listOf(tagConcept), slug, name, description, hints, createdAt) + fun content(event: AddressableListItemEvent) = TagElementContent.parse(event.content) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagPin.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagPin.kt index ec871fba8a..b0d82202c5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagPin.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagPin.kt @@ -24,11 +24,11 @@ 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.tags.ParentListTag 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 @@ -79,33 +79,47 @@ data class TagPin( * @param viewer the pubkey that will sign, needed for the deterministic `d`. */ fun build( - tagPinningConcept: String, + tagPinningConcepts: Collection, tagElement: AddressableListItemEvent, viewer: HexKey, curationMethod: CurationMethod, createdAt: Long = TimeUtils.now(), ) = AddressableListItemEvent.build( - parent = ParentListTag.classify(tagPinningConcept), + 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) } - /** Null unless [event] joins [tagPinningConcept]. */ + 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, - tagPinningConcept: String, + honoredNamespaces: Set, ): TagPin? { - if (tagPinningConcept !in event.parentListPointers()) return null + if (event.parentListPointers().none { it in honoredNamespaces }) return null return TagPin( - tag = event.tags.firstNotNullOfOrNull(ATag::parseAddress), - tagEventId = event.tags.firstNotNullOfOrNull(ETag::parseId), + 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)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/InheritFromTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/InheritFromTag.kt index ec948c0a6a..b60a5e1aea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/InheritFromTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/InheritFromTag.kt @@ -21,6 +21,7 @@ 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 @@ -82,15 +83,16 @@ data class InheritFromTag( */ const val UNAFFILIATED = "b-tag-deferred" - fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[1] != UNAFFILIATED + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && CoordinateShape.matches(tag[1]) fun isUnaffiliatedMarker(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == UNAFFILIATED fun parse(tag: Array): InheritFromTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } - ensure(tag[1] != UNAFFILIATED) { 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))) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt new file mode 100644 index 0000000000..ab0579b6df --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt @@ -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>) = assertIs(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(EventFactory.create("1".repeat(64), alice, 1L, 39998, arrayOf(arrayOf("d", "dogs")), "", "")) + val new = assertIs(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(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) + } +}