M2 search: Postgres FTS backend + top search bar + results
- Migration 0005: generated tsvector column (title A + body B) + GIN index on notes; GET /api/notes/search?q= (websearch_to_tsquery, ts_rank, ACL-scoped, excludes trash), labels merged into results. - Persistent AppShell layout (parent route + <RouterView> children) so the new top search box keeps focus across board/search/label navigation. - SearchView (debounced live search from the shell → /search?q=, results masonry, no-match empty state); BoardView/SearchView render inside the shared shell. 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:
@@ -0,0 +1,33 @@
|
|||||||
|
"""notes full-text search vector
|
||||||
|
|
||||||
|
Revision ID: 0005
|
||||||
|
Revises: 0004
|
||||||
|
Create Date: 2026-07-20
|
||||||
|
|
||||||
|
A generated tsvector column (title weight A, body weight B) + GIN index, so
|
||||||
|
search is index-backed and always in sync with the row (no trigger to maintain).
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0005"
|
||||||
|
down_revision = "0004"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE notes ADD COLUMN search_vector tsvector
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('english', coalesce(body, '')), 'B')
|
||||||
|
) STORED
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_notes_search")
|
||||||
|
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector")
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useSessionStore } from "../stores/session";
|
import { useSessionStore } from "../stores/session";
|
||||||
import { useConfigStore } from "../stores/config";
|
import { useConfigStore } from "../stores/config";
|
||||||
@@ -14,6 +14,8 @@ const config = useConfigStore();
|
|||||||
const labels = useLabelsStore();
|
const labels = useLabelsStore();
|
||||||
|
|
||||||
const managing = ref(false);
|
const managing = ref(false);
|
||||||
|
const searchText = ref(typeof route.query.q === "string" ? route.query.q : "");
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!labels.loaded) void labels.load();
|
if (!labels.loaded) void labels.load();
|
||||||
@@ -21,6 +23,24 @@ onMounted(() => {
|
|||||||
|
|
||||||
const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));
|
const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));
|
||||||
|
|
||||||
|
function onSearch(value: string) {
|
||||||
|
searchText.value = value;
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
const q = searchText.value.trim();
|
||||||
|
if (q) router.push({ name: "search", query: { q } });
|
||||||
|
else if (route.name === "search") router.push("/");
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the search box when navigating to a non-search view.
|
||||||
|
watch(
|
||||||
|
() => route.name,
|
||||||
|
(name) => {
|
||||||
|
if (name !== "search") searchText.value = "";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
await session.logout();
|
await session.logout();
|
||||||
await router.replace("/login");
|
await router.replace("/login");
|
||||||
@@ -32,15 +52,27 @@ async function signOut() {
|
|||||||
<header
|
<header
|
||||||
class="sticky top-0 z-20 border-b border-neutral-200 bg-neutral-50/90 backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/90"
|
class="sticky top-0 z-20 border-b border-neutral-200 bg-neutral-50/90 backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/90"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-4 px-4 py-3">
|
<div class="flex items-center gap-3 px-4 py-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-brand text-sm font-black text-neutral-900">
|
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-brand text-sm font-black text-neutral-900">
|
||||||
TS
|
TS
|
||||||
</div>
|
</div>
|
||||||
<span class="hidden font-semibold sm:inline">{{ config.siteName }}</span>
|
<span class="hidden font-semibold sm:inline">{{ config.siteName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="hidden text-sm text-neutral-500 sm:inline dark:text-neutral-400">{{
|
<div class="flex flex-1 justify-center">
|
||||||
|
<input
|
||||||
|
:value="searchText"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search notes…"
|
||||||
|
aria-label="Search notes"
|
||||||
|
class="w-full max-w-md rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
|
@input="onSearch(($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 items-center gap-3">
|
||||||
|
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">{{
|
||||||
session.user?.display_name
|
session.user?.display_name
|
||||||
}}</span>
|
}}</span>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
@@ -98,7 +130,7 @@ async function signOut() {
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="min-w-0 flex-1"><slot /></main>
|
<main class="min-w-0 flex-1"><RouterView /></main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LabelsModal v-if="managing" @close="managing = false" />
|
<LabelsModal v-if="managing" @close="managing = false" />
|
||||||
|
|||||||
@@ -6,28 +6,17 @@ const router = createRouter({
|
|||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
|
// Persistent authed shell (sidebar + top bar + search); children render in it.
|
||||||
path: "/",
|
path: "/",
|
||||||
name: "board",
|
component: () => import("../components/AppShell.vue"),
|
||||||
component: () => import("../views/BoardView.vue"),
|
|
||||||
meta: { requiresAuth: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/archive",
|
|
||||||
name: "archive",
|
|
||||||
component: () => import("../views/BoardView.vue"),
|
|
||||||
meta: { requiresAuth: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/trash",
|
|
||||||
name: "trash",
|
|
||||||
component: () => import("../views/BoardView.vue"),
|
|
||||||
meta: { requiresAuth: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/label/:id",
|
|
||||||
name: "label",
|
|
||||||
component: () => import("../views/BoardView.vue"),
|
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{ path: "", name: "board", component: () => import("../views/BoardView.vue") },
|
||||||
|
{ path: "archive", name: "archive", component: () => import("../views/BoardView.vue") },
|
||||||
|
{ path: "trash", name: "trash", component: () => import("../views/BoardView.vue") },
|
||||||
|
{ path: "label/:id", name: "label", component: () => import("../views/BoardView.vue") },
|
||||||
|
{ path: "search", name: "search", component: () => import("../views/SearchView.vue") },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
@@ -63,8 +52,6 @@ router.beforeEach(async (to) => {
|
|||||||
if (to.meta.requiresAdmin && !session.user?.is_admin) {
|
if (to.meta.requiresAdmin && !session.user?.is_admin) {
|
||||||
return { name: "board" };
|
return { name: "board" };
|
||||||
}
|
}
|
||||||
// Registration closed: keep people out of the register screen (the very first
|
|
||||||
// account is still creatable because allow_registration defaults to true).
|
|
||||||
if (to.name === "register" && !config.allowRegistration) {
|
if (to.name === "register" && !config.allowRegistration) {
|
||||||
return { name: "login" };
|
return { name: "login" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
||||||
import AppShell from "../components/AppShell.vue";
|
|
||||||
import QuickAdd from "../components/QuickAdd.vue";
|
import QuickAdd from "../components/QuickAdd.vue";
|
||||||
import NoteCard from "../components/NoteCard.vue";
|
import NoteCard from "../components/NoteCard.vue";
|
||||||
import NoteEditor from "../components/NoteEditor.vue";
|
import NoteEditor from "../components/NoteEditor.vue";
|
||||||
@@ -50,7 +49,6 @@ function closeEditor() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell>
|
|
||||||
<div class="mx-auto w-full max-w-6xl px-4 py-6">
|
<div class="mx-auto w-full max-w-6xl px-4 py-6">
|
||||||
<QuickAdd v-if="isMainBoard" autofocus class="mb-8" />
|
<QuickAdd v-if="isMainBoard" autofocus class="mb-8" />
|
||||||
|
|
||||||
@@ -70,10 +68,7 @@ function closeEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
|
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
|
||||||
<h2
|
<h2 v-if="pinnedNotes.length" class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||||
v-if="pinnedNotes.length"
|
|
||||||
class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400"
|
|
||||||
>
|
|
||||||
Others
|
Others
|
||||||
</h2>
|
</h2>
|
||||||
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||||
@@ -91,5 +86,4 @@ function closeEditor() {
|
|||||||
<template v-if="editing">
|
<template v-if="editing">
|
||||||
<NoteEditor :note="editing" @close="closeEditor" />
|
<NoteEditor :note="editing" @close="closeEditor" />
|
||||||
</template>
|
</template>
|
||||||
</AppShell>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { Note } from "../stores/notes";
|
||||||
|
import NoteCard from "../components/NoteCard.vue";
|
||||||
|
import NoteEditor from "../components/NoteEditor.vue";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const results = ref<Note[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const editing = ref<Note | null>(null);
|
||||||
|
|
||||||
|
const query = computed(() => (typeof route.query.q === "string" ? route.query.q : ""));
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const q = query.value.trim();
|
||||||
|
if (!q) {
|
||||||
|
results.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ notes: Note[] }>(`/api/notes/search?q=${encodeURIComponent(q)}`);
|
||||||
|
results.value = res.notes;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(query, run, { immediate: true });
|
||||||
|
|
||||||
|
function openEditor(n: Note) {
|
||||||
|
editing.value = n;
|
||||||
|
}
|
||||||
|
async function closeEditor() {
|
||||||
|
editing.value = null;
|
||||||
|
await run(); // reflect any edits made from a result
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto w-full max-w-6xl px-4 py-6">
|
||||||
|
<p class="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
<template v-if="query"
|
||||||
|
>Results for <span class="font-semibold text-neutral-800 dark:text-neutral-200">{{ query }}</span></template
|
||||||
|
>
|
||||||
|
<template v-else>Type in the search box to find your notes.</template>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="py-20 text-center text-sm text-neutral-400">Searching…</div>
|
||||||
|
|
||||||
|
<div v-else-if="query && results.length === 0" class="py-20 text-center">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">No matches</h2>
|
||||||
|
<p class="mt-1 text-sm text-neutral-400">Nothing found for "{{ query }}".</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||||
|
<NoteCard v-for="n in results" :key="n.id" :note="n" @open="openEditor" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="editing">
|
||||||
|
<NoteEditor :note="editing" @close="closeEditor" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
@@ -4,7 +4,7 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from quart import Blueprint, g, jsonify, request
|
from quart import Blueprint, g, jsonify, request
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, func, literal_column, select
|
||||||
|
|
||||||
from .acl import visible_to_user
|
from .acl import visible_to_user
|
||||||
from .auth import login_required
|
from .auth import login_required
|
||||||
@@ -93,6 +93,36 @@ async def list_notes():
|
|||||||
return jsonify({"notes": out})
|
return jsonify({"notes": out})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/search")
|
||||||
|
@login_required
|
||||||
|
async def search_notes():
|
||||||
|
q = (request.args.get("q") or "").strip()
|
||||||
|
if not q:
|
||||||
|
return jsonify({"notes": []})
|
||||||
|
async with session_scope() as db:
|
||||||
|
tsquery = func.websearch_to_tsquery("english", q)
|
||||||
|
# search_vector is a generated column (migration 0005), not mapped on the ORM.
|
||||||
|
search_col = literal_column("notes.search_vector")
|
||||||
|
stmt = (
|
||||||
|
select(Note)
|
||||||
|
.where(
|
||||||
|
visible_to_user("note", Note.owner_id, Note.id, g.user_id),
|
||||||
|
Note.deleted_at.is_(None),
|
||||||
|
search_col.op("@@")(tsquery),
|
||||||
|
)
|
||||||
|
.order_by(func.ts_rank(search_col, tsquery).desc(), Note.updated_at.desc())
|
||||||
|
.limit(100)
|
||||||
|
)
|
||||||
|
notes = (await db.scalars(stmt)).all()
|
||||||
|
labels_map = await _labels_for_notes(db, [n.id for n in notes])
|
||||||
|
out = []
|
||||||
|
for n in notes:
|
||||||
|
data = n.serialize()
|
||||||
|
data["labels"] = labels_map.get(n.id, [])
|
||||||
|
out.append(data)
|
||||||
|
return jsonify({"notes": out})
|
||||||
|
|
||||||
|
|
||||||
@bp.post("")
|
@bp.post("")
|
||||||
@login_required
|
@login_required
|
||||||
async def create_note():
|
async def create_note():
|
||||||
|
|||||||
@@ -50,3 +50,9 @@ async def test_notes_create_requires_auth(app):
|
|||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
resp = await client.post("/api/notes", json={"body": "hi"})
|
resp = await client.post("/api/notes", json={"body": "hi"})
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_requires_auth(app):
|
||||||
|
client = app.test_client()
|
||||||
|
resp = await client.get("/api/notes/search?q=hello")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|||||||
Reference in New Issue
Block a user