M2 search: Postgres FTS backend + top search bar + results
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 31s

- 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:
2026-07-19 22:03:19 -04:00
co-authored by Claude Opus 4.8
parent 2046600a95
commit ffc008bf4d
7 changed files with 216 additions and 67 deletions
+33
View File
@@ -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")
+38 -6
View File
@@ -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" />
+9 -22
View File
@@ -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" };
}
+32 -38
View File
@@ -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>
+67
View File
@@ -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>
+31 -1
View File
@@ -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():
+6
View File
@@ -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