URLs unfurl on their own, and a lone link becomes the note
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m16s
Desktop (Tauri) / Update manifest (push) Successful in 6s

Operator: *"I'd like for URLs to unfurl. To be the whole note when the note is a
single URL, and to be a compact slot on the bottom of the note when the URL is
inline. We also need to support multiple URLs in a single note."*

Less new machinery than it sounds: `unfurl.py` already fetched and parsed OG
tags, SSRF-hardened, and `note_link_previews` was already `UNIQUE(note_id, url)`
— so several URLs per note has worked at the storage layer all along. What was
missing was that it needed a button, had one size, and drew that size in the
wrong place.

**Automatic, and never in the way.** New `unfurl_queue.py` detects a body's URLs
and fetches them on a background task AFTER the note is committed. Capture speed
is the product: an unfurl is a five-second timeout against a host nobody
controls, and a note has to persist the instant someone stops typing. Scheduled
from create, from a body edit, and from a synced push — so a linked desktop or
Android client gets previews too, on its next pull. An unlinked one has no server
to ask and simply has none, which is the honest consequence of being offline.

Safe to call on every save: it re-reads what's cached and does nothing when
nothing is new. Capped at five URLs per note, silent on every failure (a link
that won't fetch isn't an error the person needs — the note is fine, the link is
still there), and it re-checks before storing, so a slow fetch can't resurrect a
preview for a URL that was deleted while it was in flight.

**Two presentations.** A note whose body is nothing but a URL renders as its
preview and nothing else — printing the raw URL under a card that already says
where it goes is saying the same thing twice, badly. Until the fetch lands, or if
it never does, the URL stands in, so the card is never blank. Anything else gets
a compact strip.

**And the strip moved.** Previews were rendered ABOVE the body, which put a
stranger's headline where the note's own first line should be — worse now that
the first line IS the note's name. They sit at the foot of the card now, under
the note's own words.

The editor's "Preview example.com" button is gone with the manual path; removing
an unwanted preview stays, and stays editor-only.

Nine tests: three on detection (order, dedupe, sentence-punctuation trimming,
non-http rejection) in the unit lane, and three in the integration lane for what
only a real database shows — the upsert landing on the right row, a second pass
fetching nothing, and a preview NOT being stored for a URL that left the body.
This commit is contained in:
2026-08-23 01:05:37 -04:00
parent c99cbb3e14
commit de72d27bd4
8 changed files with 310 additions and 67 deletions
+31 -7
View File
@@ -1,12 +1,24 @@
<script setup lang="ts">
import type { LinkPreview } from "../stores/notes";
defineProps<{ preview: LinkPreview; removable?: boolean }>();
/**
* A fetched link preview, in one of two sizes.
*
* `compact` is a single row — favicon-less, one line of title, the site name — for a
* URL mentioned *inside* a note that has its own text. The note is the thing; the
* link is a footnote to it.
*
* Full size is for a note that is NOTHING but a URL. There the link IS the note, and
* a compact strip would be a card with nothing on it.
*/
defineProps<{ preview: LinkPreview; removable?: boolean; compact?: boolean }>();
defineEmits<{ (e: "remove"): void }>();
</script>
<template>
<div class="group/lp relative overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-700">
<div
class="group/lp relative overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-700"
>
<a
:href="preview.url"
target="_blank"
@@ -19,16 +31,28 @@ defineEmits<{ (e: "remove"): void }>();
alt=""
loading="lazy"
decoding="async"
class="h-auto w-24 shrink-0 self-stretch object-cover"
class="h-auto shrink-0 self-stretch object-cover"
:class="compact ? 'w-12' : 'w-24'"
/>
<div class="min-w-0 flex-1 px-3 py-2">
<p v-if="preview.site_name" class="truncate text-[11px] uppercase tracking-wide text-neutral-400">
<div class="min-w-0 flex-1" :class="compact ? 'px-2 py-1.5' : 'px-3 py-2'">
<p
v-if="preview.site_name"
class="truncate uppercase tracking-wide text-neutral-400"
:class="compact ? 'text-[10px]' : 'text-[11px]'"
>
{{ preview.site_name }}
</p>
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">
<p
class="truncate font-medium text-neutral-800 dark:text-neutral-100"
:class="compact ? 'text-xs' : 'text-sm'"
>
{{ preview.title || preview.url }}
</p>
<p v-if="preview.description" class="mt-0.5 line-clamp-2 text-xs text-neutral-500 dark:text-neutral-400">
<!-- The description is the first thing to go when there is no room for it. -->
<p
v-if="preview.description && !compact"
class="mt-0.5 line-clamp-2 text-xs text-neutral-500 dark:text-neutral-400"
>
{{ preview.description }}
</p>
</div>
+32 -4
View File
@@ -68,6 +68,26 @@ const otherAttachments = computed(() => props.note.attachments.filter((a) => !a.
// exactly, and skips parsing a body the card was never going to show.
const PREVIEW_LINES = 8;
// --- Links ------------------------------------------------------------------
//
// 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);
/** 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();
return props.note.previews.find((p) => p.url === url) ?? null;
});
const bodyPreview = computed(() => {
const lines = props.note.body.split("\n");
if (lines.length <= PREVIEW_LINES) return props.note.body;
@@ -226,9 +246,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
</span>
</div>
<div v-if="note.previews.length" class="mb-2 flex flex-col gap-2">
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" />
</div>
<!-- One render path: every note is a body plus, maybe, checkable items.
A focusable div rather than a <button>, because a checklist nests interactive
@@ -241,7 +258,11 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
@click="emit('open', note)"
@keydown.enter="emit('open', note)"
>
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<!-- A lone URL renders as its preview and nothing else. Until the fetch lands
or if it never does the URL itself stands in, so the card is never
blank and the link is never unreachable. -->
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="bodyPreview" />
</div>
<p
@@ -251,6 +272,13 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
Empty note
</p>
</div>
<!-- Inline links: a compact strip at the FOOT of the card, under the note's own
words rather than stacked on top of them. They were above the body until
M13 — which put a stranger's headline where the note's first line should be. -->
<div v-if="!isLoneUrl && note.previews.length" class="mt-2 flex flex-col gap-1">
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact />
</div>
<NoteChecklist
v-if="note.items.length"
:class="note.body ? 'mt-2' : ''"
+7 -55
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import { useConfigStore } from "../stores/config";
import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue";
@@ -24,7 +23,6 @@ const props = withDefaults(defineProps<{ note?: Note | null; initialBody?: strin
});
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
const notes = useNotesStore();
const config = useConfigStore();
const noteId = ref<string | null>(props.note?.id ?? null);
const body = ref(props.note?.body ?? props.initialBody);
@@ -323,41 +321,12 @@ async function uploadFile(file: File) {
}
}
// ---- link previews (URL unfurl) ----
const unfurling = ref<string | null>(null); // the URL currently being fetched
const unfurlError = ref("");
// Bare http(s) URLs in the body; trailing sentence punctuation trimmed.
const URL_RE = /(https?:\/\/[^\s<>"'\])]+)/g;
const detectedUrls = computed(() => {
const out: string[] = [];
for (const m of body.value.matchAll(URL_RE)) {
const u = m[1].replace(/[.,;:!?]+$/, "");
if (!out.includes(u)) out.push(u);
}
return out;
});
const previewedUrls = computed(() => new Set(liveNote.value.previews.map((p) => p.url)));
const unpreviewedUrls = computed(() => detectedUrls.value.filter((u) => !previewedUrls.value.has(u)));
async function addPreview(url: string) {
const id = await ensureDraft();
if (!id) return;
unfurling.value = url;
unfurlError.value = "";
try {
await notes.unfurl(id, url);
} catch (e) {
unfurlError.value = (e as { error?: string }).error ?? "Couldn't fetch a preview for that link.";
} finally {
unfurling.value = null;
}
}
function shortUrl(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
// ---- link previews ----
//
// Nothing to trigger any more: the server unfurls a note's URLs in the background
// after each save (`unfurl_queue.py`) and the preview arrives on a later read. What
// is left here is removing one you don't want — the editor is the only place with
// room to offer that, and the card deliberately doesn't.
async function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
@@ -494,7 +463,7 @@ function revPreview(rev: NoteRevision): string {
</div>
<p v-if="uploadError" class="text-xs text-red-600 dark:text-red-400">{{ uploadError }}</p>
<!-- Link previews: stored preview cards + one "Preview <domain>" per detected URL -->
<!-- Fetched automatically after each save; removable here and nowhere else. -->
<div v-if="liveNote.previews.length" class="flex flex-col gap-2">
<LinkPreview
v-for="p in liveNote.previews"
@@ -504,23 +473,6 @@ function revPreview(rev: NoteRevision): string {
@remove="notes.deletePreview(liveNote.id, p.id)"
/>
</div>
<div
v-if="config.enableUrlUnfurl && !liveNote.trashed && unpreviewedUrls.length"
class="flex flex-wrap gap-1.5"
>
<button
v-for="u in unpreviewedUrls"
:key="u"
type="button"
class="inline-flex items-center gap-1 rounded-full border border-neutral-200 px-2 py-0.5 text-xs text-neutral-500 hover:bg-neutral-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:border-neutral-700 dark:hover:bg-neutral-800"
:disabled="unfurling === u"
@click="addPreview(u)"
>
<Icon name="link" />
{{ unfurling === u ? "Fetching…" : `Preview ${shortUrl(u)}` }}
</button>
</div>
<p v-if="unfurlError" class="text-xs text-red-600 dark:text-red-400">{{ unfurlError }}</p>
<textarea
ref="bodyInput"