diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c06b1d..3e513c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,12 @@ All notable changes to `doc` are documented here. ### Added +- PostgreSQL full-text search with tsvector/GIN index and `matchField` provenance (#71): both + `GET /api/doc?keyword=` and `GET /api/v1/documents?query=` now report `matchField` + (`title` | `content` | `both`) so host UIs can highlight where a hit was found. A plain-text + extraction of TipTap `content` is persisted in `contentSearch` and indexed via a trigger-maintained + `search_vector` tsvector column with `websearch_to_tsquery()`. The existing `contains` fallback + remains active for SQLite and unmigrated environments. - Extend document search to content body and add time-range and sort filters (#68): both `GET /api/doc?keyword=` and `GET /api/v1/documents?query=` now match title OR content (case-insensitive). New `after` / `before` (ISO 8601) filter by `updatedAt`; `sort` accepts diff --git a/package.json b/package.json index 1e5d973..3d745b8 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "format:fix": "prettier --write --list-different .", "prepare": "husky", "db:preflight": "prisma db execute --file prisma/preflight/ensure-share-relation-unique.sql --schema prisma/schema.prisma", - "db:push": "npm run db:preflight && prisma db push", + "db:search-index": "prisma db execute --file prisma/preflight/ensure-search-index.sql --schema prisma/schema.prisma", + "db:push": "npm run db:preflight && prisma db push && npm run db:search-index", "test": "vitest", "test:e2e": "playwright test", "test:e2e:full": "DOC_E2E_FULL_LOOP=1 playwright test e2e/workspace.spec.ts", diff --git a/prisma/preflight/ensure-search-index.sql b/prisma/preflight/ensure-search-index.sql new file mode 100644 index 0000000..8feee99 --- /dev/null +++ b/prisma/preflight/ensure-search-index.sql @@ -0,0 +1,54 @@ +DO $$ +BEGIN + IF to_regclass('"Doc"') IS NULL THEN + RETURN; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'Doc' AND column_name = 'contentSearch' + ) THEN + RETURN; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'Doc' AND column_name = 'search_vector' + ) THEN + ALTER TABLE "Doc" ADD COLUMN "search_vector" tsvector; + END IF; + + CREATE OR REPLACE FUNCTION "doc_search_vector_update"() + RETURNS TRIGGER AS $trigger$ + BEGIN + NEW."search_vector" := + setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') || + setweight(to_tsvector('english', coalesce(NEW."contentSearch", '')), 'B'); + RETURN NEW; + END; + $trigger$ LANGUAGE plpgsql; + + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger + WHERE tgname = 'doc_search_vector_trigger' + ) THEN + CREATE TRIGGER doc_search_vector_trigger + BEFORE INSERT OR UPDATE ON "Doc" + FOR EACH ROW + EXECUTE FUNCTION "doc_search_vector_update"(); + END IF; + + UPDATE "Doc" + SET "search_vector" = + setweight(to_tsvector('english', coalesce(title, '')), 'A') || + setweight(to_tsvector('english', coalesce("contentSearch", '')), 'B') + WHERE "search_vector" IS NULL; + + IF NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE indexname = 'Doc_search_vector_idx' + ) THEN + CREATE INDEX "Doc_search_vector_idx" ON "Doc" USING GIN ("search_vector"); + END IF; +END +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index af21a3a..996c5b2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,11 @@ model Doc { title String content String contentBinary Bytes? + contentSearch String? // plain-text extraction of TipTap JSON for search + /// Trigger-maintained tsvector. Prisma has no first-class tsvector type; the + /// column and GIN index are created by prisma/preflight/ensure-search-index.sql + /// and are not read or written through the Prisma client. + search_vector Unsupported("tsvector")? isDeleted Boolean @default(false) isStar Boolean @default(false) sortOrder Int diff --git a/services/collaboration/src/db/doc.ts b/services/collaboration/src/db/doc.ts index 6e2efdb..290c336 100644 --- a/services/collaboration/src/db/doc.ts +++ b/services/collaboration/src/db/doc.ts @@ -3,6 +3,7 @@ import type { QueryResultRow } from 'pg' import { pgClient, reconnect } from './client.js' import { errorMessage } from '../lib/error.js' import { sendEmail } from '../lib/mailer.js' +import { extractPlainTextFromJson } from '../lib/tiptap-text-extractor.js' export interface StoredDocumentRow extends QueryResultRow { content: string | null @@ -21,8 +22,9 @@ export interface MonitorDocumentRow extends QueryResultRow { */ export async function updateDocJsonStr(id: string, jsonStr: string): Promise { try { - const sql = `update "Doc" set content = $1, "updatedAt" = $2 where id = $3` - const values = [jsonStr, new Date(), id] + const contentSearch = extractPlainTextFromJson(jsonStr) + const sql = `update "Doc" set content = $1, "contentSearch" = $2, "updatedAt" = $3 where id = $4` + const values = [jsonStr, contentSearch || null, new Date(), id] const result = await pgClient.query(sql, values) return result.rowCount ?? 0 } catch (error) { @@ -69,8 +71,9 @@ export async function updateDocBinary(id: string, binary: Uint8Array): Promise { try { - const sql = `update "Doc" set "contentBinary" = $1, content = $2, "updatedAt" = $3 where id = $4` - const values = [binary, jsonStr, new Date(), id] + const contentSearch = extractPlainTextFromJson(jsonStr) + const sql = `update "Doc" set "contentBinary" = $1, content = $2, "contentSearch" = $3, "updatedAt" = $4 where id = $5` + const values = [binary, jsonStr, contentSearch || null, new Date(), id] const result = await pgClient.query(sql, values) return result.rowCount ?? 0 } catch (error) { diff --git a/services/collaboration/src/lib/tiptap-text-extractor.ts b/services/collaboration/src/lib/tiptap-text-extractor.ts new file mode 100644 index 0000000..a799402 --- /dev/null +++ b/services/collaboration/src/lib/tiptap-text-extractor.ts @@ -0,0 +1,34 @@ +interface TipTapNode { + type: string + text?: string + content?: TipTapNode[] +} + +export function extractPlainText(value: unknown): string { + if (value == null || typeof value !== 'object' || Array.isArray(value)) return '' + const parts: string[] = [] + collectText(value as TipTapNode, parts) + return parts.join(' ').replace(/\s+/g, ' ').trim() +} + +export function extractPlainTextFromJson(jsonStr: string): string { + if (!jsonStr?.trim()) return '' + try { + const parsed = JSON.parse(jsonStr) + return extractPlainText(parsed) + } catch { + return '' + } +} + +function collectText(node: TipTapNode, parts: string[]) { + if (node.type === 'text' && typeof node.text === 'string') { + parts.push(node.text) + return + } + if (node.content && Array.isArray(node.content)) { + for (const child of node.content) { + collectText(child, parts) + } + } +} diff --git a/src/__tests__/api/doc-create-permissions.test.ts b/src/__tests__/api/doc-create-permissions.test.ts index 726be73..a803f5a 100644 --- a/src/__tests__/api/doc-create-permissions.test.ts +++ b/src/__tests__/api/doc-create-permissions.test.ts @@ -131,6 +131,7 @@ describe('POST /api/doc permissions', () => { title: 'Source copy', content: '{"type":"doc"}', contentBinary: Buffer.from('binary'), + contentSearch: null, parentId: null, sortOrder: 1024, userId: 'owner', diff --git a/src/__tests__/lib/api-v1-documents.test.ts b/src/__tests__/lib/api-v1-documents.test.ts index fdf39b7..5f82301 100644 --- a/src/__tests__/lib/api-v1-documents.test.ts +++ b/src/__tests__/lib/api-v1-documents.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ findMany: vi.fn(), updateMany: vi.fn(), nextSortOrder: vi.fn(), + fullTextSearch: vi.fn(), })) vi.mock('server-only', () => ({})) @@ -26,6 +27,10 @@ vi.mock('@/db/db', () => ({ vi.mock('@/lib/doc-sort-order', () => ({ getNextSortOrderForParent: mocks.nextSortOrder, })) +vi.mock('@/lib/doc-search', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, fullTextSearch: mocks.fullTextSearch } +}) import { ApiV1Error } from '@/lib/api-v1' import { @@ -159,6 +164,10 @@ describe('TipTap API codec', () => { }) describe('v1 document service', () => { + beforeEach(() => { + mocks.fullTextSearch.mockResolvedValue(null) + }) + test('lists only the principal documents with stable pagination', async () => { mocks.findMany.mockResolvedValue([ metadata, @@ -212,6 +221,31 @@ describe('v1 document service', () => { ) }) + test('falls back to contains when full-text search returns no hits', async () => { + mocks.fullTextSearch.mockResolvedValue([]) + mocks.findMany.mockResolvedValue([]) + + await listApiDocuments('user-1', new URLSearchParams({ query: '中文关键词' })) + + const where = mocks.findMany.mock.calls[0][0].where + expect(where.id).toBeUndefined() + expect(where.OR).toEqual([ + { title: { contains: '中文关键词', mode: 'insensitive' } }, + { content: { contains: '中文关键词', mode: 'insensitive' } }, + ]) + }) + + test('restricts ids when full-text search returns hits', async () => { + mocks.fullTextSearch.mockResolvedValue([{ id: 'doc-1', matchField: 'title' }]) + mocks.findMany.mockResolvedValue([]) + + await listApiDocuments('user-1', new URLSearchParams({ query: 'Example' })) + + const where = mocks.findMany.mock.calls[0][0].where + expect(where.id).toEqual({ in: ['doc-1'] }) + expect(where.OR).toBeUndefined() + }) + test('filters by time range with after and before params', async () => { mocks.findMany.mockResolvedValue([]) diff --git a/src/__tests__/lib/doc-search.test.ts b/src/__tests__/lib/doc-search.test.ts new file mode 100644 index 0000000..198d64f --- /dev/null +++ b/src/__tests__/lib/doc-search.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + queryRaw: vi.fn(), + queryRawUnsafe: vi.fn(), +})) + +vi.mock('server-only', () => ({})) +vi.mock('@/db/db', () => ({ + db: { + $queryRaw: mocks.queryRaw, + $queryRawUnsafe: mocks.queryRawUnsafe, + }, +})) + +import { computeMatchField, fullTextSearch, hasSearchVector } from '@/lib/doc-search' + +beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() +}) + +describe('computeMatchField', () => { + test('returns "title" when query matches only title', () => { + expect(computeMatchField('My Document', 'some content', 'document')).toBe('title') + }) + + test('returns "content" when query matches only content', () => { + expect(computeMatchField('My Document', 'some content about testing', 'testing')).toBe('content') + }) + + test('returns "both" when query matches title and content', () => { + expect(computeMatchField('My Document', 'document content', 'document')).toBe('both') + }) + + test('returns "content" when contentSearch is null', () => { + expect(computeMatchField('My Document', null, 'content')).toBe('content') + }) + + test('is case-insensitive', () => { + expect(computeMatchField('MY DOCUMENT', 'SOME CONTENT', 'document')).toBe('title') + expect(computeMatchField('my document', 'some content', 'document')).toBe('title') + }) +}) + +describe('hasSearchVector', () => { + test('returns true when search_vector column exists', async () => { + const { hasSearchVector: freshHasSearchVector } = await import('@/lib/doc-search') + mocks.queryRaw.mockResolvedValueOnce([{ exists: true }]) + expect(await freshHasSearchVector()).toBe(true) + }) + + test('returns false when search_vector column does not exist', async () => { + const { hasSearchVector: freshHasSearchVector } = await import('@/lib/doc-search') + mocks.queryRaw.mockResolvedValueOnce([{ exists: false }]) + expect(await freshHasSearchVector()).toBe(false) + }) + + test('returns false on database error', async () => { + const { hasSearchVector: freshHasSearchVector } = await import('@/lib/doc-search') + mocks.queryRaw.mockRejectedValueOnce(new Error('connection failed')) + expect(await freshHasSearchVector()).toBe(false) + }) +}) + +describe('fullTextSearch', () => { + test('returns null when search_vector is not available', async () => { + const { fullTextSearch: freshFullTextSearch } = await import('@/lib/doc-search') + mocks.queryRaw.mockResolvedValueOnce([{ exists: false }]) + const result = await freshFullTextSearch('user-1', 'test', { isDeleted: false }) + expect(result).toBeNull() + }) + + test('returns search hits with matchField when search_vector is available', async () => { + const { fullTextSearch: freshFullTextSearch } = await import('@/lib/doc-search') + mocks.queryRaw.mockResolvedValueOnce([{ exists: true }]) + mocks.queryRawUnsafe.mockResolvedValueOnce([ + { id: 'doc-1', title: 'Test Document', contentSearch: 'some content' }, + { id: 'doc-2', title: 'Another Doc', contentSearch: 'test content here' }, + ]) + + const result = await freshFullTextSearch('user-1', 'test', { isDeleted: false }) + expect(result).toEqual([ + { id: 'doc-1', matchField: 'title' }, + { id: 'doc-2', matchField: 'content' }, + ]) + }) + + test('returns null on query error', async () => { + const { fullTextSearch: freshFullTextSearch } = await import('@/lib/doc-search') + mocks.queryRaw.mockResolvedValueOnce([{ exists: true }]) + mocks.queryRawUnsafe.mockRejectedValueOnce(new Error('query failed')) + + const result = await freshFullTextSearch('user-1', 'test', { isDeleted: false }) + expect(result).toBeNull() + }) + + test('returns an empty array when tsquery matches nothing (caller must fall back)', async () => { + const { fullTextSearch: freshFullTextSearch } = await import('@/lib/doc-search') + mocks.queryRaw.mockResolvedValueOnce([{ exists: true }]) + mocks.queryRawUnsafe.mockResolvedValueOnce([]) + + const result = await freshFullTextSearch('user-1', '中文关键词', { isDeleted: false }) + expect(result).toEqual([]) + }) +}) diff --git a/src/__tests__/lib/tiptap-text-extractor.test.ts b/src/__tests__/lib/tiptap-text-extractor.test.ts new file mode 100644 index 0000000..74f9a6e --- /dev/null +++ b/src/__tests__/lib/tiptap-text-extractor.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment node + +import { describe, expect, test } from 'vitest' + +import { extractPlainText, extractPlainTextFromJson } from '@/lib/tiptap-text-extractor' + +describe('extractPlainText', () => { + test('extracts text from a simple TipTap document', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello world' }], + }, + ], + } + expect(extractPlainText(doc)).toBe('Hello world') + }) + + test('extracts text from multiple paragraphs', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'First paragraph' }], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Second paragraph' }], + }, + ], + } + expect(extractPlainText(doc)).toBe('First paragraph Second paragraph') + }) + + test('extracts text from nested structures', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'blockquote', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Quoted text' }], + }, + ], + }, + ], + } + expect(extractPlainText(doc)).toBe('Quoted text') + }) + + test('handles text with marks (bold, italic, etc.)', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Normal ' }, + { type: 'text', text: 'bold', marks: [{ type: 'bold' }] }, + { type: 'text', text: ' text' }, + ], + }, + ], + } + expect(extractPlainText(doc)).toBe('Normal bold text') + }) + + test('returns empty string for null or invalid input', () => { + expect(extractPlainText(null)).toBe('') + expect(extractPlainText(undefined)).toBe('') + expect(extractPlainText('string')).toBe('') + expect(extractPlainText([])).toBe('') + }) + + test('returns empty string for document with no text', () => { + const doc = { + type: 'doc', + content: [{ type: 'horizontalRule' }], + } + expect(extractPlainText(doc)).toBe('') + }) + + test('collapses multiple whitespace', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: ' Multiple spaces ' }], + }, + ], + } + expect(extractPlainText(doc)).toBe('Multiple spaces') + }) +}) + +describe('extractPlainTextFromJson', () => { + test('extracts text from JSON string', () => { + const json = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'From JSON' }], + }, + ], + }) + expect(extractPlainTextFromJson(json)).toBe('From JSON') + }) + + test('returns empty string for invalid JSON', () => { + expect(extractPlainTextFromJson('not json')).toBe('') + expect(extractPlainTextFromJson('')).toBe('') + }) + + test('returns empty string for empty input', () => { + expect(extractPlainTextFromJson('')).toBe('') + expect(extractPlainTextFromJson(' ')).toBe('') + }) +}) diff --git a/src/app/api/doc/route.ts b/src/app/api/doc/route.ts index 0647b43..5095e8f 100644 --- a/src/app/api/doc/route.ts +++ b/src/app/api/doc/route.ts @@ -10,6 +10,8 @@ import { JsonBodyError, readJsonBody } from '@/lib/read-json-body' import { ApiV1Error } from '@/lib/api-v1' import { EMPTY_TIPTAP_DOCUMENT, encodeTiptapDocument } from '@/lib/tiptap-codec' import { parseOptionalDate, parseSort, buildSearchWhere, buildDateWhere, getOrderBy, SortValue } from '@/lib/doc-query' +import { fullTextSearch, computeMatchField, type MatchField } from '@/lib/doc-search' +import { extractPlainText } from '@/lib/tiptap-text-extractor' const MAX_CREATE_REQUEST_BYTES = 1024 * 1024 @@ -124,6 +126,8 @@ export async function POST(request: Request) { } } + const contentSearch = extractPlainText(JSON.parse(content)) + if (parentId) { const parent = await db.doc.findFirst({ where: { @@ -147,6 +151,7 @@ export async function POST(request: Request) { title, content, contentBinary, + contentSearch: contentSearch || null, parentId, sortOrder, userId: user.id!, @@ -262,6 +267,22 @@ export async function GET(request: NextRequest) { const orderBy = getOrderBy(sort) + let searchHits: Map | null = null + if (keyword != null) { + const hits = await fullTextSearch(user.id || '', keyword, { + isDeleted: whereOpt.isDeleted, + isStar: whereOpt.isStar, + }) + // Empty array means tsquery ran and missed (typical for zh-cn against + // english config). Fall back to the #69 contains OR so we do not emit + // `id: { in: [] }` and wipe the result set. + if (hits && hits.length > 0) { + searchHits = new Map(hits.map((h) => [h.id, h.matchField])) + whereOpt.id = { in: hits.map((h) => h.id) } + delete whereOpt.OR + } + } + const list = await db.doc.findMany({ select: { id: true, @@ -270,6 +291,7 @@ export async function GET(request: NextRequest) { isDeleted: true, createdAt: true, updatedAt: true, + contentSearch: true, }, where: { userId: user.id || '', @@ -278,7 +300,23 @@ export async function GET(request: NextRequest) { orderBy, }) - return Response.json(genSuccessData(list || [])) + const result = list.map((doc) => { + const base = { + id: doc.id, + title: doc.title, + parentId: doc.parentId, + isDeleted: doc.isDeleted, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + } + if (keyword == null) return base + const matchField = searchHits + ? (searchHits.get(doc.id) ?? 'content') + : computeMatchField(doc.title, doc.contentSearch, keyword.toLowerCase()) + return { ...base, matchField } + }) + + return Response.json(genSuccessData(result || [])) } // 删除多个 docs diff --git a/src/lib/api-v1-documents.ts b/src/lib/api-v1-documents.ts index 40ff259..ffeb1b5 100644 --- a/src/lib/api-v1-documents.ts +++ b/src/lib/api-v1-documents.ts @@ -9,6 +9,8 @@ import { ApiV1Error } from '@/lib/api-v1' import { getNextSortOrderForParent } from '@/lib/doc-sort-order' import { EMPTY_TIPTAP_DOCUMENT, encodeTiptapDocument } from '@/lib/tiptap-codec' import { parseOptionalDate, parseSort, buildSearchWhere, buildDateWhere, getOrderBy, SortValue } from '@/lib/doc-query' +import { extractPlainText } from '@/lib/tiptap-text-extractor' +import { fullTextSearch, computeMatchField, type MatchField } from '@/lib/doc-search' const DEFAULT_LIST_LIMIT = 50 const MAX_LIST_LIMIT = 100 @@ -54,6 +56,7 @@ const documentMetadataSelect = { isDeleted: true, createdAt: true, updatedAt: true, + contentSearch: true, } satisfies Prisma.DocSelect type DocumentMetadata = Prisma.DocGetPayload<{ select: typeof documentMetadataSelect }> @@ -130,7 +133,7 @@ export function apiDocumentEtag(document: Pick toMetadataDto(document)), + documents: documents.map((document) => { + const matchField = query + ? searchHits + ? (searchHits.get(document.id) ?? 'content') + : computeMatchField(document.title, document.contentSearch, query.toLowerCase()) + : undefined + return toMetadataDto(document, 'owner', matchField) + }), nextCursor: hasMore && last ? encodeCursor({ @@ -279,6 +306,7 @@ export async function createApiDocument(userId: string, input: ReturnType { + if (searchVectorAvailable !== null) return searchVectorAvailable + try { + const result = await db.$queryRaw<{ exists: boolean }[]>` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'Doc' AND column_name = 'search_vector' + ) AS exists + ` + searchVectorAvailable = result[0]?.exists === true + return searchVectorAvailable + } catch (error) { + console.warn('hasSearchVector probe failed; using contains fallback', error) + searchVectorAvailable = false + return false + } +} + +export interface SearchHit { + id: string + matchField: MatchField +} + +export async function fullTextSearch( + userId: string, + query: string, + filters: { isDeleted?: boolean; isStar?: boolean } +): Promise { + if (!(await hasSearchVector())) return null + + try { + const conditions = [`"userId" = $1`] + const values: unknown[] = [userId] + let idx = 2 + + if (filters.isDeleted !== undefined) { + conditions.push(`"isDeleted" = $${idx++}`) + values.push(filters.isDeleted) + } + if (filters.isStar !== undefined) { + conditions.push(`"isStar" = $${idx++}`) + values.push(filters.isStar) + } + + conditions.push(`"search_vector" @@ websearch_to_tsquery('english', $${idx})`) + values.push(query) + + const sql = `SELECT id, title, "contentSearch" FROM "Doc" WHERE ${conditions.join(' AND ')}` + const rows = await db.$queryRawUnsafe<{ id: string; title: string; contentSearch: string | null }[]>(sql, ...values) + + const q = query.toLowerCase() + return rows.map((row) => ({ + id: row.id, + matchField: computeMatchField(row.title, row.contentSearch, q), + })) + } catch (error) { + console.warn('fullTextSearch failed; falling back to contains', error) + return null + } +} + +/** + * Substring heuristic on `title` / `contentSearch`, not ts_rank / ts_headline. + * Callers use this both for contains fallback rows and to label FTS hits. + */ +export function computeMatchField( + title: string, + contentSearch: string | null | undefined, + queryLower: string +): MatchField { + const titleMatch = title.toLowerCase().includes(queryLower) + const contentMatch = (contentSearch ?? '').toLowerCase().includes(queryLower) + if (titleMatch && contentMatch) return 'both' + if (titleMatch) return 'title' + return 'content' +} diff --git a/src/lib/tiptap-text-extractor.ts b/src/lib/tiptap-text-extractor.ts new file mode 100644 index 0000000..a799402 --- /dev/null +++ b/src/lib/tiptap-text-extractor.ts @@ -0,0 +1,34 @@ +interface TipTapNode { + type: string + text?: string + content?: TipTapNode[] +} + +export function extractPlainText(value: unknown): string { + if (value == null || typeof value !== 'object' || Array.isArray(value)) return '' + const parts: string[] = [] + collectText(value as TipTapNode, parts) + return parts.join(' ').replace(/\s+/g, ' ').trim() +} + +export function extractPlainTextFromJson(jsonStr: string): string { + if (!jsonStr?.trim()) return '' + try { + const parsed = JSON.parse(jsonStr) + return extractPlainText(parsed) + } catch { + return '' + } +} + +function collectText(node: TipTapNode, parts: string[]) { + if (node.type === 'text' && typeof node.text === 'string') { + parts.push(node.text) + return + } + if (node.content && Array.isArray(node.content)) { + for (const child of node.content) { + collectText(child, parts) + } + } +}