diff --git a/frontend/src/components/NoteEditor.vue b/frontend/src/components/NoteEditor.vue index c8d5b1f..f38af5b 100644 --- a/frontend/src/components/NoteEditor.vue +++ b/frontend/src/components/NoteEditor.vue @@ -2,7 +2,7 @@ import { computed, nextTick, onMounted, ref, watch } from "vue"; import { api } from "../api/client"; import { useNotesStore } from "../stores/notes"; -import { useTitlesStore } from "../stores/titles"; +import { useTitlesStore, type TitleEntry } from "../stores/titles"; import ColorPicker from "./ColorPicker.vue"; import Icon from "./Icon.vue"; import LabelPicker from "./LabelPicker.vue"; @@ -93,10 +93,28 @@ const linkQuery = ref(""); const linkStart = ref(-1); // index of the `[[` that opened the current token const linkSelected = ref(0); -const linkMatches = computed(() => { - const q = linkQuery.value.trim().toLowerCase(); - return titles.items.filter((t) => t.title && t.title.toLowerCase().includes(q)).slice(0, 8); -}); +// [[ autocomplete searches note NAME *and* body via the server (link-search), so you +// can link by recalling any phrase — not just the exact name. Debounced so we don't +// fire a request on every keystroke. +const linkMatches = ref([]); +let linkTimer: ReturnType | undefined; + +function refreshLinkMatches() { + if (linkTimer) clearTimeout(linkTimer); + const q = linkQuery.value.trim(); + linkTimer = setTimeout(async () => { + try { + const res = await api.get<{ results: TitleEntry[] }>( + `/api/notes/link-search?q=${encodeURIComponent(q)}`, + ); + // Never suggest linking a note to itself. + linkMatches.value = res.results.filter((r) => r.id !== props.note.id).slice(0, 8); + } catch { + linkMatches.value = []; + } + linkSelected.value = 0; + }, 120); +} // On every edit, check whether the caret sits inside an unclosed `[[…` token // and, if so, open the suggestion menu with the partial title as the query. @@ -119,6 +137,7 @@ function onBodyInput() { linkStart.value = open; linkSelected.value = 0; linkMenu.value = true; + refreshLinkMatches(); } function insertLink(title: string) { diff --git a/src/thoughtsync/notes.py b/src/thoughtsync/notes.py index e10ae4a..613c79e 100644 --- a/src/thoughtsync/notes.py +++ b/src/thoughtsync/notes.py @@ -6,7 +6,7 @@ import uuid from datetime import datetime, timezone from quart import Blueprint, g, jsonify, request, send_file -from sqlalchemy import delete, func, literal_column, select +from sqlalchemy import case, delete, func, literal_column, select from .acl import visible_to_user from .auth import login_required @@ -281,6 +281,42 @@ async def list_titles(): ) +def _escape_like(s: str) -> str: + """Escape LIKE wildcards so user input matches literally (escape char = \\).""" + return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +@bp.get("/link-search") +@login_required +async def link_search(): + # Autocomplete source for [[wiki-links]]: match the query against a note's display + # NAME *or* its BODY, so you can link by recalling any phrase — not just the name. + # Substring ILIKE (good for partial-word typing, deterministic, fine at personal + # scale; the FTS index still powers the heavier /search). Name matches rank above + # body-only matches, and a name prefix above a mid-name substring. Empty q → recent. + q = (request.args.get("q") or "").strip() + async with session_scope() as db: + base = select(Note).where( + Note.owner_id == g.user_id, Note.deleted_at.is_(None), Note.display_title != "" + ) + if not q: + stmt = base.order_by(Note.updated_at.desc()).limit(10) + else: + esc = _escape_like(q) + name_hit = Note.display_title.ilike(f"%{esc}%", escape="\\") + stmt = ( + base.where(name_hit | Note.body.ilike(f"%{esc}%", escape="\\")) + .order_by( + case((name_hit, 0), else_=1), + case((Note.display_title.ilike(f"{esc}%", escape="\\"), 0), else_=1), + Note.updated_at.desc(), + ) + .limit(10) + ) + rows = (await db.scalars(stmt)).all() + return jsonify({"results": [{"id": str(n.id), "title": n.display_title} for n in rows]}) + + @bp.get("//backlinks") @login_required async def note_backlinks(note_id: str): diff --git a/tests/test_notes.py b/tests/test_notes.py index 8ebc49c..c0166e9 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -3,6 +3,7 @@ import pytest from thoughtsync.app import create_app from thoughtsync.models.note import NOTE_COLORS, Note from thoughtsync.notes import ( + _escape_like, derive_display_title, is_empty_note, normalize_color, @@ -129,12 +130,26 @@ def test_derive_display_title_caps_length(): assert derive_display_title(long, "body") == "x" * 200 +def test_escape_like(): + # LIKE wildcards in user input must be neutralized so they match literally. + assert _escape_like("100%") == "100\\%" + assert _escape_like("a_b") == "a\\_b" + assert _escape_like("c:\\path") == "c:\\\\path" + assert _escape_like("plain") == "plain" + + async def test_titles_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/titles") assert resp.status_code == 401 +async def test_link_search_requires_auth(app): + client = app.test_client() + resp = await client.get("/api/notes/link-search?q=hi") + assert resp.status_code == 401 + + async def test_graph_requires_auth(app): client = app.test_client() resp = await client.get("/api/graph")