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
+6
View File
@@ -37,6 +37,7 @@ from ..models.note_revision import NoteRevision
from ..responses import json_error, not_found, parse_uuid
from ..retention import purge_note
from ..settings import get_setting
from ..unfurl_queue import schedule as schedule_unfurls
from ..unfurl import UnfurlError, unfurl
from ._bp import bp
from .helpers import (
@@ -454,6 +455,9 @@ async def create_note():
await _reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
# After the commit, never before it: the note is saved and the response is
# about to go out. Any link previews arrive on a later read.
schedule_unfurls(note.id, note.body)
return jsonify(await _serialize_note(db, note)), 201
@@ -509,6 +513,8 @@ async def update_note(note_id: str):
db.add(NoteRevision(note_id=note.id, body=old_body))
await db.commit()
await db.refresh(note)
if note.body != old_body:
schedule_unfurls(note.id, note.body)
return jsonify(await _serialize_note(db, note))
+6
View File
@@ -35,6 +35,7 @@ from .notes import (
)
from .retention import purge_note
from .serialize import serialize_label_sync
from .unfurl_queue import schedule as schedule_unfurls
bp = Blueprint("sync", __name__, url_prefix="/api/sync")
@@ -325,6 +326,11 @@ async def _apply_note(db, ch: dict) -> dict:
await _apply_note_manual_labels(db, note, ch)
await db.flush()
await db.refresh(note, ["sync_revision"])
# A note pushed from a linked client gets the same link previews as one typed into
# the web app — the client picks them up on its next pull. Scheduled rather than
# awaited: a push batch must not wait on somebody else's website.
if creating or note.body != old_body:
schedule_unfurls(note.id, note.body)
return {
"id": str(nid),
"entity": "note",
+127
View File
@@ -0,0 +1,127 @@
"""Unfurling a note's URLs in the background, after the note is already saved.
## Why this is not done inline
Capture speed is the product. Unfurling is a 5-second-timeout network call to a host
nobody controls, and a note must persist the instant someone stops typing — so the
save returns first and the preview catches up. A person who pastes a link and closes
the composer has already done the thing they came to do.
## Why it is on the server rather than in each client
The server sees every note that reaches it, from all three surfaces, so detection and
fetching live in one place instead of three. A linked desktop or Android client pushes
its note and picks the preview up on the next pull; an unlinked one has no server to
ask and simply has no preview until it links, which is the honest consequence of being
offline rather than a gap to paper over.
## What it deliberately does not do
Fail loudly. A preview that could not be fetched is not an error the person needs —
the note is fine, it just has no card. The link is still in the body, still clickable,
still searchable.
"""
from __future__ import annotations
import asyncio
import logging
import re
import uuid
from sqlalchemy import select
from .db import session_scope
from .models.note import Note
from .models.note_link_preview import NoteLinkPreview
from .settings import get_setting
from .unfurl import UnfurlError, unfurl
logger = logging.getLogger(__name__)
# Matches the frontend's detector (NoteEditor.vue) so both surfaces agree on what
# counts as a link. Trailing sentence punctuation is stripped below rather than in the
# pattern — a URL can legitimately end in most of these characters, just not when the
# sentence does.
_URL_RE = re.compile(r"(https?://[^\s<>\"'\])]+)")
# Per note, per save. A body pasted full of links should not turn into a burst of
# outbound requests; nobody is reading forty preview cards on one card anyway.
MAX_URLS_PER_NOTE = 5
# Background tasks are only weakly referenced by the event loop, so without a strong
# reference here a task can be garbage-collected mid-flight. Discarded on completion.
_running: set[asyncio.Task] = set()
def detect_urls(body: str | None) -> list[str]:
"""Distinct http(s) URLs in a note body, in order, trailing punctuation trimmed."""
out: list[str] = []
for match in _URL_RE.finditer(body or ""):
url = match.group(1).rstrip(".,;:!?")
if url and url not in out:
out.append(url)
return out
async def _fetch_and_store(note_id: uuid.UUID, url: str) -> None:
"""Unfurl one URL and cache it against the note. Silent on every failure."""
try:
preview = await unfurl(url)
except UnfurlError as e:
# Expected and uninteresting: a dead link, a private address, a non-page.
logger.debug("no preview for %s: %s", url, e)
return
except Exception:
logger.warning("unexpected failure unfurling %s", url, exc_info=True)
return
async with session_scope() as db:
# The note may have been deleted or the URL removed while the fetch was in
# flight, so re-check rather than assuming the world held still.
note = await db.scalar(select(Note).where(Note.id == note_id, Note.deleted_at.is_(None)))
if note is None or url not in detect_urls(note.body):
return
row = await db.scalar(
select(NoteLinkPreview).where(NoteLinkPreview.note_id == note_id, NoteLinkPreview.url == url)
)
if row is None:
row = NoteLinkPreview(note_id=note_id, url=url)
db.add(row)
row.title = preview["title"]
row.description = preview["description"]
row.image_url = preview["image_url"]
row.site_name = preview["site_name"]
await db.commit()
async def _unfurl_new_urls(note_id: uuid.UUID, body: str) -> None:
async with session_scope() as db:
if not await get_setting(db, "enable_url_unfurl"):
return
cached = set(
(
await db.scalars(select(NoteLinkPreview.url).where(NoteLinkPreview.note_id == note_id))
).all()
)
fresh = [u for u in detect_urls(body) if u not in cached][:MAX_URLS_PER_NOTE]
for url in fresh:
await _fetch_and_store(note_id, url)
def schedule(note_id: uuid.UUID, body: str | None) -> None:
"""Queue an unfurl pass for a note that was just written. Returns immediately.
Safe to call on every save: it re-reads what is already cached and does nothing
when there is nothing new, so an edit that doesn't touch the links costs one
cheap query on a background task rather than a fetch.
"""
if not body or not detect_urls(body):
return
try:
task = asyncio.create_task(_unfurl_new_urls(note_id, body))
except RuntimeError:
# No running loop — a script or a test calling the write path directly. The
# note is saved either way; only the preview is skipped.
return
_running.add(task)
task.add_done_callback(_running.discard)