feat(quartz): parse browser shares into NIP-84 highlights

Adds a shared-highlight parsing layer under nip84Highlights/parse that turns
the free-form text a browser hands Amethyst on "Share selection" into the
pieces of a kind:9802 highlight:

- SharedHighlightParser normalises selection-only, selection+URL, URL-only and
  "link to highlight" (#:~:text= fragment) shares into a SharedHighlight.
- TextFragmentParser decodes/strips WICG text-fragment directives (prefix,
  start, end, suffix), leaving literal '+' verbatim.
- UrlTrackerCleaner strips utm_*/fbclid/etc. from the source URL per NIP-84's
  "clean the URL from trackers" guidance, preserving path and fragment.

Also adds a HighlightEvent.create() builder overload that assembles the r,
textquoteselector, context and comment tags from parsed data, centralising
what the desktop publish action does by hand.

Covered by commonTest suites for each piece plus a builder round-trip.
This commit is contained in:
Claude
2026-07-29 02:22:06 +00:00
parent 87b83a7c16
commit 69c3e3accc
9 changed files with 919 additions and 0 deletions
@@ -190,5 +190,45 @@ class HighlightEvent(
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): HighlightEvent = signer.sign(createdAt, KIND, emptyArray(), msg)
/**
* Builds a fully-tagged NIP-84 highlight from the pieces a browser share (or the
* highlight composer) produces. The highlighted passage becomes the event `content`;
* the remaining inputs are emitted as their NIP-84 tags when present:
*
* - [url] → an `r` source reference (normalized by [ReferenceTag]; clean it of
* trackers with [com.vitorpamplona.quartz.nip84Highlights.parse.UrlTrackerCleaner] first),
* - [prefix]/[suffix] → a `textquoteselector` anchor (the `exact` field stays a
* placeholder since the passage already lives in `content`),
* - [context] → the surrounding paragraph as a `context` tag,
* - [comment] → the user's own note as a `comment` tag (turns it into a quote highlight).
*/
suspend fun create(
quote: String,
url: String? = null,
prefix: String? = null,
suffix: String? = null,
comment: String? = null,
context: String? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): HighlightEvent {
val tags = mutableListOf<Array<String>>()
if (!url.isNullOrBlank()) {
tags.add(ReferenceTag.assemble(url))
}
if (!prefix.isNullOrEmpty() || !suffix.isNullOrEmpty()) {
tags.add(TextQuoteSelectorTag.assemble(null, prefix, suffix))
}
if (!context.isNullOrBlank()) {
tags.add(ContextTag.assemble(context))
}
if (!comment.isNullOrBlank()) {
tags.add(CommentTag.assemble(comment))
}
return signer.sign(createdAt, KIND, tags.toTypedArray(), quote)
}
}
}
@@ -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.nip84Highlights.parse
/**
* The structured result of parsing a browser share into the pieces a NIP-84 kind:9802
* highlight needs. Produced by [SharedHighlightParser]; consumed by
* [com.vitorpamplona.quartz.nip84Highlights.HighlightEvent.Companion.create] and by the
* highlight composer UI (which pre-fills its fields and lets the user confirm/edit before
* signing).
*
* @property quote the highlighted passage → the event `content`
* @property url the cleaned source URL (trackers and text-fragment stripped) → an `r` tag
* @property prefix text just before the highlight, for a `textquoteselector` anchor
* @property suffix text just after the highlight, for a `textquoteselector` anchor
*/
class SharedHighlight(
val quote: String?,
val url: String?,
val prefix: String?,
val suffix: String?,
) {
/** True when nothing usable was found (neither a passage nor a source URL). */
fun isEmpty(): Boolean = quote.isNullOrBlank() && url.isNullOrBlank()
/** True when there is at least a highlighted passage or a source URL. */
fun isNotEmpty(): Boolean = !isEmpty()
fun hasSelector(): Boolean = !prefix.isNullOrEmpty() || !suffix.isNullOrEmpty()
}
@@ -0,0 +1,97 @@
/*
* 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.nip84Highlights.parse
/**
* Turns the free-form text a browser hands Amethyst on "Share selection" into the pieces of
* a NIP-84 highlight. The share intent's plain text can arrive in several shapes and this
* parser normalises all of them:
*
* - **Selection only** `"Some highlighted sentence."` quote, no URL.
* - **Selection + page URL** `"Some highlighted sentence."\n\nhttps://example.com/post`
* (what many browsers and read-it-later apps emit) quote + source URL.
* - **URL only** `https://example.com/post` → source URL, no quote yet.
* - **Link to highlight** `https://example.com/post#:~:text=prefix-,Some%20sentence,-after`
* (Chrome/Edge/Safari "Copy link to highlight") the passage decoded from the text
* fragment plus its prefix/suffix anchors, with the fragment stripped off the stored URL.
*
* The URL is always cleaned of tracking parameters ([UrlTrackerCleaner]) and of its
* text-fragment directive ([TextFragmentParser]) before being returned. Surrounding quote
* marks the browser wraps around the selection are trimmed off the passage.
*
* The result is a best-effort pre-fill: the composer screen lets the user confirm and edit
* every field before the event is signed.
*/
object SharedHighlightParser {
private val URL_REGEX = Regex("""https?://\S+""", RegexOption.IGNORE_CASE)
// Trailing punctuation that is part of the surrounding sentence, not the URL token.
private const val URL_TRAILING_TRIM = ".,;:!?)]}>\"'»”’"
// Quote marks a browser may wrap around a shared selection (straight, curly, guillemets).
private const val QUOTE_CHARS = "\"'“”‘’«»"
fun parse(shared: String): SharedHighlight {
val input = shared.trim()
if (input.isEmpty()) return SharedHighlight(null, null, null, null)
// The source URL is normally appended after the selection, so prefer the last URL in
// the string (a URL inside the highlighted text itself stays part of the quote).
val match = URL_REGEX.findAll(input).lastOrNull()
var url: String? = null
var prefix: String? = null
var suffix: String? = null
var fragmentQuote: String? = null
var remainder = input
if (match != null) {
val rawToken = match.value
val rawUrl = rawToken.trimEnd(*URL_TRAILING_TRIM.toCharArray())
val fragment = TextFragmentParser.parse(rawUrl)
prefix = fragment?.prefix
suffix = fragment?.suffix
fragmentQuote = fragment?.start
val stripped = TextFragmentParser.stripTextFragment(rawUrl)
url = UrlTrackerCleaner.clean(stripped).takeIf { it.isNotBlank() }
// Remove the whole matched token (incl. any trailing punctuation) from the passage.
remainder = input.removeRange(match.range).trim()
}
val quote = cleanQuote(remainder) ?: fragmentQuote?.let { cleanQuote(it) }
return SharedHighlight(
quote = quote,
url = url,
prefix = prefix,
suffix = suffix,
)
}
/** Trims surrounding whitespace and matching quote marks; returns null when nothing is left. */
private fun cleanQuote(text: String): String? {
val trimmed = text.trim { it.isWhitespace() || it in QUOTE_CHARS }
return trimmed.ifBlank { null }
}
}
@@ -0,0 +1,156 @@
/*
* 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.nip84Highlights.parse
/**
* The decoded pieces of a WICG Text Fragment directive
* (`#:~:text=[prefix-,]textStart[,textEnd][,-suffix]`), the anchor browsers append when a
* user shares a "link to highlight" of selected text.
*
* All fields are percent-decoded and never blank (empty pieces become null).
*
* @property prefix the text immediately before the match, used for disambiguation
* @property start the beginning of the matched (highlighted) text the whole match when [end] is null
* @property end the end of the matched text when the browser split a long selection into start/end bounds
* @property suffix the text immediately after the match, used for disambiguation
*/
class TextFragment(
val prefix: String?,
val start: String?,
val end: String?,
val suffix: String?,
)
/**
* Parses (and strips) WICG Text Fragment directives from a URL.
*
* Text fragments live in the URL fragment after a `:~:` delimiter, e.g.
* `https://example.com/page#:~:text=prefix-,highlighted%20text,-suffix`. This is what
* Chrome/Edge/Safari emit when a user shares a link to selected text; the same encoding
* is used by the "Copy link to highlight" and text-selection share actions.
*/
object TextFragmentParser {
private const val DIRECTIVE_DELIMITER = ":~:"
private const val TEXT_PARAM = "text="
/**
* Extracts the first `text=` directive from [url]'s fragment, or null when there is
* no text fragment. Only the first `text=` directive is read a URL may carry several
* (`&text=`), but a single highlight maps to one passage.
*/
fun parse(url: String): TextFragment? {
val hashIndex = url.indexOf('#')
if (hashIndex < 0) return null
val fragment = url.substring(hashIndex + 1)
val directiveIndex = fragment.indexOf(DIRECTIVE_DELIMITER)
if (directiveIndex < 0) return null
val directives = fragment.substring(directiveIndex + DIRECTIVE_DELIMITER.length)
val textParam = directives.split("&").firstOrNull { it.startsWith(TEXT_PARAM) } ?: return null
val value = textParam.substring(TEXT_PARAM.length)
if (value.isEmpty()) return null
// Commas that belong to the highlighted text itself are percent-encoded (%2C), so the
// raw commas here are always the directive's own start/end/prefix/suffix separators.
val tokens = value.split(",").toMutableList()
var prefix: String? = null
var suffix: String? = null
if (tokens.isNotEmpty() && tokens.first().endsWith("-")) {
prefix = tokens.removeAt(0).dropLast(1)
}
if (tokens.isNotEmpty() && tokens.last().startsWith("-")) {
suffix = tokens.removeAt(tokens.size - 1).drop(1)
}
val start = tokens.getOrNull(0)
val end = tokens.getOrNull(1)
return TextFragment(
prefix = decode(prefix),
start = decode(start),
end = decode(end),
suffix = decode(suffix),
)
}
/**
* Returns [url] with any `:~:` text-fragment directive removed, so it can be stored as a
* clean `r` source reference. A surrounding `#` that only introduced the directive is
* dropped too; a real element-id fragment before the `:~:` is kept.
*/
fun stripTextFragment(url: String): String {
val hashIndex = url.indexOf('#')
if (hashIndex < 0) return url
val fragment = url.substring(hashIndex + 1)
val directiveIndex = fragment.indexOf(DIRECTIVE_DELIMITER)
if (directiveIndex < 0) return url
val beforeDirective = fragment.substring(0, directiveIndex)
val base = url.substring(0, hashIndex)
return if (beforeDirective.isEmpty()) base else "$base#$beforeDirective"
}
private fun decode(value: String?): String? {
if (value.isNullOrEmpty()) return null
return percentDecode(value).takeIf { it.isNotEmpty() }
}
/**
* Percent-decodes a text-fragment component. Unlike form decoding it leaves `+`
* verbatim (a literal plus in the text is `+`, not a space spaces arrive as `%20`),
* and decodes multi-byte UTF-8 sequences byte-by-byte.
*/
private fun percentDecode(input: String): String {
if (!input.contains('%')) return input
val bytes = ArrayList<Byte>(input.length)
var i = 0
while (i < input.length) {
val c = input[i]
if (c == '%' && i + 2 < input.length) {
val hi = hexValue(input[i + 1])
val lo = hexValue(input[i + 2])
if (hi >= 0 && lo >= 0) {
bytes.add(((hi shl 4) or lo).toByte())
i += 3
continue
}
}
// Non-escape character: re-encode as UTF-8 so it round-trips with decoded bytes.
c.toString().encodeToByteArray().forEach { bytes.add(it) }
i++
}
return bytes.toByteArray().decodeToString()
}
private fun hexValue(c: Char): Int =
when (c) {
in '0'..'9' -> c - '0'
in 'a'..'f' -> c - 'a' + 10
in 'A'..'F' -> c - 'A' + 10
else -> -1
}
}
@@ -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.nip84Highlights.parse
/**
* Strips known tracking parameters from a URL's query string.
*
* NIP-84 asks clients to "do a best effort of cleaning the URL from trackers" before
* tagging the source of a highlight, so the same passage highlighted from two different
* campaign links collapses to one canonical `r` tag instead of leaking the sharer's
* `utm_*`/`fbclid`/etc. attribution into the published event.
*
* Only the query component is touched the path and any fragment (including a
* `#:~:text=` directive) are preserved verbatim.
*/
object UrlTrackerCleaner {
/**
* Exact parameter names known to be pure tracking/attribution noise. Names are matched
* case-insensitively. Any parameter whose name starts with `utm_` is also dropped
* regardless of this set.
*/
private val TRACKER_PARAMS =
setOf(
"fbclid",
"gclid",
"gclsrc",
"gbraid",
"wbraid",
"dclid",
"msclkid",
"yclid",
"twclid",
"ttclid",
"igshid",
"igsh",
"mc_eid",
"mc_cid",
"mkt_tok",
"_hsenc",
"_hsmi",
"vero_id",
"vero_conv",
"oly_anon_id",
"oly_enc_id",
"wickedid",
"ncid",
"s_cid",
"cmpid",
"spm",
"scm",
"ref_src",
"ref_url",
"_ga",
)
private fun isTracker(name: String): Boolean {
val lower = name.lowercase()
return lower.startsWith("utm_") || lower in TRACKER_PARAMS
}
fun clean(url: String): String {
val queryStart = url.indexOf('?')
if (queryStart < 0) return url
// Keep any fragment (element id and/or `:~:text=` directive) untouched.
val fragmentStart = url.indexOf('#', queryStart)
val query = if (fragmentStart >= 0) url.substring(queryStart + 1, fragmentStart) else url.substring(queryStart + 1)
val fragment = if (fragmentStart >= 0) url.substring(fragmentStart) else ""
val base = url.substring(0, queryStart)
val kept =
query
.split("&")
.filter { it.isNotEmpty() && !isTracker(it.substringBefore("=")) }
return if (kept.isEmpty()) {
base + fragment
} else {
base + "?" + kept.joinToString("&") + fragment
}
}
}
@@ -0,0 +1,142 @@
/*
* 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.nip84Highlights
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip84Highlights.parse.SharedHighlightParser
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class HighlightEventBuilderTest {
private val signer = NostrSignerInternal(KeyPair())
@Test
fun buildsBarePassageWithNoTags() =
runTest {
val event = HighlightEvent.create(quote = "just a passage", signer = signer)
assertEquals(HighlightEvent.KIND, event.kind)
assertEquals("just a passage", event.quote())
assertTrue(event.tags.isEmpty())
}
@Test
fun buildsSourceReferenceTag() =
runTest {
val event =
HighlightEvent.create(
quote = "a passage",
url = "https://example.com/post",
signer = signer,
)
assertEquals("https://example.com/post", event.inUrl())
}
@Test
fun buildsSelectorFromPrefixSuffix() =
runTest {
val event =
HighlightEvent.create(
quote = "the passage",
url = "https://example.com/post",
prefix = "before ",
suffix = " after",
signer = signer,
)
val selector = event.textQuoteSelector()
assertNull(selector?.exact) // placeholder — the passage is in .content
assertEquals("before ", selector?.prefix)
assertEquals(" after", selector?.suffix)
}
@Test
fun omitsSelectorWhenNoPrefixOrSuffix() =
runTest {
val event =
HighlightEvent.create(
quote = "the passage",
url = "https://example.com/post",
signer = signer,
)
assertNull(event.textQuoteSelector())
}
@Test
fun buildsCommentAndContextTags() =
runTest {
val event =
HighlightEvent.create(
quote = "the passage",
url = "https://example.com/post",
comment = "my note about it",
context = "The surrounding paragraph with the passage in it.",
signer = signer,
)
assertEquals("my note about it", event.comment())
assertEquals("The surrounding paragraph with the passage in it.", event.context())
}
@Test
fun blankOptionalsAreSkipped() =
runTest {
val event =
HighlightEvent.create(
quote = "the passage",
url = " ",
comment = "",
context = " ",
signer = signer,
)
assertTrue(event.tags.isEmpty())
}
@Test
fun roundTripsFromSharedHighlightParser() =
runTest {
val parsed =
SharedHighlightParser.parse(
"https://example.com/post?utm_source=x#:~:text=the%20-,highlighted%20passage,-follows",
)
val event =
HighlightEvent.create(
quote = parsed.quote!!,
url = parsed.url,
prefix = parsed.prefix,
suffix = parsed.suffix,
signer = signer,
)
assertEquals("highlighted passage", event.quote())
assertEquals("https://example.com/post", event.inUrl())
assertEquals("the ", event.textQuoteSelector()?.prefix)
assertEquals("follows", event.textQuoteSelector()?.suffix)
}
}
@@ -0,0 +1,131 @@
/*
* 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.nip84Highlights.parse
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class SharedHighlightParserTest {
@Test
fun emptyInputYieldsEmptyResult() {
val result = SharedHighlightParser.parse(" ")
assertTrue(result.isEmpty())
assertNull(result.quote)
assertNull(result.url)
}
@Test
fun selectionOnly() {
val result = SharedHighlightParser.parse("Nostr is a simple, open protocol.")
assertEquals("Nostr is a simple, open protocol.", result.quote)
assertNull(result.url)
assertTrue(result.isNotEmpty())
}
@Test
fun urlOnly() {
val result = SharedHighlightParser.parse("https://example.com/post")
assertNull(result.quote)
assertEquals("https://example.com/post", result.url)
}
@Test
fun selectionThenUrlOnSeparateLines() {
val result =
SharedHighlightParser.parse("Nostr is a simple, open protocol.\n\nhttps://example.com/post")
assertEquals("Nostr is a simple, open protocol.", result.quote)
assertEquals("https://example.com/post", result.url)
}
@Test
fun stripsWrappingQuotesFromSelection() {
val result = SharedHighlightParser.parse("\"Nostr is great\" https://example.com/post")
assertEquals("Nostr is great", result.quote)
assertEquals("https://example.com/post", result.url)
}
@Test
fun stripsCurlyQuotesAndGuillemets() {
assertEquals("inside", SharedHighlightParser.parse("“inside”").quote)
assertEquals("inside", SharedHighlightParser.parse("«inside»").quote)
}
@Test
fun cleansTrackersFromSharedUrl() {
val result =
SharedHighlightParser.parse("Some quote\n\nhttps://example.com/post?utm_source=twitter&id=9")
assertEquals("Some quote", result.quote)
assertEquals("https://example.com/post?id=9", result.url)
}
@Test
fun linkToHighlightWithoutSeparateSelectionUsesFragmentText() {
val result =
SharedHighlightParser.parse(
"https://example.com/post#:~:text=the%20-,highlighted%20passage,-and%20on",
)
assertEquals("highlighted passage", result.quote)
assertEquals("https://example.com/post", result.url)
assertEquals("the ", result.prefix)
assertEquals("and on", result.suffix)
assertTrue(result.hasSelector())
}
@Test
fun explicitSelectionWinsOverFragmentTextButKeepsAnchors() {
// The browser shared the exact selection AND a link-to-highlight; keep the readable
// selection as the passage but retain the prefix/suffix anchors from the fragment.
val result =
SharedHighlightParser.parse(
"The full readable passage.\n\nhttps://example.com/post#:~:text=before-,The%20full,-after",
)
assertEquals("The full readable passage.", result.quote)
assertEquals("https://example.com/post", result.url)
assertEquals("before", result.prefix)
assertEquals("after", result.suffix)
}
@Test
fun trailingSentencePunctuationNotSwallowedIntoUrl() {
val result = SharedHighlightParser.parse("See (https://example.com/post).")
assertEquals("https://example.com/post", result.url)
}
@Test
fun urlInsideSelectionStaysWithQuoteWhenSourceAppended() {
// The last URL is treated as the source; an earlier URL inside the passage is kept.
val result =
SharedHighlightParser.parse("Visit https://inside.example first.\n\nhttps://source.example/a")
assertEquals("Visit https://inside.example first.", result.quote)
assertEquals("https://source.example/a", result.url)
}
@Test
fun noSelectorWhenPlainUrl() {
val result = SharedHighlightParser.parse("quote\n\nhttps://example.com")
assertFalse(result.hasSelector())
assertNull(result.prefix)
assertNull(result.suffix)
}
}
@@ -0,0 +1,118 @@
/*
* 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.nip84Highlights.parse
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class TextFragmentParserTest {
@Test
fun returnsNullWhenNoFragment() {
assertNull(TextFragmentParser.parse("https://example.com/post"))
}
@Test
fun returnsNullForPlainElementFragment() {
assertNull(TextFragmentParser.parse("https://example.com/post#section-2"))
}
@Test
fun parsesStartOnly() {
val fragment = TextFragmentParser.parse("https://example.com/post#:~:text=hello%20world")
assertEquals("hello world", fragment?.start)
assertNull(fragment?.prefix)
assertNull(fragment?.suffix)
assertNull(fragment?.end)
}
@Test
fun parsesPrefixStartSuffix() {
val fragment =
TextFragmentParser.parse(
"https://example.com/post#:~:text=the%20-,highlighted%20passage,-follows%20on",
)
assertEquals("the ", fragment?.prefix)
assertEquals("highlighted passage", fragment?.start)
assertEquals("follows on", fragment?.suffix)
}
@Test
fun parsesStartAndEndRange() {
val fragment =
TextFragmentParser.parse("https://example.com/post#:~:text=start%20of,end%20of")
assertEquals("start of", fragment?.start)
assertEquals("end of", fragment?.end)
}
@Test
fun decodesEncodedCommaWithinText() {
// A comma that belongs to the passage arrives percent-encoded (%2C) so it is not
// mistaken for the directive's own start/end separator.
val fragment = TextFragmentParser.parse("https://example.com/post#:~:text=one%2C%20two%2C%20three")
assertEquals("one, two, three", fragment?.start)
assertNull(fragment?.end)
}
@Test
fun leavesLiteralPlusVerbatim() {
// Form decoding would turn `+` into a space; a text fragment must not.
val fragment = TextFragmentParser.parse("https://example.com/post#:~:text=c%2B%2B%20rocks")
assertEquals("c++ rocks", fragment?.start)
}
@Test
fun readsFirstTextDirectiveWhenSeveral() {
val fragment =
TextFragmentParser.parse("https://example.com/post#:~:text=first&text=second")
assertEquals("first", fragment?.start)
}
@Test
fun parsesDirectiveAfterElementId() {
val fragment = TextFragmentParser.parse("https://example.com/post#heading:~:text=quoted")
assertEquals("quoted", fragment?.start)
}
@Test
fun stripsDirectiveAndBareHash() {
assertEquals(
"https://example.com/post",
TextFragmentParser.stripTextFragment("https://example.com/post#:~:text=hello"),
)
}
@Test
fun stripsDirectiveButKeepsElementId() {
assertEquals(
"https://example.com/post#heading",
TextFragmentParser.stripTextFragment("https://example.com/post#heading:~:text=hello"),
)
}
@Test
fun stripLeavesPlainFragmentUntouched() {
assertEquals(
"https://example.com/post#section",
TextFragmentParser.stripTextFragment("https://example.com/post#section"),
)
}
}
@@ -0,0 +1,87 @@
/*
* 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.nip84Highlights.parse
import kotlin.test.Test
import kotlin.test.assertEquals
class UrlTrackerCleanerTest {
@Test
fun keepsUrlsWithoutQuery() {
assertEquals("https://example.com/post", UrlTrackerCleaner.clean("https://example.com/post"))
}
@Test
fun keepsMeaningfulQueryParams() {
assertEquals(
"https://example.com/search?q=nostr&page=2",
UrlTrackerCleaner.clean("https://example.com/search?q=nostr&page=2"),
)
}
@Test
fun stripsUtmParams() {
assertEquals(
"https://example.com/post?id=42",
UrlTrackerCleaner.clean("https://example.com/post?utm_source=twitter&id=42&utm_medium=social"),
)
}
@Test
fun stripsKnownClickIds() {
assertEquals(
"https://example.com/post",
UrlTrackerCleaner.clean("https://example.com/post?fbclid=abc123&gclid=xyz"),
)
}
@Test
fun dropsQuestionMarkWhenOnlyTrackersRemain() {
assertEquals(
"https://example.com/post",
UrlTrackerCleaner.clean("https://example.com/post?utm_campaign=spring"),
)
}
@Test
fun matchesTrackerNamesCaseInsensitively() {
assertEquals(
"https://example.com/post",
UrlTrackerCleaner.clean("https://example.com/post?UTM_Source=x&FBCLID=y"),
)
}
@Test
fun preservesFragmentAfterCleaning() {
assertEquals(
"https://example.com/post?id=42#section",
UrlTrackerCleaner.clean("https://example.com/post?utm_source=x&id=42#section"),
)
}
@Test
fun preservesTextFragmentDirective() {
assertEquals(
"https://example.com/post#:~:text=hello",
UrlTrackerCleaner.clean("https://example.com/post?fbclid=abc#:~:text=hello"),
)
}
}