Merge remote-tracking branch 'origin/main' into claude/nip88-polls-quartz-p5fBa

This commit is contained in:
Claude
2026-05-16 19:15:32 +00:00
239 changed files with 10674 additions and 1496 deletions
+1 -1
View File
@@ -370,7 +370,7 @@ mavenPublishing {
coordinates(
groupId = "com.vitorpamplona.quartz",
artifactId = "quartz",
version = "1.08.0",
version = "1.09.2",
)
// Configure publishing to Maven Central
@@ -26,6 +26,8 @@ fun Event.anyHashTag(onEach: (str: String) -> Boolean) = tags.anyHashTag(onEach)
fun Event.countHashtags() = tags.countHashtags()
fun Event.hasMoreHashtagsThan(limit: Int) = tags.hasMoreHashtagsThan(limit)
fun Event.hasHashtags() = tags.hasHashtags()
fun Event.hashtags() = tags.hashtags()
@@ -46,6 +46,13 @@ class HashtagTag {
return tag[1]
}
fun parseLowercase(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].lowercase()
}
fun assemble(name: String) = arrayOf(TAG_NAME, name)
fun assembleDualCase(name: String): List<Array<String>> {
@@ -38,8 +38,29 @@ fun TagArray.hashtags() = this.mapNotNull(HashtagTag::parse)
fun TagArray.countHashtags() = this.count(HashtagTag::isTagged)
fun TagArray.hasMoreHashtagsThan(limit: Int): Boolean {
val count = this.count(HashtagTag::isTagged)
return count > limit && this.countUnique(count, HashtagTag::parseLowercase) > limit
}
fun TagArray.isTaggedHash(hashtag: String) = this.isTagged(HashtagTag.TAG_NAME, hashtag, true)
fun TagArray.isTaggedHashes(hashtags: Set<String>) = this.isAnyLowercaseTagged(HashtagTag.TAG_NAME, hashtags)
fun TagArray.firstIsTaggedHashes(hashtags: Set<String>) = this.firstAnyLowercaseTaggedValue(HashtagTag.TAG_NAME, hashtags)
public inline fun <T, U> Array<out T>.countUnique(
size: Int,
transform: (T) -> U?,
): Int {
// Pre-allocate hash set to avoid frequent resizing for larger arrays
val seen = HashSet<U>(size)
var count = 0
for (element in this) {
val value = transform(element)
if (value != null && seen.add(value)) {
++count
}
}
return count
}
@@ -47,14 +47,26 @@ data class Nip05Id(
fun toDomainUrl(): String = domainUrl(domain)
companion object {
// NIP-05 localpart: dot-separated atoms of [a-z0-9_-]. Forbids leading,
// trailing, and consecutive dots (NIP-05 + RFC 5321 local-part rules).
private val LOCAL_PART_REGEX = Regex("^[a-z0-9_-]+(\\.[a-z0-9_-]+)*$")
// Hostname: dot-separated labels of [a-z0-9-], no leading/trailing hyphen,
// require at least one dot so single-label garbage (e.g. "s!ayer") is rejected.
private val DOMAIN_REGEX =
Regex("^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$")
fun parse(nip05address: String): Nip05Id? {
val parts = nip05address.trim().lowercase().split("@")
return when (parts.size) {
2 -> Nip05Id(parts[0], parts[1])
1 -> Nip05Id(parts[0], "_")
else -> null
}
if (parts.size != 2) return null
val name = parts[0]
val domain = parts[1]
if (!LOCAL_PART_REGEX.matches(name)) return null
if (!DOMAIN_REGEX.matches(domain)) return null
// Reject IP literals — NIP-05 expects a hostname, and a digits-only
// TLD is the cheapest way to tell them apart from real domains.
if (domain.substringAfterLast('.').all { it.isDigit() }) return null
return Nip05Id(name, domain)
}
fun assemble(
@@ -181,6 +181,9 @@ class CommentEvent(
fun isScoped(scopeTest: (String) -> Boolean) = tags.any { RootIdentifierTag.isTagged(it, scopeTest) || ReplyIdentifierTag.isTagged(it, scopeTest) }
/** True when the comment points at an external identifier (`I` tag), e.g. a hashtag, geohash or url. */
fun hasRootScopeIdentifier() = tags.any { RootIdentifierTag.match(it) }
fun hasRootScopeKind(kind: String) = tags.any(RootKindTag::isKind, kind)
fun hasReplyScopeKind(kind: String) = tags.any(ReplyKindTag::isKind, kind)
@@ -94,6 +94,66 @@ class Nip05Test {
assertNull(parsedNip05)
}
@Test
fun `parse rejects empty localpart`() {
assertNull(Nip05Id.parse("@example.com"))
}
@Test
fun `parse rejects empty domain`() {
assertNull(Nip05Id.parse("alice@"))
}
@Test
fun `parse rejects domain without a dot`() {
assertNull(Nip05Id.parse("alice@localhost"))
}
@Test
fun `parse rejects domain with illegal character`() {
assertNull(Nip05Id.parse("_@s!ayer"))
assertNull(Nip05Id.parse("@s!ayer"))
}
@Test
fun `parse rejects localpart with illegal character`() {
assertNull(Nip05Id.parse("al!ce@example.com"))
assertNull(Nip05Id.parse("alice space@example.com"))
}
@Test
fun `parse rejects bare string without at-sign`() {
assertNull(Nip05Id.parse("alice"))
assertNull(Nip05Id.parse("example.com"))
}
@Test
fun `parse rejects domain label with leading or trailing hyphen`() {
assertNull(Nip05Id.parse("alice@-example.com"))
assertNull(Nip05Id.parse("alice@example-.com"))
}
@Test
fun `parse rejects localpart with leading trailing or consecutive dots`() {
assertNull(Nip05Id.parse(".alice@example.com"))
assertNull(Nip05Id.parse("alice.@example.com"))
assertNull(Nip05Id.parse("alice..bob@example.com"))
}
@Test
fun `parse rejects IPv4 literal as domain`() {
assertNull(Nip05Id.parse("alice@192.168.1.1"))
assertNull(Nip05Id.parse("alice@8.8.8.8"))
}
@Test
fun `parse accepts wildcard underscore localpart`() {
val nip05 = Nip05Id.parse("_@example.com")
assertNotNull(nip05)
assertEquals("_", nip05.name)
assertEquals("example.com", nip05.domain)
}
@Test
fun `execute assemble url with valid value returns nip05 url`() {
// given
@@ -21,7 +21,6 @@
package com.vitorpamplona.quartz.nip06KeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlin.test.Ignore
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -57,11 +56,4 @@ class Nip06CommonTest {
val privateKeyHex21 = nip06.privateKeyFromMnemonic(menemonic1, 42).toHexKey()
assertEquals("ad993054383da74e955f8b86346365b5ffd6575992e1de3738dda9f94407052b", privateKeyHex21)
}
@Test
@Ignore()
fun fromSeedNip06FromSnort() {
val privateKeyNsec = nip06.privateKeyFromMnemonic(snortTest).toHexKey()
assertEquals("nsec1ppw9ltr2x9qwg9a2qnmgv98tfruy2ejnja7me76mwmsreu3s8u2sscj5nt", privateKeyNsec)
}
}