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"> <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" />
+9 -22
View File
@@ -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" };
} }
+1 -7
View File
@@ -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>
+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 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():
+6
View File
@@ -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