mirror of
https://github.com/docmost/docmost.git
synced 2026-08-26 00:07:04 +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",
|
"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;
|
||||||
|
|||||||
@@ -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) =>
|
||||||
'tsv',
|
qb.where(
|
||||||
'@@',
|
'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);
|
||||||
|
}
|
||||||
+1
-1
Submodule apps/server/src/ee updated: 605c291e5b...641346a5b8
Reference in New Issue
Block a user