feat: search

This commit is contained in:
Philipinho
2024-01-30 00:14:21 +01:00
parent e0e5f7c43d
commit a0ec2f30ca
22 changed files with 509 additions and 161 deletions

View File

@ -0,0 +1,11 @@
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { searchPage } from '@/features/search/services/search-service';
import { IPageSearch } from '@/features/search/types/search.types';
export function usePageSearchQuery(query: string): UseQueryResult<IPageSearch[], Error> {
return useQuery({
queryKey: ['page-history', query],
queryFn: () => searchPage(query),
enabled: !!query,
});
}

View File

@ -1,43 +1,59 @@
import { rem } from '@mantine/core';
import { Spotlight, SpotlightActionData } from '@mantine/spotlight';
import { IconHome, IconDashboard, IconSettings, IconSearch } from '@tabler/icons-react';
import { Group, Center, Text } from '@mantine/core';
import { Spotlight } from '@mantine/spotlight';
import { IconFileDescription, IconHome, IconSearch, IconSettings } from '@tabler/icons-react';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDebouncedValue } from '@mantine/hooks';
import { usePageSearchQuery } from '@/features/search/queries/search-query';
const actions: SpotlightActionData[] = [
{
id: 'home',
label: 'Home',
description: 'Get to home page',
onClick: () => console.log('Home'),
leftSection: <IconHome style={{ width: rem(24), height: rem(24) }} stroke={1.5} />,
},
{
id: 'dashboard',
label: 'Dashboard',
description: 'Get full information about current system status',
onClick: () => console.log('Dashboard'),
leftSection: <IconDashboard style={{ width: rem(24), height: rem(24) }} stroke={1.5} />,
},
{
id: 'settings',
label: 'Settings',
description: 'Account settings and workspace management',
onClick: () => console.log('Settings'),
leftSection: <IconSettings style={{ width: rem(24), height: rem(24) }} stroke={1.5} />,
},
];
export function SearchSpotlight() {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const { data: searchResults, isLoading, error } = usePageSearchQuery(debouncedSearchQuery)
const items = (searchResults && searchResults.length > 0 ? searchResults : [])
.map((item) => (
<Spotlight.Action key={item.title} onClick={() => navigate(`/p/${item.id}`)}>
<Group wrap="nowrap" w="100%">
<Center>
{item?.icon ? (
<span style={{ fontSize: "20px" }}>{ item.icon }</span>
) : (
<IconFileDescription size={20} />
)}
</Center>
<div style={{ flex: 1 }}>
<Text>{item.title}</Text>
{item?.highlight && (
<Text opacity={0.6} size="xs" dangerouslySetInnerHTML={{ __html: item.highlight }}/>
)}
</div>
</Group>
</Spotlight.Action>
));
return (
<>
<Spotlight
actions={actions}
nothingFound="Nothing found..."
highlightQuery
searchProps={{
leftSection: <IconSearch style={{ width: rem(20), height: rem(20) }} stroke={1.5} />,
placeholder: 'Search...',
}}
/>
<Spotlight.Root query={query}
onQueryChange={setQuery}
scrollable
overlayProps={{
backgroundOpacity: 0.55,
}}>
<Spotlight.Search placeholder="Search..."
leftSection={
<IconSearch size={20} stroke={1.5} />
} />
<Spotlight.ActionsList>
{items.length > 0 ? items : <Spotlight.Empty>No results found...</Spotlight.Empty>}
</Spotlight.ActionsList>
</Spotlight.Root>
</>
);
}

View File

@ -0,0 +1,7 @@
import api from '@/lib/api-client';
import { IPageSearch } from '@/features/search/types/search.types';
export async function searchPage(query: string): Promise<IPageSearch[]> {
const req = await api.post<IPageSearch[]>('/search', { query });
return req.data as any;
}

View File

@ -0,0 +1,12 @@
export interface IPageSearch {
id: string;
title: string;
icon: string;
parentPageId: string;
creatorId: string;
createdAt: Date;
updatedAt: Date;
rank: string;
highlight: string;
}

View File

@ -48,6 +48,7 @@
"fs-extra": "^11.1.1",
"mime-types": "^2.1.35",
"pg": "^8.11.3",
"pg-tsquery": "^8.4.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"sanitize-filename-ts": "^1.0.2",

View File

@ -0,0 +1,44 @@
import { StarterKit } from '@tiptap/starter-kit';
import { TextAlign } from '@tiptap/extension-text-align';
import { TaskList } from '@tiptap/extension-task-list';
import { TaskItem } from '@tiptap/extension-task-item';
import { Underline } from '@tiptap/extension-underline';
import { Link } from '@tiptap/extension-link';
import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript';
import { Highlight } from '@tiptap/extension-highlight';
import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import { TrailingNode, Comment } from '@docmost/editor-ext';
import { generateHTML, generateJSON } from '@tiptap/html';
import { generateText, JSONContent } from '@tiptap/core';
export const tiptapExtensions = [
StarterKit,
Comment,
TextAlign,
TaskList,
TaskItem,
Underline,
Link,
Superscript,
SubScript,
Highlight,
Typography,
TrailingNode,
TextStyle,
Color,
];
export function jsonToHtml(tiptapJson: JSONContent) {
return generateHTML(tiptapJson, tiptapExtensions);
}
export function htmlToJson(html: string) {
return generateJSON(html, tiptapExtensions);
}
export function jsonToText(tiptapJson: JSONContent) {
return generateText(tiptapJson, tiptapExtensions);
}

View File

@ -7,19 +7,8 @@ import * as Y from 'yjs';
import { PageService } from '../../core/page/services/page.service';
import { Injectable } from '@nestjs/common';
import { TiptapTransformer } from '@hocuspocus/transformer';
import { StarterKit } from '@tiptap/starter-kit';
import { TextAlign } from '@tiptap/extension-text-align';
import { TaskList } from '@tiptap/extension-task-list';
import { TaskItem } from '@tiptap/extension-task-item';
import { Underline } from '@tiptap/extension-underline';
import { Link } from '@tiptap/extension-link';
import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript';
import { Highlight } from '@tiptap/extension-highlight';
import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import { TrailingNode, Comment } from '@docmost/editor-ext';
import { jsonToHtml, jsonToText, tiptapExtensions } from '../collaboration.util';
import { generateText } from '@tiptap/core'
@Injectable()
export class PersistenceExtension implements Extension {
@ -55,22 +44,12 @@ export class PersistenceExtension implements Extension {
if (page.content) {
console.log('converting json to ydoc');
const ydoc = TiptapTransformer.toYdoc(page.content, 'default', [
StarterKit,
Comment,
TextAlign,
TaskList,
TaskItem,
Underline,
Link,
Superscript,
SubScript,
Highlight,
Typography,
TrailingNode,
TextStyle,
Color,
]);
const ydoc = TiptapTransformer.toYdoc(
page.content,
'default',
tiptapExtensions,
);
Y.encodeStateAsUpdate(ydoc);
return ydoc;
}
@ -87,8 +66,16 @@ export class PersistenceExtension implements Extension {
const tiptapJson = TiptapTransformer.fromYdoc(document, 'default');
const ydocState = Buffer.from(Y.encodeStateAsUpdate(document));
const textContent = jsonToText(tiptapJson);
console.log(jsonToText(tiptapJson));
try {
await this.pageService.updateState(pageId, tiptapJson, ydocState);
await this.pageService.updateState(
pageId,
tiptapJson,
textContent,
ydocState,
);
} catch (err) {
console.error(`Failed to update page ${documentName}`);
}

View File

@ -7,6 +7,7 @@ import { StorageModule } from './storage/storage.module';
import { AttachmentModule } from './attachment/attachment.module';
import { EnvironmentModule } from '../environment/environment.module';
import { CommentModule } from './comment/comment.module';
import { SearchModule } from './search/search.module';
@Module({
imports: [
@ -19,6 +20,7 @@ import { CommentModule } from './comment/comment.module';
}),
AttachmentModule,
CommentModule,
SearchModule,
],
})
export class CoreModule {}

View File

@ -22,21 +22,34 @@ export class Page {
@Column({ length: 500, nullable: true })
title: string;
@Column({ nullable: true })
icon: string;
@Column({ type: 'jsonb', nullable: true })
content: string;
@Column({ type: 'text', nullable: true })
html: string;
@Column({ type: 'text', nullable: true })
textContent: string;
@Column({
type: 'tsvector',
generatedType: 'STORED',
asExpression:
"setweight(to_tsvector('english', coalesce(pages.title, '')), 'A') || setweight(to_tsvector('english', coalesce(pages.\"textContent\", '')), 'B')",
select: false,
nullable: true,
})
tsv: string;
@Column({ type: 'bytea', nullable: true })
ydoc: any;
@Column({ nullable: true })
slug: string;
@Column({ nullable: true })
icon: string;
@Column({ nullable: true })
coverPhoto: string;

View File

@ -26,6 +26,11 @@ import { PageHistoryRepository } from './repositories/page-history.repository';
PageRepository,
PageHistoryRepository,
],
exports: [PageService, PageOrderingService, PageHistoryService],
exports: [
PageService,
PageOrderingService,
PageHistoryService,
PageRepository,
],
})
export class PageModule {}

View File

@ -108,11 +108,13 @@ export class PageService {
async updateState(
pageId: string,
content: any,
textContent: string,
ydoc: any,
userId?: string, // TODO: fix this
): Promise<void> {
await this.pageRepository.update(pageId, {
content: content,
textContent: textContent,
ydoc: ydoc,
...(userId && { lastUpdatedById: userId }),
});

View File

@ -0,0 +1,9 @@
export class SearchResponseDto {
id: string;
title: string;
icon: string;
parentPageId: string;
creatorId: string;
rank: string;
highlight: string;
}

View 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;
}

View 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();
});
});

View 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;
}
}

View 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 {}

View 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();
});
});

View 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;
}
}