A lone link's preview is looked up where the server filed it

The server unfurls what detect_urls finds, trailing .,;:!? trimmed, and files the
preview under that. The web and Android cards looked a lone link's preview up
under body.trim(), punctuation included, so a note reading
"https://example.com/a." never showed its card.

- grammar.json gains a urls section: what the server finds in a body, and the
  link a lone-link note is filed under.
- The web's rule moves out of NoteCard into notes/links.ts loneUrl(); Android's
  LinkPreviewRow gets the same loneUrl(); both trim like the server.
- The server and web suites run the cases; Android's JVM test pins them by hand,
  as it does the tint.

Fixes #5399. DRY pass #2, batch 3 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:30:32 -04:00
co-authored by Claude Opus 5.5
parent 3766d59b2e
commit 1c4bf56058
7 changed files with 99 additions and 13 deletions
@@ -35,13 +35,26 @@ private val COMPACT_IMAGE_WIDTH = 48.dp
/**
* A URL that is the WHOLE body, whitespace either side allowed.
*
* Mirrors `LONE_URL_RE` in `NoteCard.vue` deliberately — the two surfaces have to
* agree on what counts as "this note is a link", or the same note reads as a card
* on one and a paragraph on the other. Someone pasting a link rarely trims it,
* Mirrors `LONE_URL_RE` in the web's `notes/links.ts` deliberately — the two surfaces
* have to agree on what counts as "this note is a link", or the same note reads as a
* card on one and a paragraph on the other. Someone pasting a link rarely trims it,
* which is why the surrounding whitespace is tolerated rather than rejected.
*/
private val LONE_URL = Regex("""^\s*(https?://[^\s<>"'\]\)]+)\s*$""")
/** The sentence's punctuation, not the link's: the server trims it before unfurling. */
private val TRAILING_PUNCTUATION = charArrayOf('.', ',', ';', ':', '!', '?')
/**
* The link a note that is nothing but a link files its preview under, or null.
*
* Trimmed the way the server's `unfurl_queue.detect_urls` trims it, since that is the
* key the preview arrives under (#5399). The web's `loneUrl` is the same rule, and
* both run the `urls` cases in `core/testdata/grammar.json` (here, by hand).
*/
fun loneUrl(body: String): String? =
LONE_URL.matchEntire(body)?.groupValues?.get(1)?.trimEnd(*TRAILING_PUNCTUATION)?.ifEmpty { null }
/**
* The preview for a note that is nothing but a URL, or null.
*
@@ -56,13 +69,12 @@ private val LONE_URL = Regex("""^\s*(https?://[^\s<>"'\]\)]+)\s*$""")
* the URL does.
*/
fun loneUrlPreview(note: Note): LinkPreview? {
if (!LONE_URL.matches(note.body)) return null
val url = note.body.trim()
val url = loneUrl(note.body) ?: return null
return note.previews.firstOrNull { it.url == url }
}
/** True when the body is a lone URL, whether or not a preview has arrived for it. */
fun isLoneUrl(note: Note): Boolean = LONE_URL.matches(note.body)
fun isLoneUrl(note: Note): Boolean = loneUrl(note.body) != null
/**
* A fetched link preview, in one of two sizes.
@@ -0,0 +1,33 @@
package com.fabledsword.inkwell.ui
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pins [loneUrl] against the `urls` cases in `core/testdata/grammar.json`, written out
* by hand as [DerivedTintTest] does the tint: these JVM tests do not read the fixture.
* The web runs the same cases in `notes/grammar.test.ts`, the server in
* `tests/test_grammar_fixture.py`, so a change to the rule changes the fixture and
* this file together, or a suite goes red.
*/
class LoneUrlTest {
@Test
fun `lone links match the fixture shared with the web and the server`() {
val cases =
listOf(
"https://example.com/a" to "https://example.com/a",
" https://example.com/a \n" to "https://example.com/a",
"https://example.com/a." to "https://example.com/a",
"https://example.com/a?q=1!" to "https://example.com/a?q=1",
"see https://example.com/a, then https://example.com/b." to null,
"<https://example.com/a>" to null,
"https://example.com/a)" to null,
"https://example.com/a https://example.com/a" to null,
"ftp://example.com/a" to null,
"" to null,
)
for ((body, lone) in cases) {
assertEquals("body [$body]", lone, loneUrl(body))
}
}
}