feat: enhance public sharing (#1057)

* fix tree nodes sort

* remove comment mark in shares

* remove clickoutside hook for now

* feat: search in shared pages

* fix user-select

* use Link

* render page icons
This commit is contained in:
Philip Okugbe
2025-04-23 14:32:35 +01:00
committed by GitHub
parent de5f90309c
commit c26a851d52
17 changed files with 420 additions and 61 deletions

View File

@ -1,6 +1,7 @@
import { Node } from '@tiptap/pm/model';
import { jsonToNode } from '../../../collaboration/collaboration.util';
import { validate as isValidUUID } from 'uuid';
import { Transform } from '@tiptap/pm/transform';
export interface MentionNode {
id: string;
@ -94,4 +95,16 @@ export function getAttachmentIds(prosemirrorJson: any) {
});
return attachmentIds;
}
export function removeMarkTypeFromDoc(doc: Node, markName: string): Node {
const { schema } = doc.type;
const markType = schema.marks[markName];
if (!markType) {
return doc;
}
const tr = new Transform(doc).removeMark(0, doc.content.size, markType);
return tr.doc;
}

View File

@ -5,8 +5,11 @@ import {
IsOptional,
IsString,
} from 'class-validator';
import { PartialType } from '@nestjs/mapped-types';
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
export class SearchDTO {
@IsNotEmpty()
@IsString()
query: string;
@ -14,6 +17,10 @@ export class SearchDTO {
@IsString()
spaceId: string;
@IsOptional()
@IsString()
shareId?: string;
@IsOptional()
@IsString()
creatorId?: string;
@ -27,6 +34,16 @@ export class SearchDTO {
offset?: number;
}
export class SearchShareDTO extends SearchDTO {
@IsNotEmpty()
@IsString()
shareId: string;
@IsOptional()
@IsString()
spaceId: string;
}
export class SearchSuggestionDTO {
@IsString()
query: string;

View File

@ -1,15 +1,19 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotImplementedException,
Post,
UseGuards,
} from '@nestjs/common';
import { SearchService } from './search.service';
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
import {
SearchDTO,
SearchShareDTO,
SearchSuggestionDTO,
} from './dto/search.dto';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
@ -19,6 +23,7 @@ import {
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { Public } from 'src/common/decorators/public.decorator';
@UseGuards(JwtAuthGuard)
@Controller('search')
@ -30,7 +35,13 @@ export class SearchController {
@HttpCode(HttpStatus.OK)
@Post()
async pageSearch(@Body() searchDto: SearchDTO, @AuthUser() user: User) {
async pageSearch(
@Body() searchDto: SearchDTO,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
delete searchDto.shareId;
if (searchDto.spaceId) {
const ability = await this.spaceAbility.createForUser(
user,
@ -40,12 +51,12 @@ export class SearchController {
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.searchService.searchPage(searchDto.query, searchDto);
}
// TODO: search all spaces user is a member of if no spaceId provided
throw new NotImplementedException();
return this.searchService.searchPage(searchDto.query, searchDto, {
userId: user.id,
workspaceId: workspace.id,
});
}
@HttpCode(HttpStatus.OK)
@ -57,4 +68,21 @@ export class SearchController {
) {
return this.searchService.searchSuggestions(dto, user.id, workspace.id);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('share-search')
async searchShare(
@Body() searchDto: SearchShareDTO,
@AuthWorkspace() workspace: Workspace,
) {
delete searchDto.spaceId;
if (!searchDto.shareId) {
throw new BadRequestException('shareId is required');
}
return this.searchService.searchPage(searchDto.query, searchDto, {
workspaceId: workspace.id,
});
}
}

View File

@ -6,6 +6,7 @@ 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';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')();
@ -15,19 +16,24 @@ export class SearchService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private pageRepo: PageRepo,
private shareRepo: ShareRepo,
private spaceMemberRepo: SpaceMemberRepo,
) {}
async searchPage(
query: string,
searchParams: SearchDTO,
opts: {
userId?: string;
workspaceId: string;
},
): Promise<SearchResponseDto[]> {
if (query.length < 1) {
return;
}
const searchQuery = tsquery(query.trim() + '*');
const queryResults = await this.db
let queryResults = this.db
.selectFrom('pages')
.select([
'id',
@ -43,18 +49,71 @@ export class SearchService {
'highlight',
),
])
.select((eb) => this.pageRepo.withSpace(eb))
.where('spaceId', '=', searchParams.spaceId)
.where('tsv', '@@', sql<string>`to_tsquery(${searchQuery})`)
.$if(Boolean(searchParams.creatorId), (qb) =>
qb.where('creatorId', '=', searchParams.creatorId),
)
.orderBy('rank', 'desc')
.limit(searchParams.limit | 20)
.offset(searchParams.offset || 0)
.execute();
.offset(searchParams.offset || 0);
const searchResults = queryResults.map((result) => {
if (!searchParams.shareId) {
queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb));
}
if (searchParams.spaceId) {
// search by spaceId
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
} else if (opts.userId && !searchParams.spaceId) {
// only search spaces the user is a member of
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(
opts.userId,
);
if (userSpaceIds.length > 0) {
queryResults = queryResults
.where('spaceId', 'in', userSpaceIds)
.where('workspaceId', '=', opts.workspaceId);
} else {
return [];
}
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
// search in shares
const shareId = searchParams.shareId;
const share = await this.shareRepo.findById(shareId);
if (!share || share.workspaceId !== opts.workspaceId) {
return [];
}
const pageIdsToSearch = [];
if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendants(
share.pageId,
{
includeContent: false,
},
);
pageIdsToSearch.push(...pageList.map((page) => page.id));
} else {
pageIdsToSearch.push(share.pageId);
}
if (pageIdsToSearch.length > 0) {
queryResults = queryResults
.where('id', 'in', pageIdsToSearch)
.where('workspaceId', '=', opts.workspaceId);
} else {
return [];
}
} else {
return [];
}
//@ts-ignore
queryResults = await queryResults.execute();
//@ts-ignore
const searchResults = queryResults.map((result: SearchResponseDto) => {
if (result.highlight) {
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')

View File

@ -15,6 +15,7 @@ import {
getAttachmentIds,
getProsemirrorContent,
isAttachmentNode,
removeMarkTypeFromDoc,
} from '../../common/helpers/prosemirror/utils';
import { Node } from '@tiptap/pm/model';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
@ -223,11 +224,7 @@ export class ShareService {
.end()
.as('found'),
])
.where(
isValidUUID(childPageId) ? 'id' : 'slugId',
'=',
childPageId,
)
.where(isValidUUID(childPageId) ? 'id' : 'slugId', '=', childPageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
@ -292,6 +289,7 @@ export class ShareService {
updateAttachmentAttr(node, 'url', token);
});
return doc.toJSON();
const removeCommentMarks = removeMarkTypeFromDoc(doc, 'comment');
return removeCommentMarks.toJSON();
}
}