mirror of
https://github.com/docmost/docmost.git
synced 2026-05-22 01:32:55 +08:00
feat: page labels/tags (#2188)
* feat: labels (WIP) * full implementation
This commit is contained in:
@@ -18,6 +18,7 @@ import { PageAccessModule } from './page/page-access/page-access.module';
|
||||
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
||||
import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { LabelModule } from './label/label.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { WatcherModule } from './watcher/watcher.module';
|
||||
import { FavoriteModule } from './favorite/favorite.module';
|
||||
@@ -39,6 +40,7 @@ import { ClsMiddleware } from 'nestjs-cls';
|
||||
CaslModule,
|
||||
PageAccessModule,
|
||||
ShareModule,
|
||||
LabelModule,
|
||||
NotificationModule,
|
||||
WatcherModule,
|
||||
SessionModule,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { LabelType } from '@docmost/db/repos/label/label.repo';
|
||||
import { PageIdDto } from '../../page/dto/page.dto';
|
||||
import { normalizeLabelName } from '../utils';
|
||||
|
||||
//TODO: We may support SPACE/TEMPLATE labels in the future
|
||||
const SUPPORTED_LABEL_TYPES: LabelType[] = [LabelType.PAGE];
|
||||
|
||||
export class AddLabelsDto extends PageIdDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(25)
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@Transform(({ value }) =>
|
||||
Array.isArray(value) ? value.map(normalizeLabelName) : value,
|
||||
)
|
||||
@MaxLength(100, { each: true })
|
||||
@Matches(/^[a-z0-9_-][a-z0-9_~-]*$/, {
|
||||
each: true,
|
||||
message:
|
||||
'Label names can only contain letters, numbers, hyphens, underscores, and tildes, and cannot start with a tilde',
|
||||
})
|
||||
names: string[];
|
||||
}
|
||||
|
||||
export class RemoveLabelDto extends PageIdDto {
|
||||
@IsUUID()
|
||||
labelId: string;
|
||||
}
|
||||
|
||||
export class FindPagesByLabelDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
labelId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? normalizeLabelName(value) : value,
|
||||
)
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export class LabelInfoDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? normalizeLabelName(value) : value,
|
||||
)
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(SUPPORTED_LABEL_TYPES)
|
||||
type: LabelType;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export class ListLabelsDto {
|
||||
@IsString()
|
||||
@IsIn(SUPPORTED_LABEL_TYPES)
|
||||
type: LabelType;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { LabelService } from './label.service';
|
||||
import {
|
||||
FindPagesByLabelDto,
|
||||
LabelInfoDto,
|
||||
ListLabelsDto,
|
||||
} from './dto/label.dto';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
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';
|
||||
import { LabelRepo, LabelType } from '@docmost/db/repos/label/label.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { emptyCursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../casl/interfaces/space-ability.type';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('labels')
|
||||
export class LabelController {
|
||||
constructor(
|
||||
private readonly labelService: LabelService,
|
||||
private readonly labelRepo: LabelRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/')
|
||||
async getLabels(
|
||||
@Body() dto: ListLabelsDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.labelService.getLabels(
|
||||
workspace.id,
|
||||
user.id,
|
||||
dto.type,
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('pages')
|
||||
async findPagesByLabel(
|
||||
@Body() dto: FindPagesByLabelDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
if (dto.spaceId) {
|
||||
await this.assertCanReadSpace(user, dto.spaceId);
|
||||
}
|
||||
|
||||
let labelId = dto.labelId;
|
||||
if (!labelId) {
|
||||
if (!dto.name) {
|
||||
throw new BadRequestException('labelId or name is required');
|
||||
}
|
||||
const label = await this.labelRepo.findByNameAndWorkspace(
|
||||
dto.name,
|
||||
workspace.id,
|
||||
LabelType.PAGE,
|
||||
);
|
||||
if (!label) {
|
||||
return emptyCursorPaginationResult(pagination.limit);
|
||||
}
|
||||
labelId = label.id;
|
||||
} else {
|
||||
const label = await this.labelRepo.findById(labelId);
|
||||
if (!label) {
|
||||
throw new NotFoundException('Label not found');
|
||||
}
|
||||
}
|
||||
|
||||
return this.labelService.findPagesByLabel(labelId, user.id, {
|
||||
spaceId: dto.spaceId,
|
||||
query: pagination.query,
|
||||
pagination,
|
||||
});
|
||||
}
|
||||
|
||||
// @HttpCode(HttpStatus.OK)
|
||||
// @Post('info')
|
||||
// async getLabelInfo(
|
||||
// @Body() dto: LabelInfoDto,
|
||||
// @AuthUser() user: User,
|
||||
// @AuthWorkspace() workspace: Workspace,
|
||||
// ) {
|
||||
// if (dto.spaceId) {
|
||||
// await this.assertCanReadSpace(user, dto.spaceId);
|
||||
// }
|
||||
//
|
||||
// return this.labelService.getLabelInfo(
|
||||
// dto.name,
|
||||
// dto.type,
|
||||
// workspace.id,
|
||||
// user.id,
|
||||
// dto.spaceId,
|
||||
// );
|
||||
// }
|
||||
|
||||
private async assertCanReadSpace(user: User, spaceId: string) {
|
||||
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LabelController } from './label.controller';
|
||||
import { LabelService } from './label.service';
|
||||
|
||||
@Module({
|
||||
controllers: [LabelController],
|
||||
providers: [LabelService],
|
||||
exports: [LabelService],
|
||||
})
|
||||
export class LabelModule {}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Label } from '@docmost/db/types/entity.types';
|
||||
import { LabelRepo, LabelType } from '@docmost/db/repos/label/label.repo';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { normalizeLabelName } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class LabelService {
|
||||
constructor(
|
||||
private readonly labelRepo: LabelRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
async addLabelsToPage(
|
||||
pageId: string,
|
||||
names: string[],
|
||||
workspaceId: string,
|
||||
): Promise<Label[]> {
|
||||
const attached: Label[] = [];
|
||||
await executeTx(this.db, async (trx) => {
|
||||
for (const name of names) {
|
||||
const label = await this.labelRepo.findOrCreate(
|
||||
name.trim(),
|
||||
workspaceId,
|
||||
LabelType.PAGE,
|
||||
trx,
|
||||
);
|
||||
await this.labelRepo.addLabelToPage(pageId, label.id, trx);
|
||||
attached.push(label);
|
||||
}
|
||||
});
|
||||
return attached;
|
||||
}
|
||||
|
||||
async removeLabelFromPage(
|
||||
pageId: string,
|
||||
labelId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
const label = await this.labelRepo.findById(labelId, trx);
|
||||
if (!label || label.workspaceId !== workspaceId) {
|
||||
throw new NotFoundException('Label not found');
|
||||
}
|
||||
|
||||
await this.labelRepo.removeLabelFromPage(
|
||||
pageId,
|
||||
labelId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
|
||||
const count = await this.labelRepo.getLabelPageCount(
|
||||
labelId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
if (count === 0) {
|
||||
await this.labelRepo.deleteLabel(labelId, workspaceId, trx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getPageLabels(pageId: string, pagination: PaginationOptions) {
|
||||
return this.labelRepo.findLabelsByPageId(pageId, pagination);
|
||||
}
|
||||
|
||||
async getLabels(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
type: LabelType,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
return this.labelRepo.findLabels(
|
||||
workspaceId,
|
||||
userId,
|
||||
type,
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
|
||||
async findPagesByLabel(
|
||||
labelId: string,
|
||||
userId: string,
|
||||
opts: {
|
||||
spaceId?: string;
|
||||
query?: string;
|
||||
pagination: PaginationOptions;
|
||||
},
|
||||
) {
|
||||
const result = await this.labelRepo.findPagesByLabelId(labelId, userId, opts);
|
||||
if (result.items.length === 0) return result;
|
||||
|
||||
const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: result.items.map((p) => p.id),
|
||||
userId,
|
||||
spaceId: opts.spaceId,
|
||||
});
|
||||
const accessible = new Set(accessibleIds);
|
||||
return {
|
||||
items: result.items.filter((p) => accessible.has(p.id)),
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
|
||||
async getLabelInfo(
|
||||
name: string,
|
||||
type: LabelType,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
spaceId?: string,
|
||||
) {
|
||||
const normalized = normalizeLabelName(name);
|
||||
const label = await this.labelRepo.findByNameAndWorkspace(
|
||||
normalized,
|
||||
workspaceId,
|
||||
type,
|
||||
);
|
||||
|
||||
// Uniform response shape.
|
||||
// We don't want to expose whether the label row exists
|
||||
const usageCount = label
|
||||
? await this.labelRepo.getLabelPageCountForUser(
|
||||
label.id,
|
||||
userId,
|
||||
spaceId,
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
name: normalized,
|
||||
usageCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function normalizeLabelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, '-').toLowerCase();
|
||||
}
|
||||
@@ -40,6 +40,8 @@ import { CreatedByUserDto } from './dto/created-by-user.dto';
|
||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
||||
import { BacklinksListDto } from './dto/backlink.dto';
|
||||
import { LabelService } from '../label/label.service';
|
||||
import { AddLabelsDto, RemoveLabelDto } from '../label/dto/label.dto';
|
||||
import {
|
||||
jsonToHtml,
|
||||
jsonToMarkdown,
|
||||
@@ -61,6 +63,7 @@ export class PageController {
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly backlinkService: BacklinkService,
|
||||
private readonly labelService: LabelService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@@ -99,6 +102,64 @@ export class PageController {
|
||||
return { ...page, permissions };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('labels')
|
||||
async getPageLabels(
|
||||
@Body() dto: PageIdDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return this.labelService.getPageLabels(page.id, pagination);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('labels/add')
|
||||
async addPageLabels(
|
||||
@Body() dto: AddLabelsDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
return this.labelService.addLabelsToPage(
|
||||
page.id,
|
||||
dto.names,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('labels/remove')
|
||||
async removePageLabel(
|
||||
@Body() dto: RemoveLabelDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
await this.labelService.removeLabelFromPage(
|
||||
page.id,
|
||||
dto.labelId,
|
||||
page.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('backlinks-count')
|
||||
async getBacklinksCount(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { StorageModule } from '../../integrations/storage/storage.module';
|
||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||
import { WatcherModule } from '../watcher/watcher.module';
|
||||
import { TransclusionModule } from './transclusion/transclusion.module';
|
||||
import { LabelModule } from '../label/label.module';
|
||||
|
||||
@Module({
|
||||
controllers: [PageController],
|
||||
@@ -23,6 +24,7 @@ import { TransclusionModule } from './transclusion/transclusion.module';
|
||||
CollaborationModule,
|
||||
WatcherModule,
|
||||
TransclusionModule,
|
||||
LabelModule,
|
||||
],
|
||||
})
|
||||
export class PageModule {}
|
||||
|
||||
@@ -425,11 +425,7 @@ export class PageService {
|
||||
|
||||
if (pageIdsToMove.length > 1) {
|
||||
// Update sub pages (all accessible pages except root)
|
||||
await this.pageRepo.updatePages(
|
||||
{ spaceId },
|
||||
childPageIds,
|
||||
trx,
|
||||
);
|
||||
await this.pageRepo.updatePages({ spaceId }, childPageIds, trx);
|
||||
}
|
||||
|
||||
if (pageIdsToMove.length > 0) {
|
||||
@@ -476,9 +472,13 @@ export class PageService {
|
||||
);
|
||||
|
||||
// Update watchers and remove those without access to new space
|
||||
await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
|
||||
trx,
|
||||
});
|
||||
await this.watcherService.movePageWatchersToSpace(
|
||||
pageIdsToMove,
|
||||
spaceId,
|
||||
{
|
||||
trx,
|
||||
},
|
||||
);
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||
pageId: pageIdsToMove,
|
||||
@@ -858,13 +858,15 @@ export class PageService {
|
||||
.selectFrom('page_ancestors')
|
||||
.selectAll('page_ancestors')
|
||||
.select((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('pages as child')
|
||||
.select(sql`1`.as('one'))
|
||||
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
|
||||
.where('child.deletedAt', 'is', null),
|
||||
).as('hasChildren'),
|
||||
eb
|
||||
.exists(
|
||||
eb
|
||||
.selectFrom('pages as child')
|
||||
.select(sql`1`.as('one'))
|
||||
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
|
||||
.where('child.deletedAt', 'is', null),
|
||||
)
|
||||
.as('hasChildren'),
|
||||
)
|
||||
.execute();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user