mirror of
https://github.com/docmost/docmost
synced 2025-03-28 21:13:28 +00:00
feat: search
This commit is contained in:
parent
e0e5f7c43d
commit
a0ec2f30ca
@ -46,7 +46,7 @@ The server will be available on `http://localhost:3000`
|
|||||||
$ pnpm nx run server:migration:create init
|
$ pnpm nx run server:migration:create init
|
||||||
|
|
||||||
# Generates 'init' migration file from existing entities to update the database schema
|
# Generates 'init' migration file from existing entities to update the database schema
|
||||||
$ pnpm nx run server::generate init
|
$ pnpm nx run server:migration:generate init
|
||||||
|
|
||||||
# Runs all pending migrations to update the database schema
|
# Runs all pending migrations to update the database schema
|
||||||
$ pnpm nx run server:migration:run
|
$ pnpm nx run server:migration:run
|
||||||
|
11
apps/client/src/features/search/queries/search-query.ts
Normal file
11
apps/client/src/features/search/queries/search-query.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
@ -1,43 +1,59 @@
|
|||||||
import { rem } from '@mantine/core';
|
import { Group, Center, Text } from '@mantine/core';
|
||||||
import { Spotlight, SpotlightActionData } from '@mantine/spotlight';
|
import { Spotlight } from '@mantine/spotlight';
|
||||||
import { IconHome, IconDashboard, IconSettings, IconSearch } from '@tabler/icons-react';
|
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() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Spotlight
|
<Spotlight.Root query={query}
|
||||||
actions={actions}
|
onQueryChange={setQuery}
|
||||||
nothingFound="Nothing found..."
|
scrollable
|
||||||
highlightQuery
|
overlayProps={{
|
||||||
searchProps={{
|
backgroundOpacity: 0.55,
|
||||||
leftSection: <IconSearch style={{ width: rem(20), height: rem(20) }} stroke={1.5} />,
|
}}>
|
||||||
placeholder: 'Search...',
|
<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>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
12
apps/client/src/features/search/types/search.types.ts
Normal file
12
apps/client/src/features/search/types/search.types.ts
Normal 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;
|
||||||
|
}
|
@ -48,6 +48,7 @@
|
|||||||
"fs-extra": "^11.1.1",
|
"fs-extra": "^11.1.1",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
|
"pg-tsquery": "^8.4.1",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sanitize-filename-ts": "^1.0.2",
|
"sanitize-filename-ts": "^1.0.2",
|
||||||
|
44
apps/server/src/collaboration/collaboration.util.ts
Normal file
44
apps/server/src/collaboration/collaboration.util.ts
Normal 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);
|
||||||
|
}
|
@ -7,19 +7,8 @@ import * as Y from 'yjs';
|
|||||||
import { PageService } from '../../core/page/services/page.service';
|
import { PageService } from '../../core/page/services/page.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
import { StarterKit } from '@tiptap/starter-kit';
|
import { jsonToHtml, jsonToText, tiptapExtensions } from '../collaboration.util';
|
||||||
import { TextAlign } from '@tiptap/extension-text-align';
|
import { generateText } from '@tiptap/core'
|
||||||
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';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PersistenceExtension implements Extension {
|
export class PersistenceExtension implements Extension {
|
||||||
@ -55,22 +44,12 @@ export class PersistenceExtension implements Extension {
|
|||||||
if (page.content) {
|
if (page.content) {
|
||||||
console.log('converting json to ydoc');
|
console.log('converting json to ydoc');
|
||||||
|
|
||||||
const ydoc = TiptapTransformer.toYdoc(page.content, 'default', [
|
const ydoc = TiptapTransformer.toYdoc(
|
||||||
StarterKit,
|
page.content,
|
||||||
Comment,
|
'default',
|
||||||
TextAlign,
|
tiptapExtensions,
|
||||||
TaskList,
|
);
|
||||||
TaskItem,
|
|
||||||
Underline,
|
|
||||||
Link,
|
|
||||||
Superscript,
|
|
||||||
SubScript,
|
|
||||||
Highlight,
|
|
||||||
Typography,
|
|
||||||
TrailingNode,
|
|
||||||
TextStyle,
|
|
||||||
Color,
|
|
||||||
]);
|
|
||||||
Y.encodeStateAsUpdate(ydoc);
|
Y.encodeStateAsUpdate(ydoc);
|
||||||
return ydoc;
|
return ydoc;
|
||||||
}
|
}
|
||||||
@ -87,8 +66,16 @@ export class PersistenceExtension implements Extension {
|
|||||||
const tiptapJson = TiptapTransformer.fromYdoc(document, 'default');
|
const tiptapJson = TiptapTransformer.fromYdoc(document, 'default');
|
||||||
const ydocState = Buffer.from(Y.encodeStateAsUpdate(document));
|
const ydocState = Buffer.from(Y.encodeStateAsUpdate(document));
|
||||||
|
|
||||||
|
const textContent = jsonToText(tiptapJson);
|
||||||
|
console.log(jsonToText(tiptapJson));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.pageService.updateState(pageId, tiptapJson, ydocState);
|
await this.pageService.updateState(
|
||||||
|
pageId,
|
||||||
|
tiptapJson,
|
||||||
|
textContent,
|
||||||
|
ydocState,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to update page ${documentName}`);
|
console.error(`Failed to update page ${documentName}`);
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import { StorageModule } from './storage/storage.module';
|
|||||||
import { AttachmentModule } from './attachment/attachment.module';
|
import { AttachmentModule } from './attachment/attachment.module';
|
||||||
import { EnvironmentModule } from '../environment/environment.module';
|
import { EnvironmentModule } from '../environment/environment.module';
|
||||||
import { CommentModule } from './comment/comment.module';
|
import { CommentModule } from './comment/comment.module';
|
||||||
|
import { SearchModule } from './search/search.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -19,6 +20,7 @@ import { CommentModule } from './comment/comment.module';
|
|||||||
}),
|
}),
|
||||||
AttachmentModule,
|
AttachmentModule,
|
||||||
CommentModule,
|
CommentModule,
|
||||||
|
SearchModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CoreModule {}
|
export class CoreModule {}
|
||||||
|
@ -22,21 +22,34 @@ export class Page {
|
|||||||
@Column({ length: 500, nullable: true })
|
@Column({ length: 500, nullable: true })
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
icon: string;
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
content: string;
|
content: string;
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
html: string;
|
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 })
|
@Column({ type: 'bytea', nullable: true })
|
||||||
ydoc: any;
|
ydoc: any;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
icon: string;
|
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
coverPhoto: string;
|
coverPhoto: string;
|
||||||
|
|
||||||
|
@ -26,6 +26,11 @@ import { PageHistoryRepository } from './repositories/page-history.repository';
|
|||||||
PageRepository,
|
PageRepository,
|
||||||
PageHistoryRepository,
|
PageHistoryRepository,
|
||||||
],
|
],
|
||||||
exports: [PageService, PageOrderingService, PageHistoryService],
|
exports: [
|
||||||
|
PageService,
|
||||||
|
PageOrderingService,
|
||||||
|
PageHistoryService,
|
||||||
|
PageRepository,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class PageModule {}
|
export class PageModule {}
|
||||||
|
@ -108,11 +108,13 @@ export class PageService {
|
|||||||
async updateState(
|
async updateState(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
content: any,
|
content: any,
|
||||||
|
textContent: string,
|
||||||
ydoc: any,
|
ydoc: any,
|
||||||
userId?: string, // TODO: fix this
|
userId?: string, // TODO: fix this
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.pageRepository.update(pageId, {
|
await this.pageRepository.update(pageId, {
|
||||||
content: content,
|
content: content,
|
||||||
|
textContent: textContent,
|
||||||
ydoc: ydoc,
|
ydoc: ydoc,
|
||||||
...(userId && { lastUpdatedById: userId }),
|
...(userId && { lastUpdatedById: userId }),
|
||||||
});
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -7,9 +7,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
|
"@hocuspocus/provider": "^2.9.0",
|
||||||
"@hocuspocus/server": "^2.9.0",
|
"@hocuspocus/server": "^2.9.0",
|
||||||
"@hocuspocus/transformer": "^2.9.0",
|
"@hocuspocus/transformer": "^2.9.0",
|
||||||
"@hocuspocus/provider": "^2.9.0",
|
|
||||||
"@tiptap/extension-code-block": "^2.1.12",
|
"@tiptap/extension-code-block": "^2.1.12",
|
||||||
"@tiptap/extension-collaboration": "^2.1.12",
|
"@tiptap/extension-collaboration": "^2.1.12",
|
||||||
"@tiptap/extension-collaboration-cursor": "^2.1.12",
|
"@tiptap/extension-collaboration-cursor": "^2.1.12",
|
||||||
@ -31,6 +31,7 @@
|
|||||||
"@tiptap/extension-text-style": "^2.1.12",
|
"@tiptap/extension-text-style": "^2.1.12",
|
||||||
"@tiptap/extension-typography": "^2.1.12",
|
"@tiptap/extension-typography": "^2.1.12",
|
||||||
"@tiptap/extension-underline": "^2.1.12",
|
"@tiptap/extension-underline": "^2.1.12",
|
||||||
|
"@tiptap/html": "^2.1.12",
|
||||||
"@tiptap/pm": "^2.1.12",
|
"@tiptap/pm": "^2.1.12",
|
||||||
"@tiptap/react": "^2.1.12",
|
"@tiptap/react": "^2.1.12",
|
||||||
"@tiptap/starter-kit": "^2.1.12",
|
"@tiptap/starter-kit": "^2.1.12",
|
||||||
|
@ -1,137 +1,158 @@
|
|||||||
import { Mark, mergeAttributes } from '@tiptap/core';
|
import { Mark, mergeAttributes } from "@tiptap/core";
|
||||||
import { commentDecoration } from './comment-decoration';
|
import { commentDecoration } from "./comment-decoration";
|
||||||
|
|
||||||
export interface ICommentOptions {
|
export interface ICommentOptions {
|
||||||
HTMLAttributes: Record<string, any>,
|
HTMLAttributes: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICommentStorage {
|
export interface ICommentStorage {
|
||||||
activeCommentId: string | null;
|
activeCommentId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const commentMarkClass = 'comment-mark';
|
export const commentMarkClass = "comment-mark";
|
||||||
export const commentDecorationMetaKey = 'decorateComment';
|
export const commentDecorationMetaKey = "decorateComment";
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
comment: {
|
comment: {
|
||||||
setCommentDecoration: () => ReturnType,
|
setCommentDecoration: () => ReturnType;
|
||||||
unsetCommentDecoration: () => ReturnType,
|
unsetCommentDecoration: () => ReturnType;
|
||||||
setComment: (commentId: string) => ReturnType,
|
setComment: (commentId: string) => ReturnType;
|
||||||
unsetComment: (commentId: string) => ReturnType,
|
unsetComment: (commentId: string) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||||
name: 'comment',
|
name: "comment",
|
||||||
exitable: true,
|
exitable: true,
|
||||||
inclusive: false,
|
inclusive: false,
|
||||||
|
|
||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
HTMLAttributes: {},
|
HTMLAttributes: {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addStorage() {
|
addStorage() {
|
||||||
return {
|
return {
|
||||||
activeCommentId: null,
|
activeCommentId: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
commentId: {
|
commentId: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: element => element.getAttribute('data-comment-id'),
|
parseHTML: (element) => element.getAttribute("data-comment-id"),
|
||||||
renderHTML: (attributes) => {
|
renderHTML: (attributes) => {
|
||||||
if (!attributes.commentId) return;
|
if (!attributes.commentId) return;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'data-comment-id': attributes.commentId,
|
"data-comment-id": attributes.commentId,
|
||||||
};
|
};
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
},
|
};
|
||||||
|
},
|
||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tag: 'span[data-comment-id]',
|
tag: "span[data-comment-id]",
|
||||||
getAttrs: (el) => !!(el as HTMLSpanElement).getAttribute('data-comment-id')?.trim() && null,
|
getAttrs: (el) =>
|
||||||
},
|
!!(el as HTMLSpanElement).getAttribute("data-comment-id")?.trim() &&
|
||||||
];
|
null,
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
setCommentDecoration: () => ({ tr, dispatch }) => {
|
setCommentDecoration:
|
||||||
|
() =>
|
||||||
|
({ tr, dispatch }) => {
|
||||||
tr.setMeta(commentDecorationMetaKey, true);
|
tr.setMeta(commentDecorationMetaKey, true);
|
||||||
if (dispatch) dispatch(tr);
|
if (dispatch) dispatch(tr);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
unsetCommentDecoration: () => ({ tr, dispatch }) => {
|
unsetCommentDecoration:
|
||||||
|
() =>
|
||||||
|
({ tr, dispatch }) => {
|
||||||
tr.setMeta(commentDecorationMetaKey, false);
|
tr.setMeta(commentDecorationMetaKey, false);
|
||||||
if (dispatch) dispatch(tr);
|
if (dispatch) dispatch(tr);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
setComment: (commentId) => ({ commands }) => {
|
setComment:
|
||||||
|
(commentId) =>
|
||||||
|
({ commands }) => {
|
||||||
if (!commentId) return false;
|
if (!commentId) return false;
|
||||||
return commands.setMark(this.name, { commentId });
|
return commands.setMark(this.name, { commentId });
|
||||||
},
|
},
|
||||||
unsetComment:
|
unsetComment:
|
||||||
(commentId) =>
|
(commentId) =>
|
||||||
({ tr, dispatch }) => {
|
({ tr, dispatch }) => {
|
||||||
if (!commentId) return false;
|
if (!commentId) return false;
|
||||||
|
|
||||||
tr.doc.descendants((node, pos) => {
|
tr.doc.descendants((node, pos) => {
|
||||||
const from = pos;
|
const from = pos;
|
||||||
const to = pos + node.nodeSize;
|
const to = pos + node.nodeSize;
|
||||||
|
|
||||||
const commentMark = node.marks.find(mark =>
|
const commentMark = node.marks.find(
|
||||||
mark.type.name === this.name && mark.attrs.commentId === commentId);
|
(mark) =>
|
||||||
|
mark.type.name === this.name &&
|
||||||
|
mark.attrs.commentId === commentId,
|
||||||
|
);
|
||||||
|
|
||||||
if (commentMark) {
|
if (commentMark) {
|
||||||
tr = tr.removeMark(from, to, commentMark);
|
tr = tr.removeMark(from, to, commentMark);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return dispatch?.(tr);
|
return dispatch?.(tr);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
const commentId = HTMLAttributes?.['data-comment-id'] || null;
|
const commentId = HTMLAttributes?.["data-comment-id"] || null;
|
||||||
const elem = document.createElement('span');
|
|
||||||
|
|
||||||
Object.entries(
|
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
return [
|
||||||
).forEach(([attr, val]) => elem.setAttribute(attr, val));
|
"span",
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||||
|
class: 'comment-mark',
|
||||||
|
"data-comment-id": commentId,
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
elem.addEventListener('click', (e) => {
|
const elem = document.createElement("span");
|
||||||
const selection = document.getSelection();
|
|
||||||
if (selection.type === 'Range') return;
|
|
||||||
|
|
||||||
this.storage.activeCommentId = commentId;
|
Object.entries(
|
||||||
const commentEventClick = new CustomEvent('ACTIVE_COMMENT_EVENT', {
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||||
bubbles: true,
|
).forEach(([attr, val]) => elem.setAttribute(attr, val));
|
||||||
detail: { commentId },
|
|
||||||
});
|
|
||||||
|
|
||||||
elem.dispatchEvent(commentEventClick);
|
elem.addEventListener("click", (e) => {
|
||||||
|
const selection = document.getSelection();
|
||||||
|
if (selection.type === "Range") return;
|
||||||
|
|
||||||
|
this.storage.activeCommentId = commentId;
|
||||||
|
const commentEventClick = new CustomEvent("ACTIVE_COMMENT_EVENT", {
|
||||||
|
bubbles: true,
|
||||||
|
detail: { commentId },
|
||||||
});
|
});
|
||||||
|
|
||||||
return elem;
|
elem.dispatchEvent(commentEventClick);
|
||||||
},
|
});
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
addProseMirrorPlugins(): Plugin[] {
|
|
||||||
// @ts-ignore
|
|
||||||
return [commentDecoration()];
|
|
||||||
},
|
|
||||||
|
|
||||||
|
return elem;
|
||||||
},
|
},
|
||||||
);
|
|
||||||
|
// @ts-ignore
|
||||||
|
addProseMirrorPlugins(): Plugin[] {
|
||||||
|
// @ts-ignore
|
||||||
|
return [commentDecoration()];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
@ -83,6 +83,9 @@ importers:
|
|||||||
'@tiptap/extension-underline':
|
'@tiptap/extension-underline':
|
||||||
specifier: ^2.1.12
|
specifier: ^2.1.12
|
||||||
version: 2.1.16(@tiptap/core@2.1.16)
|
version: 2.1.16(@tiptap/core@2.1.16)
|
||||||
|
'@tiptap/html':
|
||||||
|
specifier: ^2.1.12
|
||||||
|
version: 2.1.16(@tiptap/core@2.1.16)(@tiptap/pm@2.1.16)
|
||||||
'@tiptap/pm':
|
'@tiptap/pm':
|
||||||
specifier: ^2.1.12
|
specifier: ^2.1.12
|
||||||
version: 2.1.16
|
version: 2.1.16
|
||||||
@ -313,6 +316,9 @@ importers:
|
|||||||
pg:
|
pg:
|
||||||
specifier: ^8.11.3
|
specifier: ^8.11.3
|
||||||
version: 8.11.3
|
version: 8.11.3
|
||||||
|
pg-tsquery:
|
||||||
|
specifier: ^8.4.1
|
||||||
|
version: 8.4.1
|
||||||
reflect-metadata:
|
reflect-metadata:
|
||||||
specifier: ^0.1.13
|
specifier: ^0.1.13
|
||||||
version: 0.1.14
|
version: 0.1.14
|
||||||
@ -4801,6 +4807,17 @@ packages:
|
|||||||
'@tiptap/core': 2.1.16(@tiptap/pm@2.1.16)
|
'@tiptap/core': 2.1.16(@tiptap/pm@2.1.16)
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@tiptap/html@2.1.16(@tiptap/core@2.1.16)(@tiptap/pm@2.1.16):
|
||||||
|
resolution: {integrity: sha512-I7yOBRxLFJookfOUCLT13mh03F1EOSnJe6Lv5LFVTLv0ThGN+/bNuDE7WwevyeEC8Il/9a/GWPDdaGKTAqiXbg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^2.0.0
|
||||||
|
'@tiptap/pm': ^2.0.0
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 2.1.16(@tiptap/pm@2.1.16)
|
||||||
|
'@tiptap/pm': 2.1.16
|
||||||
|
zeed-dom: 0.9.26
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@tiptap/pm@2.1.16:
|
/@tiptap/pm@2.1.16:
|
||||||
resolution: {integrity: sha512-yibLkjtgbBSnWCXbDyKM5kgIGLfMvfbRfFzb8T0uz4PI/L54o0a4fiWSW5Fg10B5+o+NAXW2wMxoId8/Tw91lQ==}
|
resolution: {integrity: sha512-yibLkjtgbBSnWCXbDyKM5kgIGLfMvfbRfFzb8T0uz4PI/L54o0a4fiWSW5Fg10B5+o+NAXW2wMxoId8/Tw91lQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -6403,6 +6420,11 @@ packages:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
/css-what@6.1.0:
|
||||||
|
resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/cssesc@3.0.0:
|
/cssesc@3.0.0:
|
||||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@ -9181,6 +9203,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==}
|
resolution: {integrity: sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/pg-tsquery@8.4.1:
|
||||||
|
resolution: {integrity: sha512-GoeRhw6o4Bpt7awdUwHq6ITOw40IWSrb5IC2qR6dF9ZECtHFGdSpnjHOl9Rumd8Rx12kLI2T9TGV0gvxD5pFgA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/pg-types@2.2.0:
|
/pg-types@2.2.0:
|
||||||
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@ -11395,6 +11422,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
/zeed-dom@0.9.26:
|
||||||
|
resolution: {integrity: sha512-HWjX8rA3Y/RI32zby3KIN1D+mgskce+She4K7kRyyx62OiVxJ5FnYm8vWq0YVAja3Tf2S1M0XAc6O2lRFcMgcQ==}
|
||||||
|
engines: {node: '>=14.13.1'}
|
||||||
|
dependencies:
|
||||||
|
css-what: 6.1.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/zod@3.22.4:
|
/zod@3.22.4:
|
||||||
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
|
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
|
||||||
dev: false
|
dev: false
|
||||||
|
Loading…
x
Reference in New Issue
Block a user