m4.5: content-aware [[ linking — autocomplete searches note body
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 33s

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:
2026-07-22 08:19:05 -04:00
co-authored by Claude Opus 4.8
parent 2b6a353666
commit 0ae02858f7
3 changed files with 76 additions and 6 deletions
+24 -5
View File
@@ -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) {
+37 -1
View File
@@ -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):
+15
View File
@@ -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")