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.
This commit is contained in:
Philipinho
2026-08-24 21:37:28 +01:00
parent 99288ff8ad
commit 5f270d0502
8 changed files with 111 additions and 12 deletions
@@ -1308,5 +1308,8 @@
"Toggle workspace knowledge only": "Toggle workspace knowledge only", "Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode", "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.", "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"
} }
@@ -16,6 +16,7 @@ import {
IconCheck, IconCheck,
IconUser, IconUser,
IconTag, IconTag,
IconLetterCase,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useGetSpacesQuery } from "@/features/space/queries/space-query";
@@ -53,6 +54,7 @@ export function SearchSpotlightFilters({
null null
); );
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]); const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
const [titleOnly, setTitleOnly] = useState(false);
const [openedFilter, setOpenedFilter] = useState<string | null>(null); const [openedFilter, setOpenedFilter] = useState<string | null>(null);
const [visibleFilters, setVisibleFilters] = useState<string[]>([]); const [visibleFilters, setVisibleFilters] = useState<string[]>([]);
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
@@ -77,12 +79,14 @@ export function SearchSpotlightFilters({
contentType, contentType,
creatorId: selectedCreatorId, creatorId: selectedCreatorId,
labelIds: selectedLabelIds, labelIds: selectedLabelIds,
titleOnly,
}); });
}, [ }, [
selectedSpaceId, selectedSpaceId,
contentType, contentType,
selectedCreatorId, selectedCreatorId,
selectedLabelIds, selectedLabelIds,
titleOnly,
onFiltersChange, onFiltersChange,
]); ]);
@@ -250,6 +254,55 @@ export function SearchSpotlightFilters({
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
{contentType !== "attachment" && !isAiMode && (
<Menu
shadow="md"
width={200}
position="bottom-start"
zIndex={getDefaultZIndex("max")}
>
<Menu.Target>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconLetterCase size={16} />}
className={classes.filterButton}
fw={500}
>
{`${t("Match")}: ${titleOnly ? t("Title only") : t("Everything")}`}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
component={RadioMenuItem}
aria-checked={!titleOnly}
onClick={() => setTitleOnly(false)}
>
<Group flex="1" gap="xs">
<Text size="sm" style={{ flex: 1 }}>
{t("Everything")}
</Text>
{!titleOnly && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
<Menu.Item
component={RadioMenuItem}
aria-checked={titleOnly}
onClick={() => setTitleOnly(true)}
>
<Group flex="1" gap="xs">
<Text size="sm" style={{ flex: 1 }}>
{t("Title only")}
</Text>
{titleOnly && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
{orderedVisibleFilters.map((filterKey) => { {orderedVisibleFilters.map((filterKey) => {
if (filterKey === "creator") { if (filterKey === "creator") {
return ( return (
@@ -33,6 +33,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
contentType?: string; contentType?: string;
creatorId?: string | null; creatorId?: string | null;
labelIds?: string[]; labelIds?: string[];
titleOnly?: boolean;
}>({ }>({
contentType: "page", contentType: "page",
}); });
@@ -58,6 +59,10 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
params.labelIds = filters.labelIds; params.labelIds = filters.labelIds;
} }
if (filters.titleOnly) {
params.titleOnly = true;
}
return params; return params;
}, [debouncedSearchQuery, filters]); }, [debouncedSearchQuery, filters]);
@@ -38,6 +38,7 @@ export interface IPageSearchParams {
shareId?: string; shareId?: string;
creatorId?: string; creatorId?: string;
labelIds?: string[]; labelIds?: string[];
titleOnly?: boolean;
} }
export interface IAttachmentSearch { export interface IAttachmentSearch {
@@ -30,6 +30,10 @@ export class SearchDTO {
@IsUUID('all', { each: true }) @IsUUID('all', { each: true })
labelIds?: string[]; labelIds?: string[];
@IsOptional()
@IsBoolean()
titleOnly?: boolean;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
limit?: number; limit?: number;
+29 -7
View File
@@ -36,6 +36,21 @@ export class SearchService {
} }
const searchQuery = tsquery(query.trim() + '*'); const searchQuery = tsquery(query.trim() + '*');
const labelIds = [...new Set(searchParams.labelIds ?? [])]; const labelIds = [...new Set(searchParams.labelIds ?? [])];
const titleOnly = searchParams.titleOnly === true;
const titleQuery = query.trim();
const rankColumn = titleOnly
? sql<number>`word_similarity(lower(f_unaccent(${titleQuery})), lower(f_unaccent(pages.title)))`.as(
'rank',
)
: sql<number>`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as(
'rank',
);
const highlightColumn = titleOnly
? sql<string>`''`.as('highlight')
: sql<string>`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
'highlight',
);
let queryResults = this.db let queryResults = this.db
.selectFrom('pages') .selectFrom('pages')
@@ -48,17 +63,24 @@ export class SearchService {
'creatorId', 'creatorId',
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
sql<number>`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as( rankColumn,
'rank', highlightColumn,
),
sql<string>`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
'highlight',
),
]) ])
.where( .$if(!titleOnly, (qb) =>
qb.where(
'tsv', 'tsv',
'@@', '@@',
sql<string>`to_tsquery('english', f_unaccent(${searchQuery}))`, sql<string>`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) => .$if(Boolean(searchParams.creatorId), (qb) =>
qb.where('creatorId', '=', searchParams.creatorId), qb.where('creatorId', '=', searchParams.creatorId),
@@ -0,0 +1,11 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
await sql`DROP INDEX IF EXISTS pages_title_trgm_idx`.execute(db);
}