"""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)