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))
}
}
}
+14
View File
@@ -5,6 +5,7 @@
"A difference between any two implementations is a note that changes shape when it syncs. Add a case here, not to one suite.",
"Lives under core/ because core is the definition, and because core/ is in the desktop, Android and server file sets, so changing a case reruns every suite that reads it.",
"display_titles: a note's name is its first line that says anything once a task marker is stripped, cut to 200 characters (not bytes). Lines split on \\n alone, as the grammar does everywhere.",
"urls: the links in a body the server unfurls (found), and the one a note that is nothing but a link files its preview under (lone). Trailing .,;:!? is the sentence's, not the link's, on every surface.",
"recurrences: the repeat rules a reminder can carry, as stored. The server, the core and the web's editor each hold the list; Android's picker pins it by hand, as it does the tint.",
"Tag cases are plain text on purpose. The web's renderer lets `code` and **bold** claim a `#` first, which the derivers do not; that is a rendering choice, not grammar, and is tested where it lives."
],
@@ -82,6 +83,19 @@
{ "body": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "title": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé" }
],
"urls": [
{ "body": "https://example.com/a", "found": ["https://example.com/a"], "lone": "https://example.com/a" },
{ "body": " https://example.com/a \n", "found": ["https://example.com/a"], "lone": "https://example.com/a" },
{ "body": "https://example.com/a.", "found": ["https://example.com/a"], "lone": "https://example.com/a" },
{ "body": "https://example.com/a?q=1!", "found": ["https://example.com/a?q=1"], "lone": "https://example.com/a?q=1" },
{ "body": "see https://example.com/a, then https://example.com/b.", "found": ["https://example.com/a", "https://example.com/b"], "lone": null },
{ "body": "<https://example.com/a>", "found": ["https://example.com/a"], "lone": null },
{ "body": "https://example.com/a)", "found": ["https://example.com/a"], "lone": null },
{ "body": "https://example.com/a https://example.com/a", "found": ["https://example.com/a"], "lone": null },
{ "body": "ftp://example.com/a", "found": [], "lone": null },
{ "body": "", "found": [], "lone": null }
],
"recurrences": ["daily", "weekly", "monthly", "yearly"],
"tint": {
+4 -7
View File
@@ -3,6 +3,7 @@ import { computed, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import { NOTE_CARD_SURFACE, labelChipClasses } from "../notes/colors";
import { rendersInline } from "../notes/attachments";
import { loneUrl } from "../notes/links";
import type { Note } from "../stores/notes";
import Icon from "./Icon.vue";
import LinkPreview from "./LinkPreview.vue";
@@ -84,18 +85,14 @@ const PREVIEW_LINES = 8;
// A note that is NOTHING but a URL is a link, and its preview is the whole card —
// showing the raw URL underneath a card that already says where it goes is saying the
// same thing twice, badly. A URL mentioned *inside* a note is a footnote to it, and
// gets a compact strip at the bottom instead.
//
// Whitespace either side still counts as lone: someone pasting a link rarely trims it.
const LONE_URL_RE = /^\s*(https?:\/\/[^\s<>"'\])]+)\s*$/;
const isLoneUrl = computed(() => LONE_URL_RE.test(props.note.body) && !props.note.items.length);
// gets a compact strip at the bottom instead. What counts as lone is notes/links.ts.
const isLoneUrl = computed(() => loneUrl(props.note.body) !== null && !props.note.items.length);
/** The preview for a lone-URL note — null while it is still being fetched, or if it
* could never be fetched at all. */
const loneUrlPreview = computed(() => {
if (!isLoneUrl.value) return null;
const url = props.note.body.trim();
const url = loneUrl(props.note.body);
return props.note.previews.find((p) => p.url === url) ?? null;
});
+7
View File
@@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest";
import fixture from "../../../core/testdata/grammar.json";
import { DERIVED_TINT_KEYS, derivedTint, resolveLabelColor, tintHash } from "./colors";
import { parseInline, parseTaskLine, renderTaskLine } from "./markdown";
import { loneUrl } from "./links";
import { RECURRENCES } from "./recurrence";
describe("checklist lines", () => {
@@ -59,3 +60,9 @@ describe("recurrences", () => {
expect(RECURRENCES.map((r) => r.value)).toEqual(fixture.recurrences);
});
});
describe("links", () => {
it.each(fixture.urls)("lone link in $body", ({ body, lone }) => {
expect(loneUrl(body)).toBe(lone);
});
});
+17
View File
@@ -0,0 +1,17 @@
// A note that is nothing but a link renders as a link card.
//
// The characters a link runs over, and the punctuation trimmed off its end, are the
// server's (unfurl_queue.detect_urls): the server files the preview under the trimmed
// link, so the card has to look it up under the same one (#5399). Android's
// LinkPreviewRow holds the same rule, and all three run the `urls` cases in
// core/testdata/grammar.json.
//
// Whitespace either side still counts as lone: someone pasting a link rarely trims it.
const LONE_URL_RE = /^\s*(https?:\/\/[^\s<>"'\])]+)\s*$/;
/** The link a lone-link note's preview is filed under, or null for any other body. */
export function loneUrl(body: string): string | null {
const match = LONE_URL_RE.exec(body);
if (!match) return null;
return match[1].replace(/[.,;:!?]+$/, "") || null;
}
+6
View File
@@ -17,6 +17,7 @@ from inkwell.notes.checklist import parse_items, render_item
from inkwell.notes.helpers import derive_display_title
from inkwell.notes.recurrence import REMINDER_RECURRENCES
from inkwell.notes.tags import parse_tags, split_body_tags
from inkwell.unfurl_queue import detect_urls
FIXTURE = json.loads(
(Path(__file__).resolve().parents[1] / "core" / "testdata" / "grammar.json").read_text(encoding="utf-8")
@@ -59,3 +60,8 @@ def test_palette_keys():
"""The palette the server accepts is the fixture's hues plus `default`, the key
unrecognised input lands on. The web checks the same hues (DERIVED_TINT_KEYS)."""
assert NOTE_COLORS == {"default", *FIXTURE["tint"]["keys"]}
@pytest.mark.parametrize("case", FIXTURE["urls"], ids=lambda c: repr(c["body"]))
def test_urls(case):
assert detect_urls(case["body"]) == case["found"]