Merge branch 'main' into chess-enhancements-and-bug-fixes
This commit is contained in:
+19
@@ -72,6 +72,15 @@ class TagArrayBuilder<T : IEvent> {
|
||||
return this
|
||||
}
|
||||
|
||||
fun addUniqueValueIfNew(tag: Array<String>): TagArrayBuilder<T> {
|
||||
if (tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this
|
||||
val list = tagList.getOrPut(tag[0], ::mutableListOf)
|
||||
if (list.none { it.valueOrNull() == tag[1] }) {
|
||||
list.add(tag)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun addAll(tag: List<Array<String>>): TagArrayBuilder<T> {
|
||||
tag.forEach(::add)
|
||||
return this
|
||||
@@ -82,6 +91,16 @@ class TagArrayBuilder<T : IEvent> {
|
||||
return this
|
||||
}
|
||||
|
||||
fun addAllUnique(tag: Array<Array<String>>): TagArrayBuilder<T> {
|
||||
tag.forEach(::addUnique)
|
||||
return this
|
||||
}
|
||||
|
||||
fun addAllUniqueValueIfNew(tag: List<Array<String>>): TagArrayBuilder<T> {
|
||||
tag.forEach(::addUniqueValueIfNew)
|
||||
return this
|
||||
}
|
||||
|
||||
fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray()
|
||||
|
||||
fun build() = toTypedArray()
|
||||
|
||||
+7
-1
@@ -52,6 +52,12 @@ class ReferenceTag {
|
||||
|
||||
fun assemble(url: String) = arrayOf(TAG_NAME, HttpUrlFormatter.normalize(url))
|
||||
|
||||
fun assemble(urls: List<String>): List<Array<String>> = urls.mapTo(HashSet()) { HttpUrlFormatter.normalize(it) }.map { arrayOf(TAG_NAME, it) }
|
||||
fun assemble(urls: List<String>): List<Array<String>> =
|
||||
urls
|
||||
.mapTo(HashSet()) {
|
||||
HttpUrlFormatter.normalize(it)
|
||||
}.map {
|
||||
arrayOf(TAG_NAME, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,15 +20,25 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip10Notes.content
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter
|
||||
import com.vitorpamplona.quartz.utils.fastFindURLs
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import com.vitorpamplona.quartz.utils.startsWithAny
|
||||
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
|
||||
|
||||
fun findURLs(text: String) = fastFindURLs(text)
|
||||
val rejectSchemes =
|
||||
listOf(
|
||||
DualCase("ftp:"),
|
||||
DualCase("ftps:"),
|
||||
DualCase("ws:"),
|
||||
DualCase("wss:"),
|
||||
DualCase("nostr:"),
|
||||
DualCase("blossom:"),
|
||||
)
|
||||
|
||||
fun buildUrlRefs(urls: List<String>): List<Array<String>> =
|
||||
urls
|
||||
.mapTo(HashSet()) { url ->
|
||||
HttpUrlFormatter.normalize(url)
|
||||
}.map {
|
||||
arrayOf("r", it)
|
||||
fun findURLs(text: String) =
|
||||
UrlDetector(text).detect().mapNotNull {
|
||||
if (it.originalUrl.startsWithAny(rejectSchemes)) {
|
||||
null
|
||||
} else {
|
||||
it.originalUrl
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -31,18 +31,18 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
|
||||
fun <T : Event> TagArrayBuilder<T>.quote(tag: QTag) = add(tag.toTagArray())
|
||||
fun <T : Event> TagArrayBuilder<T>.quote(tag: QTag) = addUniqueValueIfNew(tag.toTagArray())
|
||||
|
||||
fun <T : Event> TagArrayBuilder<T>.quotes(tag: List<QTag>) = addAll(tag.map { it.toTagArray() })
|
||||
fun <T : Event> TagArrayBuilder<T>.quotes(tag: List<QTag>) = addAllUniqueValueIfNew(tag.map { it.toTagArray() })
|
||||
|
||||
fun <T : Event> TagArrayBuilder<T>.quote(entity: Entity) =
|
||||
when (entity) {
|
||||
is NNote -> add(entity.toQuoteTagArray())
|
||||
is NEvent -> add(entity.toQuoteTagArray())
|
||||
is NAddress -> add(entity.toQuoteTagArray())
|
||||
is NEmbed -> add(entity.toQuoteTagArray())
|
||||
is NPub -> add(entity.toQuoteTagArray())
|
||||
is NProfile -> add(entity.toQuoteTagArray())
|
||||
is NNote -> addUniqueValueIfNew(entity.toQuoteTagArray())
|
||||
is NEvent -> addUniqueValueIfNew(entity.toQuoteTagArray())
|
||||
is NAddress -> addUniqueValueIfNew(entity.toQuoteTagArray())
|
||||
is NEmbed -> addUniqueValueIfNew(entity.toQuoteTagArray())
|
||||
is NPub -> addUniqueValueIfNew(entity.toQuoteTagArray())
|
||||
is NProfile -> addUniqueValueIfNew(entity.toQuoteTagArray())
|
||||
else -> this
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -21,6 +21,5 @@
|
||||
package com.vitorpamplona.quartz.nip89AppHandlers.clientTag
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
||||
|
||||
fun Event.client() = tags.zapraiserAmount()
|
||||
fun Event.client() = tags.client()
|
||||
|
||||
+2
-2
@@ -24,9 +24,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
|
||||
|
||||
fun TagArrayBuilder<Event>.client(name: String) = addUnique(ClientTag.assemble(name))
|
||||
fun <T : Event> TagArrayBuilder<T>.client(name: String) = addUnique(ClientTag.assemble(name))
|
||||
|
||||
fun TagArrayBuilder<Event>.client(
|
||||
fun <T : Event> TagArrayBuilder<T>.client(
|
||||
name: String,
|
||||
address: AddressHint,
|
||||
) = addUnique(ClientTag.assemble(name, address.addressId, address.relay))
|
||||
|
||||
@@ -20,12 +20,47 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.utils
|
||||
|
||||
expect object Rfc3986 {
|
||||
fun normalize(uri: String): String
|
||||
import io.kotlingeekdev.urireference.URIReference
|
||||
|
||||
fun isValidUrl(url: String): Boolean
|
||||
object Rfc3986 {
|
||||
fun normalize(uri: String): String = URIReference.parse(uri).normalize().toString()
|
||||
|
||||
fun normalizeAndRemoveFragment(url: String): String
|
||||
fun isValidUrl(url: String): Boolean =
|
||||
runCatching {
|
||||
URIReference.parse(url)
|
||||
}.isSuccess
|
||||
|
||||
fun host(url: String): String
|
||||
fun normalizeAndRemoveFragment(url: String): String =
|
||||
URIReference
|
||||
.parse(url)
|
||||
.normalize()!!
|
||||
.toStringNoFragment()
|
||||
.internIfPossible()
|
||||
|
||||
fun host(url: String): String =
|
||||
URIReference
|
||||
.parse(url)
|
||||
.host
|
||||
?.value
|
||||
.toString()
|
||||
}
|
||||
|
||||
fun URIReference.toStringSchemeHost(): String {
|
||||
val sb = StringBuilder()
|
||||
|
||||
if (scheme != null) sb.append(scheme).append(":")
|
||||
if (authority != null) sb.append("//").append(authority.toString())
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun URIReference.toStringNoFragment(): String {
|
||||
val sb = StringBuilder()
|
||||
|
||||
if (scheme != null) sb.append(scheme).append(":")
|
||||
if (host != null) sb.append("//").append(host.toString())
|
||||
if (path != null) sb.append(path)
|
||||
if (query != null) sb.append("?").append(query)
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
* 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.utils
|
||||
|
||||
expect fun fastFindURLs(text: String): List<String>
|
||||
+13
-2
@@ -298,11 +298,22 @@ class DomainNameReader(
|
||||
topLevelLength = currentLabelLength
|
||||
}
|
||||
|
||||
var lastWasAscii: Boolean? = null
|
||||
var lastWasAscii: Boolean? =
|
||||
if (current.isNullOrEmpty()) {
|
||||
null
|
||||
} else {
|
||||
val last = current.last()
|
||||
if (isDot(last)) {
|
||||
null
|
||||
} else {
|
||||
last.code < INTERNATIONAL_CHAR_START
|
||||
}
|
||||
}
|
||||
var isAscii = false
|
||||
|
||||
while (!done && !reader.eof()) {
|
||||
val curr: Char = reader.read()
|
||||
|
||||
isAscii = curr.code < INTERNATIONAL_CHAR_START
|
||||
if (lastWasAscii == null) {
|
||||
lastWasAscii = isAscii
|
||||
@@ -765,7 +776,7 @@ class DomainNameReader(
|
||||
* The start of the utf character code table which indicates that this character is an international character.
|
||||
* Everything below this value is either a-z,A-Z,0-9 or symbols that are not included in domain name.
|
||||
*/
|
||||
private const val INTERNATIONAL_CHAR_START = 192
|
||||
const val INTERNATIONAL_CHAR_START = 192
|
||||
|
||||
/**
|
||||
* The maximum length of each label in the domain name.
|
||||
|
||||
+23
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.utils.urldetector.detection
|
||||
import com.vitorpamplona.quartz.utils.urldetector.Url
|
||||
import com.vitorpamplona.quartz.utils.urldetector.UrlMarker
|
||||
import com.vitorpamplona.quartz.utils.urldetector.UrlPart
|
||||
import com.vitorpamplona.quartz.utils.urldetector.detection.DomainNameReader.Companion.INTERNATIONAL_CHAR_START
|
||||
import kotlin.math.max
|
||||
import kotlin.text.deleteRange
|
||||
|
||||
@@ -84,6 +85,9 @@ class UrlDetector(
|
||||
var length = 0
|
||||
var position = 0
|
||||
|
||||
var lastWasAscii: Boolean? = null
|
||||
var isAscii = false
|
||||
|
||||
// until end of string read the contents
|
||||
while (!reader.eof()) {
|
||||
// read the next char to process.
|
||||
@@ -96,6 +100,7 @@ class UrlDetector(
|
||||
}
|
||||
readEnd(ReadEndState.InvalidUrl)
|
||||
length = 0
|
||||
lastWasAscii = null
|
||||
}
|
||||
|
||||
'%' -> {
|
||||
@@ -116,6 +121,7 @@ class UrlDetector(
|
||||
length = 0
|
||||
}
|
||||
}
|
||||
lastWasAscii = null
|
||||
}
|
||||
|
||||
'\u3002', '\uFF0E', '\uFF61', '.' -> {
|
||||
@@ -125,6 +131,7 @@ class UrlDetector(
|
||||
readEnd(ReadEndState.InvalidUrl)
|
||||
}
|
||||
length = 0
|
||||
lastWasAscii = null
|
||||
}
|
||||
|
||||
'@' -> {
|
||||
@@ -136,6 +143,7 @@ class UrlDetector(
|
||||
}
|
||||
length = 0
|
||||
}
|
||||
lastWasAscii = null
|
||||
}
|
||||
|
||||
'[' -> {
|
||||
@@ -153,6 +161,7 @@ class UrlDetector(
|
||||
reader.seek(beginning)
|
||||
}
|
||||
length = 0
|
||||
lastWasAscii = null
|
||||
}
|
||||
|
||||
'/' -> {
|
||||
@@ -180,15 +189,29 @@ class UrlDetector(
|
||||
hasScheme = readHtml5Root()
|
||||
length = buffer.length
|
||||
}
|
||||
lastWasAscii = null
|
||||
}
|
||||
|
||||
':' -> {
|
||||
// add the ":" to the url and check for scheme/username
|
||||
buffer.append(curr)
|
||||
length = processColon(length)
|
||||
lastWasAscii = null
|
||||
}
|
||||
|
||||
else -> {
|
||||
isAscii = curr.code < INTERNATIONAL_CHAR_START
|
||||
if (lastWasAscii == null) {
|
||||
lastWasAscii = isAscii
|
||||
} else if (isAscii != lastWasAscii) {
|
||||
// threat changes in char as a space
|
||||
if (buffer.isNotEmpty() && hasScheme) {
|
||||
reader.goBack()
|
||||
readDomainName(buffer.substring(length))
|
||||
}
|
||||
length = 0
|
||||
}
|
||||
|
||||
buffer.append(curr)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -21,7 +21,6 @@
|
||||
package com.vitorpamplona.quartz.nip10Notes.urls
|
||||
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
||||
import com.vitorpamplona.quartz.utils.fastFindURLs
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertEquals
|
||||
@@ -31,7 +30,7 @@ class UrlsDetectorTest {
|
||||
|
||||
@Test
|
||||
fun detectUrlNumber() {
|
||||
val detectedLinks = fastFindURLs(testSentence)
|
||||
val detectedLinks = findURLs(testSentence)
|
||||
assertEquals(2, detectedLinks.size)
|
||||
}
|
||||
|
||||
@@ -48,7 +47,7 @@ class UrlsDetectorTest {
|
||||
*/
|
||||
@Test
|
||||
fun doesNotCrashOnJapaneseText() {
|
||||
val detectedLinks = fastFindURLs("今北産業")
|
||||
val detectedLinks = findURLs("今北産業")
|
||||
assertEquals(0, detectedLinks.size)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,6 @@ class UrlTest {
|
||||
assertNotNull(url.path)
|
||||
assertNotNull(url.query)
|
||||
assertNotNull(url.fragment)
|
||||
assertEquals(-1, url.port) // getPart(PORT) returns null → -1
|
||||
assertEquals(443, url.port) // getPart(PORT) returns null → -1
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -739,6 +739,37 @@ class UriDetectionTest {
|
||||
runTest("blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net", "blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBrokenCaseInProduction() {
|
||||
runTest("今北産業")
|
||||
runTest("http://test.com今北産業", "http://test.com")
|
||||
runTest("ftp://test.com今北産業", "ftp://test.com")
|
||||
runTest("test.com今北産業", "test.com")
|
||||
runTest("wss://test.com今北産業", "wss://test.com")
|
||||
runTest("blossom:test.com今北産業", "blossom:test.com")
|
||||
runTest("nostr:test.com今北産業", "nostr:test.com")
|
||||
runTest("nostr:test今北産業", "nostr:test")
|
||||
runTest("nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m")
|
||||
|
||||
runTest("今北産業http://test.com", "http://test.com")
|
||||
runTest("今北産業ftp://test.com", "ftp://test.com")
|
||||
runTest("今北産業test.com", "test.com")
|
||||
runTest("今北産業wss://test.com", "wss://test.com")
|
||||
runTest("今北産業blossom:test.com", "blossom:test.com")
|
||||
runTest("今北産業nostr:test.com", "nostr:test.com")
|
||||
runTest("今北産業nostr:test", "nostr:test")
|
||||
runTest("今北産業nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m")
|
||||
|
||||
runTest("今北産業http://test.com今北産業", "http://test.com")
|
||||
runTest("今北産業ftp://test.com今北産業", "ftp://test.com")
|
||||
runTest("今北産業test.com今北産業", "test.com")
|
||||
runTest("今北産業wss://test.com今北産業", "wss://test.com")
|
||||
runTest("今北産業blossom:test.com今北産業", "blossom:test.com")
|
||||
runTest("今北産業nostr:test.com今北産業", "nostr:test.com")
|
||||
runTest("今北産業nostr:test今北産業", "nostr:test")
|
||||
runTest("今北産業nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFullText() {
|
||||
val text =
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
* 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.utils
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import platform.Foundation.NSURLComponents
|
||||
import swiftbridge.Rfc3986UriBridge
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual object Rfc3986 {
|
||||
private val rfc3986UriBridge = Rfc3986UriBridge()
|
||||
|
||||
actual fun normalize(uri: String): String =
|
||||
rfc3986UriBridge
|
||||
.normalizeUrlWithUrl(uri, null)
|
||||
?.let { if (it.last() == '/') it else "$it/" } ?: throw Exception("Could not normalize URI: $uri")
|
||||
|
||||
actual fun isValidUrl(url: String): Boolean = rfc3986UriBridge.isUrlValidWithUrl(url)
|
||||
|
||||
actual fun normalizeAndRemoveFragment(url: String): String =
|
||||
NSURLComponents(url)
|
||||
.toStringNoFragment()
|
||||
.internIfPossible()
|
||||
|
||||
actual fun host(url: String): String = rfc3986UriBridge.hostFromUriWithUrl(url, null) ?: throw Exception("Could not retrieve host from URL.")
|
||||
}
|
||||
|
||||
fun NSURLComponents.toStringNoFragment(): String {
|
||||
val sb = StringBuilder()
|
||||
|
||||
if (scheme != null) sb.append(scheme).append(":")
|
||||
if (host != null) sb.append("//").append(host.toString())
|
||||
if (path != null) sb.append(path)
|
||||
if (query != null) sb.append("?").append(query)
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
* 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.utils
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import swiftbridge.UrlDetector
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun fastFindURLs(text: String): List<String> {
|
||||
val detectorInstance = UrlDetector()
|
||||
return detectorInstance.findURLsWithText(text) as List<String>
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* 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.nip19Bech32
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NIP19EmbedTests {
|
||||
@Test
|
||||
fun testEmbedKind1Event() =
|
||||
runTest {
|
||||
val signer =
|
||||
NostrSignerInternal(
|
||||
KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")),
|
||||
)
|
||||
|
||||
val textNote =
|
||||
signer.sign(
|
||||
TextNoteEvent.build("I like this. It could solve the ninvite problem in #1062, and it seems like it could be applied very broadly to limit the spread of events that shouldn't stand on their own or need to be private. The one question I have is how long are these embeds? If it's 50 lines of text, that breaks the human readable (or at least parseable) requirement of kind 1s. Also, encoding json in a tlv is silly, we should at least use the tlv to reduce the payload size."),
|
||||
)
|
||||
|
||||
assertNotNull(textNote)
|
||||
|
||||
val bech32 = NEmbed.create(textNote)
|
||||
|
||||
println(bech32)
|
||||
|
||||
val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event
|
||||
|
||||
assertTrue(decodedNote.verify())
|
||||
|
||||
assertEquals(textNote.toJson(), decodedNote.toJson())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVisionPrescriptionEmbedEvent() =
|
||||
runTest {
|
||||
val signer =
|
||||
NostrSignerInternal(
|
||||
KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")),
|
||||
)
|
||||
|
||||
val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionFhir))
|
||||
|
||||
assertNotNull(eyeglassesPrescriptionEvent)
|
||||
|
||||
val bech32 = NEmbed.create(eyeglassesPrescriptionEvent)
|
||||
|
||||
println(eyeglassesPrescriptionEvent.toJson())
|
||||
println(bech32)
|
||||
|
||||
val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event
|
||||
|
||||
assertTrue(decodedNote.verify())
|
||||
|
||||
assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVisionPrescriptionBundleEmbedEvent() =
|
||||
runTest {
|
||||
val signer =
|
||||
NostrSignerInternal(
|
||||
KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")),
|
||||
)
|
||||
|
||||
val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionBundle))
|
||||
|
||||
assertNotNull(eyeglassesPrescriptionEvent)
|
||||
|
||||
val bech32 = NEmbed.create(eyeglassesPrescriptionEvent)
|
||||
|
||||
println(eyeglassesPrescriptionEvent.toJson())
|
||||
println(bech32)
|
||||
|
||||
val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event
|
||||
|
||||
assertTrue(decodedNote.verify())
|
||||
|
||||
assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVisionPrescriptionBundle2EmbedEvent() =
|
||||
runTest {
|
||||
val signer =
|
||||
NostrSignerInternal(
|
||||
KeyPair(decodePrivateKeyAsHexOrNull("nsec1arn3jlxv20y76n8ek8ydecy9ga06rl7aw8evznjylc3ap00hwkvqx4vvy6")!!.hexToByteArray()),
|
||||
)
|
||||
|
||||
val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionBundle2))
|
||||
|
||||
assertNotNull(eyeglassesPrescriptionEvent)
|
||||
|
||||
val bech32 = NEmbed.create(eyeglassesPrescriptionEvent)
|
||||
|
||||
println(eyeglassesPrescriptionEvent.toJson())
|
||||
println(bech32)
|
||||
|
||||
val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event
|
||||
|
||||
assertTrue(decodedNote.verify())
|
||||
|
||||
assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTimsNembed() {
|
||||
val uri = "nembed1r79ssq9446hkwqhl642ukmku8qg0c92pu7w3j0jyfte8tc7tvg85vmrys8x3sqgle5vjy7jpjswqhphl0kd6yf4sz0n3peyjq5rp3zkat4w6c6j3f7um0724jmfu5456xxgg2yxkn8dp23j64xsn9npcggzafyh2effyntqrqxzja8dp52kpcvc9zqxlj86e8mx05vevzxkeprjkfs4wmppxm3p96vj6yvu2mqgf5l4v99492r2qsggquxuv93uzx244652h2kkj8xseg9xkq0afpygknjtty9j4ju5v0nm9mezux9wyl6s5wr7lzce7cj397mnu0u04ha7aq3w7exelrhe3zs3l3urwa9sp36u80npllrs0hmsxqdn0fsuyav3nv0azjs5suzuurg2uymncjxez8p9xksc2j6gw992enjflgrdd7n5uq2xrpvfrd3rckw624ey0elvm6grr27tyzlf4vaswgm5vc3hdyczsl983g2j8e67r6z5zt30lat84ma4wclkwwxxrcflvdsuwd7346h7zqav4vdwe3gkt9lr87sfk4aqd2aey03tt4eyspldrqcmkx9pqe2pn63rv7grwwalr86akuldnvjm6m87wrw9sdwns8wq0rnsmj57vqwtc3g7hkwum3vl2dda78dwkycgfzw6qna3ufhpatcvq5a4hm4ehl45an8umwt0clf7rn77ctke475qglwu86hhfwhn7dkca4pkfpyc4y75rll6nvr5qc8nlhf8mk22celn5mecvyuzxd830drhdck9tcdpcafymk8wajwu2w8ha8gatggjfvq0a4jlf2sdamzj0ysqks9dk8me3q7a0qpmf6vykurkrcls4pug3u4pn4u26ezx3h8e482n07x2nsmu80dpufxqc0ttcyzhnppguxma4d8aumdawnlsyy7yzcuxl7lw5y9p4nv5h8fn6u8anpm2tsze3p6mgxy9j9uuqfxg2jvlmtjpakna5m4hln0msmw804hnun96h66fh62270yhhljnmmdl7jln07ll5vft7e870hemcld34a09n943ed6629fgtctsftma9q6tf4jfm2p0ukd2j2n2dpz53fqrkk4ctdcy2j5jar095g5jntf6u807ggkzauzt6uqkwk4tg5w7w55kskspc9663zx5dzzzfwpg3q546g2ve4kukr70n0a46eyce2crsqqq247ql5"
|
||||
|
||||
val decodedNote = (Nip19Parser.uriToRoute(uri)?.entity as NEmbed).event
|
||||
|
||||
assertTrue(decodedNote.verify())
|
||||
|
||||
assertEquals(timsPrescription, decodedNote.toJson())
|
||||
}
|
||||
|
||||
val visionPrescriptionFhir = "{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"Patient/Donald Duck\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"Practitioner/Adam Careful\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}"
|
||||
val visionPrescriptionBundle = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Careful\",\"given\":[\"Adam\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Duck\",\"given\":[\"Donald\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"#2\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}"
|
||||
|
||||
val visionPrescriptionBundle2 = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Smith\",\"given\":[\"Dr. Joe\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Doe\",\"given\":[\"Jane\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}"
|
||||
val timsPrescription = "{\"id\":\"18d8b22e6455dfc9f4c6d6be8c2cf015e961b8d160dfe5e4b7fc1578f2c4e0be\",\"pubkey\":\"46f1826abf5b03de972192e619e25fa94d775a1c555efe53a775412dbf49889b\",\"created_at\":1739566773,\"kind\":82,\"tags\":[[\"p\",\"46f1826abf5b03de972192e619e25fa94d775a1c555efe53a775412dbf49889b\"]],\"content\":\"{\\\"resourceType\\\": \\\"VisionPrescription\\\", \\\"id\\\": \\\"eyeglass-prescription-001\\\", \\\"status\\\": \\\"active\\\", \\\"created\\\": \\\"2025-02-14T10:00:00Z\\\", \\\"patient\\\": {\\\"reference\\\": \\\"Patient/12345\\\", \\\"display\\\": \\\"John Doe\\\"}, \\\"encounter\\\": {\\\"reference\\\": \\\"Encounter/67890\\\"}, \\\"dateWritten\\\": \\\"2025-02-10T15:00:00Z\\\", \\\"prescriber\\\": {\\\"reference\\\": \\\"Practitioner/56789\\\", \\\"display\\\": \\\"Dr. Emily Smith\\\"}, \\\"lensSpecification\\\": [{\\\"product\\\": {\\\"coding\\\": [{\\\"system\\\": \\\"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\\\", \\\"code\\\": \\\"lens\\\", \\\"display\\\": \\\"Eyeglasses\\\"}]}, \\\"eye\\\": \\\"right\\\", \\\"sphere\\\": -2.5, \\\"cylinder\\\": -1.0, \\\"axis\\\": 180, \\\"prism\\\": [{\\\"amount\\\": 0.5, \\\"base\\\": \\\"up\\\"}], \\\"add\\\": 2.0, \\\"duration\\\": {\\\"value\\\": 24, \\\"unit\\\": \\\"months\\\", \\\"system\\\": \\\"http://unitsofmeasure.org\\\", \\\"code\\\": \\\"mo\\\"}, \\\"note\\\": [{\\\"text\\\": \\\"Right eye prescription for near-sightedness with astigmatism.\\\"}]}, {\\\"product\\\": {\\\"coding\\\": [{\\\"system\\\": \\\"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\\\", \\\"code\\\": \\\"lens\\\", \\\"display\\\": \\\"Eyeglasses\\\"}]}, \\\"eye\\\": \\\"left\\\", \\\"sphere\\\": -3.0, \\\"cylinder\\\": -0.75, \\\"axis\\\": 160, \\\"prism\\\": [{\\\"amount\\\": 0.5, \\\"base\\\": \\\"down\\\"}], \\\"add\\\": 2.0, \\\"duration\\\": {\\\"value\\\": 24, \\\"unit\\\": \\\"months\\\", \\\"system\\\": \\\"http://unitsofmeasure.org\\\", \\\"code\\\": \\\"mo\\\"}, \\\"note\\\": [{\\\"text\\\": \\\"Left eye prescription for near-sightedness with astigmatism.\\\"}]}]}\",\"sig\":\"d22d3b86aea397094de8b6cdf69decdfd886c90008aeebf95fd43a2770b37d486b313bff5bdb44b78e33bbaa3f336d74ee8b36bc5b16050374054246c72d93c2\"}"
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
* 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.utils
|
||||
|
||||
import org.czeal.rfc3986.URIReference
|
||||
|
||||
actual object Rfc3986 {
|
||||
actual fun normalize(uri: String) =
|
||||
URIReference
|
||||
.parse(uri)
|
||||
.normalize()
|
||||
.toString()
|
||||
|
||||
actual fun isValidUrl(url: String): Boolean =
|
||||
runCatching {
|
||||
URIReference.parse(url)
|
||||
}.isSuccess
|
||||
|
||||
actual fun normalizeAndRemoveFragment(url: String): String =
|
||||
URIReference
|
||||
.parse(url)
|
||||
.normalize()
|
||||
.toStringNoFragment()
|
||||
.intern()
|
||||
|
||||
actual fun host(url: String): String = URIReference.parse(url).host.value
|
||||
}
|
||||
|
||||
fun URIReference.toStringSchemeHost(): String {
|
||||
val sb = StringBuilder()
|
||||
|
||||
if (scheme != null) sb.append(scheme).append(":")
|
||||
if (authority != null) sb.append("//").append(authority.toString())
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun URIReference.toStringNoFragment(): String {
|
||||
val sb = StringBuilder()
|
||||
|
||||
if (scheme != null) sb.append(scheme).append(":")
|
||||
if (authority != null) sb.append("//").append(authority.toString())
|
||||
if (path != null) sb.append(path)
|
||||
if (query != null) sb.append("?").append(query)
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* 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.utils
|
||||
|
||||
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
|
||||
|
||||
actual fun fastFindURLs(text: String): List<String> = UrlDetector(text).detect().map { it.originalUrl }
|
||||
@@ -1,23 +0,0 @@
|
||||
//
|
||||
// Created by NullDev on 31/12/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RFC_3986
|
||||
|
||||
@objcMembers public class Rfc3986UriBridge: NSObject {
|
||||
public func normalizeUrl(url: String) throws -> String {
|
||||
let uri = try RFC_3986.URI(url)
|
||||
let normalized = uri.normalized()
|
||||
return normalized.value
|
||||
}
|
||||
|
||||
public func isUrlValid(url: String) -> Bool {
|
||||
return RFC_3986.isValidURI(url)
|
||||
}
|
||||
|
||||
public func hostFromUri(url: String) throws -> String {
|
||||
let actualUri = try RFC_3986.URI(url)
|
||||
return actualUri.host!
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//
|
||||
// Created by NullDev on 20/01/2026.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@objcMembers public class UrlDetector: NSObject {
|
||||
public func findURLs(text: String) -> [String] {
|
||||
var links = [String]()
|
||||
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
let matches = detector.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count))
|
||||
|
||||
for match in matches {
|
||||
guard let range = Range(match.range, in: text) else { continue }
|
||||
let url = text[range]
|
||||
links.append(String(url))
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user