From 69c3e3accc56dbe10dfe5528b7ea97d0d72f07f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 23:39:21 +0000 Subject: [PATCH 1/8] 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. --- .../quartz/nip84Highlights/HighlightEvent.kt | 40 +++++ .../nip84Highlights/parse/SharedHighlight.kt | 48 ++++++ .../parse/SharedHighlightParser.kt | 97 +++++++++++ .../parse/TextFragmentParser.kt | 156 ++++++++++++++++++ .../parse/UrlTrackerCleaner.kt | 100 +++++++++++ .../HighlightEventBuilderTest.kt | 142 ++++++++++++++++ .../parse/SharedHighlightParserTest.kt | 131 +++++++++++++++ .../parse/TextFragmentParserTest.kt | 118 +++++++++++++ .../parse/UrlTrackerCleanerTest.kt | 87 ++++++++++ 9 files changed, 919 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlight.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlightParser.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParser.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/UrlTrackerCleaner.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEventBuilderTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlightParserTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParserTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/UrlTrackerCleanerTest.kt 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"), + ) + } +} From 1f353398661bc27be005698871ac387ac6a6eb35 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 23:53:02 +0000 Subject: [PATCH 2/8] feat(android): share browser selection to a NIP-84 highlight composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "New Highlight" share target and composer so a user can select text in a browser, hit Share → Amethyst, and publish it as a kind:9802 highlight. - Manifest: a text/plain-only ShareAsHighlightAlias activity-alias, matched at runtime by ShareIntentRouting.isShareAsHighlight (mirrors the Send-as-DM alias pattern). - AppNavigation: both the launch-intent and warm onNewIntent parse blocks now branch on the highlight alias, run the shared text through SharedHighlightParser, and open Route.NewHighlight pre-split into quote/url/prefix/suffix. - NewHighlightScreen + NewHighlightPostViewModel: a trimmed composer (passage, source URL, optional note — no polls/zaps/media/scheduling) that publishes via account.signAndComputeBroadcast(HighlightEvent.build(...)). Also adds HighlightEvent.build(), the unsigned EventTemplate counterpart of create(), for the sign-and-broadcast pipeline. Verified with :quartz:jvmTest and :amethyst:compileFdroidDebugKotlin. --- amethyst/src/main/AndroidManifest.xml | 18 +++ .../amethyst/ui/navigation/AppNavigation.kt | 46 ++++++- .../ui/navigation/ShareIntentRouting.kt | 15 +- .../amethyst/ui/navigation/routes/Routes.kt | 14 ++ .../highlights/NewHighlightPostViewModel.kt | 101 ++++++++++++++ .../loggedIn/highlights/NewHighlightScreen.kt | 129 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 5 + .../quartz/nip84Highlights/HighlightEvent.kt | 32 ++++- .../HighlightEventBuilderTest.kt | 17 +++ 9 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightPostViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightScreen.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index de5ea4ed7a..7ef7b9f0c9 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -295,6 +295,24 @@ + + + + + + + + + +