feat: internal page links and mentions (#604)

* Work on mentions

* fix: properly parse page slug

* fix editor suggestion bugs

* mentions must start with whitespace

* add icon to page mention render

* feat: backlinks - WIP

* UI - WIP

* permissions check
* use FTS for page suggestion

* cleanup

* WIP

* page title fallback

* feat: handle internal link paste

* link styling

* WIP

* Switch back to LIKE operator for search suggestion

* WIP
* scope to workspaceId
* still create link for pages not found

* select necessary columns

* cleanups
This commit is contained in:
Philip Okugbe
2025-02-14 15:36:44 +00:00
committed by GitHub
parent 0ef6b1978a
commit e209aaa272
46 changed files with 1679 additions and 101 deletions

View File

@ -5,7 +5,7 @@ import { AttachmentService } from '../services/attachment.service';
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { Space } from '@docmost/db/types/entity.types';
@Processor(QueueName.ATTACHEMENT_QUEUE)
@Processor(QueueName.ATTACHMENT_QUEUE)
export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(AttachmentProcessor.name);
constructor(private readonly attachmentService: AttachmentService) {

View File

@ -33,9 +33,21 @@ export class SearchSuggestionDTO {
@IsOptional()
@IsBoolean()
includeUsers?: string;
includeUsers?: boolean;
@IsOptional()
@IsBoolean()
includeGroups?: number;
includeGroups?: boolean;
@IsOptional()
@IsBoolean()
includePages?: boolean;
@IsOptional()
@IsString()
spaceId?: string;
@IsOptional()
@IsNumber()
limit?: number;
}

View File

@ -48,11 +48,13 @@ export class SearchController {
throw new NotImplementedException();
}
@HttpCode(HttpStatus.OK)
@Post('suggest')
async searchSuggestions(
@Body() dto: SearchSuggestionDTO,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.searchService.searchSuggestions(dto, workspace.id);
return this.searchService.searchSuggestions(dto, user.id, workspace.id);
}
}

View File

@ -5,6 +5,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')();
@ -14,6 +15,7 @@ export class SearchService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private pageRepo: PageRepo,
private spaceMemberRepo: SpaceMemberRepo,
) {}
async searchPage(
@ -29,15 +31,15 @@ export class SearchService {
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'icon',
'parentPageId',
'slugId',
'creatorId',
'createdAt',
'updatedAt',
sql<number>`ts_rank(tsv, to_tsquery(${searchQuery}))`.as('rank'),
sql<string>`ts_headline('english', text_content, to_tsquery(${searchQuery}), 'MinWords=9, MaxWords=10, MaxFragments=10')`.as(
sql<string>`ts_headline('english', text_content, to_tsquery(${searchQuery}),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
'highlight',
),
])
@ -66,35 +68,59 @@ export class SearchService {
async searchSuggestions(
suggestion: SearchSuggestionDTO,
userId: string,
workspaceId: string,
) {
const limit = 25;
const userSearch = this.db
.selectFrom('users')
.select(['id', 'name', 'avatarUrl'])
.where((eb) => eb('users.name', 'ilike', `%${suggestion.query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit);
const groupSearch = this.db
.selectFrom('groups')
.select(['id', 'name', 'description'])
.where((eb) => eb('groups.name', 'ilike', `%${suggestion.query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit);
let users = [];
let groups = [];
let pages = [];
const limit = suggestion?.limit || 10;
const query = suggestion.query.toLowerCase().trim();
if (suggestion.includeUsers) {
users = await userSearch.execute();
users = await this.db
.selectFrom('users')
.select(['id', 'name', 'avatarUrl'])
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit)
.execute();
}
if (suggestion.includeGroups) {
groups = await groupSearch.execute();
groups = await this.db
.selectFrom('groups')
.select(['id', 'name', 'description'])
.where((eb) => eb(sql`LOWER(groups.name)`, 'like', `%${query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit)
.execute();
}
return { users, groups };
if (suggestion.includePages) {
let pageSearch = this.db
.selectFrom('pages')
.select(['id', 'slugId', 'title', 'icon', 'spaceId'])
.where((eb) => eb(sql`LOWER(pages.title)`, 'like', `%${query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit);
// only search spaces the user has access to
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
if (suggestion?.spaceId) {
if (userSpaceIds.includes(suggestion.spaceId)) {
pageSearch = pageSearch.where('spaceId', '=', suggestion.spaceId);
pages = await pageSearch.execute();
}
} else if (userSpaceIds?.length > 0) {
// we need this check or the query will throw an error if the userSpaceIds array is empty
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
pages = await pageSearch.execute();
}
}
return { users, groups, pages };
}
}

View File

@ -24,7 +24,7 @@ export class SpaceService {
private spaceRepo: SpaceRepo,
private spaceMemberService: SpaceMemberService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHEMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {}
async createSpace(