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