From 684519384be0821db290c188a8f9f268c10645e4 Mon Sep 17 00:00:00 2001 From: beo3000 Date: Wed, 25 Feb 2026 20:51:53 +0100 Subject: [PATCH] added search --- docs/feature-search.md | 136 ++++++++++++++++++ ka-note/VERSION | 2 +- .../src/lib/components/CommandBar.svelte | 80 ++++++++++- ka-note/client/src/lib/db/schema.ts | 11 ++ ka-note/client/src/lib/stores/settings.ts | 32 +++++ ka-note/server/drizzle/0013_fts_search.sql | 7 + ka-note/server/drizzle/meta/_journal.json | 7 + ka-note/server/ka-note.db-shm | Bin 32768 -> 32768 bytes ka-note/server/ka-note.db-wal | Bin 333752 -> 440872 bytes ka-note/server/src/db/connection.ts | 1 + ka-note/server/src/index.ts | 21 ++- ka-note/server/src/lib/ai-export-service.ts | 56 ++++---- ka-note/server/src/lib/sync-service.ts | 38 ++++- ka-note/server/src/routes/search.ts | 80 +++++++++++ ka-note/server/src/routes/trash.ts | 4 +- 15 files changed, 444 insertions(+), 31 deletions(-) create mode 100644 docs/feature-search.md create mode 100644 ka-note/client/src/lib/stores/settings.ts create mode 100644 ka-note/server/drizzle/0013_fts_search.sql create mode 100644 ka-note/server/src/routes/search.ts diff --git a/docs/feature-search.md b/docs/feature-search.md new file mode 100644 index 0000000..0397bf2 --- /dev/null +++ b/docs/feature-search.md @@ -0,0 +1,136 @@ +# Full-Text Search + +## Overview + +Ka-Note implements a hybrid full-text search strategy: small in-memory corpora (contexts, page titles) are filtered client-side; the large corpus (history entry text, page body) is indexed server-side using SQLite FTS5 and queried via HTTP. + +## Architecture + +### Search tiers + +| Entity | Where | Method | +|---|---|---| +| Contexts (name) | Client only | Substring on in-memory Svelte store | +| Pages (title) | Client only | Substring on in-memory Svelte store | +| HistoryEntries (text) | Server FTS5 | Debounced HTTP GET /api/search | +| Pages (body) | Server FTS5 | Debounced HTTP GET /api/search | + +History entries are the primary scaling concern (years of daily journals → tens of thousands of rows). SQLite FTS5 with BM25 ranking handles this efficiently without additional infrastructure. + +### Offline fallback + +When the server is unreachable, CommandBar falls back to local results (contexts, page titles) only and shows a notice: "Server nicht erreichbar — nur lokale Ergebnisse". + +--- + +## Server + +### FTS5 tables + +Migration: `server/drizzle/0013_fts_search.sql` + +Two virtual tables using the `unicode61` tokenizer (handles German umlauts correctly, no stemming): + +- `fts_history` — content table backed by `history_entries` (columns: `text`) +- `fts_pages` — content table backed by `pages` (columns: `title`, `body`) + +Both tables are populated via `INSERT INTO fts_*(...) VALUES('rebuild')` on first migration run. + +### Index maintenance + +FTS index is updated synchronously after every write, covering all server-side write paths: + +| Write path | File | FTS update | +|---|---|---| +| Sync push (primary client sync) | `sync-service.ts` → `pushChanges()` | after each upsert | +| Trash / soft-delete | `routes/trash.ts` | after batch update | +| AI bundle upload (ZIP) | `ai-export-service.ts` → `applyOps()` | after each op | +| AI legacy JSON upload | `ai-export-service.ts` → `applyOps()` | after each op | +| Startup drift recovery | `index.ts` `setImmediate` | full rebuild if mismatch > 10 | + +All paths use `better-sqlite3` prepared statements. Shared helper `applyOps()` in `ai-export-service.ts` handles both upload variants. Soft-deleted rows are removed from FTS; active rows are re-indexed via `INSERT OR REPLACE … SELECT`. + +**Startup consistency check:** On each server start, row counts of `history_entries` (non-deleted) and `fts_history` are compared. If the difference exceeds 10, both FTS tables are rebuilt via `INSERT INTO fts_*(fts_*) VALUES('rebuild')`. This guards against index drift after DB restores or backup imports. + +### Raw SQLite access + +File: `server/src/db/connection.ts` + +The `better-sqlite3` instance is exported as `sqlite` alongside the Drizzle `db`. This is needed for FTS prepared statements (Drizzle has no FTS5 DSL). + +### Search endpoint + +``` +GET /api/search?q=&limit= +Authorization: Bearer +``` + +Response: +```json +{ + "history": [ + { "id": "...", "topicId": "...", "date": "2025-01-15", "snippet": "...text..." } + ], + "pages": [ + { "id": "...", "title": "Page Title", "snippet": "...body text..." } + ] +} +``` + +- `q` must be ≥ 2 characters; shorter queries return empty results. +- `limit` is capped at 20 server-side. +- Each word in `q` is automatically appended with `*` for prefix matching (`"term"*`). +- Results are ranked by BM25 (`ORDER BY rank`). +- FTS5 query errors (invalid syntax from special characters) return empty results instead of HTTP 500. +- Soft-deleted entries are excluded via the FTS delete-on-soft-delete strategy. + +File: `server/src/routes/search.ts` + +--- + +## Client + +### Settings store + +File: `client/src/lib/stores/settings.ts` + +Generic key-value settings backed by a Dexie `settings` table (version 13). Provides: + +- `getSetting(key, default)` — async one-time read +- `setSetting(key, value)` — async write +- `settingStore(key, default)` — reactive Svelte store backed by `liveQuery` + +The `searchResultsLimit` store (default: 3) controls how many server results are requested. + +### CommandBar integration + +File: `client/src/lib/components/CommandBar.svelte` + +In navigate mode (query ≥ 2 chars, not starting with `/`): + +1. **Immediately (sync):** Filters `$contextsQuery` and `$pagesQuery` by substring match on name/title. +2. **After 250ms debounce:** Calls `authFetch('/api/search?q=...&limit=...')` using the existing `apiClient` helper. +3. **On success:** Server results are appended after local results. Pages already found by title match are deduplicated. +4. **On error:** `isOffline = true`, a footer notice is shown, local results remain visible. +5. **Total results** are capped at 10. + +History results deep-link to `/context/daily-log?date=YYYY-MM-DD`. + +--- + +## Settings + +| Key | Type | Default | Description | +|---|---|---|---| +| `searchResultsLimit` | number | 3 | Max server search results per entity type | + +To change: write to Dexie via `setSetting('searchResultsLimit', 5)` or add a Settings UI field. + +--- + +## Scaling notes + +- FTS5 + BM25 scales to millions of rows. No action needed as data grows. +- The `unicode61` tokenizer handles Unicode correctly. Stemming can be added later by changing `tokenize='unicode61'` to `tokenize='porter unicode61'` in the migration. +- If topic title search needs FTS in future, add `fts_topics` following the same pattern. +- Offline full-text search for history (e.g. via MiniSearch in a Web Worker) is a possible v2 enhancement. diff --git a/ka-note/VERSION b/ka-note/VERSION index 94296eb..00c0a32 100644 --- a/ka-note/VERSION +++ b/ka-note/VERSION @@ -1 +1 @@ -1.1.75 \ No newline at end of file +1.1.77 \ No newline at end of file diff --git a/ka-note/client/src/lib/components/CommandBar.svelte b/ka-note/client/src/lib/components/CommandBar.svelte index 98b751d..72f85d5 100644 --- a/ka-note/client/src/lib/components/CommandBar.svelte +++ b/ka-note/client/src/lib/components/CommandBar.svelte @@ -15,6 +15,8 @@ pageNameExists, } from "$lib/db/repositories"; import { newId, today } from "$lib/db/helpers"; + import { authFetch } from "$lib/auth/apiClient"; + import { searchResultsLimit } from "$lib/stores/settings"; const contextsQuery = allActiveContexts(); const pagesQuery = allPages(); @@ -25,6 +27,77 @@ let recentContextIds = $state([]); let isMac = $state(false); + // Server FTS results + interface ServerResult { + id: string; + type: "nav-history" | "nav-wiki"; + icon: string; + label: string; + badge: string; + action: () => void; + } + let serverResults = $state([]); + let isOffline = $state(false); + let searchTimer: ReturnType | null = null; + + $effect(() => { + const q = query.trim(); + if (q.length < 2 || q.startsWith("/")) { + serverResults = []; + isOffline = false; + if (searchTimer) clearTimeout(searchTimer); + return; + } + if (searchTimer) clearTimeout(searchTimer); + searchTimer = setTimeout(async () => { + try { + const limit = $searchResultsLimit; + const res = await authFetch(`/api/search?q=${encodeURIComponent(q)}&limit=${limit}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json() as { + history: { id: string; topicId: string; date: string; snippet: string }[]; + pages: { id: string; title: string; snippet: string }[]; + }; + const localPageIds = new Set( + ($pagesQuery ?? []) + .filter((p) => p.title.toLowerCase().includes(q.toLowerCase())) + .map((p) => p.id), + ); + const combined: ServerResult[] = [ + ...data.history.map((h) => ({ + id: `hist-${h.id}`, + type: "nav-history" as const, + icon: "📓", + label: h.snippet.replace(/<[^>]+>/g, ""), + badge: `JOURNAL ${h.date}`, + action: () => { + closeBar(); + goto(`/context/daily-log?date=${h.date}`); + }, + })), + ...data.pages + .filter((p) => !localPageIds.has(p.id)) + .map((p) => ({ + id: `page-fts-${p.id}`, + type: "nav-wiki" as const, + icon: "📄", + label: p.title, + badge: "WIKI", + action: () => { + closeBar(); + goto(`/wiki/${p.id}`); + }, + })), + ]; + serverResults = combined; + isOffline = false; + } catch { + isOffline = true; + serverResults = []; + } + }, 250); + }); + onMount(() => { isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; }); @@ -474,7 +547,7 @@ } } - return searchResults; + return [...searchResults, ...serverResults].slice(0, 10); }); // Reset selection when query changes @@ -627,6 +700,11 @@ Keine Ergebnisse für "{query}" {/if} + {#if isOffline} +
+ Server nicht erreichbar — nur lokale Ergebnisse +
+ {/if} {/if} diff --git a/ka-note/client/src/lib/db/schema.ts b/ka-note/client/src/lib/db/schema.ts index 9e2711c..60971f2 100644 --- a/ka-note/client/src/lib/db/schema.ts +++ b/ka-note/client/src/lib/db/schema.ts @@ -12,6 +12,12 @@ export interface ImageBlob { version: number; } +export interface AppSetting { + key: string; + value: unknown; + updatedAt: string; +} + export class KaNoteDB extends Dexie { contexts!: EntityTable; topics!: EntityTable; @@ -22,6 +28,7 @@ export class KaNoteDB extends Dexie { pages!: EntityTable; notebooks!: EntityTable; pageNotebooks!: EntityTable; + settings!: EntityTable; constructor() { super('ka-note'); @@ -126,6 +133,10 @@ export class KaNoteDB extends Dexie { if (p.isFavorite === undefined) p.isFavorite = false; }); }); + + this.version(13).stores({ + settings: '&key', + }); } } diff --git a/ka-note/client/src/lib/stores/settings.ts b/ka-note/client/src/lib/stores/settings.ts new file mode 100644 index 0000000..4594a05 --- /dev/null +++ b/ka-note/client/src/lib/stores/settings.ts @@ -0,0 +1,32 @@ +import { liveQuery } from 'dexie'; +import { readable } from 'svelte/store'; +import { db } from '$lib/db/schema.js'; + +function now(): string { + return new Date().toISOString(); +} + +export async function getSetting(key: string, defaultValue: T): Promise { + const row = await db.settings.get(key); + return row !== undefined ? (row.value as T) : defaultValue; +} + +export async function setSetting(key: string, value: T): Promise { + await db.settings.put({ key, value, updatedAt: now() }); +} + +/** Reactive store for a single setting. Updates when the DB changes. */ +export function settingStore(key: string, defaultValue: T) { + return readable(defaultValue, (set) => { + const subscription = liveQuery(async () => { + const row = await db.settings.get(key); + return row !== undefined ? (row.value as T) : defaultValue; + }).subscribe({ + next: (v) => set(v), + error: (e) => console.error('[settings] liveQuery error', e), + }); + return () => subscription.unsubscribe(); + }); +} + +export const searchResultsLimit = settingStore('searchResultsLimit', 3); diff --git a/ka-note/server/drizzle/0013_fts_search.sql b/ka-note/server/drizzle/0013_fts_search.sql new file mode 100644 index 0000000..61e466a --- /dev/null +++ b/ka-note/server/drizzle/0013_fts_search.sql @@ -0,0 +1,7 @@ +CREATE VIRTUAL TABLE IF NOT EXISTS fts_history USING fts5(id UNINDEXED, user_id UNINDEXED, text, date UNINDEXED, topic_id UNINDEXED, content='history_entries', content_rowid='rowid', tokenize='unicode61'); +--> statement-breakpoint +CREATE VIRTUAL TABLE IF NOT EXISTS fts_pages USING fts5(id UNINDEXED, user_id UNINDEXED, title, body, content='pages', content_rowid='rowid', tokenize='unicode61'); +--> statement-breakpoint +INSERT INTO fts_history(fts_history) VALUES('rebuild'); +--> statement-breakpoint +INSERT INTO fts_pages(fts_pages) VALUES('rebuild'); diff --git a/ka-note/server/drizzle/meta/_journal.json b/ka-note/server/drizzle/meta/_journal.json index f33705e..67475c4 100644 --- a/ka-note/server/drizzle/meta/_journal.json +++ b/ka-note/server/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1772004047537, "tag": "0012_chunky_stature", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1772100000000, + "tag": "0013_fts_search", + "breakpoints": true } ] } \ No newline at end of file diff --git a/ka-note/server/ka-note.db-shm b/ka-note/server/ka-note.db-shm index ca173d7dd2b7fb9b9df787c76fbc19cb26905795..f1b645329ebca495ee17eb2d01b494e8ab74a662 100644 GIT binary patch delta 446 zcmZo@U}|V!s+V}A%K!t63=9IB#K2}o%&ZhjYeg^@8C!hRD2WT!ybuj2o4Gp0_y$oO;fqZ}3{#xyYVK|YX~ l4rbmf0x~ne%sZw37#YJigDr^wQkR(~uc_5yj07?@nE?8pkdXiY delta 278 zcmZo@U}|V!s+V}A%K!q*K+MR%AP@+oCjs&0Ph29ef2>}Yr|!F{JwsS-rGPrW)aNbE zq^buR1qL8<|04mYFcX98#zt?(%_d9>>^3_vWiW2O;;6&8*@)>66QllSL#E4&n=iP7 z1uii%8csH3(%k&R>k1=KsTR}ZZ+=&R3>B`;?;;^WjGN!Yz*w*1|8Y+?V*0iDQ4R|e bnDHPV%D7hqW!x$K2Qums>*O`HT3|B)>55;V diff --git a/ka-note/server/ka-note.db-wal b/ka-note/server/ka-note.db-wal index 6728356f109a0c402878fd9948198ce8cde18597..4aa1bb20c86101a4b5cc0fafc92b7bc271197bfe 100644 GIT binary patch delta 21869 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 V3;K>F7M3lnJH!E00tZw8 diff --git a/ka-note/server/src/db/connection.ts b/ka-note/server/src/db/connection.ts index f25121c..2be9be6 100644 --- a/ka-note/server/src/db/connection.ts +++ b/ka-note/server/src/db/connection.ts @@ -21,6 +21,7 @@ sqlite.pragma('journal_mode = WAL'); sqlite.pragma('foreign_keys = ON'); export const db = drizzle(sqlite, { schema }); +export { sqlite }; // Run migrations on startup const migrationsFolder = path.resolve(__dirname, '../../drizzle'); diff --git a/ka-note/server/src/index.ts b/ka-note/server/src/index.ts index 76df163..0ecdfb7 100644 --- a/ka-note/server/src/index.ts +++ b/ka-note/server/src/index.ts @@ -15,7 +15,9 @@ import adminRoutes from './routes/admin.js'; import pushRoutes from './routes/push.js'; import backupRoutes from './routes/backup.js'; import apiKeyRoutes from './routes/api-keys.js'; +import searchRoutes from './routes/search.js'; import { runScheduledBackup, runIfMissed, checkIntegrity } from './lib/backup-service.js'; +import { sqlite } from './db/connection.js'; const app = new OpenAPIHono(); @@ -27,13 +29,26 @@ app.onError((err, c) => { return c.json({ error: 'internal server error', detail: err.message }, 500); }); -// Integrity check at startup +// Integrity check + FTS consistency check at startup setImmediate(() => { try { checkIntegrity(); } catch (e) { console.error('[db] startup integrity_check threw:', e); } + + try { + const heCount = (sqlite.prepare('SELECT COUNT(*) AS n FROM history_entries WHERE deleted_at IS NULL').get() as { n: number }).n; + const ftsCount = (sqlite.prepare('SELECT COUNT(*) AS n FROM fts_history').get() as { n: number }).n; + if (Math.abs(heCount - ftsCount) > 10) { + console.warn(`[fts] Index mismatch (history_entries=${heCount}, fts_history=${ftsCount}), rebuilding...`); + sqlite.prepare("INSERT INTO fts_history(fts_history) VALUES('rebuild')").run(); + sqlite.prepare("INSERT INTO fts_pages(fts_pages) VALUES('rebuild')").run(); + console.log('[fts] Rebuild complete'); + } + } catch (e) { + console.error('[fts] startup check threw:', e); + } }); // Public routes @@ -81,6 +96,10 @@ app.route('/api/backup', backupRoutes); app.use('/api/api-keys/*', authMiddleware); app.route('/api/api-keys', apiKeyRoutes); +app.use('/api/search/*', authMiddleware); +app.use('/api/search', authMiddleware); +app.route('/api/search', searchRoutes); + // OpenAPI spec + Scalar UI app.openAPIRegistry.registerComponent('securitySchemes', 'BearerAuth', { type: 'http', diff --git a/ka-note/server/src/lib/ai-export-service.ts b/ka-note/server/src/lib/ai-export-service.ts index 6705378..36fd191 100644 --- a/ka-note/server/src/lib/ai-export-service.ts +++ b/ka-note/server/src/lib/ai-export-service.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'crypto'; -import { db } from '../db/connection.js'; +import { db, sqlite } from '../db/connection.js'; import { aiLocks, contexts, topics, historyEntries, ratings, imageBlobs, pages, notebooks, pageNotebooks } from '../db/schema.js'; import { eq, and, sql } from 'drizzle-orm'; import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate'; @@ -8,6 +8,34 @@ import { generateAiReadme } from './ai-agent-readme.js'; const LOCK_EXPIRY_HOURS = Number(process.env.AI_LOCK_EXPIRY_HOURS ?? 24); +const stmtFtsHistoryUpsert = sqlite.prepare(`INSERT OR REPLACE INTO fts_history(rowid, id, user_id, text, date, topic_id) SELECT rowid, id, user_id, text, date, topic_id FROM history_entries WHERE id = ? AND user_id = ?`); +const stmtFtsHistoryDelete = sqlite.prepare(`DELETE FROM fts_history WHERE id = ? AND user_id = ?`); +const stmtFtsPagesUpsert = sqlite.prepare(`INSERT OR REPLACE INTO fts_pages(rowid, id, user_id, title, body) SELECT rowid, id, user_id, title, body FROM pages WHERE id = ? AND user_id = ?`); +const stmtFtsPagesDelete = sqlite.prepare(`DELETE FROM fts_pages WHERE id = ? AND user_id = ?`); + +type AiTableDef = typeof contexts | typeof topics | typeof historyEntries | typeof ratings | typeof imageBlobs | typeof pages | typeof notebooks | typeof pageNotebooks; + +async function applyOps(ops: Array<{ action: 'insert' | 'update'; table: AiTableDef; row: Record }>, userId: string): Promise { + let accepted = 0; + for (const op of ops) { + if (op.action === 'insert') { + await db.insert(op.table).values(op.row as never); + } else { + await db.update(op.table).set(op.row as never) + .where(and(sql`${op.table.id} = ${op.row.id}`, sql`${op.table.userId} = ${userId}`)); + } + accepted++; + if (op.table === historyEntries) { + if (op.row.deletedAt) stmtFtsHistoryDelete.run(op.row.id, userId); + else stmtFtsHistoryUpsert.run(op.row.id, userId); + } else if (op.table === pages) { + if (op.row.deletedAt) stmtFtsPagesDelete.run(op.row.id, userId); + else stmtFtsPagesUpsert.run(op.row.id, userId); + } + } + return accepted; +} + function now(): string { return new Date().toISOString(); } @@ -195,7 +223,7 @@ export interface AiUploadResult { conflicts: Array<{ entityType: string; entityId: string; clientVersion: number; serverVersion: number }>; } -type TableDef = typeof contexts | typeof topics | typeof historyEntries | typeof ratings | typeof imageBlobs | typeof pages | typeof notebooks | typeof pageNotebooks; +type TableDef = AiTableDef; async function checkConflict( table: TableDef, @@ -389,17 +417,7 @@ export async function applyUploadFromZip( return { result: { accepted: 0, skipped, conflicts }, conflict: true }; } - let accepted = 0; - for (const op of ops) { - if (op.action === 'insert') { - await db.insert(op.table).values(op.row as never); - } else { - await db.update(op.table).set(op.row as never) - .where(and(sql`${op.table.id} = ${op.row.id}`, sql`${op.table.userId} = ${userId}`)); - } - accepted++; - } - + const accepted = await applyOps(ops, userId); return { result: { accepted, skipped, conflicts }, conflict: false }; } @@ -489,16 +507,6 @@ export async function applyUpload( return { result: { accepted: 0, skipped, conflicts }, conflict: true }; } - let accepted = 0; - for (const op of ops) { - if (op.action === 'insert') { - await db.insert(op.table).values(op.row as never); - } else { - await db.update(op.table).set(op.row as never) - .where(and(sql`${op.table.id} = ${op.row.id}`, sql`${op.table.userId} = ${userId}`)); - } - accepted++; - } - + const accepted = await applyOps(ops, userId); return { result: { accepted, skipped, conflicts }, conflict: false }; } diff --git a/ka-note/server/src/lib/sync-service.ts b/ka-note/server/src/lib/sync-service.ts index c1e46d6..628e492 100644 --- a/ka-note/server/src/lib/sync-service.ts +++ b/ka-note/server/src/lib/sync-service.ts @@ -1,4 +1,4 @@ -import { db } from '../db/connection.js'; +import { db, sqlite } from '../db/connection.js'; import { contexts, topics, historyEntries, ratings, imageBlobs, pages, notebooks, pageNotebooks } from '../db/schema.js'; import { and, gt, eq, sql, isNotNull, lt } from 'drizzle-orm'; import type { @@ -8,6 +8,24 @@ import type { type TableDef = typeof contexts | typeof topics | typeof historyEntries | typeof ratings | typeof imageBlobs | typeof pages | typeof notebooks | typeof pageNotebooks; +// FTS index maintenance (prepared statements for performance) +const stmtFtsHistoryUpsert = sqlite.prepare(` + INSERT OR REPLACE INTO fts_history(rowid, id, user_id, text, date, topic_id) + SELECT rowid, id, user_id, text, date, topic_id + FROM history_entries WHERE id = ? AND user_id = ? +`); +const stmtFtsHistoryDelete = sqlite.prepare(` + DELETE FROM fts_history WHERE id = ? AND user_id = ? +`); +const stmtFtsPagesUpsert = sqlite.prepare(` + INSERT OR REPLACE INTO fts_pages(rowid, id, user_id, title, body) + SELECT rowid, id, user_id, title, body + FROM pages WHERE id = ? AND user_id = ? +`); +const stmtFtsPagesDelete = sqlite.prepare(` + DELETE FROM fts_pages WHERE id = ? AND user_id = ? +`); + function now(): string { return new Date().toISOString(); } @@ -242,7 +260,14 @@ export async function pushChanges(request: SyncPushRequest, userId: string): Pro purgedAt: he.purgedAt ?? null, version: he.version, }; - if (await upsertEntity(historyEntries, row, conflicts, 'historyEntry', userId)) accepted++; + if (await upsertEntity(historyEntries, row, conflicts, 'historyEntry', userId)) { + accepted++; + if (he.deletedAt) { + stmtFtsHistoryDelete.run(he.id, userId); + } else { + stmtFtsHistoryUpsert.run(he.id, userId); + } + } } for (const rat of rats) { @@ -278,7 +303,14 @@ export async function pushChanges(request: SyncPushRequest, userId: string): Pro for (const pg of pgs) { const row = { id: pg.id, userId, title: pg.title, body: pg.body, isPrivate: pg.isPrivate, isFavorite: pg.isFavorite ?? false, sortOrder: pg.sortOrder, updatedAt: pg.updatedAt, deletedAt: pg.deletedAt, purgedAt: pg.purgedAt ?? null, version: pg.version }; - if (await upsertEntity(pages, row, conflicts, 'page', userId)) accepted++; + if (await upsertEntity(pages, row, conflicts, 'page', userId)) { + accepted++; + if (pg.deletedAt) { + stmtFtsPagesDelete.run(pg.id, userId); + } else { + stmtFtsPagesUpsert.run(pg.id, userId); + } + } } for (const nb of nbs) { diff --git a/ka-note/server/src/routes/search.ts b/ka-note/server/src/routes/search.ts new file mode 100644 index 0000000..9f5fce2 --- /dev/null +++ b/ka-note/server/src/routes/search.ts @@ -0,0 +1,80 @@ +import { Hono } from 'hono'; +import { sqlite } from '../db/connection.js'; +import type { AuthEnv } from '../middleware/auth.js'; + +const search = new Hono(); + +interface HistoryResult { + id: string; + topicId: string; + date: string; + snippet: string; +} + +interface PageResult { + id: string; + title: string; + snippet: string; +} + +const stmtSearchHistory = sqlite.prepare(` + SELECT + id, + topic_id AS topicId, + date, + snippet(fts_history, 2, '', '', '...', 12) AS snippet + FROM fts_history + WHERE fts_history MATCH ? + AND user_id = ? + AND id NOT IN ( + SELECT id FROM history_entries WHERE deleted_at IS NOT NULL AND user_id = ? + ) + ORDER BY rank + LIMIT ? +`); + +const stmtSearchPages = sqlite.prepare(` + SELECT + id, + title, + snippet(fts_pages, 2, '', '', '...', 12) AS snippet + FROM fts_pages + WHERE fts_pages MATCH ? + AND user_id = ? + AND id NOT IN ( + SELECT id FROM pages WHERE deleted_at IS NOT NULL AND user_id = ? + ) + ORDER BY rank + LIMIT ? +`); + +search.get('/', (c) => { + const auth = c.get('auth'); + const userId = auth.userId; + + const q = c.req.query('q')?.trim() ?? ''; + const limit = Math.min(Number(c.req.query('limit') ?? 5), 20); + + if (q.length < 2) { + return c.json({ history: [], pages: [] }); + } + + // Build FTS5 prefix query: each word gets a trailing * for prefix matching + const ftsQuery = q + .split(/\s+/) + .filter(Boolean) + .map((t) => `"${t.replace(/"/g, '')}"*`) + .join(' '); + + try { + const history = stmtSearchHistory.all(ftsQuery, userId, userId, limit) as HistoryResult[]; + const pages = stmtSearchPages.all(ftsQuery, userId, userId, limit) as PageResult[]; + return c.json({ history, pages }); + } catch (err) { + // FTS5 query syntax errors (e.g. special chars) → return empty rather than 500 + console.warn('[search] FTS query error:', err instanceof Error ? err.message : err); + return c.json({ history: [], pages: [] }); + } +}); + +export default search; diff --git a/ka-note/server/src/routes/trash.ts b/ka-note/server/src/routes/trash.ts index 390354a..213baec 100644 --- a/ka-note/server/src/routes/trash.ts +++ b/ka-note/server/src/routes/trash.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { db } from '../db/connection.js'; +import { db, sqlite } from '../db/connection.js'; import { contexts, topics, historyEntries, ratings } from '../db/schema.js'; import { and, eq, inArray, sql } from 'drizzle-orm'; import { handle } from '../lib/route-utils.js'; @@ -74,6 +74,8 @@ trash.delete('/', handle('trash/delete', async (c) => { if (historyIds.size > 0) { await db.update(historyEntries).set({ deletedAt: ts, purgedAt: ts, updatedAt: ts, version: sql`${historyEntries.version} + 1` }) .where(and(eq(historyEntries.userId, userId), inArray(historyEntries.id, [...historyIds]))); + const stmtDel = sqlite.prepare('DELETE FROM fts_history WHERE id = ? AND user_id = ?'); + for (const id of historyIds) stmtDel.run(id, userId); } if (topicIds.size > 0) { await db.update(topics).set({ deletedAt: ts, purgedAt: ts, updatedAt: ts, version: sql`${topics.version} + 1` })