From 5f270d0502606cb40d3e57d83df6a3d8a4e53c78 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:37:28 +0100 Subject: [PATCH] feat: add title-only search mode Adds a titleOnly flag to page search: the Postgres driver matches titles via trigram-indexed unaccented LIKE ranked by word_similarity, and a Match filter in the search spotlight switches between everything and title-only matching. --- .../public/locales/en-US/translation.json | 5 +- .../components/search-spotlight-filters.tsx | 53 +++++++++++++++++++ .../search/components/search-spotlight.tsx | 5 ++ .../src/features/search/types/search.types.ts | 1 + apps/server/src/core/search/dto/search.dto.ts | 4 ++ apps/server/src/core/search/search.service.ts | 42 +++++++++++---- .../20260824T211732-page-title-trgm-index.ts | 11 ++++ apps/server/src/ee | 2 +- 8 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index e5768c7e4..1855a6f8c 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1308,5 +1308,8 @@ "Toggle workspace knowledge only": "Toggle workspace knowledge only", "Read-only mode": "Read-only mode", "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.", - "Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode" + "Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode", + "Match": "Match", + "Everything": "Everything", + "Title only": "Title only" } diff --git a/apps/client/src/features/search/components/search-spotlight-filters.tsx b/apps/client/src/features/search/components/search-spotlight-filters.tsx index 6814248e9..36179e4b8 100644 --- a/apps/client/src/features/search/components/search-spotlight-filters.tsx +++ b/apps/client/src/features/search/components/search-spotlight-filters.tsx @@ -16,6 +16,7 @@ import { IconCheck, IconUser, IconTag, + IconLetterCase, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useGetSpacesQuery } from "@/features/space/queries/space-query"; @@ -53,6 +54,7 @@ export function SearchSpotlightFilters({ null ); const [selectedLabelIds, setSelectedLabelIds] = useState([]); + const [titleOnly, setTitleOnly] = useState(false); const [openedFilter, setOpenedFilter] = useState(null); const [visibleFilters, setVisibleFilters] = useState([]); const [workspace] = useAtom(workspaceAtom); @@ -77,12 +79,14 @@ export function SearchSpotlightFilters({ contentType, creatorId: selectedCreatorId, labelIds: selectedLabelIds, + titleOnly, }); }, [ selectedSpaceId, contentType, selectedCreatorId, selectedLabelIds, + titleOnly, onFiltersChange, ]); @@ -250,6 +254,55 @@ export function SearchSpotlightFilters({ + {contentType !== "attachment" && !isAiMode && ( + + + + + + setTitleOnly(false)} + > + + + {t("Everything")} + + {!titleOnly && } + + + setTitleOnly(true)} + > + + + {t("Title only")} + + {titleOnly && } + + + + + )} + {orderedVisibleFilters.map((filterKey) => { if (filterKey === "creator") { return ( diff --git a/apps/client/src/features/search/components/search-spotlight.tsx b/apps/client/src/features/search/components/search-spotlight.tsx index 4c71a5b38..ee16f08b5 100644 --- a/apps/client/src/features/search/components/search-spotlight.tsx +++ b/apps/client/src/features/search/components/search-spotlight.tsx @@ -33,6 +33,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { contentType?: string; creatorId?: string | null; labelIds?: string[]; + titleOnly?: boolean; }>({ contentType: "page", }); @@ -58,6 +59,10 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { params.labelIds = filters.labelIds; } + if (filters.titleOnly) { + params.titleOnly = true; + } + return params; }, [debouncedSearchQuery, filters]); diff --git a/apps/client/src/features/search/types/search.types.ts b/apps/client/src/features/search/types/search.types.ts index 9a87aa32c..60ae5d985 100644 --- a/apps/client/src/features/search/types/search.types.ts +++ b/apps/client/src/features/search/types/search.types.ts @@ -38,6 +38,7 @@ export interface IPageSearchParams { shareId?: string; creatorId?: string; labelIds?: string[]; + titleOnly?: boolean; } export interface IAttachmentSearch { diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts index e5393063a..d66bc9fd8 100644 --- a/apps/server/src/core/search/dto/search.dto.ts +++ b/apps/server/src/core/search/dto/search.dto.ts @@ -30,6 +30,10 @@ export class SearchDTO { @IsUUID('all', { each: true }) labelIds?: string[]; + @IsOptional() + @IsBoolean() + titleOnly?: boolean; + @IsOptional() @IsNumber() limit?: number; diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index bba1d0e1d..ceb1468d3 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -36,6 +36,21 @@ export class SearchService { } const searchQuery = tsquery(query.trim() + '*'); const labelIds = [...new Set(searchParams.labelIds ?? [])]; + const titleOnly = searchParams.titleOnly === true; + const titleQuery = query.trim(); + + const rankColumn = titleOnly + ? sql`word_similarity(lower(f_unaccent(${titleQuery})), lower(f_unaccent(pages.title)))`.as( + 'rank', + ) + : sql`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as( + 'rank', + ); + const highlightColumn = titleOnly + ? sql`''`.as('highlight') + : sql`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as( + 'highlight', + ); let queryResults = this.db .selectFrom('pages') @@ -48,17 +63,24 @@ export class SearchService { 'creatorId', 'createdAt', 'updatedAt', - sql`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as( - 'rank', - ), - sql`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as( - 'highlight', - ), + rankColumn, + highlightColumn, ]) - .where( - 'tsv', - '@@', - sql`to_tsquery('english', f_unaccent(${searchQuery}))`, + .$if(!titleOnly, (qb) => + qb.where( + 'tsv', + '@@', + sql`to_tsquery('english', f_unaccent(${searchQuery}))`, + ), + ) + .$if(titleOnly, (qb) => + qb.where((eb) => + eb( + sql`lower(f_unaccent(pages.title))`, + 'like', + sql`lower(f_unaccent(${`%${titleQuery}%`}))`, + ), + ), ) .$if(Boolean(searchParams.creatorId), (qb) => qb.where('creatorId', '=', searchParams.creatorId), diff --git a/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts b/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts new file mode 100644 index 000000000..def048f3f --- /dev/null +++ b/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts @@ -0,0 +1,11 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE INDEX IF NOT EXISTS pages_title_trgm_idx ON pages USING gin (lower(f_unaccent(title)) gin_trgm_ops)`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX IF EXISTS pages_title_trgm_idx`.execute(db); +} diff --git a/apps/server/src/ee b/apps/server/src/ee index 605c291e5..641346a5b 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 605c291e5b2c13cda73174943a268449860dd43b +Subproject commit 641346a5b837d47abe32a911707aa072e205b175