mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
feat: render NIP-84 highlights from web highlighter clients
Web-based highlighter clients publish kind:9802 highlights with W3C Web Annotation selectors (textquoteselector / textpositionselector / rangeselector) instead of a NIP-84 `context` tag. These were previously ignored, so the highlight rendered without its surrounding paragraph and the "jump to page" link couldn't disambiguate repeated quotes. - Parse the W3C textquoteselector into TextQuoteSelectorTag (exact/prefix/ suffix; a "-" or empty exact is treated as a placeholder since the quote lives in .content). - HighlightEvent.contextOrReconstructed() prefers an explicit `context` tag and otherwise rebuilds the paragraph from prefix + content + suffix, so the in-context bolding still works. - Build a disambiguated Text Fragment URL (`#:~:text=prefix-,exact,-suffix`) from the selector's prefix/suffix so the source link scrolls to the correct occurrence. The position/range selectors are left unparsed; they only matter for an in-app live-page re-highlighter, which we don't have. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Di4UurD9SQGrpScX7uy2Kh
This commit is contained in:
@@ -79,12 +79,16 @@ fun RenderHighlight(
|
||||
) {
|
||||
val noteEvent = note.event as? HighlightEvent ?: return
|
||||
|
||||
val selector = noteEvent.textQuoteSelector()
|
||||
|
||||
DisplayHighlight(
|
||||
comment = noteEvent.comment(),
|
||||
highlight = noteEvent.quote(),
|
||||
context = noteEvent.context(),
|
||||
context = noteEvent.contextOrReconstructed(),
|
||||
authorHex = noteEvent.author(),
|
||||
url = noteEvent.inUrl(),
|
||||
textFragmentPrefix = selector?.prefix,
|
||||
textFragmentSuffix = selector?.suffix,
|
||||
postAddress = noteEvent.inPostAddress(),
|
||||
postVersion = noteEvent.inPostVersion(),
|
||||
makeItShort = makeItShort,
|
||||
@@ -152,6 +156,8 @@ fun DisplayHighlight(
|
||||
context: String?,
|
||||
authorHex: String?,
|
||||
url: String?,
|
||||
textFragmentPrefix: String? = null,
|
||||
textFragmentSuffix: String? = null,
|
||||
postAddress: Address?,
|
||||
postVersion: ETag?,
|
||||
makeItShort: Boolean,
|
||||
@@ -220,6 +226,8 @@ fun DisplayHighlight(
|
||||
highlightQuote = highlight,
|
||||
authorHex = authorHex,
|
||||
baseUrl = url,
|
||||
textFragmentPrefix = textFragmentPrefix,
|
||||
textFragmentSuffix = textFragmentSuffix,
|
||||
postAddress = postAddress,
|
||||
postVersion = postVersion,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -228,11 +236,53 @@ fun DisplayHighlight(
|
||||
}
|
||||
}
|
||||
|
||||
private const val FRAGMENT_EDGE_WORDS = 4
|
||||
|
||||
/**
|
||||
* Builds a URL with a Text Fragment (`#:~:text=`) directive that scrolls the source page
|
||||
* to the highlighted text. When a W3C `textquoteselector` supplies surrounding prefix/suffix
|
||||
* text, a few words of each are added as `prefix-,` / `,-suffix` disambiguators so the browser
|
||||
* lands on the correct occurrence even when the quote repeats on the page.
|
||||
*
|
||||
* See https://wicg.github.io/scroll-to-text-fragment/
|
||||
*/
|
||||
private fun buildTextFragmentUrl(
|
||||
baseUrl: String,
|
||||
exact: String,
|
||||
prefix: String?,
|
||||
suffix: String?,
|
||||
): String {
|
||||
val separator = if (baseUrl.contains("#")) "&" else "#"
|
||||
|
||||
val prefixPart = trimToFragmentEdge(prefix, keepStart = false)?.let { "${Uri.encode(it)}-," } ?: ""
|
||||
val suffixPart = trimToFragmentEdge(suffix, keepStart = true)?.let { ",-${Uri.encode(it)}" } ?: ""
|
||||
|
||||
return "$baseUrl$separator:~:text=$prefixPart${Uri.encode(exact)}$suffixPart"
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps only the [FRAGMENT_EDGE_WORDS] words nearest the highlight (the last words when
|
||||
* [keepStart] is false, for a prefix; the first words when true, for a suffix) and collapses
|
||||
* whitespace so newlines from the selector don't break Text Fragment matching.
|
||||
*/
|
||||
private fun trimToFragmentEdge(
|
||||
text: String?,
|
||||
keepStart: Boolean,
|
||||
): String? {
|
||||
if (text == null) return null
|
||||
val words = text.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||
if (words.isEmpty()) return null
|
||||
val slice = if (keepStart) words.take(FRAGMENT_EDGE_WORDS) else words.takeLast(FRAGMENT_EDGE_WORDS)
|
||||
return slice.joinToString(" ")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayQuoteAuthor(
|
||||
highlightQuote: String,
|
||||
authorHex: String?,
|
||||
baseUrl: String?,
|
||||
textFragmentPrefix: String? = null,
|
||||
textFragmentSuffix: String? = null,
|
||||
postAddress: Address?,
|
||||
postVersion: ETag?,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -292,7 +342,10 @@ private fun DisplayQuoteAuthor(
|
||||
}
|
||||
|
||||
baseUrl != null -> {
|
||||
val url = "$baseUrl${if (baseUrl.contains("#")) "&" else "#"}:~:text=${Uri.encode(highlightQuote)}"
|
||||
val url =
|
||||
remember(baseUrl, highlightQuote, textFragmentPrefix, textFragmentSuffix) {
|
||||
buildTextFragmentUrl(baseUrl, highlightQuote, textFragmentPrefix, textFragmentSuffix)
|
||||
}
|
||||
|
||||
DisplayEntryForAUrl(url, userBase, accountViewModel, nav)
|
||||
}
|
||||
|
||||
+18
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip22Comments.RootScope
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.tags.CommentTag
|
||||
import com.vitorpamplona.quartz.nip84Highlights.tags.ContextTag
|
||||
import com.vitorpamplona.quartz.nip84Highlights.tags.TextQuoteSelectorTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Immutable
|
||||
@@ -123,6 +124,23 @@ class HighlightEvent(
|
||||
|
||||
fun context() = tags.firstNotNullOfOrNull(ContextTag::parse)
|
||||
|
||||
fun textQuoteSelector() = tags.firstNotNullOfOrNull(TextQuoteSelectorTag::parse)
|
||||
|
||||
/**
|
||||
* The paragraph-level context surrounding the highlight, preferring an explicit
|
||||
* NIP-84 `context` tag and falling back to reconstructing it from a W3C
|
||||
* `textquoteselector`'s prefix/suffix (as web highlighter clients emit) so the
|
||||
* in-context rendering still works when no `context` tag is present.
|
||||
*/
|
||||
fun contextOrReconstructed(): String? {
|
||||
context()?.let { return it }
|
||||
|
||||
val selector = textQuoteSelector() ?: return null
|
||||
if (selector.prefix == null && selector.suffix == null) return null
|
||||
|
||||
return (selector.prefix ?: "") + content + (selector.suffix ?: "")
|
||||
}
|
||||
|
||||
fun inPost() = firstTaggedATag()
|
||||
|
||||
fun inPostAddress() = firstTaggedAddress()
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.tags
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* W3C Web Annotation TextQuoteSelector, as emitted by web-based highlighter clients
|
||||
* alongside NIP-84 kind:9802 highlights. It anchors the highlight inside the source
|
||||
* page ([HighlightEvent.inUrl]) by the exact text and the text immediately before
|
||||
* (prefix) and after (suffix) it.
|
||||
*
|
||||
* The tag is laid out as `["textquoteselector", exact, prefix, suffix]`. The exact
|
||||
* field is often a placeholder ("-" or empty) because the highlighted text is already
|
||||
* carried by the event's `.content`; [exact] is null in that case.
|
||||
*/
|
||||
@Immutable
|
||||
class TextQuoteSelectorTag(
|
||||
val exact: String?,
|
||||
val prefix: String?,
|
||||
val suffix: String?,
|
||||
) {
|
||||
companion object {
|
||||
const val TAG_NAME = "textquoteselector"
|
||||
|
||||
private fun field(value: String?): String? = value?.takeIf { it.isNotEmpty() && it != "-" }
|
||||
|
||||
fun parse(tag: Array<String>): TextQuoteSelectorTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
|
||||
val exact = field(tag.getOrNull(1))
|
||||
val prefix = field(tag.getOrNull(2))
|
||||
val suffix = field(tag.getOrNull(3))
|
||||
|
||||
ensure(exact != null || prefix != null || suffix != null) { return null }
|
||||
|
||||
return TextQuoteSelectorTag(exact, prefix, suffix)
|
||||
}
|
||||
|
||||
fun assemble(
|
||||
exact: String?,
|
||||
prefix: String?,
|
||||
suffix: String?,
|
||||
) = arrayOf(TAG_NAME, exact ?: "-", prefix ?: "", suffix ?: "")
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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 kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class HighlightEventTest {
|
||||
// The kind:9802 highlight emitted by a web highlighter client: no `context` tag,
|
||||
// but W3C Web Annotation selectors carrying the surrounding prefix/suffix.
|
||||
private val webHighlight =
|
||||
HighlightEvent(
|
||||
id = "8d7ae10a57ef178a17563a6ecbf9a399bb1796a2e032ca72703b00913b4cfd42",
|
||||
pubKey = "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
|
||||
createdAt = 1784322253,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("r", "https://geohot.github.io//blog/jekyll/update/2026/05/03/punk-or-why-i-dont-stream.html"),
|
||||
arrayOf("textquoteselector", "-", "Your food is prechewed for you. ", "\n\nAnd it’s not like there’s anyw"),
|
||||
arrayOf("textpositionselector", "1861", "1938"),
|
||||
arrayOf("rangeselector", "/main[1]/div[1]/article[1]/div[1]/p[5]", "/main[1]/div[1]/article[1]/div[1]/p[5]", "257", "334"),
|
||||
),
|
||||
content = "The caged tiger prefers a pot of meat slop to an antelope they have to chase.",
|
||||
sig = "3d7040846e0e9fea7ebd58b3f3377290e6677f6b1fbef6bd6957cc76d262a7c190f4d17609a5cee941f9e24d6e131b78784a9e6e8d289efa8af813ad3c2f23ea",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun parsesReferenceUrlAndIgnoresUnknownSelectorsForSource() {
|
||||
assertEquals(
|
||||
"https://geohot.github.io//blog/jekyll/update/2026/05/03/punk-or-why-i-dont-stream.html",
|
||||
webHighlight.inUrl(),
|
||||
)
|
||||
assertNull(webHighlight.context())
|
||||
assertNull(webHighlight.comment())
|
||||
assertNull(webHighlight.author())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesTextQuoteSelectorTreatingDashExactAsPlaceholder() {
|
||||
val selector = webHighlight.textQuoteSelector()
|
||||
assertEquals(null, selector?.exact) // "-" placeholder means the quote is in .content
|
||||
assertEquals("Your food is prechewed for you. ", selector?.prefix)
|
||||
assertEquals("\n\nAnd it’s not like there’s anyw", selector?.suffix)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconstructsContextFromSelectorWhenNoContextTag() {
|
||||
assertEquals(
|
||||
"Your food is prechewed for you. The caged tiger prefers a pot of meat slop to an antelope they have to chase.\n\nAnd it’s not like there’s anyw",
|
||||
webHighlight.contextOrReconstructed(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefersExplicitContextTagOverSelectorReconstruction() {
|
||||
val withContext =
|
||||
HighlightEvent(
|
||||
id = "00",
|
||||
pubKey = "00",
|
||||
createdAt = 0,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("context", "An explicit paragraph of context around the quote."),
|
||||
arrayOf("textquoteselector", "-", "before ", " after"),
|
||||
),
|
||||
content = "the quote",
|
||||
sig = "00",
|
||||
)
|
||||
|
||||
assertEquals("An explicit paragraph of context around the quote.", withContext.contextOrReconstructed())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returnsNullContextWhenNeitherContextTagNorSelectorPresent() {
|
||||
val bare =
|
||||
HighlightEvent(
|
||||
id = "00",
|
||||
pubKey = "00",
|
||||
createdAt = 0,
|
||||
tags = arrayOf(arrayOf("r", "https://example.com")),
|
||||
content = "a bare highlight",
|
||||
sig = "00",
|
||||
)
|
||||
|
||||
assertNull(bare.textQuoteSelector())
|
||||
assertNull(bare.contextOrReconstructed())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user