m4.5: content-aware [[ linking — autocomplete searches note body
The [[ autocomplete only matched note names, so you could only link a note you could name. Now it searches note NAME *and* body, so you can link by recalling any phrase. - new GET /api/notes/link-search?q= — owner-scoped, non-trashed; substring ILIKE on display_title OR body; ranked name-first, then name-prefix, then recency; empty q returns recent notes as suggestions. Deterministic (no semantic/AI search); the FTS index still powers the heavier /search. LIKE wildcards in q are escaped. - editor [[ autocomplete now calls link-search (debounced 120ms) instead of filtering the cached titles index; excludes the note itself; inserts the matched note's display name as [[Name]]. - unit tests for the LIKE-escaping + a link-search auth guard. Second item of M4.5; builds on the display_title work (every note has a name to link to). Command-palette content search is a natural follow-on, left out to keep this focused. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -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<TitleEntry[]>([]);
|
||||
let linkTimer: ReturnType<typeof setTimeout> | 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) {
|
||||
|
||||
@@ -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("/<note_id>/backlinks")
|
||||
@login_required
|
||||
async def note_backlinks(note_id: str):
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user