mirror of
https://github.com/docmost/docmost.git
synced 2026-08-25 15:57:11 +08:00
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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [titleOnly, setTitleOnly] = useState(false);
|
||||
const [openedFilter, setOpenedFilter] = useState<string | null>(null);
|
||||
const [visibleFilters, setVisibleFilters] = useState<string[]>([]);
|
||||
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({
|
||||
</Menu.Dropdown>
|
||||
</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) => {
|
||||
if (filterKey === "creator") {
|
||||
return (
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface IPageSearchParams {
|
||||
shareId?: string;
|
||||
creatorId?: string;
|
||||
labelIds?: string[];
|
||||
titleOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface IAttachmentSearch {
|
||||
|
||||
@@ -30,6 +30,10 @@ export class SearchDTO {
|
||||
@IsUUID('all', { each: true })
|
||||
labelIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
titleOnly?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
limit?: number;
|
||||
|
||||
@@ -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<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
|
||||
.selectFrom('pages')
|
||||
@@ -48,17 +63,24 @@ export class SearchService {
|
||||
'creatorId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
sql<number>`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as(
|
||||
'rank',
|
||||
),
|
||||
sql<string>`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
|
||||
'highlight',
|
||||
),
|
||||
rankColumn,
|
||||
highlightColumn,
|
||||
])
|
||||
.where(
|
||||
'tsv',
|
||||
'@@',
|
||||
sql<string>`to_tsquery('english', f_unaccent(${searchQuery}))`,
|
||||
.$if(!titleOnly, (qb) =>
|
||||
qb.where(
|
||||
'tsv',
|
||||
'@@',
|
||||
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) =>
|
||||
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);
|
||||
}
|
||||
+1
-1
Submodule apps/server/src/ee updated: 605c291e5b...641346a5b8
Reference in New Issue
Block a user