mirror of
https://github.com/docmost/docmost.git
synced 2025-11-12 15:12:39 +10:00
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:
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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, ' ')
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user