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">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useSessionStore } from "../stores/session";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
@@ -14,6 +14,8 @@ const config = useConfigStore();
|
||||
const labels = useLabelsStore();
|
||||
|
||||
const managing = ref(false);
|
||||
const searchText = ref(typeof route.query.q === "string" ? route.query.q : "");
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
onMounted(() => {
|
||||
if (!labels.loaded) void labels.load();
|
||||
@@ -21,6 +23,24 @@ onMounted(() => {
|
||||
|
||||
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() {
|
||||
await session.logout();
|
||||
await router.replace("/login");
|
||||
@@ -32,15 +52,27 @@ async function signOut() {
|
||||
<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"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-3 px-4 py-3">
|
||||
<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">
|
||||
TS
|
||||
</div>
|
||||
<span class="hidden font-semibold sm:inline">{{ config.siteName }}</span>
|
||||
</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
|
||||
}}</span>
|
||||
<RouterLink
|
||||
@@ -98,7 +130,7 @@ async function signOut() {
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="min-w-0 flex-1"><slot /></main>
|
||||
<main class="min-w-0 flex-1"><RouterView /></main>
|
||||
</div>
|
||||
|
||||
<LabelsModal v-if="managing" @close="managing = false" />
|
||||
|
||||
@@ -6,28 +6,17 @@ const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
// Persistent authed shell (sidebar + top bar + search); children render in it.
|
||||
path: "/",
|
||||
name: "board",
|
||||
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"),
|
||||
component: () => import("../components/AppShell.vue"),
|
||||
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",
|
||||
@@ -63,8 +52,6 @@ router.beforeEach(async (to) => {
|
||||
if (to.meta.requiresAdmin && !session.user?.is_admin) {
|
||||
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) {
|
||||
return { name: "login" };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
||||
import AppShell from "../components/AppShell.vue";
|
||||
import QuickAdd from "../components/QuickAdd.vue";
|
||||
import NoteCard from "../components/NoteCard.vue";
|
||||
import NoteEditor from "../components/NoteEditor.vue";
|
||||
@@ -50,46 +49,41 @@ function closeEditor() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="mx-auto w-full max-w-6xl px-4 py-6">
|
||||
<QuickAdd v-if="isMainBoard" autofocus class="mb-8" />
|
||||
<div class="mx-auto w-full max-w-6xl px-4 py-6">
|
||||
<QuickAdd v-if="isMainBoard" autofocus class="mb-8" />
|
||||
|
||||
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading…</div>
|
||||
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading…</div>
|
||||
|
||||
<div v-else-if="notes.items.length === 0" class="py-24 text-center">
|
||||
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">{{ emptyState.title }}</h2>
|
||||
<p class="mt-1 text-sm text-neutral-400">{{ emptyState.subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<template v-if="isMainBoard">
|
||||
<section v-if="pinnedNotes.length">
|
||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
|
||||
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in pinnedNotes" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
|
||||
<h2
|
||||
v-if="pinnedNotes.length"
|
||||
class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400"
|
||||
>
|
||||
Others
|
||||
</h2>
|
||||
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in otherNotes" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in notes.items" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="notes.items.length === 0" class="py-24 text-center">
|
||||
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">{{ emptyState.title }}</h2>
|
||||
<p class="mt-1 text-sm text-neutral-400">{{ emptyState.subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<template v-if="editing">
|
||||
<NoteEditor :note="editing" @close="closeEditor" />
|
||||
<template v-else>
|
||||
<template v-if="isMainBoard">
|
||||
<section v-if="pinnedNotes.length">
|
||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
|
||||
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in pinnedNotes" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
|
||||
<h2 v-if="pinnedNotes.length" class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
Others
|
||||
</h2>
|
||||
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in otherNotes" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in notes.items" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
</template>
|
||||
</AppShell>
|
||||
</div>
|
||||
|
||||
<template v-if="editing">
|
||||
<NoteEditor :note="editing" @close="closeEditor" />
|
||||
</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 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 .auth import login_required
|
||||
@@ -93,6 +93,36 @@ async def list_notes():
|
||||
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("")
|
||||
@login_required
|
||||
async def create_note():
|
||||
|
||||
@@ -50,3 +50,9 @@ async def test_notes_create_requires_auth(app):
|
||||
client = app.test_client()
|
||||
resp = await client.post("/api/notes", json={"body": "hi"})
|
||||
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