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) {
|
||||
|
||||
Reference in New Issue
Block a user