mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
feat(quartz): implement NIP-CC geocaching (kinds 37516, 7516, 7517, 37517)
Adds nipCCGeocaching/, laid out like nip88Polls: a folder per subject, each
with its event class, TagArrayExt (read), TagArrayBuilderExt (write) and
tags/ holding one class per tag with the usual TAG_NAME/isTag/parse/assemble
shape and `ensure { return null }` guards.
- listing/ 37516, addressable + RootScope + SearchableEvent
- foundLog/ 7516, AddressHintProvider + SearchableEvent
- verification/ 7517 and the validator NIP-CC specifies
- curation/ 37517, addressable + SearchableEvent
- comment/ the dnf/note/maintenance/archived `t` vocabulary on kind 1111
- firstToFind/ the claim resolver
Reuses what already exists rather than restating it: DTag, GeoHashTag,
nip23's ImageTag, nip51's TitleTag/DescriptionTag/RelayTag, ATag, and
CommentEvent itself — the non-found logs are plain NIP-22 comments rooted on
the listing, so no new kind was needed for them.
Decisions worth review:
- `t` means three things (cache type, the `archived` marker, and the comment
log type). The parsers are scoped per kind so `archived` can never read as
a cache type, and the builders use addUniqueValueIfNew so writing a type
cannot silently un-archive a cache or drop an author's hashtags.
- `n` modifiers are a map keyed by category, not a string set: the spec makes
the category the unit of exclusivity (one per category, first wins) and
requires unknown values to be ignored rather than fatal.
- The 7517 `a` tag is NOT a NIP-01 `a` tag -- it is `<finder-hex>:<naddr>`,
which Address.parse rejects and logs a warning for. FinderCacheTag parses it
itself; it writes the naddr form and reads both that and a plain
kind:pubkey:d, kept unambiguous by requiring 64 hex chars on the left.
- The embedded verification payload is attacker-controlled, so a malformed one
is a null rather than an exception that would take a feed down with it.
- The validator checks signer, finder, cache, content and signature. Tests are
written as the attack each check stops: replaying someone else's
verification into your log, replaying one earned at an easier cache, and
signing your own.
- created_at is forgeable, so first-to-find is provisional (earliest verified
log, ties by ascending id) until the owner publishes `F`, after which `F`
wins unconditionally. Both the theft and the lock-in are pinned by tests.
- The hint is deliberately NOT indexed for NIP-50: matching a hint hands out
the answer. It gets a rot13() display transform instead, which the spec asks
clients for and which did not exist anywhere in the repo.
- 37515 is referenced but never defined by the spec, so readers accept it and
nothing writes it. NIP-GD is a dangling link, so `mission` stays free text.
GeoHashTag gains a bounded geoMipMap(geohash, min, max) overload -- purely
additive -- because listings want the 3..9 band, not every prefix from one
character up.
Registers all four kinds in EventFactory and KindNames, adds 7516/37516/37517
to SearchableKinds with their rows in the golden fixture and the
searchable-kinds skill table, and extends IndexableFieldVisitorTest.
quartz:jvmTest: 5133 tests, 0 failures (79 of them new).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WG7T9hAHDMKPaHsWvqySEr
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
|
||||
Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexableContent()`
|
||||
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-08-25.
|
||||
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-09-17.
|
||||
|
||||
Counts: 130 concrete classes covering 133 kind values (`GitStatusEvent` spans 4 kinds;
|
||||
Counts: 133 concrete classes covering 136 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/`.
|
||||
|
||||
@@ -55,6 +55,7 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
|
||||
| 5302 | NIP90ContentSearchRequestEvent | nip90Dvms/contentSearch | `searchQuery() ?: ""` |
|
||||
| 5303 | NIP90PeopleSearchRequestEvent | nip90Dvms/peopleSearch | `searchQuery() ?: ""` |
|
||||
| 6969 | ZapPollEvent | experimental/zapPolls | `buildString { append(content); pollOptionsArray().forEach { append('\n').append(it.descriptor) } }` |
|
||||
| 7516 | GeocacheFoundLogEvent | nipCCGeocaching/foundLog | `content` |
|
||||
| 8333 | OnchainZapEvent | nipBCOnchainZaps/zap | `content` |
|
||||
| 9002 | EditMetadataEvent | nip29RelayGroups/moderation | `(listOfNotNull(name(), about()) + hashtags())` NL |
|
||||
| 9041 | GoalEvent | nip75ZapGoals | `listOfNotNull(summary(), content)` NL |
|
||||
@@ -129,6 +130,8 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
|
||||
| 35128 | NamedSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL |
|
||||
| 35129 | NamedNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
|
||||
| 36787 | MusicTrackEvent | experimental/music/track | `listOfNotNull(title(), artist(), album(), content)` NL |
|
||||
| 37516 | GeocacheListingEvent | nipCCGeocaching/listing | `listOfNotNull(cacheName(), content)` NL (the `hint` is deliberately not indexed — matching a hint is spoiling it) |
|
||||
| 37517 | GeocacheCurationListEvent | nipCCGeocaching/curation | `listOfNotNull(title(), description(), content)` NL |
|
||||
| 38000 | MintRecommendationEvent | nip87Ecash/recommendation | `content` |
|
||||
| 38192 | Ps1SaveEvent | experimental/ps1saves | `listOfNotNull(summary(), saveTitle(), region(), filename())` NL |
|
||||
| 38383 | P2POrderEvent | nip69P2pOrderEvents | `(listOfNotNull(makerName(), currency()) + paymentMethods().orEmpty()).joinToString(" ")` (SP) |
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# NIP-CC (Geocaching) — gap analysis for Quartz and Amethyst
|
||||
|
||||
Status: **analysis / not started**. Nothing in the repo implements NIP-CC today.
|
||||
Status: **step 1 done** — the Quartz protocol package (`quartz/…/nipCCGeocaching/`) and its
|
||||
tests are in. Nothing in `amethyst/` or `commons/` touches NIP-CC yet, so the app is still blind
|
||||
to geocaches; §3 onwards is unstarted.
|
||||
|
||||
Spec: <https://github.com/nostr-protocol/nips/blob/master/CC.md> (merged into
|
||||
`master`; listed in the NIPs README at line 117 and in the kind tables for
|
||||
@@ -23,11 +25,13 @@ ROT13 hints so they don't spoil.
|
||||
|
||||
### Confirmed with a grep, not from memory
|
||||
|
||||
At the time of the survey,
|
||||
```
|
||||
grep -rn "37516\|7517\|37517\|[Gg]eocach" --include=*.kt
|
||||
```
|
||||
returns only incidental hex substrings in unrelated tests. There is **no**
|
||||
geocaching code in `quartz`, `commons`, `commonsUI`, `amethyst`, or `cli`.
|
||||
returned only incidental hex substrings in unrelated tests — no geocaching code anywhere in
|
||||
`quartz`, `commons`, `commonsUI`, `amethyst`, or `cli`. The Quartz half of that gap is now
|
||||
closed; the rest stands.
|
||||
|
||||
## 2. Quartz — new code
|
||||
|
||||
@@ -214,8 +218,11 @@ These are the parts worth a careful reviewer, not the event codecs:
|
||||
|
||||
## 5. Suggested sequencing
|
||||
|
||||
1. Quartz protocol package + tests + the three registration files. Self-contained,
|
||||
verifiable with `./gradlew :quartz:test`, no UI decisions.
|
||||
1. ~~Quartz protocol package + tests + the three registration files.~~ **Done** — 79 tests in
|
||||
`quartz/src/commonTest/…/nipCCGeocaching/`, plus the three new rows in the
|
||||
`indexable-content.golden` fixture and the `searchable-kinds.md` table. The layout follows
|
||||
nip88Polls: a folder per subject, each with its event, `TagArrayExt`, `TagArrayBuilderExt`
|
||||
and `tags/`.
|
||||
2. Read path in Amethyst: `LocalCache` + `GeocacheCard` + `NoteCompose` dispatch
|
||||
+ the one-line `PostsByGeohashKinds` addition. Caches become visible in the
|
||||
existing geohash/AroundMe feeds with zero new navigation.
|
||||
|
||||
@@ -319,6 +319,10 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.curation.GeocacheCurationListEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
|
||||
@@ -401,6 +405,10 @@ object KindNames {
|
||||
ChatMessageRelayListEvent.KIND to KindName("DM Relays", "17"),
|
||||
ClassifiedsEvent.KIND to KindName("Classifieds", "99"),
|
||||
CommentEvent.KIND to KindName("Comments", "22"),
|
||||
GeocacheListingEvent.KIND to KindName("Geocache", "CC"),
|
||||
GeocacheFoundLogEvent.KIND to KindName("Geocache Found Log", "CC"),
|
||||
GeocacheVerificationEvent.KIND to KindName("Geocache Verification", "CC"),
|
||||
GeocacheCurationListEvent.KIND to KindName("Geocache Curation List", "CC"),
|
||||
CommunityDefinitionEvent.KIND to KindName("Community Def", "72"),
|
||||
CommunityListEvent.KIND to KindName("Community List", "72"),
|
||||
CommunityPostApprovalEvent.KIND to KindName("Community Post", "72"),
|
||||
|
||||
+21
@@ -40,6 +40,27 @@ class GeoHashTag {
|
||||
|
||||
fun geoMipMap(geohash: String): List<String> = geohash.indices.map { geohash.substring(0, it + 1) }.reversed()
|
||||
|
||||
/**
|
||||
* The prefixes of [geohash] between [minPrecision] and [maxPrecision] characters,
|
||||
* coarse-to-fine.
|
||||
*
|
||||
* [geoMipMap] starts at one character and runs fine-to-coarse, which is what the geohash
|
||||
* chat channels want. Specs that pin a precision band instead — NIP-CC asks geocache
|
||||
* listings for 3 to 9 characters — want this: a 1- or 2-character geohash spans thousands
|
||||
* of kilometres, so tagging one is noise on the relay and useless for proximity search.
|
||||
*
|
||||
* Returns an empty list when [geohash] is shorter than [minPrecision].
|
||||
*/
|
||||
fun geoMipMap(
|
||||
geohash: String,
|
||||
minPrecision: Int,
|
||||
maxPrecision: Int,
|
||||
): List<String> {
|
||||
val finest = minOf(maxPrecision, geohash.length)
|
||||
if (minPrecision > finest) return emptyList()
|
||||
return (minPrecision..finest).map { geohash.substring(0, it) }
|
||||
}
|
||||
|
||||
fun geohashMipMap(geohash: String): TagArray = geoMipMap(geohash).map { assembleSingle(it) }.toTypedArray()
|
||||
|
||||
fun assemble(geohash: String) = geohashMipMap(geohash)
|
||||
|
||||
@@ -103,6 +103,7 @@ object SearchableKinds {
|
||||
5302, // NIP90ContentSearchRequestEvent
|
||||
5303, // NIP90PeopleSearchRequestEvent
|
||||
6969, // ZapPollEvent
|
||||
7516, // GeocacheFoundLogEvent
|
||||
8333, // OnchainZapEvent
|
||||
9002, // EditMetadataEvent
|
||||
9041, // GoalEvent
|
||||
@@ -183,6 +184,8 @@ object SearchableKinds {
|
||||
35128, // NamedSiteEvent
|
||||
35129, // NamedNappletEvent
|
||||
36787, // MusicTrackEvent
|
||||
37516, // GeocacheListingEvent
|
||||
37517, // GeocacheCurationListEvent
|
||||
38000, // MintRecommendationEvent
|
||||
38192, // Ps1SaveEvent
|
||||
38383, // P2POrderEvent
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.comment
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogType
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* The non-found logs of NIP-CC: did-not-find, notes and maintenance reports.
|
||||
*
|
||||
* These are not a geocaching kind at all — they are ordinary NIP-22 comments (kind 1111) rooted
|
||||
* on the geocache listing, with a `t` tag naming the log type. That is the whole point of the
|
||||
* design: a client that already renders comment threads renders a cache's history for free, and
|
||||
* the `dnf` pile-up that tells you a cache has gone missing is just a thread.
|
||||
*
|
||||
* The root and the parent are both the listing, which is what
|
||||
* [CommentEvent.replyBuilder] produces for an addressable event: `A`/`K`/`P` and `a`/`k`/`p`
|
||||
* pointing at the cache. It also adds `E`/`e` for the listing's event id, which NIP-22 permits
|
||||
* and the rest of this codebase relies on.
|
||||
*/
|
||||
object GeocacheLogComment {
|
||||
fun build(
|
||||
message: String,
|
||||
cache: EventHintBundle<GeocacheListingEvent>,
|
||||
type: GeocacheLogType,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<CommentEvent>.() -> Unit = {},
|
||||
) = CommentEvent.replyBuilder(
|
||||
message,
|
||||
// EventHintBundle is invariant, so the listing's bundle is rebuilt as a bundle of Event.
|
||||
EventHintBundle<Event>(cache.event, cache.relay, cache.authorHomeRelay),
|
||||
createdAt,
|
||||
) {
|
||||
geocacheLogType(type)
|
||||
initializer()
|
||||
}
|
||||
|
||||
fun didNotFind(
|
||||
message: String,
|
||||
cache: EventHintBundle<GeocacheListingEvent>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<CommentEvent>.() -> Unit = {},
|
||||
) = build(message, cache, GeocacheLogType.DNF, createdAt, initializer)
|
||||
|
||||
fun note(
|
||||
message: String,
|
||||
cache: EventHintBundle<GeocacheListingEvent>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<CommentEvent>.() -> Unit = {},
|
||||
) = build(message, cache, GeocacheLogType.NOTE, createdAt, initializer)
|
||||
|
||||
fun needsMaintenance(
|
||||
message: String,
|
||||
cache: EventHintBundle<GeocacheListingEvent>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<CommentEvent>.() -> Unit = {},
|
||||
) = build(message, cache, GeocacheLogType.MAINTENANCE, createdAt, initializer)
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipCCGeocaching.comment
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogType
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogTypeTag
|
||||
|
||||
/**
|
||||
* Adds the log type.
|
||||
*
|
||||
* [addUniqueValueIfNew] rather than `addUnique` because `t` is shared with hashtags on this kind
|
||||
* — replacing every `t` would drop the author's topics along with it.
|
||||
*/
|
||||
fun TagArrayBuilder<CommentEvent>.geocacheLogType(type: GeocacheLogType) = addUniqueValueIfNew(GeocacheLogTypeTag.assemble(type))
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.comment
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogType
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogTypeTag
|
||||
|
||||
/**
|
||||
* The log type of a kind 1111 comment on a geocache.
|
||||
*
|
||||
* NIP-CC: "If no `t` tag is present, the comment is assumed to be a general note." Hashtags in
|
||||
* `t` are skipped rather than mistaken for a type, because only the four defined codes parse.
|
||||
*/
|
||||
fun TagArray.geocacheLogType() = firstNotNullOfOrNull(GeocacheLogTypeTag::parse) ?: GeocacheLogType.NOTE
|
||||
|
||||
/** The declared log type, or null where the comment left it to the default. */
|
||||
fun TagArray.declaredGeocacheLogType() = firstNotNullOfOrNull(GeocacheLogTypeTag::parse)
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipCCGeocaching.comment.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** The log types NIP-CC defines for a kind 1111 comment on a geocache. */
|
||||
enum class GeocacheLogType(
|
||||
val code: String,
|
||||
) {
|
||||
/** Did not find — the searcher looked and came away empty-handed. */
|
||||
DNF("dnf"),
|
||||
|
||||
/** Helpful or neutral context. The default when a comment does not say. */
|
||||
NOTE("note"),
|
||||
|
||||
/** The cache needs attention: wet, full, damaged, muggled. */
|
||||
MAINTENANCE("maintenance"),
|
||||
|
||||
/** The owner retiring the cache, preserving its history rather than deleting it. */
|
||||
ARCHIVED("archived"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String?): GeocacheLogType? = entries.firstOrNull { it.code == code }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `t` tag of a geocache log comment (kind 1111).
|
||||
*
|
||||
* `t` is also NIP-01's hashtag tag, and a log comment may carry both. Parsing to the enum rather
|
||||
* than to the raw value is what keeps them apart: a hashtag is simply not one of these four codes
|
||||
* and falls through.
|
||||
*
|
||||
* Not to be confused with the `t` on a *listing*, which carries the cache type and the archived
|
||||
* marker — see [com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheTypeTag].
|
||||
*/
|
||||
class GeocacheLogTypeTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "t"
|
||||
|
||||
fun isTag(tag: Array<String>) = parse(tag) != null
|
||||
|
||||
fun parse(tag: Array<String>): GeocacheLogType? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
return GeocacheLogType.fromCode(tag[1])
|
||||
}
|
||||
|
||||
fun assemble(type: GeocacheLogType) = arrayOf(TAG_NAME, type.code)
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.curation
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
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.containsAllTagNamesWithValues
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
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.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
|
||||
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListTheme
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.MapStyle
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* A geocache curation list (kind 37517): an ordered collection of caches, presented as an
|
||||
* adventure, a trail, a treasure hunt, or whatever the creator has in mind.
|
||||
*
|
||||
* The `a` references may point at caches by any author — a list is not limited to its creator's
|
||||
* own caches — and their order is meaningful, so [geocaches] preserves it.
|
||||
*
|
||||
* `theme` and `map` are presentation *defaults* the viewer is allowed to override; see
|
||||
* [com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListThemeTag].
|
||||
*
|
||||
* Required tags: `d`, `title`, and at least one `a`.
|
||||
*/
|
||||
@Immutable
|
||||
class GeocacheCurationListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
AddressHintProvider,
|
||||
SearchableEvent {
|
||||
override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n")
|
||||
|
||||
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
|
||||
if (!visitor.visit(title())) return
|
||||
if (!visitor.visit(description())) return
|
||||
visitor.visit(content)
|
||||
}
|
||||
|
||||
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
|
||||
|
||||
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
|
||||
|
||||
fun title() = tags.listTitle()
|
||||
|
||||
fun description() = tags.listDescription()
|
||||
|
||||
fun image() = tags.listImage()
|
||||
|
||||
fun geohashes() = tags.geohashes()
|
||||
|
||||
/** The list's default page theme, or null when it names one this version does not know. */
|
||||
fun theme() = tags.listTheme()
|
||||
|
||||
fun themeCode() = tags.listThemeCode()
|
||||
|
||||
/** The list's initial map style, or null when it names one this version does not know. */
|
||||
fun mapStyle() = tags.listMapStyle()
|
||||
|
||||
fun mapStyleCode() = tags.listMapStyleCode()
|
||||
|
||||
/** The curated caches, in list order. */
|
||||
fun geocaches() = tags.curatedGeocaches()
|
||||
|
||||
/** Every `a` reference in order, including any that do not point at a cache. */
|
||||
fun addresses() = tags.curatedAddresses()
|
||||
|
||||
fun isWellFormed() = tags.containsAllTagNamesWithValues(REQUIRED_FIELDS) && geocaches().isNotEmpty()
|
||||
|
||||
companion object {
|
||||
const val KIND = 37517
|
||||
|
||||
/** Coarsest `g` tag worth publishing for a list (~156km). */
|
||||
const val MIN_TAGGED_PRECISION = 3
|
||||
|
||||
/** Finest `g` tag NIP-CC asks a list for (~1.2km) — a trail centre, not a hiding spot. */
|
||||
const val MAX_TAGGED_PRECISION = 6
|
||||
|
||||
val REQUIRED_FIELDS = setOf(DTag.TAG_NAME, TitleTag.TAG_NAME, ATag.TAG_NAME)
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun build(
|
||||
title: String,
|
||||
geocaches: List<Address>,
|
||||
content: String = "",
|
||||
description: String? = null,
|
||||
image: String? = null,
|
||||
geohash: String? = null,
|
||||
theme: ListTheme? = null,
|
||||
mapStyle: MapStyle? = null,
|
||||
dTag: String = Uuid.random().toString(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<GeocacheCurationListEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, content, createdAt) {
|
||||
dTag(dTag)
|
||||
listTitle(title)
|
||||
geocaches(geocaches)
|
||||
|
||||
description?.let { listDescription(it) }
|
||||
image?.let { listImage(it) }
|
||||
geohash?.let { listLocation(it) }
|
||||
theme?.let { listTheme(it) }
|
||||
mapStyle?.let { listMapStyle(it) }
|
||||
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.curation
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
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.geohash.GeoHashTag
|
||||
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListTheme
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListThemeTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.MapStyle
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.MapStyleTag
|
||||
|
||||
fun TagArrayBuilder<GeocacheCurationListEvent>.listTitle(title: String) = addUnique(TitleTag.assemble(title))
|
||||
|
||||
fun TagArrayBuilder<GeocacheCurationListEvent>.listDescription(description: String) = addUnique(DescriptionTag.assemble(description))
|
||||
|
||||
fun TagArrayBuilder<GeocacheCurationListEvent>.listImage(url: String) = addUnique(ImageTag.assemble(url))
|
||||
|
||||
fun TagArrayBuilder<GeocacheCurationListEvent>.listTheme(theme: ListTheme) = addUnique(ListThemeTag.assemble(theme))
|
||||
|
||||
fun TagArrayBuilder<GeocacheCurationListEvent>.listMapStyle(style: MapStyle) = addUnique(MapStyleTag.assemble(style))
|
||||
|
||||
/**
|
||||
* The `g` ladder for a list centred on [geohash], from 3 to 6 characters.
|
||||
*
|
||||
* Coarser than a listing's 3..9 on purpose: NIP-CC asks a list for discovery precision, and a
|
||||
* trail's centre point is not a place anybody walks to.
|
||||
*/
|
||||
fun TagArrayBuilder<GeocacheCurationListEvent>.listLocation(geohash: String) =
|
||||
addAll(
|
||||
GeoHashTag.geoMipMap(geohash, GeocacheCurationListEvent.MIN_TAGGED_PRECISION, GeocacheCurationListEvent.MAX_TAGGED_PRECISION).map(GeoHashTag::assembleSingle),
|
||||
)
|
||||
|
||||
/** Appends one cache. Call order is the list order NIP-CC says to preserve. */
|
||||
fun TagArrayBuilder<GeocacheCurationListEvent>.geocache(
|
||||
cache: Address,
|
||||
relayHint: NormalizedRelayUrl? = null,
|
||||
) = addUniqueValueIfNew(ATag.assemble(cache, relayHint))
|
||||
|
||||
fun TagArrayBuilder<GeocacheCurationListEvent>.geocaches(caches: List<Address>) = addAllUniqueValueIfNew(caches.map { ATag.assemble(it, null) })
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.curation
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListThemeTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.MapStyleTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
|
||||
|
||||
fun TagArray.listTitle() = firstNotNullOfOrNull(TitleTag::parse)
|
||||
|
||||
fun TagArray.listDescription() = firstNotNullOfOrNull(DescriptionTag::parse)
|
||||
|
||||
fun TagArray.listImage() = firstNotNullOfOrNull(ImageTag::parse)
|
||||
|
||||
fun TagArray.listTheme() = firstNotNullOfOrNull(ListThemeTag::parse)
|
||||
|
||||
fun TagArray.listThemeCode() = firstNotNullOfOrNull(ListThemeTag::parseCode)
|
||||
|
||||
fun TagArray.listMapStyle() = firstNotNullOfOrNull(MapStyleTag::parse)
|
||||
|
||||
fun TagArray.listMapStyleCode() = firstNotNullOfOrNull(MapStyleTag::parseCode)
|
||||
|
||||
/** Every `a` tag, in publication order — which NIP-CC says is meaningful. */
|
||||
fun TagArray.curatedAddresses() = mapNotNull(ATag::parseAddress)
|
||||
|
||||
/**
|
||||
* The curated geocaches, in order, filtered to the kinds NIP-CC allows a list to reference.
|
||||
*
|
||||
* A list can point at anything; only 37516 (and the referenced-but-undefined 37515) are caches.
|
||||
* Dropping the rest keeps an unrelated address out of a treasure-hunt itinerary.
|
||||
*/
|
||||
fun TagArray.curatedGeocaches(): List<Address> =
|
||||
curatedAddresses().filter {
|
||||
it.kind == GeocacheListingEvent.KIND || it.kind == GeocacheListingEvent.LEGACY_KIND
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipCCGeocaching.curation.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** The page themes NIP-CC defines for a curation list. */
|
||||
enum class ListTheme(
|
||||
val code: String,
|
||||
) {
|
||||
ADVENTURE("adventure"),
|
||||
MOJAVE("mojave"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String?): ListTheme? = entries.firstOrNull { it.code == code }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `theme` tag of a curation list (kind 37517).
|
||||
*
|
||||
* A *default*, not an instruction: NIP-CC says to apply it "unless the user has explicitly chosen
|
||||
* a different theme".
|
||||
*/
|
||||
class ListThemeTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "theme"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): ListTheme? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
return ListTheme.fromCode(tag[1])
|
||||
}
|
||||
|
||||
fun parseCode(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(theme: ListTheme) = arrayOf(TAG_NAME, theme.code)
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipCCGeocaching.curation.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** The map styles NIP-CC defines for a curation list. */
|
||||
enum class MapStyle(
|
||||
val code: String,
|
||||
) {
|
||||
ORIGINAL("original"),
|
||||
DARK("dark"),
|
||||
SATELLITE("satellite"),
|
||||
ADVENTURE("adventure"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String?): MapStyle? = entries.firstOrNull { it.code == code }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `map` tag of a curation list (kind 37517).
|
||||
*
|
||||
* The *initial* style, which NIP-CC explicitly says the user must be able to change.
|
||||
*/
|
||||
class MapStyleTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "map"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): MapStyle? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
return MapStyle.fromCode(tag[1])
|
||||
}
|
||||
|
||||
fun parseCode(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(style: MapStyle) = arrayOf(TAG_NAME, style.code)
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.firstToFind
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationValidator
|
||||
|
||||
/**
|
||||
* Who claimed a `first-to-find` cache.
|
||||
*
|
||||
* NIP-CC decides this in two stages, and the second one exists because the first cannot be
|
||||
* trusted:
|
||||
*
|
||||
* 1. **Provisionally**, the winner is the verified found log with the earliest `created_at`, ties
|
||||
* broken by ascending lexicographic event id.
|
||||
* 2. **Finally**, once the owner publishes an `F` tag on the listing, that pubkey is the winner —
|
||||
* "regardless of which verified found log currently appears earliest".
|
||||
*
|
||||
* Stage 2 is not a convenience. `created_at` is author-supplied, so anyone who finds the cache a
|
||||
* month late can sign a log dated before the real winner's and take the claim by stage 1 alone.
|
||||
* Locking the winner in is what makes the claim stick, which is why [winnerPubKey] reads `F`
|
||||
* first and only falls back to timestamps while none has been published.
|
||||
*
|
||||
* Every function here filters to *verified* logs first. An unverified found log on a
|
||||
* `first-to-find` cache is somebody's word; the claim is reserved for logs that carry a kind 7517
|
||||
* that holds up against the listing.
|
||||
*/
|
||||
object FirstToFindResolver {
|
||||
/** The logs that are about [listing] and carry a verification that checks out against it. */
|
||||
fun verifiedLogs(
|
||||
listing: GeocacheListingEvent,
|
||||
logs: List<GeocacheFoundLogEvent>,
|
||||
): List<GeocacheFoundLogEvent> =
|
||||
logs.filter {
|
||||
it.geocache() == listing.address() && GeocacheVerificationValidator.isValid(it, listing)
|
||||
}
|
||||
|
||||
/**
|
||||
* The earliest verified log, ties broken by ascending event id.
|
||||
*
|
||||
* "Provisional" is literal: this is only the winner while the owner has not locked one in,
|
||||
* and a log with a forged `created_at` wins it.
|
||||
*/
|
||||
fun provisionalWinningLog(
|
||||
listing: GeocacheListingEvent,
|
||||
logs: List<GeocacheFoundLogEvent>,
|
||||
): GeocacheFoundLogEvent? = verifiedLogs(listing, logs).minWithOrNull(compareBy({ it.createdAt }, { it.id }))
|
||||
|
||||
/**
|
||||
* The winning log: the owner's locked-in winner's earliest verified log where `F` is
|
||||
* published, the provisional winner otherwise.
|
||||
*
|
||||
* Null on a cache that is not `first-to-find` — there is no exclusive claim to award.
|
||||
*/
|
||||
fun winningLog(
|
||||
listing: GeocacheListingEvent,
|
||||
logs: List<GeocacheFoundLogEvent>,
|
||||
): GeocacheFoundLogEvent? {
|
||||
if (!listing.isFirstToFind()) return null
|
||||
|
||||
val lockedIn = listing.firstToFindWinner() ?: return provisionalWinningLog(listing, logs)
|
||||
|
||||
return verifiedLogs(listing, logs)
|
||||
.filter { it.pubKey == lockedIn }
|
||||
.minWithOrNull(compareBy({ it.createdAt }, { it.id }))
|
||||
}
|
||||
|
||||
/**
|
||||
* The pubkey holding the exclusive claim, or null if nobody does yet.
|
||||
*
|
||||
* An `F` tag wins even when no log for it is in [logs] — the owner has confirmed the claim
|
||||
* and the log may simply not have been fetched.
|
||||
*/
|
||||
fun winnerPubKey(
|
||||
listing: GeocacheListingEvent,
|
||||
logs: List<GeocacheFoundLogEvent>,
|
||||
): HexKey? {
|
||||
if (!listing.isFirstToFind()) return null
|
||||
return listing.firstToFindWinner() ?: provisionalWinningLog(listing, logs)?.pubKey
|
||||
}
|
||||
|
||||
/** Whether the owner has locked the winner in, as opposed to it resting on timestamps. */
|
||||
fun isLockedIn(listing: GeocacheListingEvent) = listing.firstToFindWinner() != null
|
||||
|
||||
/**
|
||||
* Whether the claim is taken.
|
||||
*
|
||||
* NIP-CC: once it is, clients should render the listing as effectively archived and hide the
|
||||
* find-submission affordances. Later verified logs stay valid records of physical presence —
|
||||
* they are simply not additional claims.
|
||||
*/
|
||||
fun isClaimed(
|
||||
listing: GeocacheListingEvent,
|
||||
logs: List<GeocacheFoundLogEvent>,
|
||||
) = winnerPubKey(listing, logs) != null
|
||||
}
|
||||
+111
@@ -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.nipCCGeocaching.foundLog
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
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.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags.GeocacheTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A found log (kind 7516): a claim that the author found a geocache.
|
||||
*
|
||||
* `content` is the log message. The `a` tag names the cache. A log may carry a kind 7517
|
||||
* verification event inline, which is what turns "I say I found it" into "I can prove I was
|
||||
* there" — but only after [com.vitorpamplona.quartz.nipCCGeocaching.verification
|
||||
* .GeocacheVerificationValidator] has checked it against the listing. [isVerified] on its own
|
||||
* means nothing more than "a verification event was attached".
|
||||
*
|
||||
* Only *found* logs are this kind. Did-not-find, notes and maintenance reports are NIP-22
|
||||
* comments rooted on the listing — see [com.vitorpamplona.quartz.nipCCGeocaching.comment
|
||||
* .GeocacheLogComment].
|
||||
*/
|
||||
@Immutable
|
||||
class GeocacheFoundLogEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
AddressHintProvider,
|
||||
SearchableEvent {
|
||||
override fun indexableContent() = content
|
||||
|
||||
override fun addressHints() = tags.mapNotNull(GeocacheTag::parseAsHint)
|
||||
|
||||
override fun linkedAddressIds() = tags.mapNotNull(GeocacheTag::parseAddressId)
|
||||
|
||||
fun geocache() = tags.geocache()
|
||||
|
||||
fun geocacheId() = tags.geocacheId()
|
||||
|
||||
fun images() = tags.logImages()
|
||||
|
||||
fun embeddedVerification() = tags.embeddedVerification()
|
||||
|
||||
/** Whether a verification event is attached at all. Says nothing about whether it is valid. */
|
||||
fun isVerified() = tags.embeddedVerification() != null
|
||||
|
||||
companion object {
|
||||
const val KIND = 7516
|
||||
|
||||
fun build(
|
||||
message: String,
|
||||
cache: EventHintBundle<GeocacheListingEvent>,
|
||||
verification: GeocacheVerificationEvent? = null,
|
||||
images: List<String>? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<GeocacheFoundLogEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, message, createdAt) {
|
||||
geocache(cache)
|
||||
verification?.let { embeddedVerification(it) }
|
||||
images?.let { logImages(it) }
|
||||
initializer()
|
||||
}
|
||||
|
||||
fun build(
|
||||
message: String,
|
||||
cache: Address,
|
||||
relayHint: NormalizedRelayUrl? = null,
|
||||
verification: GeocacheVerificationEvent? = null,
|
||||
images: List<String>? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<GeocacheFoundLogEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, message, createdAt) {
|
||||
geocache(cache, relayHint)
|
||||
verification?.let { embeddedVerification(it) }
|
||||
images?.let { logImages(it) }
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipCCGeocaching.foundLog
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags.EmbeddedVerificationTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags.GeocacheTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent
|
||||
|
||||
fun TagArrayBuilder<GeocacheFoundLogEvent>.geocache(cache: EventHintBundle<GeocacheListingEvent>) = addUnique(GeocacheTag.assemble(cache))
|
||||
|
||||
fun TagArrayBuilder<GeocacheFoundLogEvent>.geocache(
|
||||
cache: Address,
|
||||
relayHint: NormalizedRelayUrl? = null,
|
||||
) = addUnique(GeocacheTag.assemble(cache, relayHint))
|
||||
|
||||
fun TagArrayBuilder<GeocacheFoundLogEvent>.embeddedVerification(verification: GeocacheVerificationEvent) = addUnique(EmbeddedVerificationTag.assemble(verification))
|
||||
|
||||
fun TagArrayBuilder<GeocacheFoundLogEvent>.logImages(urls: List<String>) = addAllUniqueValueIfNew(urls.map(ImageTag::assemble))
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipCCGeocaching.foundLog
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags.EmbeddedVerificationTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags.GeocacheTag
|
||||
|
||||
fun TagArray.geocache() = firstNotNullOfOrNull(GeocacheTag::parseAddress)
|
||||
|
||||
fun TagArray.geocacheId() = firstNotNullOfOrNull(GeocacheTag::parseAddressId)
|
||||
|
||||
fun TagArray.logImages() = mapNotNull(ImageTag::parse)
|
||||
|
||||
fun TagArray.embeddedVerification() = firstNotNullOfOrNull(EmbeddedVerificationTag::parse)
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `verification` tag of a found log (kind 7516): a whole kind 7517 event, as JSON, inline.
|
||||
*
|
||||
* NIP-CC allows the verification to be embedded, published standalone, or both; embedding is what
|
||||
* makes a log self-contained, so this is the common case.
|
||||
*
|
||||
* The payload is attacker-controlled — anybody can publish a found log with any string in this
|
||||
* tag — so a parse failure has to be an ordinary null rather than an exception that takes the
|
||||
* surrounding feed down with it.
|
||||
*
|
||||
* Note this returns a [GeocacheVerificationEvent] only because kind 7517 is registered with
|
||||
* [com.vitorpamplona.quartz.utils.EventFactory]. Unregistered, the same JSON would parse to a
|
||||
* plain [Event] and every embedded verification in the wild would read as absent.
|
||||
*/
|
||||
class EmbeddedVerificationTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "verification"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): GeocacheVerificationEvent? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
|
||||
return try {
|
||||
Event.fromJson(tag[1]) as? GeocacheVerificationEvent
|
||||
} catch (e: Exception) {
|
||||
Log.w("EmbeddedVerificationTag") { "Could not parse the embedded verification event: ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun assemble(verification: GeocacheVerificationEvent) = arrayOf(TAG_NAME, verification.toJson())
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.foundLog.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
|
||||
|
||||
/**
|
||||
* The `a` tag of a found log (kind 7516): the geocache listing the log is about.
|
||||
*
|
||||
* An ordinary NIP-01 addressable reference — unlike the composite `a` on a kind 7517 — so the
|
||||
* parsing delegates to [ATag]. The named wrapper exists so the package reads in its own
|
||||
* vocabulary and so the assemble overload can take a [GeocacheListingEvent] directly.
|
||||
*/
|
||||
class GeocacheTag {
|
||||
companion object {
|
||||
const val TAG_NAME = ATag.TAG_NAME
|
||||
|
||||
fun isTag(tag: Array<String>) = ATag.isTagged(tag)
|
||||
|
||||
fun parse(tag: Array<String>) = ATag.parse(tag)
|
||||
|
||||
fun parseAddress(tag: Array<String>) = ATag.parseAddress(tag)
|
||||
|
||||
fun parseAddressId(tag: Array<String>) = ATag.parseAddressId(tag)
|
||||
|
||||
fun parseAsHint(tag: Array<String>) = ATag.parseAsHint(tag)
|
||||
|
||||
fun assemble(
|
||||
cache: Address,
|
||||
relayHint: NormalizedRelayUrl?,
|
||||
) = ATag.assemble(cache, relayHint)
|
||||
|
||||
fun assemble(cache: EventHintBundle<GeocacheListingEvent>) = assemble(cache.event.address(), cache.relay)
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.listing
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize
|
||||
|
||||
/**
|
||||
* The geohash precision rules NIP-CC puts on geocache listings (kind 37516).
|
||||
*
|
||||
* Two different numbers are involved and they are easy to confuse:
|
||||
*
|
||||
* - [MIN_TAGGED]..[MAX_TAGGED] is the band of `g` tags a listing publishes, so that relays can
|
||||
* answer proximity queries at several zoom levels.
|
||||
* - [minPublishPrecision] is how precise the *location the owner picked* must be before a client
|
||||
* accepts the submission at all. A cache you cannot walk up to is not a cache.
|
||||
*/
|
||||
object GeocacheGeohash {
|
||||
/** Coarsest `g` tag worth publishing (~156km). */
|
||||
const val MIN_TAGGED = 3
|
||||
|
||||
/** Finest `g` tag NIP-CC asks for (~5m). */
|
||||
const val MAX_TAGGED = 9
|
||||
|
||||
/** NIP-CC: "Validate geohash precision meets minimum requirements (8+ characters …)". */
|
||||
const val MIN_PUBLISH = 8
|
||||
|
||||
/** "… 9+ for micro caches" — a 38m box does not find a film canister. */
|
||||
const val MIN_PUBLISH_MICRO = 9
|
||||
|
||||
/** The precision a submission of [size] must reach before a client accepts it. */
|
||||
fun minPublishPrecision(size: CacheSize?) = if (size == CacheSize.MICRO) MIN_PUBLISH_MICRO else MIN_PUBLISH
|
||||
|
||||
/** Whether [geohash] is precise enough to publish as a cache of [size]. */
|
||||
fun isPreciseEnough(
|
||||
geohash: String,
|
||||
size: CacheSize?,
|
||||
) = geohash.length >= minPublishPrecision(size)
|
||||
|
||||
/** The `g` values a listing at [geohash] should publish, coarse-to-fine. */
|
||||
fun ladder(geohash: String) = GeoHashTag.geoMipMap(geohash, MIN_TAGGED, MAX_TAGGED)
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.listing
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
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.containsAllTagNamesWithValues
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
|
||||
import com.vitorpamplona.quartz.nip22Comments.RootScope
|
||||
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheNameTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSizeTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.DifficultyTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TerrainTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* A geocache listing (kind 37516), as defined by
|
||||
* [NIP-CC](https://github.com/nostr-protocol/nips/blob/master/CC.md).
|
||||
*
|
||||
* Addressable, so the owner keeps editing the same cache — that is how a cache gets archived,
|
||||
* how its hint is corrected, and how a first-to-find winner is locked in. `content` is the cache
|
||||
* description.
|
||||
*
|
||||
* Community history about the cache does *not* live here: found logs are kind
|
||||
* [com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent] and everything else
|
||||
* (did-not-find, notes, maintenance) is a NIP-22 comment rooted on this listing — see
|
||||
* [com.vitorpamplona.quartz.nipCCGeocaching.comment.GeocacheLogComment].
|
||||
*
|
||||
* Required tags: `d`, `name`, `g`, `D`, `T`, `S`. See [isWellFormed].
|
||||
*/
|
||||
@Immutable
|
||||
class GeocacheListingEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
RootScope,
|
||||
SearchableEvent {
|
||||
// The hint and the mission are deliberately absent: a cache is found by walking to it, and a
|
||||
// search that matches on "in the branches" hands out the answer to anyone who types it.
|
||||
override fun indexableContent() = listOfNotNull(cacheName(), content).joinToString("\n")
|
||||
|
||||
// The read path: the same fields indexableContent() joins, without building the join a scan
|
||||
// would throw away.
|
||||
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
|
||||
if (!visitor.visit(cacheName())) return
|
||||
visitor.visit(content)
|
||||
}
|
||||
|
||||
fun cacheName() = tags.cacheName()
|
||||
|
||||
/** Every `g` tag as published, coarse-to-fine. */
|
||||
fun geohashes() = tags.geohashes()
|
||||
|
||||
/** The finest `g` tag — the one that actually points at the cache. */
|
||||
fun location() = tags.geohashes().maxByOrNull { it.length }
|
||||
|
||||
fun difficulty() = tags.difficulty()
|
||||
|
||||
fun terrain() = tags.terrain()
|
||||
|
||||
fun cacheSize() = tags.cacheSize()
|
||||
|
||||
fun cacheSizeCode() = tags.cacheSizeCode()
|
||||
|
||||
/** The `t` cache type, defaulting to `traditional` when the listing does not say. */
|
||||
fun cacheTypeCode() = tags.cacheTypeCode()
|
||||
|
||||
fun cacheType() = tags.cacheType()
|
||||
|
||||
fun isArchived() = tags.isArchived()
|
||||
|
||||
fun typeModifiers() = tags.typeModifiers()
|
||||
|
||||
fun typeModifierCodes() = tags.typeModifierCodes()
|
||||
|
||||
fun hasTypeModifier(modifier: TypeModifier) = tags.hasTypeModifier(modifier)
|
||||
|
||||
fun isFirstToFind() = tags.isFirstToFind()
|
||||
|
||||
fun hint() = tags.hint()
|
||||
|
||||
/** The hint, ROT13'd for display so a reader does not spoil themselves by scrolling past it. */
|
||||
fun hintRot13() = tags.hint()?.let(::rot13)
|
||||
|
||||
fun mission() = tags.mission()
|
||||
|
||||
fun hasMission() = tags.mission() != null
|
||||
|
||||
fun verificationKey() = tags.verificationKey()
|
||||
|
||||
/** Whether finders can prove physical presence at this cache. */
|
||||
fun requiresVerification() = tags.verificationKey() != null
|
||||
|
||||
/**
|
||||
* The locked-in first-to-find winner, or null.
|
||||
*
|
||||
* Only honoured on a listing that carries the `first-to-find` modifier: NIP-CC scopes `F` to
|
||||
* that modifier, and an `F` on any other cache would silently invent a claim nobody made.
|
||||
*/
|
||||
fun firstToFindWinner() = if (isFirstToFind()) tags.firstToFindWinner() else null
|
||||
|
||||
fun images() = tags.cacheImages()
|
||||
|
||||
fun logRelays() = tags.logRelays()
|
||||
|
||||
fun isWellFormed() = tags.containsAllTagNamesWithValues(REQUIRED_FIELDS)
|
||||
|
||||
companion object {
|
||||
const val KIND = 37516
|
||||
|
||||
/**
|
||||
* A kind NIP-CC references once — the curation list accepts "kind 37516 or 37515" — but
|
||||
* never defines. Readers accept it; nothing here ever writes it.
|
||||
*/
|
||||
const val LEGACY_KIND = 37515
|
||||
|
||||
val REQUIRED_FIELDS =
|
||||
setOf(
|
||||
DTag.TAG_NAME,
|
||||
CacheNameTag.TAG_NAME,
|
||||
GeoHashTag.TAG_NAME,
|
||||
DifficultyTag.TAG_NAME,
|
||||
TerrainTag.TAG_NAME,
|
||||
CacheSizeTag.TAG_NAME,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun build(
|
||||
name: String,
|
||||
description: String,
|
||||
geohash: String,
|
||||
difficulty: Int,
|
||||
terrain: Int,
|
||||
size: CacheSize,
|
||||
type: CacheType? = null,
|
||||
modifiers: Collection<TypeModifier> = emptySet(),
|
||||
hint: String? = null,
|
||||
mission: String? = null,
|
||||
verificationPubKey: HexKey? = null,
|
||||
images: List<String>? = null,
|
||||
relays: List<NormalizedRelayUrl>? = null,
|
||||
dTag: String = Uuid.random().toString(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<GeocacheListingEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, description, createdAt) {
|
||||
dTag(dTag)
|
||||
cacheName(name)
|
||||
cacheLocation(geohash)
|
||||
difficulty(difficulty)
|
||||
terrain(terrain)
|
||||
cacheSize(size)
|
||||
|
||||
type?.let { cacheType(it) }
|
||||
if (modifiers.isNotEmpty()) typeModifiers(modifiers)
|
||||
hint?.let { hint(it) }
|
||||
mission?.let { mission(it) }
|
||||
verificationPubKey?.let { verificationKey(it) }
|
||||
images?.let { cacheImages(it) }
|
||||
relays?.let { logRelays(it) }
|
||||
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipCCGeocaching.listing
|
||||
|
||||
/**
|
||||
* ROT13, the encoding geocaching has always used to keep a hint from spoiling itself.
|
||||
*
|
||||
* NIP-CC asks clients to "support hint encoding, such as ROT13, to prevent spoilers". The `hint`
|
||||
* tag itself travels as plaintext, so this is a display transform: show the rotated text, and
|
||||
* rotate it back when the reader asks for it. Rotating twice returns the original, so one
|
||||
* function serves both directions.
|
||||
*
|
||||
* Only ASCII letters move; digits, punctuation and every non-ASCII letter are left alone, which
|
||||
* is what every other geocaching implementation does and what makes the round trip exact.
|
||||
*/
|
||||
fun rot13(text: String): String =
|
||||
text
|
||||
.map { char ->
|
||||
when (char) {
|
||||
in 'a'..'z' -> 'a' + ((char - 'a') + 13) % 26
|
||||
in 'A'..'Z' -> 'A' + ((char - 'A') + 13) % 26
|
||||
else -> char
|
||||
}
|
||||
}.joinToString("")
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.listing
|
||||
|
||||
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.geohash.GeoHashTag
|
||||
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.RelayTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheNameTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSizeTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheTypeTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.DifficultyTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.FirstToFindWinnerTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.HintTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.MissionTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TerrainTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifierTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.VerificationKeyTag
|
||||
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.cacheName(name: String) = addUnique(CacheNameTag.assemble(name))
|
||||
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.difficulty(rating: Int) = addUnique(DifficultyTag.assemble(rating))
|
||||
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.terrain(rating: Int) = addUnique(TerrainTag.assemble(rating))
|
||||
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.cacheSize(size: CacheSize) = addUnique(CacheSizeTag.assemble(size))
|
||||
|
||||
/**
|
||||
* The `g` ladder for a cache at [geohash], from 3 to 9 characters.
|
||||
*
|
||||
* Deliberately not [com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash], which publishes
|
||||
* every prefix from one character up.
|
||||
*/
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.cacheLocation(geohash: String) = addAll(GeocacheGeohash.ladder(geohash).map(GeoHashTag::assembleSingle))
|
||||
|
||||
/**
|
||||
* The cache type. Kept off [addUnique] because `t` also carries the `archived` marker — replacing
|
||||
* every `t` here would un-retire a cache the owner had archived.
|
||||
*/
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.cacheType(type: CacheType) = addUniqueValueIfNew(CacheTypeTag.assemble(type))
|
||||
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.cacheType(code: String) = addUniqueValueIfNew(CacheTypeTag.assemble(code))
|
||||
|
||||
/** Retires the cache, preserving its history instead of deleting it. */
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.archived() = addUniqueValueIfNew(CacheTypeTag.assembleArchived())
|
||||
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.typeModifier(modifier: TypeModifier) = addUniqueValueIfNew(TypeModifierTag.assemble(modifier))
|
||||
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.typeModifiers(modifiers: Collection<TypeModifier>) = addAllUniqueValueIfNew(TypeModifierTag.assemble(modifiers))
|
||||
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.hint(hint: String) = addUnique(HintTag.assemble(hint))
|
||||
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.mission(mission: String) = addUnique(MissionTag.assemble(mission))
|
||||
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.verificationKey(pubKey: HexKey) = addUnique(VerificationKeyTag.assemble(pubKey))
|
||||
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.firstToFindWinner(winnerPubKey: HexKey) = addUnique(FirstToFindWinnerTag.assemble(winnerPubKey))
|
||||
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.cacheImages(urls: List<String>) = addAllUniqueValueIfNew(urls.map(ImageTag::assemble))
|
||||
|
||||
fun TagArrayBuilder<GeocacheListingEvent>.logRelays(relays: List<NormalizedRelayUrl>) = addAllUniqueValueIfNew(relays.map(RelayTag::assemble))
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.listing
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.fastAny
|
||||
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
|
||||
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.RelayTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheNameTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSizeTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheTypeTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.DifficultyTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.FirstToFindWinnerTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.HintTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.MissionTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TerrainTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifierCategory
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifierTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.VerificationKeyTag
|
||||
|
||||
fun TagArray.cacheName() = firstNotNullOfOrNull(CacheNameTag::parse)
|
||||
|
||||
fun TagArray.difficulty() = firstNotNullOfOrNull(DifficultyTag::parse)
|
||||
|
||||
fun TagArray.terrain() = firstNotNullOfOrNull(TerrainTag::parse)
|
||||
|
||||
fun TagArray.cacheSize() = firstNotNullOfOrNull(CacheSizeTag::parse)
|
||||
|
||||
/** The raw `S` value, which may be outside the [CacheSize] vocabulary. */
|
||||
fun TagArray.cacheSizeCode() = firstNotNullOfOrNull(CacheSizeTag::parseCode)
|
||||
|
||||
/** The `t` cache type as published, defaulting to `traditional` per NIP-CC when absent. */
|
||||
fun TagArray.cacheTypeCode() = firstNotNullOfOrNull(CacheTypeTag::parseTypeCode) ?: CacheType.TRADITIONAL.code
|
||||
|
||||
/** The parsed [CacheType], or null when the listing names a client-defined type. */
|
||||
fun TagArray.cacheType() = CacheType.fromCode(cacheTypeCode())
|
||||
|
||||
/** Whether the owner retired this cache with `["t", "archived"]`. */
|
||||
fun TagArray.isArchived() = fastAny(CacheTypeTag::isArchived)
|
||||
|
||||
/**
|
||||
* The known `n` modifiers, at most one per category.
|
||||
*
|
||||
* NIP-CC rule 2: where a listing carries several values from one category, the first occurrence
|
||||
* wins and the rest are ignored. Unknown values are dropped (rule 4) — see [typeModifierCodes]
|
||||
* to see them anyway.
|
||||
*/
|
||||
fun TagArray.typeModifiers(): Map<TypeModifierCategory, TypeModifier> {
|
||||
val byCategory = mutableMapOf<TypeModifierCategory, TypeModifier>()
|
||||
fastForEach { tag ->
|
||||
TypeModifierTag.parse(tag)?.let {
|
||||
if (!byCategory.containsKey(it.category)) byCategory[it.category] = it
|
||||
}
|
||||
}
|
||||
return byCategory
|
||||
}
|
||||
|
||||
/** Every raw `n` value in publication order, unknown modifiers included. */
|
||||
fun TagArray.typeModifierCodes() = mapNotNull(TypeModifierTag::parseCode)
|
||||
|
||||
fun TagArray.hasTypeModifier(modifier: TypeModifier) = typeModifiers()[modifier.category] == modifier
|
||||
|
||||
fun TagArray.isFirstToFind() = hasTypeModifier(TypeModifier.FIRST_TO_FIND)
|
||||
|
||||
fun TagArray.hint() = firstNotNullOfOrNull(HintTag::parse)
|
||||
|
||||
/** The first `mission` tag. A listing must not carry more than one; extras are ignored. */
|
||||
fun TagArray.mission() = firstNotNullOfOrNull(MissionTag::parse)
|
||||
|
||||
fun TagArray.verificationKey() = firstNotNullOfOrNull(VerificationKeyTag::parse)
|
||||
|
||||
/** The first `F` tag. Only meaningful on a `first-to-find` listing; extras are ignored. */
|
||||
fun TagArray.firstToFindWinner() = firstNotNullOfOrNull(FirstToFindWinnerTag::parse)
|
||||
|
||||
fun TagArray.cacheImages() = mapNotNull(ImageTag::parse)
|
||||
|
||||
/** The `r` tags: relays the owner prefers logs to be published to. */
|
||||
fun TagArray.logRelays() = mapNotNull(RelayTag::parse)
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** The `name` tag of a geocache listing (kind 37516): the cache's human-readable name. */
|
||||
class CacheNameTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "name"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(name: String) = arrayOf(TAG_NAME, name)
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** The container sizes NIP-CC defines for the `S` tag. */
|
||||
enum class CacheSize(
|
||||
val code: String,
|
||||
) {
|
||||
MICRO("micro"),
|
||||
SMALL("small"),
|
||||
REGULAR("regular"),
|
||||
LARGE("large"),
|
||||
OTHER("other"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String?): CacheSize? = entries.firstOrNull { it.code == code }
|
||||
}
|
||||
}
|
||||
|
||||
/** The `S` tag of a geocache listing (kind 37516): the container size. */
|
||||
class CacheSizeTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "S"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
/** The parsed [CacheSize], or null when the tag is missing or names a size we don't know. */
|
||||
fun parse(tag: Array<String>): CacheSize? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
return CacheSize.fromCode(tag[1])
|
||||
}
|
||||
|
||||
/** The raw `S` value, even when it is outside the vocabulary above. */
|
||||
fun parseCode(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(size: CacheSize) = arrayOf(TAG_NAME, size.code)
|
||||
}
|
||||
}
|
||||
+85
@@ -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.nipCCGeocaching.listing.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The cache types NIP-CC names as common. The vocabulary is deliberately open — the spec says
|
||||
* types "are determined by individual clients" — so [CacheTypeTag.parseTypeCode] hands back
|
||||
* unknown codes unchanged and only [CacheTypeTag.parse] narrows to this set.
|
||||
*/
|
||||
enum class CacheType(
|
||||
val code: String,
|
||||
) {
|
||||
TRADITIONAL("traditional"),
|
||||
MULTI("multi"),
|
||||
MYSTERY("mystery"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String?): CacheType? = entries.firstOrNull { it.code == code }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `t` tag of a geocache listing (kind 37516).
|
||||
*
|
||||
* `t` carries two unrelated things on this kind: the cache *type* (`traditional`, `multi`,
|
||||
* `mystery`, or anything a client invents) and the retirement marker [ARCHIVED], which owners
|
||||
* add to preserve a cache's history instead of deleting it. A third meaning — the log type on
|
||||
* a kind 1111 comment — lives in
|
||||
* [com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogTypeTag]. The parsers are
|
||||
* kept apart per kind on purpose: sharing one would let `archived` read as a cache type.
|
||||
*/
|
||||
class CacheTypeTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "t"
|
||||
|
||||
/** `["t", "archived"]` retires a listing. It is a lifecycle marker, never a cache type. */
|
||||
const val ARCHIVED = "archived"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
/** The raw `t` value, including [ARCHIVED]. */
|
||||
fun parseCode(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
/** The raw `t` value when it names a cache type — never [ARCHIVED]. */
|
||||
fun parseTypeCode(tag: Array<String>): String? = parseCode(tag)?.takeIf { it != ARCHIVED }
|
||||
|
||||
/** The parsed [CacheType], or null when the value is [ARCHIVED], missing, or client-defined. */
|
||||
fun parse(tag: Array<String>): CacheType? = CacheType.fromCode(parseTypeCode(tag))
|
||||
|
||||
fun isArchived(tag: Array<String>) = parseCode(tag) == ARCHIVED
|
||||
|
||||
fun assemble(type: CacheType) = arrayOf(TAG_NAME, type.code)
|
||||
|
||||
fun assemble(code: String) = arrayOf(TAG_NAME, code)
|
||||
|
||||
fun assembleArchived() = arrayOf(TAG_NAME, ARCHIVED)
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.listing.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `D` tag of a geocache listing (kind 37516): how hard the cache is to work out, 1..5.
|
||||
*
|
||||
* Values outside the range do not parse. NIP-CC states the range without saying what an
|
||||
* out-of-range value means, and a 9 read as "very hard" would sort above every legitimate
|
||||
* cache in a client that ranks on it — so it is treated as absent instead.
|
||||
*/
|
||||
class DifficultyTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "D"
|
||||
const val MIN = 1
|
||||
const val MAX = 5
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): Int? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
val rating = tag[1].toIntOrNull() ?: return null
|
||||
ensure(rating in MIN..MAX) { return null }
|
||||
return rating
|
||||
}
|
||||
|
||||
/** Clamped rather than rejected: a composer's off-by-one should not throw mid-publish. */
|
||||
fun assemble(rating: Int) = arrayOf(TAG_NAME, rating.coerceIn(MIN, MAX).toString())
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.listing.tags
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* The `F` tag of a geocache listing (kind 37516): the locked-in first-to-find winner.
|
||||
*
|
||||
* The owner publishes it in a revision of the listing once they have confirmed a claim, normally
|
||||
* alongside `["t", "archived"]`. It exists because `created_at` is author-supplied and forgeable:
|
||||
* without it, a later log carrying a backdated timestamp would displace the real winner. Once
|
||||
* present, clients MUST attribute the claim to this pubkey regardless of timestamps — which is
|
||||
* what [com.vitorpamplona.quartz.nipCCGeocaching.firstToFind.FirstToFindResolver] implements.
|
||||
*
|
||||
* Only meaningful on a listing carrying the `first-to-find` `n` modifier.
|
||||
*/
|
||||
class FirstToFindWinnerTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "F"
|
||||
|
||||
/** [Hex.isHex64] does not check the length itself, so a 70-char value would pass it alone. */
|
||||
private fun isPubKey(value: String) = value.length == 64 && Hex.isHex64(value)
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && isPubKey(tag[1])
|
||||
|
||||
fun parse(tag: Array<String>): HexKey? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(isPubKey(tag[1])) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(winnerPubKey: HexKey) = arrayOf(TAG_NAME, winnerPubKey)
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `hint` tag of a geocache listing (kind 37516): plaintext help for finding the cache.
|
||||
*
|
||||
* The tag is plaintext on the wire. NIP-CC asks clients to obscure it in the UI — see
|
||||
* [com.vitorpamplona.quartz.nipCCGeocaching.listing.rot13] — so a reader does not spoil
|
||||
* themselves by scrolling past it.
|
||||
*/
|
||||
class HintTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "hint"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(hint: String) = arrayOf(TAG_NAME, hint)
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `mission` tag of a geocache listing (kind 37516): the "Key Quest" a finder is expected to
|
||||
* complete to legitimately claim the cache.
|
||||
*
|
||||
* A listing must not carry more than one; where one does, the spec says to use the first, which
|
||||
* is what [com.vitorpamplona.quartz.nipCCGeocaching.listing.mission] does.
|
||||
*
|
||||
* NIP-CC says completions may be recorded as NIP-GD "Good Deed" events referencing the cache.
|
||||
* No such NIP exists in the repository yet, so nothing here models it — `mission` is free text.
|
||||
*/
|
||||
class MissionTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "mission"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(mission: String) = arrayOf(TAG_NAME, mission)
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `T` tag of a geocache listing (kind 37516): how hard the cache is to reach, 1..5.
|
||||
*
|
||||
* Out-of-range values do not parse, for the same reason as [DifficultyTag].
|
||||
*/
|
||||
class TerrainTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "T"
|
||||
const val MIN = 1
|
||||
const val MAX = 5
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): Int? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
val rating = tag[1].toIntOrNull() ?: return null
|
||||
ensure(rating in MIN..MAX) { return null }
|
||||
return rating
|
||||
}
|
||||
|
||||
/** Clamped rather than rejected: a composer's off-by-one should not throw mid-publish. */
|
||||
fun assemble(rating: Int) = arrayOf(TAG_NAME, rating.coerceIn(MIN, MAX).toString())
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.listing.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The category a [TypeModifier] belongs to.
|
||||
*
|
||||
* NIP-CC allows at most one `n` modifier per category and lets modifiers from different
|
||||
* categories compose freely, so the category — not the modifier — is the unit of exclusivity.
|
||||
* That is why this is modelled as a map keyed by category rather than a set of strings.
|
||||
*/
|
||||
enum class TypeModifierCategory {
|
||||
/** How claims on the treasure are interpreted. */
|
||||
CLAIM_SEMANTICS,
|
||||
|
||||
/** What the physical treasure *is*. */
|
||||
PRIZE_NATURE,
|
||||
}
|
||||
|
||||
/** The `n` type modifiers NIP-CC defines today. New ones are expected; see [TypeModifierTag]. */
|
||||
enum class TypeModifier(
|
||||
val code: String,
|
||||
val category: TypeModifierCategory,
|
||||
) {
|
||||
/** Single-claim cache: the earliest verified found log is the exclusive claim. */
|
||||
FIRST_TO_FIND("first-to-find", TypeModifierCategory.CLAIM_SEMANTICS),
|
||||
|
||||
/** The cache itself is a physical work of art. */
|
||||
ART("art", TypeModifierCategory.PRIZE_NATURE),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String?): TypeModifier? = entries.firstOrNull { it.code == code }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `n` tag of a geocache listing (kind 37516): a type modifier.
|
||||
*
|
||||
* Unknown values parse to null rather than making the listing unreadable — forward
|
||||
* compatibility is an explicit requirement of the spec, which reserves the right to define new
|
||||
* modifiers and new categories. [parseCode] exposes the raw value for clients that want to show
|
||||
* a modifier they don't understand.
|
||||
*/
|
||||
class TypeModifierTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "n"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
/** The parsed [TypeModifier], or null for a modifier this version does not know. */
|
||||
fun parse(tag: Array<String>): TypeModifier? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
return TypeModifier.fromCode(tag[1])
|
||||
}
|
||||
|
||||
/** The raw `n` value, whether or not it is a known modifier. */
|
||||
fun parseCode(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(modifier: TypeModifier) = arrayOf(TAG_NAME, modifier.code)
|
||||
|
||||
fun assemble(modifiers: Collection<TypeModifier>) = modifiers.map { assemble(it) }
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.listing.tags
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* The `verification` tag of a geocache listing (kind 37516): the public half of the keypair a
|
||||
* finder proves physical presence with.
|
||||
*
|
||||
* The matching private key lives at the cache itself, normally behind a QR code. It is not the
|
||||
* owner's key and is not an account key — see
|
||||
* [com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent].
|
||||
*/
|
||||
class VerificationKeyTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "verification"
|
||||
|
||||
/** [Hex.isHex64] does not check the length itself, so a 70-char value would pass it alone. */
|
||||
private fun isPubKey(value: String) = value.length == 64 && Hex.isHex64(value)
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && isPubKey(tag[1])
|
||||
|
||||
fun parse(tag: Array<String>): HexKey? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(isPubKey(tag[1])) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(pubKey: HexKey) = arrayOf(TAG_NAME, pubKey)
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.verification
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
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.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A geocache verification event (kind 7517): cryptographic proof that someone stood at a cache.
|
||||
*
|
||||
* **Signed by the cache's verification key, not by the finder and not by the cache owner.** The
|
||||
* private half lives at the cache itself, normally behind a QR code, so producing one of these
|
||||
* requires having physically been there. A finder signs it with an ephemeral signer built from
|
||||
* the scanned key, embeds the result in their found log, and throws the key away — it is not an
|
||||
* account key and must never reach a keystore, an account, or a log line.
|
||||
*
|
||||
* Read [GeocacheVerificationValidator] before trusting one: a verification event on its own
|
||||
* proves only that *somebody* was at the cache. It becomes evidence about a particular finder
|
||||
* only once the signer, the finder and the cache address have all been checked against the
|
||||
* listing and the log.
|
||||
*/
|
||||
@Immutable
|
||||
class GeocacheVerificationEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
fun finderCache() = tags.finderCache()
|
||||
|
||||
/** The pubkey this verification was issued to. */
|
||||
fun finder() = tags.finder()
|
||||
|
||||
/** The cache it was issued at. */
|
||||
fun cache() = tags.verifiedCache()
|
||||
|
||||
/** Whether `content` matches the static format NIP-CC mandates for [finder]. */
|
||||
fun hasExpectedContent() = finder()?.let { content == contentFor(it) } ?: false
|
||||
|
||||
companion object {
|
||||
const val KIND = 7517
|
||||
|
||||
/** NIP-CC fixes the content exactly: `"Geocache verification for <finder-npub>"`. */
|
||||
fun contentFor(finderPubKey: HexKey) = "Geocache verification for ${NPub.create(finderPubKey)}"
|
||||
|
||||
fun build(
|
||||
finderPubKey: HexKey,
|
||||
cache: Address,
|
||||
relayHint: NormalizedRelayUrl? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<GeocacheVerificationEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, contentFor(finderPubKey), createdAt) {
|
||||
finderCache(finderPubKey, cache, relayHint)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.verification
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
|
||||
|
||||
/** Why a kind 7517 does not prove what it claims to prove. Ordered cheapest check first. */
|
||||
enum class VerificationFailure {
|
||||
/** The found log carries no `verification` tag, or its payload did not parse as a 7517. */
|
||||
NO_EMBEDDED_VERIFICATION,
|
||||
|
||||
/** The listing has no `verification` tag, so nothing at this cache can be verified. */
|
||||
CACHE_HAS_NO_VERIFICATION_KEY,
|
||||
|
||||
/** The `a` tag is missing or is not the `<finder-hex>:<cache>` pair NIP-CC defines. */
|
||||
MALFORMED_FINDER_CACHE_TAG,
|
||||
|
||||
/** The 7517 names a different cache than the listing it is being checked against. */
|
||||
CACHE_MISMATCH,
|
||||
|
||||
/** The 7517 was issued to somebody other than the author of the found log. */
|
||||
FINDER_MISMATCH,
|
||||
|
||||
/** `content` is not the static `"Geocache verification for <npub>"` string. */
|
||||
CONTENT_MISMATCH,
|
||||
|
||||
/** The 7517 was signed by some other key than the cache's verification key. */
|
||||
WRONG_SIGNER,
|
||||
|
||||
/** The id or the signature does not check out. */
|
||||
BAD_SIGNATURE,
|
||||
}
|
||||
|
||||
/**
|
||||
* The four checks NIP-CC lists under "Verification Validation", plus the two the spec leaves
|
||||
* implicit.
|
||||
*
|
||||
* Each one exists because skipping it breaks something concrete:
|
||||
*
|
||||
* - **signer** — without it, anybody signs their own "verification" and every cache is verified.
|
||||
* - **finder** — without it, a verification issued to somebody else can be lifted out of their
|
||||
* log and replayed into yours. This is the check that makes the proof about *a person* rather
|
||||
* than about the cache.
|
||||
* - **cache** — without it, a verification earned at an easy cache is replayed at a hard one.
|
||||
* - **signature** — without it, all three of the above are checks against unauthenticated text.
|
||||
* - **content** — NIP-CC fixes the content string, so a mismatch means the event was not
|
||||
* produced by a conforming client and its other fields are not worth trusting either.
|
||||
*
|
||||
* Note what a valid result does and does not mean. It means somebody who had access to the
|
||||
* private key at the cache issued this to that finder for that cache. It does not mean the finder
|
||||
* went there in person — a key that has leaked verifies just as well, which is why NIP-CC treats
|
||||
* these as evidence of presence rather than proof of identity, and why first-to-find claims get
|
||||
* locked in by the owner rather than decided by timestamps.
|
||||
*/
|
||||
object GeocacheVerificationValidator {
|
||||
/**
|
||||
* Validates [verification] as proof that [finderPubKey] was at [listing].
|
||||
*
|
||||
* @return null when it checks out, or the first [VerificationFailure] found.
|
||||
*/
|
||||
fun validate(
|
||||
verification: GeocacheVerificationEvent,
|
||||
listing: GeocacheListingEvent,
|
||||
finderPubKey: HexKey,
|
||||
): VerificationFailure? {
|
||||
val expectedSigner = listing.verificationKey() ?: return VerificationFailure.CACHE_HAS_NO_VERIFICATION_KEY
|
||||
val claim = verification.finderCache() ?: return VerificationFailure.MALFORMED_FINDER_CACHE_TAG
|
||||
|
||||
if (claim.cache != listing.address()) return VerificationFailure.CACHE_MISMATCH
|
||||
if (claim.finderPubKey != finderPubKey) return VerificationFailure.FINDER_MISMATCH
|
||||
if (verification.content != GeocacheVerificationEvent.contentFor(claim.finderPubKey)) return VerificationFailure.CONTENT_MISMATCH
|
||||
if (verification.pubKey != expectedSigner) return VerificationFailure.WRONG_SIGNER
|
||||
|
||||
// Last because it is the only check that costs a curve operation.
|
||||
if (!verification.verify()) return VerificationFailure.BAD_SIGNATURE
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Validates the verification embedded in [log], attributing it to the log's own author. */
|
||||
fun validate(
|
||||
log: GeocacheFoundLogEvent,
|
||||
listing: GeocacheListingEvent,
|
||||
): VerificationFailure? {
|
||||
val verification = log.embeddedVerification() ?: return VerificationFailure.NO_EMBEDDED_VERIFICATION
|
||||
return validate(verification, listing, log.pubKey)
|
||||
}
|
||||
|
||||
fun isValid(
|
||||
verification: GeocacheVerificationEvent,
|
||||
listing: GeocacheListingEvent,
|
||||
finderPubKey: HexKey,
|
||||
) = validate(verification, listing, finderPubKey) == null
|
||||
|
||||
/** Whether [log] carries a verification that holds up against [listing]. */
|
||||
fun isValid(
|
||||
log: GeocacheFoundLogEvent,
|
||||
listing: GeocacheListingEvent,
|
||||
) = validate(log, listing) == null
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.verification
|
||||
|
||||
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.nipCCGeocaching.verification.tags.FinderCacheTag
|
||||
|
||||
fun TagArrayBuilder<GeocacheVerificationEvent>.finderCache(
|
||||
finderPubKey: HexKey,
|
||||
cache: Address,
|
||||
relayHint: NormalizedRelayUrl? = null,
|
||||
) = addUnique(FinderCacheTag.assemble(finderPubKey, cache, relayHint))
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching.verification
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.verification.tags.FinderCacheTag
|
||||
|
||||
fun TagArray.finderCache() = firstNotNullOfOrNull(FinderCacheTag::parse)
|
||||
|
||||
fun TagArray.finder() = firstNotNullOfOrNull(FinderCacheTag::parseFinder)
|
||||
|
||||
fun TagArray.verifiedCache() = firstNotNullOfOrNull(FinderCacheTag::parseCache)
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipCCGeocaching.verification.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.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `a` tag of a geocache verification event (kind 7517): who was at which cache.
|
||||
*
|
||||
* **This is not a NIP-01 `a` tag.** NIP-CC defines its value as
|
||||
* `"<finder-pubkey-hex>:<geocache-naddr>"` — a colon-joined pair, where a NIP-01 address is
|
||||
* `kind:pubkey:d`. [com.vitorpamplona.quartz.nip01Core.core.Address.parse] rejects it (two
|
||||
* segments fail its three-segment guard, and the remainder does not start with `naddr1`) and logs
|
||||
* a warning on the way out, so a generic `a`-tag reader that meets one of these gets nothing
|
||||
* useful and a noisy log. Hence a parser of its own.
|
||||
*
|
||||
* Reading is slightly more generous than writing: the cache half goes through
|
||||
* [Address.parse], which accepts both the spec's `naddr1…` and a plain `kind:pubkey:d`. The
|
||||
* finder half must be exactly 64 hex characters, which is what keeps the two forms unambiguous —
|
||||
* a bare NIP-01 address would have `37516` on the left and is rejected.
|
||||
*/
|
||||
@Immutable
|
||||
data class FinderCacheTag(
|
||||
val finderPubKey: HexKey,
|
||||
val cache: Address,
|
||||
) {
|
||||
fun toTagArray() = assemble(finderPubKey, cache, null)
|
||||
|
||||
companion object {
|
||||
const val TAG_NAME = "a"
|
||||
|
||||
private fun isPubKey(value: String) = value.length == 64 && Hex.isHex64(value)
|
||||
|
||||
fun isTag(tag: Array<String>) = parse(tag) != null
|
||||
|
||||
fun parse(tag: Array<String>): FinderCacheTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
|
||||
val separator = tag[1].indexOf(':')
|
||||
ensure(separator > 0) { return null }
|
||||
|
||||
val finder = tag[1].substring(0, separator)
|
||||
ensure(isPubKey(finder)) { return null }
|
||||
|
||||
val cache = Address.parse(tag[1].substring(separator + 1)) ?: return null
|
||||
|
||||
return FinderCacheTag(finder, cache)
|
||||
}
|
||||
|
||||
fun parseFinder(tag: Array<String>) = parse(tag)?.finderPubKey
|
||||
|
||||
fun parseCache(tag: Array<String>) = parse(tag)?.cache
|
||||
|
||||
fun assemble(
|
||||
finderPubKey: HexKey,
|
||||
cache: Address,
|
||||
relayHint: NormalizedRelayUrl?,
|
||||
) = arrayOf(TAG_NAME, "$finderPubKey:${NAddress.create(cache.kind, cache.pubKeyHex, cache.dTag, relayHint)}")
|
||||
}
|
||||
}
|
||||
@@ -416,6 +416,10 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.curation.GeocacheCurationListEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
|
||||
@@ -643,6 +647,10 @@ class EventFactory {
|
||||
FollowListEvent.KIND -> FollowListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FundraiserEvent.KIND -> FundraiserEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
GeocacheListingEvent.KIND -> GeocacheListingEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
GeocacheFoundLogEvent.KIND -> GeocacheFoundLogEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
GeocacheVerificationEvent.KIND -> GeocacheVerificationEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
GeocacheCurationListEvent.KIND -> GeocacheCurationListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
GeohashChatEvent.KIND -> GeohashChatEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
GeohashListEvent.KIND -> GeohashListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
// kind:20001 is shared by BitChat's GeohashPresenceEvent and Buzz's
|
||||
|
||||
+10
@@ -89,6 +89,16 @@ class IndexableFieldVisitorTest {
|
||||
// Community definition: name + description + rules + content.
|
||||
assertAgrees(34550, arrayOf(arrayOf("name", "N"), arrayOf("description", "D"), arrayOf("rules", "R")), "c")
|
||||
assertAgrees(34550, emptyArray(), "c")
|
||||
|
||||
// Geocache listing: name + content. The `hint` is deliberately not indexed, so a listing
|
||||
// that has one must still rejoin to exactly name + content.
|
||||
assertAgrees(37516, arrayOf(arrayOf("name", "First Treasure"), arrayOf("hint", "In the branches")), "a cache")
|
||||
assertAgrees(37516, emptyArray(), "a cache")
|
||||
|
||||
// Geocache curation list: title + description + content.
|
||||
assertAgrees(37517, arrayOf(arrayOf("title", "T"), arrayOf("description", "D")), "c")
|
||||
assertAgrees(37517, arrayOf(arrayOf("title", "T")), "c")
|
||||
assertAgrees(37517, emptyArray(), "c")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.firstToFind.FirstToFindResolver
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The first-to-find claim rules.
|
||||
*
|
||||
* The interesting case is the forged timestamp: `created_at` is author-supplied, so the
|
||||
* provisional ordering can be stolen by anyone willing to backdate a log. The `F` tag is what
|
||||
* takes it back, and these tests pin that it actually does.
|
||||
*/
|
||||
class FirstToFindResolverTest {
|
||||
private val owner = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd"
|
||||
private val cacheKey = NostrSignerInternal(KeyPair())
|
||||
private val winner = NostrSignerInternal(KeyPair())
|
||||
private val latecomer = NostrSignerInternal(KeyPair())
|
||||
|
||||
private val cacheAddress = Address(GeocacheListingEvent.KIND, owner, "linocut-aftermath")
|
||||
|
||||
private fun listing(
|
||||
firstToFind: Boolean = true,
|
||||
lockedInWinner: String? = null,
|
||||
withVerificationKey: Boolean = true,
|
||||
) = GeocacheListingEvent(
|
||||
"id",
|
||||
owner,
|
||||
1_748_619_568L,
|
||||
buildList {
|
||||
add(arrayOf("d", cacheAddress.dTag))
|
||||
add(arrayOf("name", "Aftermath"))
|
||||
add(arrayOf("g", "u4xsu6ryb"))
|
||||
add(arrayOf("D", "2"))
|
||||
add(arrayOf("T", "2"))
|
||||
add(arrayOf("S", "small"))
|
||||
if (firstToFind) add(arrayOf("n", "first-to-find"))
|
||||
if (withVerificationKey) add(arrayOf("verification", cacheKey.pubKey))
|
||||
lockedInWinner?.let { add(arrayOf("F", it)) }
|
||||
}.toTypedArray(),
|
||||
"",
|
||||
"sig",
|
||||
)
|
||||
|
||||
private suspend fun verifiedLog(
|
||||
finder: NostrSignerInternal,
|
||||
createdAt: Long,
|
||||
id: String = createdAt.toString().padStart(64, '0'),
|
||||
): GeocacheFoundLogEvent {
|
||||
val verification = cacheKey.sign(GeocacheVerificationEvent.build(finder.pubKey, cacheAddress, createdAt = createdAt))
|
||||
return GeocacheFoundLogEvent(
|
||||
id,
|
||||
finder.pubKey,
|
||||
createdAt,
|
||||
arrayOf(arrayOf("a", cacheAddress.toValue()), arrayOf("verification", verification.toJson())),
|
||||
"Found it!",
|
||||
"sig",
|
||||
)
|
||||
}
|
||||
|
||||
private fun unverifiedLog(
|
||||
finder: NostrSignerInternal,
|
||||
createdAt: Long,
|
||||
id: String = createdAt.toString().padStart(64, '0'),
|
||||
) = GeocacheFoundLogEvent(
|
||||
id,
|
||||
finder.pubKey,
|
||||
createdAt,
|
||||
arrayOf(arrayOf("a", cacheAddress.toValue())),
|
||||
"Found it!",
|
||||
"sig",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun theEarliestVerifiedLogHoldsTheProvisionalClaim() =
|
||||
runTest {
|
||||
val first = verifiedLog(winner, 1_000L)
|
||||
val second = verifiedLog(latecomer, 2_000L)
|
||||
val cache = listing()
|
||||
|
||||
assertEquals(first.id, FirstToFindResolver.provisionalWinningLog(cache, listOf(second, first))?.id)
|
||||
assertEquals(winner.pubKey, FirstToFindResolver.winnerPubKey(cache, listOf(second, first)))
|
||||
assertTrue(FirstToFindResolver.isClaimed(cache, listOf(second, first)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tiesOnCreatedAtBreakOnAscendingEventId() =
|
||||
runTest {
|
||||
val a = verifiedLog(winner, 1_000L, id = "a".repeat(64))
|
||||
val b = verifiedLog(latecomer, 1_000L, id = "b".repeat(64))
|
||||
|
||||
assertEquals(a.id, FirstToFindResolver.provisionalWinningLog(listing(), listOf(b, a))?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnverifiedLogIsNotAClaimHoweverEarlyItIs() =
|
||||
runTest {
|
||||
// "I got here first" without a 7517 is somebody's word. The exclusive claim is
|
||||
// reserved for logs that prove physical presence.
|
||||
val wordOfMouth = unverifiedLog(latecomer, 1L)
|
||||
val real = verifiedLog(winner, 9_000L)
|
||||
|
||||
assertEquals(real.id, FirstToFindResolver.provisionalWinningLog(listing(), listOf(wordOfMouth, real))?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLogAboutAnotherCacheIsNotACandidate() =
|
||||
runTest {
|
||||
val elsewhere =
|
||||
GeocacheFoundLogEvent(
|
||||
"e".repeat(64),
|
||||
latecomer.pubKey,
|
||||
1L,
|
||||
arrayOf(arrayOf("a", Address(GeocacheListingEvent.KIND, owner, "some-other-cache").toValue())),
|
||||
"Found it!",
|
||||
"sig",
|
||||
)
|
||||
val real = verifiedLog(winner, 9_000L)
|
||||
|
||||
assertEquals(listOf(real.id), FirstToFindResolver.verifiedLogs(listing(), listOf(elsewhere, real)).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aForgedEarlierTimestampStealsTheProvisionalClaim() =
|
||||
runTest {
|
||||
// Not a bug to fix here — this is exactly the weakness NIP-CC calls out, and the
|
||||
// reason the owner gets to lock a winner in. Pinned so the next test means something.
|
||||
val real = verifiedLog(winner, 5_000L)
|
||||
val backdated = verifiedLog(latecomer, 1L)
|
||||
|
||||
assertEquals(latecomer.pubKey, FirstToFindResolver.winnerPubKey(listing(), listOf(real, backdated)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theFTagBeatsAnyTimestamp() =
|
||||
runTest {
|
||||
// "clients MUST attribute the exclusive claim to the pubkey in the `F` tag,
|
||||
// regardless of which verified found log currently appears earliest."
|
||||
val real = verifiedLog(winner, 5_000L)
|
||||
val backdated = verifiedLog(latecomer, 1L)
|
||||
val cache = listing(lockedInWinner = winner.pubKey)
|
||||
|
||||
assertEquals(winner.pubKey, FirstToFindResolver.winnerPubKey(cache, listOf(real, backdated)))
|
||||
assertEquals(real.id, FirstToFindResolver.winningLog(cache, listOf(real, backdated))?.id)
|
||||
assertTrue(FirstToFindResolver.isLockedIn(cache))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLockedInWinnerHoldsEvenWithNoLogFetchedForThem() =
|
||||
runTest {
|
||||
// The owner has confirmed the claim; the winning log may simply not be in hand yet.
|
||||
val cache = listing(lockedInWinner = winner.pubKey)
|
||||
|
||||
assertEquals(winner.pubKey, FirstToFindResolver.winnerPubKey(cache, emptyList()))
|
||||
assertNull(FirstToFindResolver.winningLog(cache, emptyList()))
|
||||
assertTrue(FirstToFindResolver.isClaimed(cache, emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theWinningLogIsTheWinnersEarliestVerifiedOne() =
|
||||
runTest {
|
||||
val early = verifiedLog(winner, 5_000L, id = "1".repeat(64))
|
||||
val later = verifiedLog(winner, 6_000L, id = "2".repeat(64))
|
||||
val cache = listing(lockedInWinner = winner.pubKey)
|
||||
|
||||
assertEquals(early.id, FirstToFindResolver.winningLog(cache, listOf(later, early))?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCacheThatIsNotFirstToFindHasNoExclusiveClaimToAward() =
|
||||
runTest {
|
||||
// Later verified logs stay valid records of presence on any cache; they are only
|
||||
// *claims* where the modifier says the cache is single-claim.
|
||||
val logs = listOf(verifiedLog(winner, 1_000L))
|
||||
val cache = listing(firstToFind = false, lockedInWinner = winner.pubKey)
|
||||
|
||||
assertNull(FirstToFindResolver.winnerPubKey(cache, logs))
|
||||
assertNull(FirstToFindResolver.winningLog(cache, logs))
|
||||
assertFalse(FirstToFindResolver.isClaimed(cache, logs))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFirstToFindCacheWithNoVerificationKeyCanNeverBeClaimed() =
|
||||
runTest {
|
||||
// NIP-CC: `first-to-find` "Requires a `verification` tag". With none, no log can be
|
||||
// verified, so no log is a claim.
|
||||
val logs = listOf(verifiedLog(winner, 1_000L))
|
||||
val cache = listing(withVerificationKey = false)
|
||||
|
||||
assertNull(FirstToFindResolver.provisionalWinningLog(cache, logs))
|
||||
assertFalse(FirstToFindResolver.isClaimed(cache, logs))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnclaimedFirstToFindCacheReportsNoWinner() {
|
||||
assertNull(FirstToFindResolver.winnerPubKey(listing(), emptyList()))
|
||||
assertFalse(FirstToFindResolver.isClaimed(listing(), emptyList()))
|
||||
assertFalse(FirstToFindResolver.isLockedIn(listing()))
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.curation.GeocacheCurationListEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListTheme
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.MapStyle
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Kind 37517, checked against the spec's Ren Fest example. */
|
||||
class GeocacheCurationListEventTest {
|
||||
private val curator = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd"
|
||||
private val otherAuthor = "b".repeat(64)
|
||||
|
||||
private fun list(vararg tags: Array<String>) = GeocacheCurationListEvent("id", curator, 1_748_619_568L, arrayOf(*tags), "Explore the grounds!", "sig")
|
||||
|
||||
private fun Array<Array<String>>.values(name: String) = filter { it.isNotEmpty() && it[0] == name }.map { it[1] }
|
||||
|
||||
@Test
|
||||
fun theKindIsRegisteredWithTheEventFactory() {
|
||||
assertTrue(EventFactory.isKnownKind(GeocacheCurationListEvent.KIND))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesTheSpecsCurationListExample() {
|
||||
val curated =
|
||||
list(
|
||||
arrayOf("d", "ren-fest-hunt-1748619568670"),
|
||||
arrayOf("title", "Texas Ren Fest Treasure Hunt"),
|
||||
arrayOf("description", "Find all the hidden treasures at the festival!"),
|
||||
arrayOf("image", "https://blossom.primal.net/banner-example.jpg"),
|
||||
arrayOf("g", "9vk"),
|
||||
arrayOf("g", "9vk5"),
|
||||
arrayOf("g", "9vk5b"),
|
||||
arrayOf("g", "9vk5b7"),
|
||||
arrayOf("theme", "adventure"),
|
||||
arrayOf("map", "adventure"),
|
||||
arrayOf("a", "37516:$curator:first-treasure-1748619568668"),
|
||||
arrayOf("a", "37516:$curator:verified-treasure-1748619568669"),
|
||||
)
|
||||
|
||||
assertEquals("Texas Ren Fest Treasure Hunt", curated.title())
|
||||
assertEquals("Find all the hidden treasures at the festival!", curated.description())
|
||||
assertEquals("https://blossom.primal.net/banner-example.jpg", curated.image())
|
||||
assertEquals(ListTheme.ADVENTURE, curated.theme())
|
||||
assertEquals(MapStyle.ADVENTURE, curated.mapStyle())
|
||||
assertEquals(4, curated.geohashes().size)
|
||||
assertEquals(2, curated.geocaches().size)
|
||||
assertTrue(curated.isWellFormed())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theOrderOfTheCachesIsPreserved() {
|
||||
// "Order is preserved and meaningful" — a trail rendered out of order is a different
|
||||
// trail.
|
||||
val curated =
|
||||
list(
|
||||
arrayOf("d", "x"),
|
||||
arrayOf("title", "t"),
|
||||
arrayOf("a", "37516:$curator:third"),
|
||||
arrayOf("a", "37516:$curator:first"),
|
||||
arrayOf("a", "37516:$curator:second"),
|
||||
)
|
||||
|
||||
assertEquals(listOf("third", "first", "second"), curated.geocaches().map { it.dTag })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aListCanSpanCachesFromSeveralAuthors() {
|
||||
val curated =
|
||||
list(
|
||||
arrayOf("d", "x"),
|
||||
arrayOf("title", "t"),
|
||||
arrayOf("a", "37516:$curator:mine"),
|
||||
arrayOf("a", "37516:$otherAuthor:theirs"),
|
||||
)
|
||||
|
||||
assertEquals(listOf(curator, otherAuthor), curated.geocaches().map { it.pubKeyHex })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAddressThatIsNotACacheIsNotInTheItinerary() {
|
||||
// A list may reference anything; only the cache kinds belong in a treasure hunt.
|
||||
val curated =
|
||||
list(
|
||||
arrayOf("d", "x"),
|
||||
arrayOf("title", "t"),
|
||||
arrayOf("a", "30023:$curator:a-blog-post"),
|
||||
arrayOf("a", "37516:$curator:a-cache"),
|
||||
)
|
||||
|
||||
assertEquals(listOf("a-cache"), curated.geocaches().map { it.dTag })
|
||||
assertEquals(2, curated.addresses().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theUndefinedLegacyKindIsAcceptedOnRead() {
|
||||
// NIP-CC says a list references "kind 37516 or 37515" but never defines 37515. Readers
|
||||
// accept it; the builder never writes it.
|
||||
val curated = list(arrayOf("d", "x"), arrayOf("title", "t"), arrayOf("a", "37515:$curator:old-cache"))
|
||||
|
||||
assertEquals(listOf("old-cache"), curated.geocaches().map { it.dTag })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnknownThemeOrMapStyleIsNotSilentlyADefault() {
|
||||
val curated = list(arrayOf("theme", "brutalist"), arrayOf("map", "topographic"))
|
||||
|
||||
assertNull(curated.theme())
|
||||
assertNull(curated.mapStyle())
|
||||
assertEquals("brutalist", curated.themeCode())
|
||||
assertEquals("topographic", curated.mapStyleCode())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aListWithNoCachesIsNotWellFormed() {
|
||||
assertFalse(list(arrayOf("d", "x"), arrayOf("title", "t")).isWellFormed())
|
||||
assertFalse(list(arrayOf("d", "x"), arrayOf("a", "37516:$curator:c")).isWellFormed())
|
||||
assertFalse(list(arrayOf("title", "t"), arrayOf("a", "37516:$curator:c")).isWellFormed())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aListOfOnlyNonCachesIsNotWellFormed() {
|
||||
// The `a` tag is there, so the tag-name gate passes; the list is still empty.
|
||||
assertFalse(list(arrayOf("d", "x"), arrayOf("title", "t"), arrayOf("a", "30023:$curator:post")).isWellFormed())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildProducesTheTagsTheSpecRequires() {
|
||||
val template =
|
||||
GeocacheCurationListEvent.build(
|
||||
title = "Texas Ren Fest Treasure Hunt",
|
||||
geocaches =
|
||||
listOf(
|
||||
Address(GeocacheListingEvent.KIND, curator, "first-treasure-1748619568668"),
|
||||
Address(GeocacheListingEvent.KIND, curator, "verified-treasure-1748619568669"),
|
||||
),
|
||||
content = "Explore the festival grounds!",
|
||||
description = "Find all the hidden treasures at the festival!",
|
||||
image = "https://blossom.primal.net/banner-example.jpg",
|
||||
geohash = "9vk5b7xy",
|
||||
theme = ListTheme.ADVENTURE,
|
||||
mapStyle = MapStyle.ADVENTURE,
|
||||
dTag = "ren-fest-hunt-1748619568670",
|
||||
createdAt = 1_748_619_568L,
|
||||
)
|
||||
|
||||
assertEquals(GeocacheCurationListEvent.KIND, template.kind)
|
||||
assertEquals(listOf("ren-fest-hunt-1748619568670"), template.tags.values("d"))
|
||||
assertEquals(listOf("Texas Ren Fest Treasure Hunt"), template.tags.values("title"))
|
||||
assertEquals(listOf("adventure"), template.tags.values("theme"))
|
||||
assertEquals(listOf("adventure"), template.tags.values("map"))
|
||||
assertEquals(
|
||||
listOf(
|
||||
"37516:$curator:first-treasure-1748619568668",
|
||||
"37516:$curator:verified-treasure-1748619568669",
|
||||
),
|
||||
template.tags.values("a"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aListsGeohashLadderStopsAtSix() {
|
||||
// A trail's centre point is not a place anybody walks to, so it is published coarser
|
||||
// than a listing's 3..9.
|
||||
val template =
|
||||
GeocacheCurationListEvent.build(
|
||||
title = "t",
|
||||
geocaches = listOf(Address(GeocacheListingEvent.KIND, curator, "c")),
|
||||
geohash = "9vk5b7xyz",
|
||||
)
|
||||
|
||||
assertEquals(listOf("9vk", "9vk5", "9vk5b", "9vk5b7"), template.tags.values("g"))
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheGeohash
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.rot13
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** The write path for kind 37516, plus the two helpers NIP-CC asks clients for. */
|
||||
class GeocacheListingBuilderTest {
|
||||
private val verificationKey = "6805d4e5c0df48b4f76e2fdcb67a2acb1d97567b01c6fe17a236dc32f34f1c07"
|
||||
|
||||
private fun Array<Array<String>>.values(name: String) = filter { it.isNotEmpty() && it[0] == name }.map { it[1] }
|
||||
|
||||
@Test
|
||||
fun buildProducesTheTagsTheSpecRequires() {
|
||||
val template =
|
||||
GeocacheListingEvent.build(
|
||||
name = "First Treasure",
|
||||
description = "The first Nostr treasure",
|
||||
geohash = "u4xsu6ryb",
|
||||
difficulty = 1,
|
||||
terrain = 1,
|
||||
size = CacheSize.SMALL,
|
||||
type = CacheType.TRADITIONAL,
|
||||
hint = "In the branches",
|
||||
dTag = "first-treasure-1748619568668",
|
||||
createdAt = 1_748_619_568L,
|
||||
)
|
||||
|
||||
assertEquals(GeocacheListingEvent.KIND, template.kind)
|
||||
assertEquals("The first Nostr treasure", template.content)
|
||||
assertEquals(listOf("first-treasure-1748619568668"), template.tags.values("d"))
|
||||
assertEquals(listOf("First Treasure"), template.tags.values("name"))
|
||||
assertEquals(listOf("1"), template.tags.values("D"))
|
||||
assertEquals(listOf("1"), template.tags.values("T"))
|
||||
assertEquals(listOf("small"), template.tags.values("S"))
|
||||
assertEquals(listOf("traditional"), template.tags.values("t"))
|
||||
assertEquals(listOf("In the branches"), template.tags.values("hint"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theGeohashLadderRunsThreeToNineCoarseToFine() {
|
||||
// The spec's own example publishes u4x .. u4xsu6ryb. A 1- or 2-character geohash spans
|
||||
// thousands of kilometres and is noise on the relay, so the ladder starts at 3.
|
||||
val template =
|
||||
GeocacheListingEvent.build(
|
||||
name = "n",
|
||||
description = "",
|
||||
geohash = "u4xsu6ryb",
|
||||
difficulty = 1,
|
||||
terrain = 1,
|
||||
size = CacheSize.SMALL,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("u4x", "u4xs", "u4xsu", "u4xsu6", "u4xsu6r", "u4xsu6ry", "u4xsu6ryb"),
|
||||
template.tags.values("g"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLadderStopsAtNineEvenForAFinerGeohash() {
|
||||
// Seven rungs, 3 through 9 — the three extra characters of precision are dropped rather
|
||||
// than published, so the finest `g` tag is never finer than the band NIP-CC defines.
|
||||
assertEquals(7, GeocacheGeohash.ladder("u4xsu6rybxyz").size)
|
||||
assertEquals("u4x", GeocacheGeohash.ladder("u4xsu6rybxyz").first())
|
||||
assertEquals("u4xsu6ryb", GeocacheGeohash.ladder("u4xsu6rybxyz").last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aGeohashTooCoarseForTheBandProducesNoTags() {
|
||||
assertEquals(emptyList(), GeocacheGeohash.ladder("u4"))
|
||||
assertEquals(emptyList(), GeoHashTag.geoMipMap("u4", 3, 9))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theBoundedMipMapLeavesTheOriginalOneAlone() {
|
||||
// The unbounded form feeds the geohash chat channels and must keep running
|
||||
// fine-to-coarse from one character.
|
||||
assertEquals(listOf("u4x", "u4", "u"), GeoHashTag.geoMipMap("u4x"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun microCachesNeedOneMoreCharacterThanEveryoneElse() {
|
||||
// "Validate geohash precision meets minimum requirements (8+ characters, 9+ for micro
|
||||
// caches)". A 38m box does not find a film canister.
|
||||
assertTrue(GeocacheGeohash.isPreciseEnough("u4xsu6ry", CacheSize.REGULAR))
|
||||
assertFalse(GeocacheGeohash.isPreciseEnough("u4xsu6ry", CacheSize.MICRO))
|
||||
assertTrue(GeocacheGeohash.isPreciseEnough("u4xsu6ryb", CacheSize.MICRO))
|
||||
assertFalse(GeocacheGeohash.isPreciseEnough("u4xsu6r", CacheSize.REGULAR))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOutOfRangeRatingIsClampedRatherThanThrown() {
|
||||
val template =
|
||||
GeocacheListingEvent.build(
|
||||
name = "n",
|
||||
description = "",
|
||||
geohash = "u4xsu6ryb",
|
||||
difficulty = 9,
|
||||
terrain = 0,
|
||||
size = CacheSize.SMALL,
|
||||
)
|
||||
|
||||
assertEquals(listOf("5"), template.tags.values("D"))
|
||||
assertEquals(listOf("1"), template.tags.values("T"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modifiersAndVerificationRoundTripThroughBuild() {
|
||||
val template =
|
||||
GeocacheListingEvent.build(
|
||||
name = "Aftermath",
|
||||
description = "linocut",
|
||||
geohash = "u4xsu6ryb",
|
||||
difficulty = 2,
|
||||
terrain = 2,
|
||||
size = CacheSize.SMALL,
|
||||
modifiers = listOf(TypeModifier.FIRST_TO_FIND, TypeModifier.ART),
|
||||
verificationPubKey = verificationKey,
|
||||
)
|
||||
|
||||
val cache = GeocacheListingEvent("id", "a".repeat(64), 1L, template.tags, template.content, "sig")
|
||||
|
||||
assertTrue(cache.isFirstToFind())
|
||||
assertTrue(cache.hasTypeModifier(TypeModifier.ART))
|
||||
assertEquals(verificationKey, cache.verificationKey())
|
||||
assertTrue(cache.isWellFormed())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rot13IsItsOwnInverseAndTouchesOnlyAsciiLetters() {
|
||||
// The hint travels as plaintext; ROT13 is the display transform that stops a reader
|
||||
// spoiling themselves by scrolling past it.
|
||||
assertEquals("Va gur oenapurf", rot13("In the branches"))
|
||||
assertEquals("In the branches", rot13(rot13("In the branches")))
|
||||
assertEquals("123 !?-é中", rot13("123 !?-é中"))
|
||||
assertEquals("nomAZ", rot13("abzNM"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theHintAccessorOffersBothForms() {
|
||||
val cache = GeocacheListingEvent("id", "a".repeat(64), 1L, arrayOf(arrayOf("hint", "In the branches")), "", "sig")
|
||||
|
||||
assertEquals("In the branches", cache.hint())
|
||||
assertEquals("Va gur oenapurf", cache.hintRot13())
|
||||
assertNull(GeocacheListingEvent("id", "a".repeat(64), 1L, emptyArray(), "", "sig").hintRot13())
|
||||
}
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifierCategory
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Kind 37516, checked against the examples in NIP-CC itself. */
|
||||
class GeocacheListingEventTest {
|
||||
private val owner = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd"
|
||||
private val verificationKey = "6805d4e5c0df48b4f76e2fdcb67a2acb1d97567b01c6fe17a236dc32f34f1c07"
|
||||
|
||||
private fun listing(vararg tags: Array<String>) = GeocacheListingEvent("id", owner, 1_748_619_568L, arrayOf(*tags), "a cache", "sig")
|
||||
|
||||
/** The "Basic Cache" example from NIP-CC, tag for tag. */
|
||||
private fun basicCacheTags(): TagArray =
|
||||
arrayOf(
|
||||
arrayOf("d", "first-treasure-1748619568668"),
|
||||
arrayOf("name", "First Treasure"),
|
||||
arrayOf("g", "u4x"),
|
||||
arrayOf("g", "u4xs"),
|
||||
arrayOf("g", "u4xsu"),
|
||||
arrayOf("g", "u4xsu6"),
|
||||
arrayOf("g", "u4xsu6r"),
|
||||
arrayOf("g", "u4xsu6ry"),
|
||||
arrayOf("g", "u4xsu6ryb"),
|
||||
arrayOf("D", "1"),
|
||||
arrayOf("T", "1"),
|
||||
arrayOf("S", "small"),
|
||||
arrayOf("t", "traditional"),
|
||||
arrayOf("hint", "In the branches"),
|
||||
arrayOf("image", "https://blossom.primal.net/74efe.jpg"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun theKindIsRegisteredWithTheEventFactory() {
|
||||
// Without this the class above is dead code: 37516 parses as a plain Event and no
|
||||
// accessor on it is ever reached.
|
||||
assertTrue(EventFactory.isKnownKind(GeocacheListingEvent.KIND))
|
||||
assertTrue(EventFactory.create<Event>("id", owner, 1L, GeocacheListingEvent.KIND, emptyArray(), "", "") is GeocacheListingEvent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesTheSpecsBasicCacheExample() {
|
||||
val cache = listing(*basicCacheTags())
|
||||
|
||||
assertEquals("first-treasure-1748619568668", cache.dTag())
|
||||
assertEquals("First Treasure", cache.cacheName())
|
||||
assertEquals(1, cache.difficulty())
|
||||
assertEquals(1, cache.terrain())
|
||||
assertEquals(CacheSize.SMALL, cache.cacheSize())
|
||||
assertEquals(CacheType.TRADITIONAL, cache.cacheType())
|
||||
assertEquals("In the branches", cache.hint())
|
||||
assertEquals(listOf("https://blossom.primal.net/74efe.jpg"), cache.images())
|
||||
assertEquals("u4xsu6ryb", cache.location())
|
||||
assertEquals(7, cache.geohashes().size)
|
||||
assertTrue(cache.isWellFormed())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesTheSpecsVerifiedCacheExample() {
|
||||
val cache =
|
||||
listing(
|
||||
arrayOf("d", "verified-treasure-1748619568669"),
|
||||
arrayOf("name", "Verified Treasure"),
|
||||
arrayOf("g", "u4xsu6ry"),
|
||||
arrayOf("D", "3"),
|
||||
arrayOf("T", "2"),
|
||||
arrayOf("S", "small"),
|
||||
arrayOf("t", "traditional"),
|
||||
arrayOf("hint", "Look for the secret code"),
|
||||
arrayOf("verification", verificationKey),
|
||||
)
|
||||
|
||||
assertEquals(verificationKey, cache.verificationKey())
|
||||
assertTrue(cache.requiresVerification())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesTheSpecsFirstToFindArtExample() {
|
||||
val cache =
|
||||
listing(
|
||||
arrayOf("d", "linocut-aftermath-1748619568671"),
|
||||
arrayOf("name", "Aftermath (Linocut #1)"),
|
||||
arrayOf("g", "u4xsu6ry"),
|
||||
arrayOf("D", "2"),
|
||||
arrayOf("T", "2"),
|
||||
arrayOf("S", "small"),
|
||||
arrayOf("t", "traditional"),
|
||||
arrayOf("n", "first-to-find"),
|
||||
arrayOf("n", "art"),
|
||||
arrayOf("verification", verificationKey),
|
||||
)
|
||||
|
||||
assertTrue(cache.isFirstToFind())
|
||||
assertTrue(cache.hasTypeModifier(TypeModifier.ART))
|
||||
assertEquals(
|
||||
mapOf(
|
||||
TypeModifierCategory.CLAIM_SEMANTICS to TypeModifier.FIRST_TO_FIND,
|
||||
TypeModifierCategory.PRIZE_NATURE to TypeModifier.ART,
|
||||
),
|
||||
cache.typeModifiers(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesTheSpecsKeyQuestExample() {
|
||||
val cache =
|
||||
listing(
|
||||
arrayOf("d", "key-quest-treasure-1748619568670"),
|
||||
arrayOf("name", "Riddle of the Old Oak"),
|
||||
arrayOf("g", "u4xsu6ry"),
|
||||
arrayOf("D", "4"),
|
||||
arrayOf("T", "2"),
|
||||
arrayOf("S", "small"),
|
||||
arrayOf("t", "mystery"),
|
||||
arrayOf("mission", "Bring a token of nature you found along the way"),
|
||||
arrayOf("verification", verificationKey),
|
||||
)
|
||||
|
||||
assertEquals(CacheType.MYSTERY, cache.cacheType())
|
||||
assertTrue(cache.hasMission())
|
||||
assertEquals("Bring a token of nature you found along the way", cache.mission())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theCacheTypeDefaultsToTraditional() {
|
||||
// "The type of cache (`t`) is optional and defaults to `traditional` if not specified."
|
||||
val cache = listing(arrayOf("d", "x"), arrayOf("name", "n"), arrayOf("g", "u4xsu6ryb"), arrayOf("D", "1"), arrayOf("T", "1"), arrayOf("S", "micro"))
|
||||
|
||||
assertEquals(CacheType.TRADITIONAL, cache.cacheType())
|
||||
assertEquals("traditional", cache.cacheTypeCode())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aClientDefinedCacheTypeSurvivesAsItsCode() {
|
||||
// "Cache types are determined by individual clients" — an unknown type must not read as
|
||||
// `traditional`, which would hide a puzzle cache behind a walk-up label.
|
||||
val cache = listing(arrayOf("t", "earthcache"))
|
||||
|
||||
assertNull(cache.cacheType())
|
||||
assertEquals("earthcache", cache.cacheTypeCode())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archivedIsALifecycleMarkerNotACacheType() {
|
||||
// `t` carries both on this kind. Reading `archived` as the type would make every retired
|
||||
// cache an unknown type, and reading the type as a lifecycle state would un-retire it.
|
||||
val cache = listing(arrayOf("t", "mystery"), arrayOf("t", "archived"))
|
||||
|
||||
assertTrue(cache.isArchived())
|
||||
assertEquals(CacheType.MYSTERY, cache.cacheType())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCacheWithNoArchivedTagIsNotArchived() {
|
||||
assertFalse(listing(arrayOf("t", "traditional")).isArchived())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun difficultyAndTerrainRejectValuesOutsideOneToFive() {
|
||||
assertNull(listing(arrayOf("D", "0")).difficulty())
|
||||
assertNull(listing(arrayOf("D", "6")).difficulty())
|
||||
assertNull(listing(arrayOf("D", "")).difficulty())
|
||||
assertNull(listing(arrayOf("D", "hard")).difficulty())
|
||||
assertNull(listing(arrayOf("T", "-1")).terrain())
|
||||
assertEquals(5, listing(arrayOf("D", "5")).difficulty())
|
||||
assertEquals(1, listing(arrayOf("T", "1")).terrain())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnknownSizeIsNotSilentlyACategory() {
|
||||
val cache = listing(arrayOf("S", "nano"))
|
||||
|
||||
assertNull(cache.cacheSize())
|
||||
assertEquals("nano", cache.cacheSizeCode())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyOneModifierPerCategoryCountsAndTheFirstWins() {
|
||||
// NIP-CC rule 2. Both values here are CLAIM_SEMANTICS-shaped in a future where more
|
||||
// exist; today only one category has two candidates, so the rule is exercised with
|
||||
// a repeat.
|
||||
val cache = listing(arrayOf("n", "first-to-find"), arrayOf("n", "first-to-find"), arrayOf("n", "art"))
|
||||
|
||||
assertEquals(2, cache.typeModifiers().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnknownModifierIsIgnoredRatherThanFatal() {
|
||||
// NIP-CC rule 4, the forward-compatibility rule: a listing using a modifier from a future
|
||||
// revision must still parse as a cache.
|
||||
val cache = listing(*basicCacheTags(), arrayOf("n", "time-limited"), arrayOf("n", "art"))
|
||||
|
||||
assertTrue(cache.isWellFormed())
|
||||
assertEquals(mapOf(TypeModifierCategory.PRIZE_NATURE to TypeModifier.ART), cache.typeModifiers())
|
||||
assertEquals(listOf("time-limited", "art"), cache.typeModifierCodes())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theFTagIsOnlyHonouredOnAFirstToFindCache() {
|
||||
// "Only valid when the treasure carries the `first-to-find` `n` modifier." Honouring it
|
||||
// anywhere else invents an exclusive claim on a cache that never had one.
|
||||
val winner = "b".repeat(64)
|
||||
|
||||
assertNull(listing(arrayOf("F", winner)).firstToFindWinner())
|
||||
assertEquals(winner, listing(arrayOf("n", "first-to-find"), arrayOf("F", winner)).firstToFindWinner())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMalformedPubKeyIsNotAVerificationKey() {
|
||||
assertNull(listing(arrayOf("verification", "not-hex")).verificationKey())
|
||||
assertNull(listing(arrayOf("verification", "")).verificationKey())
|
||||
// isHex64 does not check the length itself; a longer string must still be rejected.
|
||||
assertNull(listing(arrayOf("verification", "a".repeat(70))).verificationKey())
|
||||
assertEquals(verificationKey, listing(arrayOf("verification", verificationKey)).verificationKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aListingMissingARequiredTagIsNotWellFormed() {
|
||||
GeocacheListingEvent.REQUIRED_FIELDS.forEach { missing ->
|
||||
val tags = basicCacheTags().filterNot { it[0] == missing }.toTypedArray()
|
||||
assertFalse(listing(*tags).isWellFormed(), "a listing without `$missing` should not be well formed")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theAddressIsTheKindPubkeyAndDTag() {
|
||||
val cache = listing(*basicCacheTags())
|
||||
|
||||
assertEquals("37516:$owner:first-treasure-1748619568668", cache.address().toValue())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aListingRoundTripsThroughJson() {
|
||||
val cache = listing(*basicCacheTags())
|
||||
val reparsed = Event.fromJson(cache.toJson())
|
||||
|
||||
assertTrue(reparsed is GeocacheListingEvent)
|
||||
assertEquals("First Treasure", reparsed.cacheName())
|
||||
assertEquals(CacheSize.SMALL, reparsed.cacheSize())
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.comment.GeocacheLogComment
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.comment.declaredGeocacheLogType
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.comment.geocacheLogType
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogType
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Kind 7516 found logs, and the kind 1111 comments that carry everything else. */
|
||||
class GeocacheLogsTest {
|
||||
private val owner = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd"
|
||||
private val finder = "a".repeat(64)
|
||||
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!
|
||||
|
||||
private val cacheAddress = Address(GeocacheListingEvent.KIND, owner, "first-treasure-1748619568668")
|
||||
|
||||
private val listing =
|
||||
GeocacheListingEvent(
|
||||
"9".repeat(64),
|
||||
owner,
|
||||
1_748_619_568L,
|
||||
arrayOf(
|
||||
arrayOf("d", cacheAddress.dTag),
|
||||
arrayOf("name", "First Treasure"),
|
||||
arrayOf("g", "u4xsu6ryb"),
|
||||
arrayOf("D", "1"),
|
||||
arrayOf("T", "1"),
|
||||
arrayOf("S", "small"),
|
||||
),
|
||||
"",
|
||||
"sig",
|
||||
)
|
||||
|
||||
private fun Array<Array<String>>.values(name: String) = filter { it.isNotEmpty() && it[0] == name }.map { it[1] }
|
||||
|
||||
@Test
|
||||
fun parsesTheSpecsFoundLogExample() {
|
||||
val log =
|
||||
GeocacheFoundLogEvent(
|
||||
"1".repeat(64),
|
||||
finder,
|
||||
1_748_619_700L,
|
||||
arrayOf(arrayOf("a", "37516:$owner:first-treasure-1748619568668")),
|
||||
"Found it! Great hiding spot.",
|
||||
"sig",
|
||||
)
|
||||
|
||||
assertEquals(cacheAddress, log.geocache())
|
||||
assertEquals("37516:$owner:first-treasure-1748619568668", log.geocacheId())
|
||||
assertEquals(listOf("37516:$owner:first-treasure-1748619568668"), log.linkedAddressIds())
|
||||
assertTrue(!log.isVerified())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFoundLogBuiltFromAHintCarriesTheRelay() {
|
||||
val template = GeocacheFoundLogEvent.build("Found it!", EventHintBundle(listing, relay), createdAt = 1L)
|
||||
|
||||
assertEquals(GeocacheFoundLogEvent.KIND, template.kind)
|
||||
assertEquals("Found it!", template.content)
|
||||
assertEquals(listOf(cacheAddress.toValue()), template.tags.values("a"))
|
||||
assertEquals(listOf(relay.url), template.tags.first { it[0] == "a" }.drop(2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFoundLogIsIndexedByItsMessage() {
|
||||
val log = GeocacheFoundLogEvent("1".repeat(64), finder, 1L, emptyArray(), "Great hiding spot", "sig")
|
||||
|
||||
assertEquals("Great hiding spot", log.indexableContent())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDnfCommentCarriesTheRootAndParentTheSpecShows() {
|
||||
// NIP-CC's DNF example: the listing is both root and parent, so A/K/P and a/k/p all
|
||||
// point at the cache.
|
||||
val template = GeocacheLogComment.didNotFind("Searched for 30 minutes.", EventHintBundle(listing), createdAt = 1L)
|
||||
|
||||
assertEquals(CommentEvent.KIND, template.kind)
|
||||
assertEquals(listOf(cacheAddress.toValue()), template.tags.values("A"))
|
||||
assertEquals(listOf("37516"), template.tags.values("K"))
|
||||
assertEquals(listOf(owner), template.tags.values("P"))
|
||||
assertEquals(listOf(cacheAddress.toValue()), template.tags.values("a"))
|
||||
assertEquals(listOf("37516"), template.tags.values("k"))
|
||||
assertEquals(listOf(owner), template.tags.values("p"))
|
||||
assertEquals(listOf("dnf"), template.tags.values("t"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLogTypeReadsBackOffTheComment() {
|
||||
val template = GeocacheLogComment.needsMaintenance("Cache is soaked.", EventHintBundle(listing), createdAt = 1L)
|
||||
val comment = CommentEvent("1".repeat(64), finder, 1L, template.tags, template.content, "sig")
|
||||
|
||||
assertEquals(GeocacheLogType.MAINTENANCE, comment.tags.geocacheLogType())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCommentWithNoTypeIsANote() {
|
||||
// "If no `t` tag is present, the comment is assumed to be a general note."
|
||||
val comment = CommentEvent("1".repeat(64), finder, 1L, arrayOf(arrayOf("A", cacheAddress.toValue())), "nice spot", "sig")
|
||||
|
||||
assertEquals(GeocacheLogType.NOTE, comment.tags.geocacheLogType())
|
||||
assertNull(comment.tags.declaredGeocacheLogType())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aHashtagIsNotMistakenForALogType() {
|
||||
// `t` is NIP-01's hashtag tag as well. Only the four defined codes parse, so a topic
|
||||
// falls through to the one that follows it.
|
||||
val comment =
|
||||
CommentEvent(
|
||||
"1".repeat(64),
|
||||
finder,
|
||||
1L,
|
||||
arrayOf(arrayOf("t", "geocaching"), arrayOf("t", "hiking"), arrayOf("t", "dnf")),
|
||||
"",
|
||||
"sig",
|
||||
)
|
||||
|
||||
assertEquals(GeocacheLogType.DNF, comment.tags.geocacheLogType())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addingALogTypeDoesNotWipeTheAuthorsHashtags() {
|
||||
val template =
|
||||
GeocacheLogComment.note("nice spot", EventHintBundle(listing), createdAt = 1L) {
|
||||
add(arrayOf("t", "geocaching"))
|
||||
}
|
||||
|
||||
assertEquals(listOf("note", "geocaching"), template.tags.values("t"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyDefinedLogTypeRoundTrips() {
|
||||
GeocacheLogType.entries.forEach { type ->
|
||||
val template = GeocacheLogComment.build("log", EventHintBundle(listing), type, createdAt = 1L)
|
||||
val comment = CommentEvent("1".repeat(64), finder, 1L, template.tags, template.content, "sig")
|
||||
|
||||
assertEquals(type, comment.tags.geocacheLogType())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOwnerCanRetireACacheThroughTheCommentVocabularyToo() {
|
||||
// NIP-CC lets owners retire a cache with an `archived` log type, keeping its history.
|
||||
val template = GeocacheLogComment.build("Retiring this one.", EventHintBundle(listing), GeocacheLogType.ARCHIVED, createdAt = 1L)
|
||||
|
||||
assertEquals(listOf("archived"), template.tags.values("t"))
|
||||
}
|
||||
}
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
/*
|
||||
* 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.nipCCGeocaching
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationValidator
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.verification.VerificationFailure
|
||||
import com.vitorpamplona.quartz.nipCCGeocaching.verification.tags.FinderCacheTag
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Kind 7517 and the validation NIP-CC puts around it.
|
||||
*
|
||||
* Each negative case here is a replay or a forgery that a missing check would let through, so
|
||||
* they are written as the attack rather than as "returns null".
|
||||
*/
|
||||
class GeocacheVerificationTest {
|
||||
private val owner = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd"
|
||||
|
||||
// The keypair that lives at the cache, behind the QR code.
|
||||
private val cacheKey = NostrSignerInternal(KeyPair())
|
||||
private val someoneElsesCacheKey = NostrSignerInternal(KeyPair())
|
||||
|
||||
private val finder = NostrSignerInternal(KeyPair())
|
||||
private val otherFinder = NostrSignerInternal(KeyPair())
|
||||
|
||||
private val cacheAddress = Address(GeocacheListingEvent.KIND, owner, "verified-treasure-1748619568669")
|
||||
private val otherCacheAddress = Address(GeocacheListingEvent.KIND, owner, "some-other-treasure")
|
||||
|
||||
private fun listing(verificationKey: String? = cacheKey.pubKey) =
|
||||
GeocacheListingEvent(
|
||||
"id",
|
||||
owner,
|
||||
1_748_619_568L,
|
||||
buildList {
|
||||
add(arrayOf("d", cacheAddress.dTag))
|
||||
add(arrayOf("name", "Verified Treasure"))
|
||||
add(arrayOf("g", "u4xsu6ryb"))
|
||||
add(arrayOf("D", "3"))
|
||||
add(arrayOf("T", "2"))
|
||||
add(arrayOf("S", "small"))
|
||||
verificationKey?.let { add(arrayOf("verification", it)) }
|
||||
}.toTypedArray(),
|
||||
"",
|
||||
"sig",
|
||||
)
|
||||
|
||||
private suspend fun verification(
|
||||
signer: NostrSignerInternal = cacheKey,
|
||||
forFinder: String = finder.pubKey,
|
||||
atCache: Address = cacheAddress,
|
||||
) = signer.sign(GeocacheVerificationEvent.build(forFinder, atCache, createdAt = 1_748_619_600L))
|
||||
|
||||
private fun foundLog(
|
||||
author: String,
|
||||
verification: GeocacheVerificationEvent?,
|
||||
cache: Address = cacheAddress,
|
||||
createdAt: Long = 1_748_619_700L,
|
||||
id: String = "1".repeat(64),
|
||||
) = GeocacheFoundLogEvent(
|
||||
id,
|
||||
author,
|
||||
createdAt,
|
||||
buildList {
|
||||
add(arrayOf("a", cache.toValue()))
|
||||
verification?.let { add(arrayOf("verification", it.toJson())) }
|
||||
}.toTypedArray(),
|
||||
"Found it!",
|
||||
"sig",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun theKindsAreRegisteredWithTheEventFactory() {
|
||||
assertTrue(EventFactory.isKnownKind(GeocacheVerificationEvent.KIND))
|
||||
assertTrue(EventFactory.isKnownKind(GeocacheFoundLogEvent.KIND))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theContentIsTheStaticStringTheSpecMandates() {
|
||||
val pubKey = "a".repeat(64)
|
||||
|
||||
assertEquals("Geocache verification for ${NPub.create(pubKey)}", GeocacheVerificationEvent.contentFor(pubKey))
|
||||
assertTrue(GeocacheVerificationEvent.contentFor(pubKey).startsWith("Geocache verification for npub1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theATagIsTheFinderAndAnNaddrJoinedByAColon() =
|
||||
runTest {
|
||||
val event = verification()
|
||||
val raw = event.tags.first { it[0] == "a" }[1]
|
||||
|
||||
assertEquals("${finder.pubKey}:", raw.substring(0, 65))
|
||||
assertTrue(raw.substring(65).startsWith("naddr1"))
|
||||
assertEquals(cacheAddress, NAddress.parse(raw.substring(65))?.address())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theCompositeATagIsNotSomethingAddressParseCanRead() =
|
||||
runTest {
|
||||
// The reason FinderCacheTag exists. A generic `a`-tag reader gets nothing from a 7517
|
||||
// (and logs a warning on the way out), so the package parses it itself.
|
||||
val raw = verification().tags.first { it[0] == "a" }[1]
|
||||
|
||||
assertNull(Address.parse(raw))
|
||||
assertNotNull(FinderCacheTag.parse(arrayOf("a", raw)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readingAlsoAcceptsThePlainAddressFormOfTheCacheHalf() {
|
||||
// Writing always emits the naddr NIP-CC specifies; reading tolerates `kind:pubkey:d`
|
||||
// because Address.parse handles both and the 64-hex finder keeps the two unambiguous.
|
||||
val tag = arrayOf("a", "${finder.pubKey}:${cacheAddress.toValue()}")
|
||||
val parsed = FinderCacheTag.parse(tag)
|
||||
|
||||
assertEquals(finder.pubKey, parsed?.finderPubKey)
|
||||
assertEquals(cacheAddress, parsed?.cache)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBareNip01AddressIsNotAFinderCachePair() {
|
||||
// `37516` on the left is not a 64-char pubkey, so there is no way to mistake one for the
|
||||
// other and no way to end up with a verification attributed to nobody.
|
||||
assertNull(FinderCacheTag.parse(arrayOf("a", cacheAddress.toValue())))
|
||||
assertNull(FinderCacheTag.parse(arrayOf("a", finder.pubKey)))
|
||||
assertNull(FinderCacheTag.parse(arrayOf("a", ":${cacheAddress.toValue()}")))
|
||||
assertNull(FinderCacheTag.parse(arrayOf("a", "${finder.pubKey}:not-an-address")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aWellFormedVerificationValidates() =
|
||||
runTest {
|
||||
assertNull(GeocacheVerificationValidator.validate(verification(), listing(), finder.pubKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aVerificationSignedByAnyOtherKeyIsRejected() =
|
||||
runTest {
|
||||
// Without the signer check, anyone signs their own "verification" and every cache in
|
||||
// the world is verified by whoever wants to claim it.
|
||||
val forged = verification(signer = someoneElsesCacheKey)
|
||||
|
||||
assertEquals(
|
||||
VerificationFailure.WRONG_SIGNER,
|
||||
GeocacheVerificationValidator.validate(forged, listing(), finder.pubKey),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aVerificationIssuedToSomeoneElseCannotBeReplayedIntoYourLog() =
|
||||
runTest {
|
||||
// This is the check that makes the proof about a person. Lift a real verification out
|
||||
// of another finder's log, paste it into yours, and without it you are verified.
|
||||
val theirs = verification(forFinder = otherFinder.pubKey)
|
||||
|
||||
assertEquals(
|
||||
VerificationFailure.FINDER_MISMATCH,
|
||||
GeocacheVerificationValidator.validate(theirs, listing(), finder.pubKey),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aVerificationEarnedAtAnotherCacheCannotBeReplayedHere() =
|
||||
runTest {
|
||||
val elsewhere = verification(atCache = otherCacheAddress)
|
||||
|
||||
assertEquals(
|
||||
VerificationFailure.CACHE_MISMATCH,
|
||||
GeocacheVerificationValidator.validate(elsewhere, listing(), finder.pubKey),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTamperedSignatureIsRejected() =
|
||||
runTest {
|
||||
val real = verification()
|
||||
val tampered =
|
||||
GeocacheVerificationEvent(real.id, real.pubKey, real.createdAt, real.tags, real.content, "0".repeat(128))
|
||||
|
||||
assertEquals(
|
||||
VerificationFailure.BAD_SIGNATURE,
|
||||
GeocacheVerificationValidator.validate(tampered, listing(), finder.pubKey),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRewrittenContentIsRejected() =
|
||||
runTest {
|
||||
val real = verification()
|
||||
val rewritten =
|
||||
GeocacheVerificationEvent(real.id, real.pubKey, real.createdAt, real.tags, "Geocache verification for someone", real.sig)
|
||||
|
||||
assertEquals(
|
||||
VerificationFailure.CONTENT_MISMATCH,
|
||||
GeocacheVerificationValidator.validate(rewritten, listing(), finder.pubKey),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCacheWithoutAVerificationKeyCannotVerifyAnything() =
|
||||
runTest {
|
||||
assertEquals(
|
||||
VerificationFailure.CACHE_HAS_NO_VERIFICATION_KEY,
|
||||
GeocacheVerificationValidator.validate(verification(), listing(verificationKey = null), finder.pubKey),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFoundLogValidatesAgainstItsOwnAuthor() =
|
||||
runTest {
|
||||
val log = foundLog(finder.pubKey, verification())
|
||||
|
||||
assertNull(GeocacheVerificationValidator.validate(log, listing()))
|
||||
assertTrue(GeocacheVerificationValidator.isValid(log, listing()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFoundLogCarryingSomeoneElsesVerificationDoesNotValidate() =
|
||||
runTest {
|
||||
// The same replay as above, entered through the log rather than the raw event: the
|
||||
// author is taken from the log, never from the verification's own `a` tag.
|
||||
val log = foundLog(otherFinder.pubKey, verification(forFinder = finder.pubKey))
|
||||
|
||||
assertEquals(VerificationFailure.FINDER_MISMATCH, GeocacheVerificationValidator.validate(log, listing()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLogWithNoVerificationSaysSoRatherThanFailingSomewhereElse() {
|
||||
val log = foundLog(finder.pubKey, null)
|
||||
|
||||
assertEquals(VerificationFailure.NO_EMBEDDED_VERIFICATION, GeocacheVerificationValidator.validate(log, listing()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aGarbageVerificationPayloadIsNullRatherThanAnException() {
|
||||
// The payload is whatever a stranger put in a public event. A parse failure that threw
|
||||
// would take the feed rendering the log down with it.
|
||||
listOf("", "not json", "{", "[1,2,3]", """{"kind":"seven"}""").forEach { payload ->
|
||||
val log =
|
||||
GeocacheFoundLogEvent(
|
||||
"1".repeat(64),
|
||||
finder.pubKey,
|
||||
1L,
|
||||
arrayOf(arrayOf("a", cacheAddress.toValue()), arrayOf("verification", payload)),
|
||||
"Found it!",
|
||||
"sig",
|
||||
)
|
||||
|
||||
assertNull(log.embeddedVerification(), "payload <$payload> should not parse")
|
||||
assertEquals(VerificationFailure.NO_EMBEDDED_VERIFICATION, GeocacheVerificationValidator.validate(log, listing()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aVerificationOfAnotherKindEmbeddedInALogIsNotAVerification() =
|
||||
runTest {
|
||||
// Well-formed JSON for a real event of the wrong kind must not be read as a 7517.
|
||||
val notAVerification = finder.sign(GeocacheFoundLogEvent.build("hi", cacheAddress))
|
||||
val log =
|
||||
GeocacheFoundLogEvent(
|
||||
"1".repeat(64),
|
||||
finder.pubKey,
|
||||
1L,
|
||||
arrayOf(arrayOf("a", cacheAddress.toValue()), arrayOf("verification", notAVerification.toJson())),
|
||||
"Found it!",
|
||||
"sig",
|
||||
)
|
||||
|
||||
assertNull(log.embeddedVerification())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEmbeddedVerificationSurvivesTheJsonRoundTrip() =
|
||||
runTest {
|
||||
val original = verification()
|
||||
val template = GeocacheFoundLogEvent.build("Found it!", cacheAddress, verification = original)
|
||||
val log = finder.sign(template)
|
||||
val reparsed = Event.fromJson(log.toJson()) as GeocacheFoundLogEvent
|
||||
|
||||
assertEquals(original.id, reparsed.embeddedVerification()?.id)
|
||||
assertNull(GeocacheVerificationValidator.validate(reparsed, listing()))
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@
|
||||
5302
|
||||
5303
|
||||
6969 The content body.
|
||||
7516 The content body.
|
||||
8333 The content body.
|
||||
9002 The Name\nThe About\nhashtag1\nhashtag2
|
||||
9041 The Summary\nThe content body.
|
||||
@@ -128,6 +129,8 @@
|
||||
35128 The Title\nThe Description
|
||||
35129 The Title\nThe Description
|
||||
36787 The Title\nThe content body.
|
||||
37516 The Name\nThe content body.
|
||||
37517 The Title\nThe Description\nThe content body.
|
||||
38000 The content body.
|
||||
38192 The Alt\nThe Title
|
||||
38383 The Name
|
||||
|
||||
Reference in New Issue
Block a user