0.2.0 — a notebook in your pocket, ready to be hosted #3
@@ -1,12 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { LinkPreview } from "../stores/notes";
|
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 }>();
|
defineEmits<{ (e: "remove"): void }>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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
|
<a
|
||||||
:href="preview.url"
|
:href="preview.url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -19,16 +31,28 @@ defineEmits<{ (e: "remove"): void }>();
|
|||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
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">
|
<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 text-[11px] uppercase tracking-wide text-neutral-400">
|
<p
|
||||||
|
v-if="preview.site_name"
|
||||||
|
class="truncate uppercase tracking-wide text-neutral-400"
|
||||||
|
:class="compact ? 'text-[10px]' : 'text-[11px]'"
|
||||||
|
>
|
||||||
{{ preview.site_name }}
|
{{ preview.site_name }}
|
||||||
</p>
|
</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 }}
|
{{ preview.title || preview.url }}
|
||||||
</p>
|
</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 }}
|
{{ preview.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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.
|
// exactly, and skips parsing a body the card was never going to show.
|
||||||
const PREVIEW_LINES = 8;
|
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 bodyPreview = computed(() => {
|
||||||
const lines = props.note.body.split("\n");
|
const lines = props.note.body.split("\n");
|
||||||
if (lines.length <= PREVIEW_LINES) return props.note.body;
|
if (lines.length <= PREVIEW_LINES) return props.note.body;
|
||||||
@@ -226,9 +246,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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.
|
<!-- One render path: every note is a body plus, maybe, checkable items.
|
||||||
A focusable div rather than a <button>, because a checklist nests interactive
|
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)"
|
@click="emit('open', note)"
|
||||||
@keydown.enter="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" />
|
<MarkdownText :text="bodyPreview" />
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
@@ -251,6 +272,13 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
|||||||
Empty note
|
Empty note
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<NoteChecklist
|
||||||
v-if="note.items.length"
|
v-if="note.items.length"
|
||||||
:class="note.body ? 'mt-2' : ''"
|
:class="note.body ? 'mt-2' : ''"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||||
import { useNotesStore } from "../stores/notes";
|
import { useNotesStore } from "../stores/notes";
|
||||||
import { useConfigStore } from "../stores/config";
|
|
||||||
import ColorPicker from "./ColorPicker.vue";
|
import ColorPicker from "./ColorPicker.vue";
|
||||||
import Icon from "./Icon.vue";
|
import Icon from "./Icon.vue";
|
||||||
import LabelPicker from "./LabelPicker.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 emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
|
||||||
const notes = useNotesStore();
|
const notes = useNotesStore();
|
||||||
const config = useConfigStore();
|
|
||||||
|
|
||||||
const noteId = ref<string | null>(props.note?.id ?? null);
|
const noteId = ref<string | null>(props.note?.id ?? null);
|
||||||
const body = ref(props.note?.body ?? props.initialBody);
|
const body = ref(props.note?.body ?? props.initialBody);
|
||||||
@@ -323,41 +321,12 @@ async function uploadFile(file: File) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- link previews (URL unfurl) ----
|
// ---- link previews ----
|
||||||
const unfurling = ref<string | null>(null); // the URL currently being fetched
|
//
|
||||||
const unfurlError = ref("");
|
// Nothing to trigger any more: the server unfurls a note's URLs in the background
|
||||||
// Bare http(s) URLs in the body; trailing sentence punctuation trimmed.
|
// after each save (`unfurl_queue.py`) and the preview arrives on a later read. What
|
||||||
const URL_RE = /(https?:\/\/[^\s<>"'\])]+)/g;
|
// is left here is removing one you don't want — the editor is the only place with
|
||||||
const detectedUrls = computed(() => {
|
// room to offer that, and the card deliberately doesn't.
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function onFileChange(e: Event) {
|
async function onFileChange(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
@@ -494,7 +463,7 @@ function revPreview(rev: NoteRevision): string {
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="uploadError" class="text-xs text-red-600 dark:text-red-400">{{ uploadError }}</p>
|
<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">
|
<div v-if="liveNote.previews.length" class="flex flex-col gap-2">
|
||||||
<LinkPreview
|
<LinkPreview
|
||||||
v-for="p in liveNote.previews"
|
v-for="p in liveNote.previews"
|
||||||
@@ -504,23 +473,6 @@ function revPreview(rev: NoteRevision): string {
|
|||||||
@remove="notes.deletePreview(liveNote.id, p.id)"
|
@remove="notes.deletePreview(liveNote.id, p.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<textarea
|
||||||
ref="bodyInput"
|
ref="bodyInput"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from ..models.note_revision import NoteRevision
|
|||||||
from ..responses import json_error, not_found, parse_uuid
|
from ..responses import json_error, not_found, parse_uuid
|
||||||
from ..retention import purge_note
|
from ..retention import purge_note
|
||||||
from ..settings import get_setting
|
from ..settings import get_setting
|
||||||
|
from ..unfurl_queue import schedule as schedule_unfurls
|
||||||
from ..unfurl import UnfurlError, unfurl
|
from ..unfurl import UnfurlError, unfurl
|
||||||
from ._bp import bp
|
from ._bp import bp
|
||||||
from .helpers import (
|
from .helpers import (
|
||||||
@@ -454,6 +455,9 @@ async def create_note():
|
|||||||
await _reconcile_tags(db, note)
|
await _reconcile_tags(db, note)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
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
|
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))
|
db.add(NoteRevision(note_id=note.id, body=old_body))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
await db.refresh(note)
|
||||||
|
if note.body != old_body:
|
||||||
|
schedule_unfurls(note.id, note.body)
|
||||||
return jsonify(await _serialize_note(db, note))
|
return jsonify(await _serialize_note(db, note))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from .notes import (
|
|||||||
)
|
)
|
||||||
from .retention import purge_note
|
from .retention import purge_note
|
||||||
from .serialize import serialize_label_sync
|
from .serialize import serialize_label_sync
|
||||||
|
from .unfurl_queue import schedule as schedule_unfurls
|
||||||
|
|
||||||
bp = Blueprint("sync", __name__, url_prefix="/api/sync")
|
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 _apply_note_manual_labels(db, note, ch)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(note, ["sync_revision"])
|
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 {
|
return {
|
||||||
"id": str(nid),
|
"id": str(nid),
|
||||||
"entity": "note",
|
"entity": "note",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -26,13 +26,15 @@ from thoughtsync.models.note import Note
|
|||||||
from thoughtsync.models.note_item import NoteItem
|
from thoughtsync.models.note_item import NoteItem
|
||||||
from thoughtsync.models.user import User
|
from thoughtsync.models.user import User
|
||||||
from thoughtsync.notes.helpers import derive_display_title
|
from thoughtsync.notes.helpers import derive_display_title
|
||||||
|
from thoughtsync.models.note_link_preview import NoteLinkPreview
|
||||||
from thoughtsync.sync import _apply_note_items
|
from thoughtsync.sync import _apply_note_items
|
||||||
|
from thoughtsync.unfurl_queue import _unfurl_new_urls, detect_urls
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
# Every table the tests touch, child-first so FKs never block the truncate.
|
# Every table the tests touch, child-first so FKs never block the truncate.
|
||||||
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
|
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
|
||||||
_TABLES = "notes, note_items, note_revisions, note_labels, labels, users"
|
_TABLES = "notes, note_items, note_revisions, note_labels, note_link_previews, labels, users"
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
@@ -201,3 +203,81 @@ async def test_a_note_with_only_items_still_has_a_name(db, owner):
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
assert (await db.scalar(select(Note.display_title).where(Note.id == note.id))) == "milk"
|
assert (await db.scalar(select(Note.display_title).where(Note.id == note.id))) == "milk"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_auto_unfurl_stores_a_preview_and_skips_what_is_cached(db, owner, monkeypatch):
|
||||||
|
"""The background pass, run inline so the assertions are deterministic.
|
||||||
|
|
||||||
|
The network is stubbed — this is about what reaches the DATABASE, not about
|
||||||
|
parsing someone's OpenGraph tags (unfurl.py's own tests cover that). What matters
|
||||||
|
here is the part only a real database can show: the unique constraint holding, the
|
||||||
|
upsert going to the right row, and a second pass not re-fetching.
|
||||||
|
"""
|
||||||
|
note = Note(
|
||||||
|
owner_id=owner.id,
|
||||||
|
body="read https://example.com/a and https://example.com/b",
|
||||||
|
display_title="read https://example.com/a and https://example.com/b",
|
||||||
|
)
|
||||||
|
db.add(note)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
async def fake_unfurl(url):
|
||||||
|
calls.append(url)
|
||||||
|
return {"url": url, "title": f"T {url}", "description": None, "image_url": None, "site_name": "example.com"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
|
||||||
|
|
||||||
|
await _unfurl_new_urls(note.id, note.body)
|
||||||
|
assert sorted(calls) == ["https://example.com/a", "https://example.com/b"]
|
||||||
|
|
||||||
|
rows = (await db.scalars(select(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))).all()
|
||||||
|
assert {r.url for r in rows} == {"https://example.com/a", "https://example.com/b"}
|
||||||
|
assert all(r.title.startswith("T ") for r in rows)
|
||||||
|
|
||||||
|
# A second pass over an unchanged body fetches nothing — the whole reason
|
||||||
|
# `schedule` is safe to call on every save.
|
||||||
|
calls.clear()
|
||||||
|
await _unfurl_new_urls(note.id, note.body)
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_auto_unfurl_drops_a_preview_whose_url_left_the_body(db, owner, monkeypatch):
|
||||||
|
"""A slow fetch must not resurrect a link the person deleted mid-flight."""
|
||||||
|
note = Note(owner_id=owner.id, body="https://example.com/gone", display_title="x")
|
||||||
|
db.add(note)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def fake_unfurl(url):
|
||||||
|
# Simulate the body changing while the request was in the air.
|
||||||
|
return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None}
|
||||||
|
|
||||||
|
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
|
||||||
|
note.body = "changed my mind"
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await _unfurl_new_urls(note.id, "https://example.com/gone")
|
||||||
|
rows = (await db.scalars(select(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))).all()
|
||||||
|
assert rows == [], "a preview was stored for a URL the note no longer contains"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_detection_agrees_with_what_gets_stored(db, owner, monkeypatch):
|
||||||
|
"""The detector and the storage path read the same body the same way."""
|
||||||
|
body = "one https://example.com/x. two (https://example.com/y) three"
|
||||||
|
assert detect_urls(body) == ["https://example.com/x", "https://example.com/y"]
|
||||||
|
|
||||||
|
note = Note(owner_id=owner.id, body=body, display_title="one")
|
||||||
|
db.add(note)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def fake_unfurl(url):
|
||||||
|
return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None}
|
||||||
|
|
||||||
|
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
|
||||||
|
await _unfurl_new_urls(note.id, body)
|
||||||
|
|
||||||
|
stored = {
|
||||||
|
r for r in (await db.scalars(select(NoteLinkPreview.url).where(NoteLinkPreview.note_id == note.id))).all()
|
||||||
|
}
|
||||||
|
assert stored == set(detect_urls(body))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import pytest
|
|||||||
from thoughtsync.app import create_app
|
from thoughtsync.app import create_app
|
||||||
from thoughtsync.common import coerce_bool, parse_dt
|
from thoughtsync.common import coerce_bool, parse_dt
|
||||||
from thoughtsync.models.note import NOTE_COLORS, Note
|
from thoughtsync.models.note import NOTE_COLORS, Note
|
||||||
|
from thoughtsync.unfurl_queue import detect_urls
|
||||||
from thoughtsync.notes import (
|
from thoughtsync.notes import (
|
||||||
_attachment_ext,
|
_attachment_ext,
|
||||||
_header_filename,
|
_header_filename,
|
||||||
@@ -386,3 +387,22 @@ def test_native_spec_roundtrip_fields():
|
|||||||
assert spec["created_at"].year == 2026
|
assert spec["created_at"].year == 2026
|
||||||
assert spec["labels"] == ["x"]
|
assert spec["labels"] == ["x"]
|
||||||
assert spec["attachments"] == [{"file": "attachments/ab/img.png", "mime": "image/png"}]
|
assert spec["attachments"] == [{"file": "attachments/ab/img.png", "mime": "image/png"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_urls_finds_each_link_once_in_order():
|
||||||
|
body = "see https://example.com/a and https://example.com/b\nand https://example.com/a again"
|
||||||
|
assert detect_urls(body) == ["https://example.com/a", "https://example.com/b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_urls_trims_sentence_punctuation():
|
||||||
|
# A URL can end in most punctuation; a SENTENCE containing one usually doesn't.
|
||||||
|
assert detect_urls("read https://example.com/page.") == ["https://example.com/page"]
|
||||||
|
assert detect_urls("(see https://example.com/x)") == ["https://example.com/x"]
|
||||||
|
# …but a path that legitimately ends in a slash or a dash keeps it.
|
||||||
|
assert detect_urls("https://example.com/dir/") == ["https://example.com/dir/"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_urls_ignores_non_http():
|
||||||
|
assert detect_urls("ftp://example.com and mailto:a@b.c and bare example.com") == []
|
||||||
|
assert detect_urls(None) == []
|
||||||
|
assert detect_urls("") == []
|
||||||
|
|||||||
Reference in New Issue
Block a user