diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt index e968685fbe..266bc88149 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt @@ -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>() + + 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) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlight.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlight.kt new file mode 100644 index 0000000000..c491ac8181 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlight.kt @@ -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() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlightParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlightParser.kt new file mode 100644 index 0000000000..8f171b5546 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlightParser.kt @@ -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 } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParser.kt new file mode 100644 index 0000000000..63c6e76096 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParser.kt @@ -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(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 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/UrlTrackerCleaner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/UrlTrackerCleaner.kt new file mode 100644 index 0000000000..552d2a9318 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/UrlTrackerCleaner.kt @@ -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 + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEventBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEventBuilderTest.kt new file mode 100644 index 0000000000..ffe396873a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEventBuilderTest.kt @@ -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) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlightParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlightParserTest.kt new file mode 100644 index 0000000000..446cb8eb77 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlightParserTest.kt @@ -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) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParserTest.kt new file mode 100644 index 0000000000..7b5c5d8d49 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParserTest.kt @@ -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"), + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/UrlTrackerCleanerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/UrlTrackerCleanerTest.kt new file mode 100644 index 0000000000..b36c995537 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/UrlTrackerCleanerTest.kt @@ -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"), + ) + } +}