mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-25 06:01:02 +10:00
feat: page history
This commit is contained in:
@ -29,8 +29,8 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.431.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.431.0",
|
||||
"@hocuspocus/server": "^2.7.1",
|
||||
"@hocuspocus/transformer": "^2.7.1",
|
||||
"@hocuspocus/server": "^2.8.1",
|
||||
"@hocuspocus/transformer": "^2.8.1",
|
||||
"@nestjs/common": "^10.2.7",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.2.7",
|
||||
@ -59,6 +59,7 @@
|
||||
"@nestjs/schematics": "^10.0.2",
|
||||
"@nestjs/testing": "^10.2.7",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/debounce": "^1.2.4",
|
||||
"@types/fs-extra": "^11.0.2",
|
||||
"@types/jest": "^29.5.5",
|
||||
"@types/mime-types": "^2.1.2",
|
||||
|
||||
@ -4,18 +4,24 @@ import WebSocket from 'ws';
|
||||
import { AuthenticationExtension } from './extensions/authentication.extension';
|
||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HistoryExtension } from './extensions/history.extension';
|
||||
|
||||
@Injectable()
|
||||
export class CollaborationGateway {
|
||||
constructor(
|
||||
private authenticationExtension: AuthenticationExtension,
|
||||
private persistenceExtension: PersistenceExtension,
|
||||
private historyExtension: HistoryExtension,
|
||||
) {}
|
||||
|
||||
private hocuspocus = HocuspocusServer.configure({
|
||||
debounce: 5000,
|
||||
maxDebounce: 10000,
|
||||
extensions: [this.authenticationExtension, this.persistenceExtension],
|
||||
extensions: [
|
||||
this.authenticationExtension,
|
||||
this.persistenceExtension,
|
||||
this.historyExtension,
|
||||
],
|
||||
});
|
||||
|
||||
handleConnection(client: WebSocket, request: IncomingMessage): any {
|
||||
|
||||
@ -9,12 +9,14 @@ import { HttpAdapterHost } from '@nestjs/core';
|
||||
import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { WebSocket } from 'ws';
|
||||
import { HistoryExtension } from './extensions/history.extension';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
CollaborationGateway,
|
||||
AuthenticationExtension,
|
||||
PersistenceExtension,
|
||||
HistoryExtension,
|
||||
],
|
||||
imports: [UserModule, AuthModule, PageModule],
|
||||
})
|
||||
|
||||
64
server/src/collaboration/extensions/history.extension.ts
Normal file
64
server/src/collaboration/extensions/history.extension.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import {
|
||||
Extension,
|
||||
onChangePayload,
|
||||
onDisconnectPayload,
|
||||
} from '@hocuspocus/server';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PageService } from '../../core/page/services/page.service';
|
||||
import { PageHistoryService } from '../../core/page/services/page-history.service';
|
||||
|
||||
@Injectable()
|
||||
export class HistoryExtension implements Extension {
|
||||
ACTIVE_EDITING_INTERVAL = 10 * 60 * 1000; // 10 minutes
|
||||
historyIntervalMap = new Map<string, NodeJS.Timeout>();
|
||||
lastEditTimeMap = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly pageService: PageService,
|
||||
private readonly pageHistoryService: PageHistoryService,
|
||||
) {}
|
||||
|
||||
async onChange(data: onChangePayload): Promise<void> {
|
||||
const pageId = data.documentName;
|
||||
this.lastEditTimeMap.set(pageId, Date.now());
|
||||
|
||||
if (!this.historyIntervalMap.has(pageId)) {
|
||||
const historyInterval = setInterval(() => {
|
||||
if (this.isActiveEditing(pageId)) {
|
||||
this.recordHistory(pageId);
|
||||
}
|
||||
}, this.ACTIVE_EDITING_INTERVAL);
|
||||
this.historyIntervalMap.set(pageId, historyInterval);
|
||||
}
|
||||
}
|
||||
|
||||
async onDisconnect(data: onDisconnectPayload): Promise<void> {
|
||||
const pageId = data.documentName;
|
||||
if (data.clientsCount === 0) {
|
||||
if (this.historyIntervalMap.has(pageId)) {
|
||||
clearInterval(this.historyIntervalMap.get(pageId));
|
||||
this.historyIntervalMap.delete(pageId);
|
||||
this.lastEditTimeMap.delete(pageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isActiveEditing(pageId: string): boolean {
|
||||
const lastEditTime = this.lastEditTimeMap.get(pageId);
|
||||
if (!lastEditTime) {
|
||||
return false;
|
||||
}
|
||||
return Date.now() - lastEditTime < this.ACTIVE_EDITING_INTERVAL;
|
||||
}
|
||||
|
||||
async recordHistory(pageId: string) {
|
||||
try {
|
||||
const page = await this.pageService.findWithContent(pageId);
|
||||
// Todo: compare if data is the same as the previous version
|
||||
await this.pageHistoryService.saveHistory(page);
|
||||
console.log(`New history created for: ${pageId}`);
|
||||
} catch (err) {
|
||||
console.error('An error occurred saving page history', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,12 +14,13 @@ export class PersistenceExtension implements Extension {
|
||||
|
||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||
const { documentName, document } = data;
|
||||
const pageId = documentName;
|
||||
|
||||
if (!document.isEmpty('default')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = await this.pageService.findById(documentName);
|
||||
const page = await this.pageService.findWithAllFields(pageId);
|
||||
|
||||
if (!page) {
|
||||
console.log('page does not exist.');
|
||||
|
||||
@ -9,10 +9,6 @@ export class CreatePageDto {
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentPageId?: string;
|
||||
|
||||
6
server/src/core/page/dto/history-details.dto.ts
Normal file
6
server/src/core/page/dto/history-details.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class HistoryDetailsDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
}
|
||||
6
server/src/core/page/dto/page-history.dto.ts
Normal file
6
server/src/core/page/dto/page-history.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class PageHistoryDto {
|
||||
@IsUUID()
|
||||
pageId: string;
|
||||
}
|
||||
63
server/src/core/page/entities/page-history.entity.ts
Normal file
63
server/src/core/page/entities/page-history.entity.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Workspace } from '../../workspace/entities/workspace.entity';
|
||||
import { Page } from './page.entity';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
@Entity('page_history')
|
||||
export class PageHistory {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
pageId: string;
|
||||
|
||||
@ManyToOne(() => Page, (page) => page.pageHistory, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'pageId' })
|
||||
page: Page;
|
||||
|
||||
@Column({ length: 500, nullable: true })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
content: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
slug: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
icon: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
coverPhoto: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
version: number;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
lastUpdatedById: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'lastUpdatedById' })
|
||||
lastUpdatedBy: User;
|
||||
|
||||
@Column()
|
||||
workspaceId: string;
|
||||
|
||||
@ManyToOne(() => Workspace, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Workspace;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@ -12,6 +12,7 @@ import {
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Workspace } from '../../workspace/entities/workspace.entity';
|
||||
import { Comment } from '../../comment/entities/comment.entity';
|
||||
import { PageHistory } from './page-history.entity';
|
||||
|
||||
@Entity('pages')
|
||||
export class Page {
|
||||
@ -101,6 +102,9 @@ export class Page {
|
||||
@OneToMany(() => Page, (page) => page.parentPage, { onDelete: 'CASCADE' })
|
||||
childPages: Page[];
|
||||
|
||||
@OneToMany(() => PageHistory, (pageHistory) => pageHistory.page)
|
||||
pageHistory: PageHistory[];
|
||||
|
||||
@OneToMany(() => Comment, (comment) => comment.page)
|
||||
comments: Comment[];
|
||||
}
|
||||
|
||||
@ -17,6 +17,9 @@ import { MovePageDto } from './dto/move-page.dto';
|
||||
import { PageDetailsDto } from './dto/page-details.dto';
|
||||
import { DeletePageDto } from './dto/delete-page.dto';
|
||||
import { PageOrderingService } from './services/page-ordering.service';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { HistoryDetailsDto } from './dto/history-details.dto';
|
||||
import { PageHistoryDto } from './dto/page-history.dto';
|
||||
|
||||
@UseGuards(JwtGuard)
|
||||
@Controller('pages')
|
||||
@ -24,13 +27,14 @@ export class PageController {
|
||||
constructor(
|
||||
private readonly pageService: PageService,
|
||||
private readonly pageOrderService: PageOrderingService,
|
||||
private readonly pageHistoryService: PageHistoryService,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/details')
|
||||
async getPage(@Body() input: PageDetailsDto) {
|
||||
return this.pageService.findWithoutYDoc(input.id);
|
||||
return this.pageService.findOne(input.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ -118,4 +122,16 @@ export class PageController {
|
||||
|
||||
return this.pageOrderService.convertToTree(workspaceId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/history')
|
||||
async getPageHistory(@Body() dto: PageHistoryDto) {
|
||||
return this.pageHistoryService.findHistoryByPageId(dto.pageId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/history/details')
|
||||
async get(@Body() dto: HistoryDetailsDto) {
|
||||
return this.pageHistoryService.findOne(dto.id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,15 +8,24 @@ import { AuthModule } from '../auth/auth.module';
|
||||
import { WorkspaceModule } from '../workspace/workspace.module';
|
||||
import { PageOrderingService } from './services/page-ordering.service';
|
||||
import { PageOrdering } from './entities/page-ordering.entity';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { PageHistory } from './entities/page-history.entity';
|
||||
import { PageHistoryRepository } from './repositories/page-history.repository';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Page, PageOrdering]),
|
||||
TypeOrmModule.forFeature([Page, PageOrdering, PageHistory]),
|
||||
AuthModule,
|
||||
WorkspaceModule,
|
||||
],
|
||||
controllers: [PageController],
|
||||
providers: [PageService, PageOrderingService, PageRepository],
|
||||
exports: [PageService, PageOrderingService, PageRepository],
|
||||
providers: [
|
||||
PageService,
|
||||
PageOrderingService,
|
||||
PageHistoryService,
|
||||
PageRepository,
|
||||
PageHistoryRepository,
|
||||
],
|
||||
exports: [PageService, PageOrderingService, PageHistoryService],
|
||||
})
|
||||
export class PageModule {}
|
||||
|
||||
26
server/src/core/page/repositories/page-history.repository.ts
Normal file
26
server/src/core/page/repositories/page-history.repository.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PageHistory } from '../entities/page-history.entity';
|
||||
|
||||
@Injectable()
|
||||
export class PageHistoryRepository extends Repository<PageHistory> {
|
||||
constructor(private dataSource: DataSource) {
|
||||
super(PageHistory, dataSource.createEntityManager());
|
||||
}
|
||||
|
||||
async findById(pageId: string) {
|
||||
return this.findOne({
|
||||
where: {
|
||||
id: pageId,
|
||||
},
|
||||
relations: ['lastUpdatedBy'],
|
||||
select: {
|
||||
lastUpdatedBy: {
|
||||
id: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -8,33 +8,49 @@ export class PageRepository extends Repository<Page> {
|
||||
super(Page, dataSource.createEntityManager());
|
||||
}
|
||||
|
||||
async findById(pageId: string) {
|
||||
return this.findOneBy({ id: pageId });
|
||||
}
|
||||
public baseFields = [
|
||||
'page.id',
|
||||
'page.title',
|
||||
'page.slug',
|
||||
'page.icon',
|
||||
'page.coverPhoto',
|
||||
'page.shareId',
|
||||
'page.parentPageId',
|
||||
'page.creatorId',
|
||||
'page.lastUpdatedById',
|
||||
'page.workspaceId',
|
||||
'page.isLocked',
|
||||
'page.status',
|
||||
'page.publishedAt',
|
||||
'page.createdAt',
|
||||
'page.updatedAt',
|
||||
'page.deletedAt',
|
||||
];
|
||||
|
||||
async findWithoutYDoc(pageId: string) {
|
||||
private async baseFind(pageId: string, selectFields: string[]) {
|
||||
return this.dataSource
|
||||
.createQueryBuilder(Page, 'page')
|
||||
.where('page.id = :id', { id: pageId })
|
||||
.select([
|
||||
'page.id',
|
||||
'page.title',
|
||||
'page.slug',
|
||||
'page.icon',
|
||||
'page.coverPhoto',
|
||||
'page.editor',
|
||||
'page.shareId',
|
||||
'page.parentPageId',
|
||||
'page.creatorId',
|
||||
'page.lastUpdatedById',
|
||||
'page.workspaceId',
|
||||
'page.isLocked',
|
||||
'page.status',
|
||||
'page.publishedAt',
|
||||
'page.createdAt',
|
||||
'page.updatedAt',
|
||||
'page.deletedAt',
|
||||
])
|
||||
.select(selectFields)
|
||||
.getOne();
|
||||
}
|
||||
|
||||
async findById(pageId: string) {
|
||||
return this.baseFind(pageId, this.baseFields);
|
||||
}
|
||||
|
||||
async findWithYDoc(pageId: string) {
|
||||
const extendedFields = [...this.baseFields, 'page.ydoc'];
|
||||
return this.baseFind(pageId, extendedFields);
|
||||
}
|
||||
|
||||
async findWithContent(pageId: string) {
|
||||
const extendedFields = [...this.baseFields, 'page.content'];
|
||||
return this.baseFind(pageId, extendedFields);
|
||||
}
|
||||
|
||||
async findWithAllFields(pageId: string) {
|
||||
const extendedFields = [...this.baseFields, 'page.content', 'page.ydoc'];
|
||||
return this.baseFind(pageId, extendedFields);
|
||||
}
|
||||
}
|
||||
|
||||
61
server/src/core/page/services/page-history.service.ts
Normal file
61
server/src/core/page/services/page-history.service.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { PageHistory } from '../entities/page-history.entity';
|
||||
import { Page } from '../entities/page.entity';
|
||||
import { PageHistoryRepository } from '../repositories/page-history.repository';
|
||||
|
||||
@Injectable()
|
||||
export class PageHistoryService {
|
||||
constructor(private pageHistoryRepo: PageHistoryRepository) {
|
||||
}
|
||||
|
||||
async findOne(historyId: string): Promise<PageHistory> {
|
||||
const history = await this.pageHistoryRepo.findById(historyId);
|
||||
if (!history) {
|
||||
throw new BadRequestException('History not found');
|
||||
}
|
||||
return history;
|
||||
}
|
||||
|
||||
async saveHistory(page: Page): Promise<void> {
|
||||
const pageHistory = new PageHistory();
|
||||
pageHistory.pageId = page.id;
|
||||
pageHistory.title = page.title;
|
||||
pageHistory.content = page.content;
|
||||
pageHistory.slug = page.slug;
|
||||
pageHistory.icon = page.icon;
|
||||
pageHistory.version = 1; // TODO: make incremental
|
||||
pageHistory.coverPhoto = page.coverPhoto;
|
||||
pageHistory.lastUpdatedById = page.lastUpdatedById ?? page.creatorId;
|
||||
pageHistory.workspaceId = page.workspaceId;
|
||||
|
||||
await this.pageHistoryRepo.save(pageHistory);
|
||||
}
|
||||
|
||||
async findHistoryByPageId(pageId: string, limit = 50, offset = 0) {
|
||||
const history = await this.pageHistoryRepo
|
||||
.createQueryBuilder('history')
|
||||
.where('history.pageId = :pageId', { pageId })
|
||||
.leftJoinAndSelect('history.lastUpdatedBy', 'user')
|
||||
.select([
|
||||
'history.id',
|
||||
'history.pageId',
|
||||
'history.title',
|
||||
'history.slug',
|
||||
'history.icon',
|
||||
'history.coverPhoto',
|
||||
'history.version',
|
||||
'history.lastUpdatedById',
|
||||
'history.workspaceId',
|
||||
'history.createdAt',
|
||||
'history.updatedAt',
|
||||
'user.id',
|
||||
'user.name',
|
||||
'user.avatarUrl',
|
||||
])
|
||||
.orderBy('history.updatedAt', 'DESC')
|
||||
.offset(offset)
|
||||
.take(limit)
|
||||
.getMany();
|
||||
return history;
|
||||
}
|
||||
}
|
||||
@ -35,8 +35,25 @@ export class PageService {
|
||||
return this.pageRepository.findById(pageId);
|
||||
}
|
||||
|
||||
async findWithoutYDoc(pageId: string) {
|
||||
return this.pageRepository.findWithoutYDoc(pageId);
|
||||
async findWithContent(pageId: string) {
|
||||
return this.pageRepository.findWithContent(pageId);
|
||||
}
|
||||
|
||||
async findWithYdoc(pageId: string) {
|
||||
return this.pageRepository.findWithYDoc(pageId);
|
||||
}
|
||||
|
||||
async findWithAllFields(pageId: string) {
|
||||
return this.pageRepository.findWithAllFields(pageId);
|
||||
}
|
||||
|
||||
async findOne(pageId: string): Promise<Page> {
|
||||
const page = await this.findById(pageId);
|
||||
if (!page) {
|
||||
throw new BadRequestException('Page not found');
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
async create(
|
||||
@ -85,7 +102,7 @@ export class PageService {
|
||||
throw new BadRequestException(`Page not found`);
|
||||
}
|
||||
|
||||
return await this.pageRepository.findWithoutYDoc(pageId);
|
||||
return await this.pageRepository.findById(pageId);
|
||||
}
|
||||
|
||||
async updateState(
|
||||
@ -240,25 +257,7 @@ export class PageService {
|
||||
const pages = await this.pageRepository
|
||||
.createQueryBuilder('page')
|
||||
.where('page.workspaceId = :workspaceId', { workspaceId })
|
||||
.select([
|
||||
'page.id',
|
||||
'page.title',
|
||||
'page.slug',
|
||||
'page.icon',
|
||||
'page.coverPhoto',
|
||||
'page.editor',
|
||||
'page.shareId',
|
||||
'page.parentPageId',
|
||||
'page.creatorId',
|
||||
'page.lastUpdatedById',
|
||||
'page.workspaceId',
|
||||
'page.isLocked',
|
||||
'page.status',
|
||||
'page.publishedAt',
|
||||
'page.createdAt',
|
||||
'page.updatedAt',
|
||||
'page.deletedAt',
|
||||
])
|
||||
.select(this.pageRepository.baseFields)
|
||||
.orderBy('page.updatedAt', 'DESC')
|
||||
.offset(offset)
|
||||
.take(limit)
|
||||
|
||||
Reference in New Issue
Block a user