mirror of
https://github.com/docmost/docmost.git
synced 2026-08-25 15:57:11 +08:00
feat: enhanced search filters (#2398)
* advanced search filters * improve labelId filtering in query * index label ids on typesense * index label ids on typesense * filters on AI search * refactor: detect typesense schema drift from the declared collection schema * feat: show secondary search filters only when applied Created by and Labels now stay hidden until they hold a value; a Filter button at the right end of the bar lists the hidden ones and opens the picked filter's dropdown immediately. * fix: keep applied search filters in the order they were added * 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. * refactor: make title-only search a toggle chip * fix: drop stale results when the search content type changes * feat: add trigram index on attachment file names * feat: extend title-only search to attachments The titleOnly flag now matches attachment file names via the separator-normalized trigram expression, and the toggle chip stays available in attachment mode as File name only. * refactor: keep the title-only label uniform across search modes * feat: index txt attachments for content search * feat: pin the current user at the top of the creator filter * refactor: dim the you suffix in the creator filter * feat: browse label-filtered pages without a query Selecting labels now lists their pages newest-first before any text is typed, on both the Postgres and Typesense drivers. * fix: match any selected label instead of requiring all * fix: use a gray spotlight selection instead of primary blue * feat: include search results in the spotlight tab order * feat: show last updated time on search results * fix: stop empty-state flicker while the search query debounces * feat: browse creator-filtered results without a query Selecting a creator with an empty query now lists their pages (or attachments in attachment mode) newest-first, with the same space and page permission filtering as typed search. * fix: show Untitled for pages without a title in search results * quick page sharing fix * minor search filter fixes --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
This commit is contained in:
@@ -116,8 +116,8 @@ export class AttachmentService {
|
||||
});
|
||||
}
|
||||
|
||||
// Only index PDFs and DOCX files
|
||||
if (['.pdf', '.docx'].includes(attachment.fileExt.toLowerCase())) {
|
||||
// Only index PDF, DOCX and TXT files
|
||||
if (['.pdf', '.docx', '.txt'].includes(attachment.fileExt.toLowerCase())) {
|
||||
await this.attachmentQueue.add(
|
||||
QueueJob.ATTACHMENT_INDEX_CONTENT,
|
||||
{
|
||||
|
||||
@@ -7,12 +7,15 @@ import { executeTx } from '@docmost/db/utils';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { normalizeLabelName } from './utils';
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { EventName } from "src/common/events/event.contants";
|
||||
|
||||
@Injectable()
|
||||
export class LabelService {
|
||||
constructor(
|
||||
private readonly labelRepo: LabelRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
@@ -34,6 +37,12 @@ export class LabelService {
|
||||
attached.push(label);
|
||||
}
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(EventName.PAGE_UPDATED, {
|
||||
pageIds: [pageId],
|
||||
workspaceId: workspaceId,
|
||||
});
|
||||
|
||||
return attached;
|
||||
}
|
||||
|
||||
@@ -64,6 +73,11 @@ export class LabelService {
|
||||
await this.labelRepo.deleteLabel(labelId, workspaceId, trx);
|
||||
}
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(EventName.PAGE_UPDATED, {
|
||||
pageIds: [pageId],
|
||||
workspaceId: workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async getPageLabels(pageId: string, pagination: PaginationOptions) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
@@ -8,9 +9,9 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
export class SearchDTO {
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
query: string;
|
||||
query?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@@ -24,6 +25,15 @@ export class SearchDTO {
|
||||
@IsUUID()
|
||||
creatorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('all', { each: true })
|
||||
labelIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
titleOnly?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
limit?: number;
|
||||
|
||||
@@ -29,12 +29,36 @@ export class SearchService {
|
||||
workspaceId: string;
|
||||
},
|
||||
): Promise<{ items: SearchResponseDto[] }> {
|
||||
const { query } = searchParams;
|
||||
const query = searchParams.query?.trim() ?? '';
|
||||
const labelIds = [...new Set(searchParams.labelIds ?? [])];
|
||||
// selected filters (labels, creator) are browsable without a query
|
||||
const browseByFilters =
|
||||
query.length < 1 &&
|
||||
(labelIds.length > 0 || Boolean(searchParams.creatorId));
|
||||
|
||||
if (query.length < 1) {
|
||||
if (query.length < 1 && !browseByFilters) {
|
||||
return { items: [] };
|
||||
}
|
||||
const searchQuery = tsquery(query.trim() + '*');
|
||||
const searchQuery = tsquery(query + '*');
|
||||
const titleOnly = searchParams.titleOnly === true;
|
||||
const titleQuery = query;
|
||||
// escape LIKE wildcards; ranking keeps the raw query
|
||||
const titleLikeQuery = query.replace(/[\\%_]/g, '\\$&');
|
||||
|
||||
const rankColumn = browseByFilters
|
||||
? sql<number>`0`.as('rank')
|
||||
: 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 = browseByFilters || 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')
|
||||
@@ -47,23 +71,41 @@ 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(!browseByFilters && !titleOnly, (qb) =>
|
||||
qb.where(
|
||||
'tsv',
|
||||
'@@',
|
||||
sql<string>`to_tsquery('english', f_unaccent(${searchQuery}))`,
|
||||
),
|
||||
)
|
||||
.$if(!browseByFilters && titleOnly, (qb) =>
|
||||
qb.where((eb) =>
|
||||
eb(
|
||||
sql`lower(f_unaccent(pages.title))`,
|
||||
'like',
|
||||
sql`lower(f_unaccent(${`%${titleLikeQuery}%`}))`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.$if(Boolean(searchParams.creatorId), (qb) =>
|
||||
qb.where('creatorId', '=', searchParams.creatorId),
|
||||
)
|
||||
.$if(labelIds?.length > 0, (qb) =>
|
||||
qb.where(
|
||||
'id',
|
||||
'in',
|
||||
this.db
|
||||
.selectFrom('pageLabels')
|
||||
.select('pageId')
|
||||
.where('labelId', 'in', labelIds),
|
||||
),
|
||||
)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('rank', 'desc')
|
||||
.$if(browseByFilters, (qb) => qb.orderBy('updatedAt', 'desc'))
|
||||
.$if(!browseByFilters, (qb) => qb.orderBy('rank', 'desc'))
|
||||
.limit(searchParams.limit || 25)
|
||||
.offset(searchParams.offset || 0);
|
||||
|
||||
@@ -71,8 +113,7 @@ export class SearchService {
|
||||
queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb));
|
||||
}
|
||||
|
||||
if (searchParams.spaceId) {
|
||||
// search by spaceId
|
||||
if (searchParams.spaceId && opts.userId) {
|
||||
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
|
||||
} else if (opts.userId && !searchParams.spaceId) {
|
||||
// only search spaces the user is a member of
|
||||
|
||||
@@ -46,8 +46,9 @@ export class ShareService {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
const isRestricted =
|
||||
await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId);
|
||||
const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
|
||||
share.pageId,
|
||||
);
|
||||
if (isRestricted) {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
@@ -110,6 +111,9 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
|
||||
//TODO: we should resolve the page from the share id
|
||||
if (!dto.pageId) throw new NotFoundException('Shared page not found');
|
||||
|
||||
const share = await this.getShareForPage(dto.pageId, workspaceId);
|
||||
|
||||
if (!share) {
|
||||
@@ -126,8 +130,9 @@ export class ShareService {
|
||||
}
|
||||
|
||||
// Block access to restricted pages
|
||||
const isRestricted =
|
||||
await this.pagePermissionRepo.hasRestrictedAncestor(page.id);
|
||||
const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
|
||||
page.id,
|
||||
);
|
||||
if (isRestricted) {
|
||||
throw new NotFoundException('Shared page not found');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
);
|
||||
|
||||
// separators normalized to spaces so space-typed queries match How_to_export.pdf
|
||||
await sql`CREATE INDEX IF NOT EXISTS attachments_file_name_trgm_idx ON attachments USING gin (lower(f_unaccent(translate(file_name, '_.-', ' '))) gin_trgm_ops)`.execute(
|
||||
db,
|
||||
);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP INDEX IF EXISTS attachments_file_name_trgm_idx`.execute(db);
|
||||
await sql`DROP INDEX IF EXISTS pages_title_trgm_idx`.execute(db);
|
||||
}
|
||||
+1
-1
Submodule apps/server/src/ee updated: 62f7456089...b51a46ef10
Reference in New Issue
Block a user