mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A2NXYQuDhvx4cmH1Nq5Zxq
This commit is contained in:
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists
|
||||
|
||||
/**
|
||||
* A cheap, allocation-free check that a value has the `<kind>:<64-hex pubkey>:<d>` coordinate
|
||||
* shape, done before anything reaches `AddressSerializer.parse`. That parser splits the string
|
||||
* and logs a warning for every value it rejects — and in this family a rejected value is
|
||||
* routine: `z` tags legitimately carry plain list names, which may be long and contain colons.
|
||||
*/
|
||||
internal object CoordinateShape {
|
||||
fun matches(value: String): Boolean {
|
||||
val firstColon = value.indexOf(':')
|
||||
// kinds are at most 5 digits (0..65535)
|
||||
if (firstColon !in 1..5) return false
|
||||
for (i in 0 until firstColon) {
|
||||
if (value[i] !in '0'..'9') return false
|
||||
}
|
||||
val pubKeyEnd = firstColon + 1 + 64
|
||||
if (value.length <= pubKeyEnd || value[pubKeyEnd] != ':') return false
|
||||
for (i in firstColon + 1 until pubKeyEnd) {
|
||||
val c = value[i]
|
||||
if (c !in '0'..'9' && c !in 'a'..'f' && c !in 'A'..'F') return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
+4
@@ -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.
|
||||
|
||||
|
||||
+39
-15
@@ -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<String>? = null
|
||||
var titles: Array<String>? = null
|
||||
var name: String? = null
|
||||
var title: String? = null
|
||||
var description: String? = null
|
||||
var comments: String? = null
|
||||
|
||||
fastForEach { tag ->
|
||||
if (tag.size < 2 || tag[1].isEmpty()) return@fastForEach
|
||||
when (tag[0]) {
|
||||
NamesTag.TAG_NAME -> if (names == null && SingularPlural.isTag(tag, NamesTag.TAG_NAME)) names = tag
|
||||
TitlesTag.TAG_NAME -> if (titles == null && SingularPlural.isTag(tag, TitlesTag.TAG_NAME)) titles = tag
|
||||
NameTag.TAG_NAME -> if (name == null) name = tag[1]
|
||||
TitleTag.TAG_NAME -> if (title == null) title = tag[1]
|
||||
DescriptionTag.TAG_NAME -> if (description == null) description = tag[1]
|
||||
CommentsTag.TAG_NAME -> if (comments == null) comments = tag[1]
|
||||
}
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
+5
-3
@@ -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)
|
||||
|
||||
+2
-1
@@ -61,6 +61,7 @@ object HeaderResolution {
|
||||
headers: Collection<AddressableListHeaderEvent>,
|
||||
): 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 }
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+9
-3
@@ -73,10 +73,16 @@ data class WordSection(
|
||||
val slug: String? = null,
|
||||
val name: String? = null,
|
||||
val title: String? = null,
|
||||
val wordTypes: List<String> = 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<String>? = null,
|
||||
/** Set on a concept's core nodes, pointing back at the concept; omitted by the header itself. */
|
||||
val coreMemberOf: List<NodeRef> = emptyList(),
|
||||
)
|
||||
val coreMemberOf: List<NodeRef>? = null,
|
||||
) {
|
||||
fun types() = wordTypes.orEmpty()
|
||||
|
||||
fun memberOf() = coreMemberOf.orEmpty()
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
|
||||
+7
-5
@@ -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)
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ class ItemKindTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "item-kind"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].toIntOrNull() != null
|
||||
fun isTag(tag: Array<String>) = parse(tag) != null
|
||||
|
||||
fun parse(tag: Array<String>): ItemKind? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
|
||||
+13
-4
@@ -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<HexKey> {
|
||||
val out = ArrayList<HexKey>()
|
||||
tags.fastForEach { tag -> (ETag.parseId(tag) ?: ParentListTag.parseEventId(tag))?.let { out.add(it) } }
|
||||
return out
|
||||
}
|
||||
|
||||
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
|
||||
|
||||
/** Addressable items plus any parent lists referenced by coordinate. */
|
||||
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<String> {
|
||||
val out = ArrayList<String>()
|
||||
tags.fastForEach { tag -> (ATag.parseValidAddress(tag) ?: ParentListTag.parseCoordinate(tag))?.let { out.add(it) } }
|
||||
return out
|
||||
}
|
||||
|
||||
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
|
||||
|
||||
|
||||
+13
-4
@@ -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<HexKey> {
|
||||
val out = ArrayList<HexKey>()
|
||||
tags.fastForEach { tag -> (ETag.parseId(tag) ?: ParentListTag.parseEventId(tag))?.let { out.add(it) } }
|
||||
return out
|
||||
}
|
||||
|
||||
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
|
||||
|
||||
/** Addressable items plus any parent lists referenced by coordinate. */
|
||||
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<String> {
|
||||
val out = ArrayList<String>()
|
||||
tags.fastForEach { tag -> (ATag.parseValidAddress(tag) ?: ParentListTag.parseCoordinate(tag))?.let { out.add(it) } }
|
||||
return out
|
||||
}
|
||||
|
||||
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
|
||||
|
||||
|
||||
+5
-4
@@ -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)
|
||||
|
||||
+8
-1
@@ -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>): 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)
|
||||
|
||||
|
||||
+44
-11
@@ -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<String>,
|
||||
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<String>,
|
||||
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<String>,
|
||||
): 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))
|
||||
}
|
||||
}
|
||||
|
||||
+27
-11
@@ -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<String>,
|
||||
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<String>,
|
||||
): 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))
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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<AddressableListItemEvent>.curationMethod(method: CurationMet
|
||||
|
||||
/** Rides alongside the concept-membership `z`, never instead of it. */
|
||||
fun TagArrayBuilder<AddressableListItemEvent>.applicabilityHint(hint: TagApplicabilityHint) = addUniqueValueIfNew(ParentListTag.assemble(hint.code))
|
||||
|
||||
/**
|
||||
* Federation: one concept `z` per authority namespace the publisher joins (e.g. a shared
|
||||
* canonical `nostr-event-tag` and the deployment's own). Joining several is opt-in; a reader
|
||||
* scanning any one of them finds the event.
|
||||
*/
|
||||
fun TagArrayBuilder<AddressableListItemEvent>.conceptNamespaces(concepts: Collection<String>) = concepts.forEach { addUniqueValueIfNew(ParentListTag.assemble(it)) }
|
||||
|
||||
internal fun Collection<String>.firstNamespace(): ParentList {
|
||||
require(isNotEmpty()) { "At least one concept namespace is required" }
|
||||
return ParentListTag.classify(first())
|
||||
}
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
+12
-3
@@ -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<String>,
|
||||
slug: String,
|
||||
name: String,
|
||||
description: String? = null,
|
||||
hints: Set<TagApplicabilityHint> = 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<TagApplicabilityHint> = emptySet(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
) = build(listOf(tagConcept), slug, name, description, hints, createdAt)
|
||||
|
||||
fun content(event: AddressableListItemEvent) = TagElementContent.parse(event.content)
|
||||
}
|
||||
|
||||
+22
-8
@@ -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<String>,
|
||||
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<String>,
|
||||
): 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))
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[1] != UNAFFILIATED
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && CoordinateShape.matches(tag[1])
|
||||
|
||||
fun isUnaffiliatedMarker(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == UNAFFILIATED
|
||||
|
||||
fun parse(tag: Array<String>): InheritFromTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
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)))
|
||||
}
|
||||
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.decentralizedLists
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.HeaderResolution
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.concepts.WordWrapper
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.AddressableListHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.itemKinds
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.ItemKindTag
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.EventTagging
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.TaggingTarget
|
||||
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritType
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
|
||||
/** Regressions found in the audit: each failed before its fix. */
|
||||
class AuditRegressionTest {
|
||||
private val alice = "a".repeat(64)
|
||||
private val bob = "b".repeat(64)
|
||||
private val ta = "d".repeat(64)
|
||||
private val other = "f".repeat(64)
|
||||
|
||||
private fun item(tags: Array<Array<String>>) = assertIs<AddressableListItemEvent>(EventFactory.create("e".repeat(64), alice, 1L, 39999, tags, "", ""))
|
||||
|
||||
// Event Taggings § "Concept namespaces & federation": one nostr-event-tag `z` per namespace.
|
||||
@Test
|
||||
fun federatedTaggingsDoNotMistakeOtherNamespacesForTaggingHeaders() {
|
||||
val tagging =
|
||||
item(
|
||||
arrayOf(
|
||||
arrayOf("d", "event-tag-awesome-tag-cccccccc-aaaaaaaa"),
|
||||
arrayOf("a", "39999:$bob:good-tag"),
|
||||
arrayOf("z", "39998:$ta:nostr-event-tag"),
|
||||
arrayOf("z", "39998:$other:nostr-event-tag"),
|
||||
arrayOf("z", "39999:$bob:tagging:awesome-tag-tagging"),
|
||||
),
|
||||
)
|
||||
|
||||
val parsed = EventTagging.parse(tagging, setOf("39998:$ta:nostr-event-tag", "39998:$other:nostr-event-tag"))!!
|
||||
assertEquals(listOf("39999:$bob:tagging:awesome-tag-tagging"), parsed.taggingHeaders)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun federatedTaggingsCanBeBuiltForSeveralNamespaces() {
|
||||
val template =
|
||||
EventTagging.build(
|
||||
nostrEventTagConcepts = listOf("39998:$ta:nostr-event-tag", "39998:$other:nostr-event-tag"),
|
||||
taggingHeader = Address(39999, bob, "tagging:awesome-tag-tagging"),
|
||||
tagSlug = "awesome-tag",
|
||||
target = TaggingTarget.ByEventId("1".repeat(64)),
|
||||
asserter = alice,
|
||||
)
|
||||
assertEquals(
|
||||
listOf("39998:$ta:nostr-event-tag", "39998:$other:nostr-event-tag", "39999:$bob:tagging:awesome-tag-tagging"),
|
||||
template.tags.filter { it[0] == "z" }.map { it[1] },
|
||||
)
|
||||
}
|
||||
|
||||
// Two versions of the same personal header must resolve to the newest, not whichever came first.
|
||||
@Test
|
||||
fun governingHeaderIsTheNewestVersionOfTheWinningAuthor() {
|
||||
val old = assertIs<AddressableListHeaderEvent>(EventFactory.create("1".repeat(64), alice, 1L, 39998, arrayOf(arrayOf("d", "dogs")), "", ""))
|
||||
val new = assertIs<AddressableListHeaderEvent>(EventFactory.create("2".repeat(64), alice, 5L, 39998, arrayOf(arrayOf("d", "dogs")), "", ""))
|
||||
assertEquals(new, HeaderResolution.governing(alice, "dogs", null, listOf(old, new)))
|
||||
}
|
||||
|
||||
// Inherit-From: element 2 is an a-tag; the value form is closed.
|
||||
@Test
|
||||
fun bTagsWhoseTargetIsNotACoordinateAreIgnored() {
|
||||
val node = item(arrayOf(arrayOf("d", "x"), arrayOf("b", "hello", "inherit"), arrayOf("b", "39998:$bob:dogs", "inherit")))
|
||||
assertEquals(listOf("39998:$bob:dogs"), node.inheritFromTargets(InheritType.INHERIT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun itemKindIsTagAgreesWithParse() {
|
||||
val tag = arrayOf("item-kind", "-1")
|
||||
assertEquals(ItemKindTag.parse(tag) != null, ItemKindTag.isTag(tag))
|
||||
assertEquals(emptyList(), arrayOf(tag).itemKinds())
|
||||
}
|
||||
|
||||
// A long list name containing a colon is still a name, and never reaches the address parser.
|
||||
@Test
|
||||
fun longNamesWithColonsStayNames() {
|
||||
val name = "a list of: " + "x".repeat(70)
|
||||
val node = item(arrayOf(arrayOf("d", "x"), arrayOf("z", name)))
|
||||
assertIs<ParentList.Name>(node.parentLists().single())
|
||||
assertFalse(name in node.linkedAddressIds())
|
||||
}
|
||||
|
||||
// Authored JSON: an explicit null in a list field must not make the whole section unreadable.
|
||||
@Test
|
||||
fun explicitNullListsInTheWordSectionAreTolerated() {
|
||||
val wrapper = WordWrapper.parse("""{"word":{"slug":"dogs","wordTypes":null,"coreMemberOf":null}}""")!!
|
||||
assertEquals("dogs", wrapper.word()?.slug)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user