mirror of
https://github.com/docmost/docmost.git
synced 2025-11-20 09:01:10 +10:00
feat: search
This commit is contained in:
9
apps/server/src/core/search/dto/search-response.dto.ts
Normal file
9
apps/server/src/core/search/dto/search-response.dto.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export class SearchResponseDto {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
parentPageId: string;
|
||||
creatorId: string;
|
||||
rank: string;
|
||||
highlight: string;
|
||||
}
|
||||
18
apps/server/src/core/search/dto/search.dto.ts
Normal file
18
apps/server/src/core/search/dto/search.dto.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class SearchDTO {
|
||||
@IsString()
|
||||
query: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
creatorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
offset?: number;
|
||||
}
|
||||
18
apps/server/src/core/search/search.controller.spec.ts
Normal file
18
apps/server/src/core/search/search.controller.spec.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SearchController } from './search.controller';
|
||||
|
||||
describe('SearchController', () => {
|
||||
let controller: SearchController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [SearchController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<SearchController>(SearchController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
44
apps/server/src/core/search/search.controller.ts
Normal file
44
apps/server/src/core/search/search.controller.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtUser } from '../../decorators/jwt-user.decorator';
|
||||
import { WorkspaceService } from '../workspace/services/workspace.service';
|
||||
import { JwtGuard } from '../auth/guards/JwtGuard';
|
||||
import { SearchService } from './search.service';
|
||||
import { SearchDTO } from './dto/search.dto';
|
||||
|
||||
@UseGuards(JwtGuard)
|
||||
@Controller('search')
|
||||
export class SearchController {
|
||||
constructor(
|
||||
private readonly searchService: SearchService,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post()
|
||||
async pageSearch(
|
||||
@Query('type') type: string,
|
||||
@Body() searchDto: SearchDTO,
|
||||
@JwtUser() jwtUser,
|
||||
) {
|
||||
const workspaceId = (
|
||||
await this.workspaceService.getUserCurrentWorkspace(jwtUser.id)
|
||||
).id;
|
||||
|
||||
if (!type || type === 'page') {
|
||||
return this.searchService.searchPage(
|
||||
searchDto.query,
|
||||
searchDto,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
13
apps/server/src/core/search/search.module.ts
Normal file
13
apps/server/src/core/search/search.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SearchController } from './search.controller';
|
||||
import { SearchService } from './search.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { WorkspaceModule } from '../workspace/workspace.module';
|
||||
import { PageModule } from '../page/page.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, WorkspaceModule, PageModule],
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
18
apps/server/src/core/search/search.service.spec.ts
Normal file
18
apps/server/src/core/search/search.service.spec.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
describe('SearchService', () => {
|
||||
let service: SearchService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [SearchService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SearchService>(SearchService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
72
apps/server/src/core/search/search.service.ts
Normal file
72
apps/server/src/core/search/search.service.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PageRepository } from '../page/repositories/page.repository';
|
||||
import { SearchDTO } from './dto/search.dto';
|
||||
import { SearchResponseDto } from './dto/search-response.dto';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const tsquery = require('pg-tsquery')();
|
||||
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(private pageRepository: PageRepository) {}
|
||||
|
||||
async searchPage(
|
||||
query: string,
|
||||
searchParams: SearchDTO,
|
||||
workspaceId: string,
|
||||
): Promise<SearchResponseDto[]> {
|
||||
if (query.length < 1) {
|
||||
return;
|
||||
}
|
||||
const searchQuery = tsquery(query.trim() + '*');
|
||||
|
||||
const selectColumns = [
|
||||
'page.id as id',
|
||||
'page.title as title',
|
||||
'page.icon as icon',
|
||||
'page.parentPageId as "parentPageId"',
|
||||
'page.creatorId as "creatorId"',
|
||||
'page.createdAt as "createdAt"',
|
||||
'page.updatedAt as "updatedAt"',
|
||||
];
|
||||
|
||||
const searchQueryBuilder = await this.pageRepository
|
||||
.createQueryBuilder('page')
|
||||
.select(selectColumns);
|
||||
|
||||
searchQueryBuilder.andWhere('page.workspaceId = :workspaceId', {
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
searchQueryBuilder
|
||||
.addSelect('ts_rank(page.tsv, to_tsquery(:searchQuery))', 'rank')
|
||||
.addSelect(
|
||||
`ts_headline('english', page.textContent, to_tsquery(:searchQuery), 'MinWords=9, MaxWords=10, MaxFragments=10')`,
|
||||
'highlight',
|
||||
)
|
||||
.andWhere('page.tsv @@ to_tsquery(:searchQuery)', { searchQuery })
|
||||
.orderBy('rank', 'DESC');
|
||||
|
||||
if (searchParams?.creatorId) {
|
||||
searchQueryBuilder.andWhere('page.creatorId = :creatorId', {
|
||||
creatorId: searchParams.creatorId,
|
||||
});
|
||||
}
|
||||
|
||||
searchQueryBuilder
|
||||
.take(searchParams.limit || 20)
|
||||
.offset(searchParams.offset || 0);
|
||||
|
||||
const results = await searchQueryBuilder.getRawMany();
|
||||
|
||||
const searchResults = results.map((result) => {
|
||||
if (result.highlight) {
|
||||
result.highlight = result.highlight
|
||||
.replace(/\r\n|\r|\n/g, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
return searchResults;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user