- Adds support for creating and replying to NIP-22 geo scope posts

- Adds support for creating and replying to NIP-22 hashtag scope posts
- Refactors NIP-73 and NIP-22 on quartz to better support scoped replies
- Creates New post screens for both hashtags and geolocated posts.
This commit is contained in:
Vitor Pamplona
2025-05-20 11:54:21 -04:00
parent 041aa1c461
commit 63340d79e2
62 changed files with 3659 additions and 220 deletions
@@ -0,0 +1,95 @@
/**
* 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.nip22Comments
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.verify
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class GeolocatedComments {
private val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray()
private val signer = NostrSignerSync(KeyPair(privateKey))
val event =
CommentEvent(
"fecb2ecf61a1433d417a784d10bd1e8ec19a916170a53ca8fb3a15fc666a6592",
"f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
1747753115,
arrayOf(
arrayOf("alt", "Reply to geo:drt3n"),
arrayOf("I", "geo:drt3n"),
arrayOf("I", "geo:drt3"),
arrayOf("I", "geo:drt"),
arrayOf("I", "geo:dr"),
arrayOf("I", "geo:d"),
arrayOf("K", "geo"),
arrayOf("i", "geo:drt3n"),
arrayOf("i", "geo:drt3"),
arrayOf("i", "geo:drt"),
arrayOf("i", "geo:dr"),
arrayOf("i", "geo:d"),
arrayOf("k", "geo"),
),
"testing",
"12070e663272f1227c639fb834eb2122fc7bb995f4c49e55ebb1dfe2135ef7347d44810bacd2e64fd26b8826fd47d2800ce6c3d3b579bb3afe39088ffd4faa60",
)
@Test
fun verifyEvent() {
assertTrue(event.verify())
}
@Test
fun testScopes() {
assertEquals("drt3n", event.geohashedScope())
assertTrue(event.isTaggedScope("drt3n", GeohashId::match))
assertTrue(event.isTaggedScope(GeohashId.toScope("drt3n")))
assertFalse(event.isTaggedScope("drt3n"))
}
@Test
fun testCreation() {
val event =
signer.sign(
CommentEvent.replyExternalIdentity(
"Message",
GeohashId("drt3n"),
),
)
assertEquals("drt3n", event!!.geohashedScope())
assertTrue(event.isTaggedScope(GeohashId.toScope("drt3n")))
assertFalse(event.isTaggedScope("drt3n"))
assertTrue(event.hasScopeKind(GeohashId.KIND))
assertTrue(event.hasRootScopeKind(GeohashId.KIND))
assertTrue(event.hasReplyScopeKind(GeohashId.KIND))
}
}
@@ -22,38 +22,21 @@ package com.vitorpamplona.quartz.nip01Core.tags.geohash
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip73ExternalIds.location.geohashedScope
fun Event.hasGeohashes() =
if (this is CommentEvent) {
this.hasGeohashes()
} else {
tags.hasGeohashes()
}
fun Event.hasGeohashes() = tags.hasGeohashes()
fun Event.isTaggedGeoHashes(hashtags: Set<String>) =
if (this is CommentEvent) {
this.isTaggedGeoHashes(hashtags)
} else {
tags.isTaggedGeoHashes(hashtags)
}
fun Event.isTaggedGeoHashes(hashtags: Set<String>) = tags.isTaggedGeoHashes(hashtags)
fun Event.isTaggedGeoHash(hashtag: String) =
if (this is CommentEvent) {
this.isTaggedGeoHash(hashtag)
} else {
tags.isTaggedGeoHash(hashtag)
}
fun Event.isTaggedGeoHash(hashtag: String) = tags.isTaggedGeoHash(hashtag)
fun Event.geohashes() =
if (this is CommentEvent) {
geohashes()
} else {
tags.geohashes()
}
fun Event.geohashes() = tags.geohashes()
fun Event.getGeoHash(): String? =
fun Event.getGeoHash(): String? = tags.getGeoHash()
fun Event.geoHashOrScope() =
if (this is CommentEvent) {
getGeoHash()
geohashedScope()
} else {
tags.getGeoHash()
}
@@ -21,9 +21,6 @@
package com.vitorpamplona.quartz.nip01Core.tags.hashtags
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
fun Event.forEachHashTag(onEach: (eventId: HexKey) -> Unit) = tags.forEachHashTag(onEach)
fun Event.anyHashTag(onEach: (str: String) -> Boolean) = tags.anyHashTag(onEach)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.tags.kinds
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
@@ -27,7 +28,7 @@ class KindTag {
companion object {
const val TAG_NAME = "k"
fun match(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
fun isTagged(
tag: Array<String>,
@@ -90,19 +90,29 @@ class CommentEvent(
fun directKinds() = tags.filter(ReplyKindTag::match)
fun isGeohashTag(tag: Array<String>) = tag.size > 1 && (tag[0] == "i" || tag[0] == "I") && tag[1].startsWith("geo:")
/** root and reply scope search */
fun isTaggedScope(scopeId: String) = tags.any { RootIdentifierTag.isTagged(it, scopeId) || ReplyIdentifierTag.isTagged(it, scopeId) }
private fun getGeoHashList() = tags.filter { isGeohashTag(it) }
fun isTaggedScopes(scopeIds: Set<String>) = tags.any { RootIdentifierTag.isTagged(it, scopeIds) || ReplyIdentifierTag.isTagged(it, scopeIds) }
fun hasGeohashes() = tags.any { isGeohashTag(it) }
fun isTaggedScope(
value: String,
match: (String, String) -> Boolean,
) = tags.any { RootIdentifierTag.isTagged(it, value, match) || ReplyIdentifierTag.isTagged(it, value, match) }
fun geohashes() = getGeoHashList().map { it[1].drop(4).lowercase() }
fun firstTaggedScopeIn(scopeIds: Set<String>) = tags.firstNotNullOfOrNull { RootIdentifierTag.matchOrNull(it, scopeIds) ?: ReplyIdentifierTag.matchOrNull(it, scopeIds) }
fun getGeoHash(): String? = geohashes().maxByOrNull { it.length }
fun isScoped(scopeTest: (String) -> Boolean) = tags.any { RootIdentifierTag.isTagged(it, scopeTest) || ReplyIdentifierTag.isTagged(it, scopeTest) }
fun isTaggedGeoHash(hashtag: String) = tags.any { isGeohashTag(it) && it[1].endsWith(hashtag, true) }
fun hasRootScopeKind(kind: String) = tags.any { RootKindTag.isKind(it, kind) }
fun isTaggedGeoHashes(hashtags: Set<String>) = geohashes().any { it in hashtags }
fun hasReplyScopeKind(kind: String) = tags.any { ReplyKindTag.isKind(it, kind) }
fun hasScopeKind(kind: String) = tags.any { RootKindTag.isKind(it, kind) || ReplyKindTag.isKind(it, kind) }
fun scopeValues(parser: (String) -> String?) = tags.mapNotNull { RootIdentifierTag.parse(it)?.let { parser(it) } }
fun firstScopeValue(parser: (String) -> String?) = tags.firstNotNullOfOrNull { RootIdentifierTag.parse(it)?.let { parser(it) } }
override fun markedReplyTos(): List<HexKey> =
tags.mapNotNull(ReplyEventTag::parseKey) +
@@ -78,7 +78,7 @@ fun TagArrayBuilder<CommentEvent>.replyKind(kind: String) = addUnique(ReplyKindT
fun TagArrayBuilder<CommentEvent>.replyKind(kind: Int) = addUnique(ReplyKindTag.assemble(kind))
fun TagArrayBuilder<CommentEvent>.replyKind(id: ExternalId) = addUnique(RootKindTag.assemble(id))
fun TagArrayBuilder<CommentEvent>.replyKind(id: ExternalId) = addUnique(ReplyKindTag.assemble(id))
fun TagArrayBuilder<CommentEvent>.replyAuthor(
pubKey: HexKey,
@@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.nip73ExternalIds.GeohashId
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
@@ -37,6 +37,43 @@ class ReplyIdentifierTag {
@JvmStatic
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
@JvmStatic
fun isTagged(
tag: Array<String>,
encodedScope: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == encodedScope
fun isTagged(
tag: Array<String>,
encodedScope: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in encodedScope
fun matchOrNull(
tag: Array<String>,
encodedScope: Set<String>,
) = if (tag.has(1) && tag[0] == RootIdentifierTag.Companion.TAG_NAME && tag[1] in encodedScope) {
tag[1]
} else {
null
}
fun isTagged(
tag: Array<String>,
test: (String) -> Boolean,
) = tag.has(1) && tag[0] == TAG_NAME && test(tag[1])
fun isTagged(
tag: Array<String>,
value: String,
match: (String, String) -> Boolean,
) = tag.has(1) && tag[0] == TAG_NAME && match(tag[1], value)
fun isTagged(
tag: Array<String>,
value: Set<String>,
match: (String, Set<String>) -> Boolean,
) = tag.has(1) && tag[0] == TAG_NAME && match(tag[1], value)
@JvmStatic
fun parse(tag: Tag): String? {
ensure(tag.has(1)) { return null }
@@ -54,7 +91,7 @@ class ReplyIdentifierTag {
@JvmStatic
fun assemble(id: ExternalId): List<Array<String>> =
when (id) {
is GeohashId -> GeoHashTag.geoMipMap(id.geohash).map { assemble(it, id.hint) }
is GeohashId -> GeoHashTag.geoMipMap(id.geohash).map { assemble(GeohashId.toScope(it), id.hint) }
else -> listOf(assemble(id.toScope(), id.hint()))
}
}
@@ -32,6 +32,12 @@ class ReplyKindTag {
@JvmStatic
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
@JvmStatic
fun isKind(
tag: Tag,
kind: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == kind
@JvmStatic
fun isTagged(
tag: Tag,
@@ -59,7 +65,7 @@ class ReplyKindTag {
fun assemble(kind: Int) = arrayOf(TAG_NAME, kind.toString())
@JvmStatic
fun assemble(id: ExternalId) = RootKindTag.assemble(id.toKind())
fun assemble(id: ExternalId) = assemble(id.toKind())
@JvmStatic
fun assemble(kinds: List<String>): List<Tag> = kinds.map { assemble(it) }
@@ -25,18 +25,54 @@ import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.nip73ExternalIds.GeohashId
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
@Immutable
class RootIdentifierTag {
class RootIdentifierTag<T : ExternalId> {
companion object {
const val TAG_NAME = "I"
@JvmStatic
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun isTagged(
tag: Array<String>,
encodedScope: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == encodedScope
fun isTagged(
tag: Array<String>,
encodedScope: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in encodedScope
fun matchOrNull(
tag: Array<String>,
encodedScope: Set<String>,
) = if (tag.has(1) && tag[0] == TAG_NAME && tag[1] in encodedScope) {
tag[1]
} else {
null
}
fun isTagged(
tag: Array<String>,
test: (String) -> Boolean,
) = tag.has(1) && tag[0] == TAG_NAME && test(tag[1])
fun isTagged(
tag: Array<String>,
value: String,
match: (String, String) -> Boolean,
) = tag.has(1) && tag[0] == TAG_NAME && match(tag[1], value)
fun isTagged(
tag: Array<String>,
value: Set<String>,
match: (String, Set<String>) -> Boolean,
) = tag.has(1) && tag[0] == TAG_NAME && match(tag[1], value)
@JvmStatic
fun parse(tag: Tag): String? {
ensure(tag.has(1)) { return null }
@@ -32,6 +32,12 @@ class RootKindTag {
@JvmStatic
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
@JvmStatic
fun isKind(
tag: Tag,
kind: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == kind
@JvmStatic
fun parse(tag: Tag): String? {
ensure(tag.has(1)) { return null }
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip70ProtectedEvts.tags
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
@@ -28,14 +29,14 @@ class ProtectedTag {
const val TAG_NAME = "-"
@JvmStatic
fun match(tag: Array<String>): Boolean {
fun match(tag: Tag): Boolean {
ensure(tag.has(0)) { return false }
ensure(tag[0] == TAG_NAME) { return false }
return true
}
@JvmStatic
fun parse(tag: Array<String>): Boolean? {
fun parse(tag: Tag): Boolean? {
ensure(tag.has(0)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return true
@@ -18,7 +18,10 @@
* 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.nip73ExternalIds
package com.vitorpamplona.quartz.nip73ExternalIds.books
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
class BookId(
val isbn: String,
@@ -31,9 +34,25 @@ class BookId(
override fun hint() = hint
companion object {
// "isbn:9780765382030"
fun toScope(isbn: String) = "isbn:" + isbn.lowercase().replace("-", "")
const val KIND = "isbn"
const val PREFIX_COLON = "isbn:"
fun toKind(isbn: String) = "isbn"
// "isbn:9780765382030"
fun toScope(isbn: String) = PREFIX_COLON + isbn.lowercase().replace("-", "")
fun toKind(isbn: String) = KIND
fun match(
encoded: String,
value: String,
): Boolean {
ensure(encoded.startsWith(PREFIX_COLON)) { return false }
return encoded.indexOf(value, PREFIX_COLON.length) > 0
}
fun parse(encoded: String): String? {
ensure(encoded.startsWith(PREFIX_COLON)) { return null }
return encoded.substring(PREFIX_COLON.length)
}
}
}
@@ -18,21 +18,12 @@
* 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.nip73ExternalIds
package com.vitorpamplona.quartz.nip73ExternalIds.books
class GeohashId(
val geohash: String,
val hint: String? = null,
) : ExternalId {
override fun toScope() = toScope(geohash)
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
override fun toKind() = toKind(geohash)
fun CommentEvent.isBookScoped() = hasScopeKind(BookId.KIND)
override fun hint() = hint
fun CommentEvent.bookScope() = firstScopeValue(BookId::parse)
companion object {
fun toScope(geohash: String) = "geo:" + geohash.lowercase()
fun toKind(geohash: String) = "geo"
}
}
fun CommentEvent.bookScopes() = scopeValues(BookId::parse)
@@ -0,0 +1,27 @@
/**
* 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.nip73ExternalIds.location
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
fun CommentEvent.isGeohashedScoped() = hasScopeKind(GeohashId.KIND)
fun CommentEvent.geohashedScope() = scopeValues(GeohashId::parse).maxByOrNull { it.length }
@@ -18,24 +18,40 @@
* 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.nip73ExternalIds
package com.vitorpamplona.quartz.nip73ExternalIds.location
import kotlin.math.min
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
class MovieId(
val isan: String,
class GeohashId(
val geohash: String,
val hint: String? = null,
) : ExternalId {
override fun toScope() = toScope(isan)
override fun toScope() = toScope(geohash)
override fun toKind() = toKind(isan)
override fun toKind() = toKind(geohash)
override fun hint() = hint
companion object {
// "isan:0000-0000-401A-0000-7"
fun toScope(isan: String) = "isan:" + isan.lowercase().substring(0, min(21, isan.length))
const val KIND = "geo"
const val PREFIX_COLON = "geo:"
fun toKind(isan: String) = "isan"
fun toScope(geohash: String) = PREFIX_COLON + geohash.lowercase()
fun toKind(geohash: String) = KIND
fun match(
encoded: String,
value: String,
): Boolean {
ensure(encoded.startsWith(PREFIX_COLON)) { return false }
return encoded.indexOf(value, PREFIX_COLON.length) > 0
}
fun parse(encoded: String): String? {
ensure(encoded.startsWith(PREFIX_COLON)) { return null }
return encoded.substring(PREFIX_COLON.length)
}
}
}
@@ -18,22 +18,12 @@
* 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.nip73ExternalIds
package com.vitorpamplona.quartz.nip73ExternalIds.movies
class PaperId(
val doi: String,
val hint: String? = null,
) : ExternalId {
override fun toScope() = toScope(doi)
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
override fun toKind() = toKind(doi)
fun CommentEvent.isMovieScoped() = hasScopeKind(MovieId.KIND)
override fun hint() = hint
fun CommentEvent.movieScope() = firstScopeValue(MovieId::parse)
companion object {
// "doi:10.1000/182"
fun toScope(doi: String) = "doi:" + doi.lowercase()
fun toKind(doi: String) = "doi"
}
}
fun CommentEvent.movieScopes() = scopeValues(MovieId::parse)
@@ -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.nip73ExternalIds.movies
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
import kotlin.math.min
/**
* 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.
*/
class MovieId(
val isan: String,
val hint: String? = null,
) : ExternalId {
override fun toScope() = toScope(isan)
override fun toKind() = toKind(isan)
override fun hint() = hint
companion object {
const val KIND = "isan"
const val PREFIX_COLON = "isan:"
// "isan:0000-0000-401A-0000-7"
fun toScope(isan: String) =
PREFIX_COLON +
isan.lowercase().substring(
0,
min(21, isan.length),
)
fun toKind(isan: String) = KIND
fun match(
encoded: String,
value: String,
): Boolean {
ensure(encoded.startsWith(PREFIX_COLON)) { return false }
return encoded.indexOf(value, PREFIX_COLON.length) > 0
}
fun parse(encoded: String): String? {
ensure(encoded.startsWith(PREFIX_COLON)) { return null }
return encoded.substring(PREFIX_COLON.length)
}
}
}
@@ -18,22 +18,12 @@
* 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.nip73ExternalIds
package com.vitorpamplona.quartz.nip73ExternalIds.papers
class PodcastFeedId(
val guid: String,
val hint: String? = null,
) : ExternalId {
override fun toScope() = toScope(guid)
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
override fun toKind() = toKind(guid)
fun CommentEvent.isPaperScoped() = hasScopeKind(PaperId.KIND)
override fun hint() = hint
fun CommentEvent.paperScope() = firstScopeValue(PaperId::parse)
companion object {
// "isbn:9780765382030"
fun toScope(guid: String) = "podcast:guid:" + guid
fun toKind(guid: String) = "podcast:guid"
}
}
fun CommentEvent.paperScopes() = scopeValues(PaperId::parse)
@@ -0,0 +1,58 @@
/**
* 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.nip73ExternalIds.papers
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
class PaperId(
val doi: String,
val hint: String? = null,
) : ExternalId {
override fun toScope() = toScope(doi)
override fun toKind() = toKind(doi)
override fun hint() = hint
companion object {
const val KIND = "doi"
const val PREFIX_COLON = "doi:"
// "doi:10.1000/182"
fun toScope(doi: String) = PREFIX_COLON + doi.lowercase()
fun toKind(doi: String) = KIND
fun match(
encoded: String,
value: String,
): Boolean {
ensure(encoded.startsWith(PREFIX_COLON)) { return false }
return encoded.indexOf(value, PREFIX_COLON.length) > 0
}
fun parse(encoded: String): String? {
ensure(encoded.startsWith(PREFIX_COLON)) { return null }
return encoded.substring(PREFIX_COLON.length)
}
}
}
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip73ExternalIds.podcasts
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
fun CommentEvent.isPodcastEpisodeScoped() = hasScopeKind(PodcastEpisodeId.KIND)
fun CommentEvent.podcastEpisodeScope() = firstScopeValue(PodcastEpisodeId::parse)
fun CommentEvent.podcastEpisodeScopes() = scopeValues(PodcastEpisodeId::parse)
fun CommentEvent.isPodcastFeedScoped() = hasScopeKind(PodcastFeedId.KIND)
fun CommentEvent.podcastFeedScope() = firstScopeValue(PodcastFeedId::parse)
fun CommentEvent.podcastFeedScopes() = scopeValues(PodcastFeedId::parse)
fun CommentEvent.isPodcastPublisherScoped() = hasScopeKind(PodcastPublisherId.KIND)
fun CommentEvent.podcastPublisherScope() = firstScopeValue(PodcastPublisherId::parse)
fun CommentEvent.podcastPublisherScopes() = scopeValues(PodcastPublisherId::parse)
@@ -18,7 +18,10 @@
* 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.nip73ExternalIds
package com.vitorpamplona.quartz.nip73ExternalIds.podcasts
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
class PodcastEpisodeId(
val guid: String,
@@ -31,9 +34,24 @@ class PodcastEpisodeId(
override fun hint() = hint
companion object {
// "isbn:9780765382030"
fun toScope(guid: String) = "podcast:item:guid:" + guid
const val KIND = "podcast:item:guid:guid"
const val PREFIX_COLON = "podcast:item:guid:"
fun toKind(guid: String) = "podcast:item:guid"
fun toScope(guid: String) = PREFIX_COLON + guid
fun toKind(guid: String) = KIND
fun match(
encoded: String,
value: String,
): Boolean {
ensure(encoded.startsWith(PREFIX_COLON)) { return false }
return encoded.indexOf(value, PREFIX_COLON.length) > 0
}
fun parse(encoded: String): String? {
ensure(encoded.startsWith(PREFIX_COLON)) { return null }
return encoded.substring(PREFIX_COLON.length)
}
}
}
@@ -0,0 +1,58 @@
/**
* 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.nip73ExternalIds.podcasts
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
class PodcastFeedId(
val guid: String,
val hint: String? = null,
) : ExternalId {
override fun toScope() = toScope(guid)
override fun toKind() = toKind(guid)
override fun hint() = hint
companion object {
const val KIND = "podcast:guid"
const val PREFIX_COLON = "podcast:guid:"
// "isbn:9780765382030"
fun toScope(guid: String) = PREFIX_COLON + guid
fun toKind(guid: String) = KIND
fun match(
encoded: String,
value: String,
): Boolean {
ensure(encoded.startsWith(PREFIX_COLON)) { return false }
return encoded.indexOf(value, PREFIX_COLON.length) > 0
}
fun parse(encoded: String): String? {
ensure(encoded.startsWith(PREFIX_COLON)) { return null }
return encoded.substring(PREFIX_COLON.length)
}
}
}
@@ -18,7 +18,10 @@
* 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.nip73ExternalIds
package com.vitorpamplona.quartz.nip73ExternalIds.podcasts
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
class PodcastPublisherId(
val guid: String,
@@ -31,9 +34,25 @@ class PodcastPublisherId(
override fun hint() = hint
companion object {
// "isbn:9780765382030"
fun toScope(guid: String) = "podcast:publisher:guid:" + guid
const val KIND = "podcast:publisher:guid"
const val PREFIX_COLON = "podcast:publisher:guid:"
fun toKind(guid: String) = "podcast:publisher:guid"
// "isbn:9780765382030"
fun toScope(guid: String) = PREFIX_COLON + guid
fun toKind(guid: String) = KIND
fun match(
encoded: String,
value: String,
): Boolean {
ensure(encoded.startsWith(PREFIX_COLON)) { return false }
return encoded.indexOf(value, PREFIX_COLON.length) > 0
}
fun parse(encoded: String): String? {
ensure(encoded.startsWith(PREFIX_COLON)) { return null }
return encoded.substring(PREFIX_COLON.length)
}
}
}
@@ -18,21 +18,12 @@
* 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.nip73ExternalIds
package com.vitorpamplona.quartz.nip73ExternalIds.topics
class HashtagId(
val topic: String,
val hint: String? = null,
) : ExternalId {
override fun toScope() = toScope(topic)
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
override fun toKind() = toKind(topic)
fun CommentEvent.isHashtagScoped() = hasScopeKind(HashtagId.KIND)
override fun hint() = hint
fun CommentEvent.hashtagScope() = firstScopeValue(HashtagId::parse)
companion object {
fun toScope(topic: String) = "#" + topic.lowercase()
fun toKind(topic: String) = "#"
}
}
fun CommentEvent.hashtagScopes() = scopeValues(HashtagId::parse)
@@ -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.nip73ExternalIds.topics
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
/**
* 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.
*/
class HashtagId(
val topic: String,
val hint: String? = null,
) : ExternalId {
override fun toScope() = toScope(topic)
override fun toKind() = toKind(topic)
override fun hint() = hint
companion object {
const val KIND = "#"
const val KIND_CHR = '#'
fun toScope(topic: String) = KIND + topic.lowercase()
fun toKind(topic: String) = KIND
fun match(
encoded: String,
value: String,
): Boolean {
ensure(encoded.length > 1) { return false }
ensure(encoded[0] == KIND_CHR) { return false }
return encoded.indexOf(value, 1) > 0
}
fun parse(encoded: String): String? {
return if (encoded.length > 1 && encoded[0] == KIND_CHR) {
encoded.substring(1)
} else {
null
}
}
}
}
@@ -0,0 +1,29 @@
/**
* 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.nip73ExternalIds.urls
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
fun CommentEvent.isUrlScoped() = hasScopeKind(UrlId.KIND)
fun CommentEvent.urlScope() = firstScopeValue(UrlId::parse)
fun CommentEvent.urlScopes() = scopeValues(UrlId::parse)
@@ -18,8 +18,10 @@
* 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.nip73ExternalIds
package com.vitorpamplona.quartz.nip73ExternalIds.urls
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
import com.vitorpamplona.quartz.utils.toStringNoFragment
import org.czeal.rfc3986.URIReference
@@ -34,8 +36,25 @@ class UrlId(
override fun hint() = hint
companion object {
const val KIND = "web"
const val PREFIX1 = "https://"
const val PREFIX2 = "http://"
fun toScope(url: String) = URIReference.parse(url).normalize().toStringNoFragment()
fun toKind(url: String) = "web"
fun toKind(url: String) = KIND
fun match(
encoded: String,
value: String,
): Boolean {
ensure(encoded.startsWith(PREFIX1) || encoded.startsWith(PREFIX2)) { return false }
return encoded == value
}
fun parse(encoded: String): String? {
ensure(encoded.startsWith(PREFIX1) || encoded.startsWith(PREFIX2)) { return null }
return encoded
}
}
}