mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 18:52:36 +10:00
fix page history generation
This commit is contained in:
@ -4,13 +4,18 @@ import classes from "./history.module.css";
|
||||
import { useAtom } from "jotai";
|
||||
import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms";
|
||||
import HistoryView from "@/features/page-history/components/history-view";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface Props {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export default function HistoryModalBody({ pageId }: Props) {
|
||||
const [activeHistoryId] = useAtom(activeHistoryIdAtom);
|
||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveHistoryId("");
|
||||
}, [pageId]);
|
||||
|
||||
return (
|
||||
<div className={classes.sidebarFlex}>
|
||||
|
||||
@ -13,6 +13,7 @@ export function usePageHistoryListQuery(
|
||||
queryKey: ["page-history-list", pageId],
|
||||
queryFn: () => getPageHistoryList(pageId),
|
||||
enabled: !!pageId,
|
||||
gcTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import { StorageModule } from './integrations/storage/storage.module';
|
||||
import { MailModule } from './integrations/mail/mail.module';
|
||||
import { QueueModule } from './integrations/queue/queue.module';
|
||||
import { StaticModule } from './integrations/static/static.module';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -26,6 +27,7 @@ import { StaticModule } from './integrations/static/static.module';
|
||||
MailModule.forRootAsync({
|
||||
imports: [EnvironmentModule],
|
||||
}),
|
||||
EventEmitterModule.forRoot(),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@ -1,29 +1,43 @@
|
||||
import { Server as HocuspocusServer } from '@hocuspocus/server';
|
||||
import { Hocuspocus, Server as HocuspocusServer } from '@hocuspocus/server';
|
||||
import { IncomingMessage } from 'http';
|
||||
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';
|
||||
import { Redis } from '@hocuspocus/extension-redis';
|
||||
import { EnvironmentService } from '../integrations/environment/environment.service';
|
||||
import { createRetryStrategy, parseRedisUrl, RedisConfig } from '../helpers';
|
||||
|
||||
@Injectable()
|
||||
export class CollaborationGateway {
|
||||
private hocuspocus: Hocuspocus;
|
||||
private redisConfig: RedisConfig;
|
||||
|
||||
constructor(
|
||||
private authenticationExtension: AuthenticationExtension,
|
||||
private persistenceExtension: PersistenceExtension,
|
||||
private historyExtension: HistoryExtension,
|
||||
) {}
|
||||
private environmentService: EnvironmentService,
|
||||
) {
|
||||
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
||||
|
||||
private hocuspocus = HocuspocusServer.configure({
|
||||
debounce: 5000,
|
||||
maxDebounce: 10000,
|
||||
unloadImmediately: false,
|
||||
extensions: [
|
||||
this.authenticationExtension,
|
||||
this.persistenceExtension,
|
||||
this.historyExtension,
|
||||
],
|
||||
});
|
||||
this.hocuspocus = HocuspocusServer.configure({
|
||||
debounce: 5000,
|
||||
maxDebounce: 10000,
|
||||
unloadImmediately: false,
|
||||
extensions: [
|
||||
this.authenticationExtension,
|
||||
this.persistenceExtension,
|
||||
new Redis({
|
||||
host: this.redisConfig.host,
|
||||
port: this.redisConfig.port,
|
||||
options: {
|
||||
password: this.redisConfig.password,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
handleConnection(client: WebSocket, request: IncomingMessage): any {
|
||||
this.hocuspocus.handleConnection(client, request);
|
||||
|
||||
@ -6,15 +6,15 @@ 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';
|
||||
import { TokenModule } from '../core/auth/token.module';
|
||||
import { HistoryListener } from './listeners/history.listener';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
CollaborationGateway,
|
||||
AuthenticationExtension,
|
||||
PersistenceExtension,
|
||||
HistoryExtension,
|
||||
HistoryListener,
|
||||
],
|
||||
imports: [TokenModule],
|
||||
})
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
import {
|
||||
Extension,
|
||||
onChangePayload,
|
||||
onDisconnectPayload,
|
||||
} from '@hocuspocus/server';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||
import { getPageId } from '../collaboration.util';
|
||||
|
||||
@Injectable()
|
||||
export class HistoryExtension implements Extension {
|
||||
private readonly logger = new Logger(HistoryExtension.name);
|
||||
|
||||
ACTIVE_EDITING_INTERVAL = 10 * 60 * 1000; // 10 minutes
|
||||
historyIntervalMap = new Map<string, NodeJS.Timeout>();
|
||||
lastEditTimeMap = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pageHistoryRepo: PageHistoryRepo,
|
||||
) {}
|
||||
|
||||
async onChange(data: onChangePayload): Promise<void> {
|
||||
const pageId = getPageId(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 = getPageId(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.pageRepo.findById(pageId, {
|
||||
includeContent: true,
|
||||
});
|
||||
// Todo: compare if data is the same as the previous version
|
||||
await this.pageHistoryRepo.saveHistory(page);
|
||||
this.logger.debug(`New history created for: ${pageId}`);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`An error occurred saving page history for: ${pageId}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,12 +8,20 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import { getPageId, jsonToText, tiptapExtensions } from '../collaboration.util';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
|
||||
@Injectable()
|
||||
export class PersistenceExtension implements Extension {
|
||||
private readonly logger = new Logger(PersistenceExtension.name);
|
||||
|
||||
constructor(private readonly pageRepo: PageRepo) {}
|
||||
constructor(
|
||||
private readonly pageRepo: PageRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||
const { documentName, document } = data;
|
||||
@ -72,16 +80,38 @@ export class PersistenceExtension implements Extension {
|
||||
const textContent = jsonToText(tiptapJson);
|
||||
|
||||
try {
|
||||
await this.pageRepo.updatePage(
|
||||
{
|
||||
content: tiptapJson,
|
||||
textContent: textContent,
|
||||
ydoc: ydocState,
|
||||
let page = null;
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
page = await this.pageRepo.findById(pageId, {
|
||||
withLock: true,
|
||||
trx,
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
this.logger.error(`Page with id ${pageId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.pageRepo.updatePage(
|
||||
{
|
||||
content: tiptapJson,
|
||||
textContent: textContent,
|
||||
ydoc: ydocState,
|
||||
lastUpdatedById: context.user.id,
|
||||
},
|
||||
pageId,
|
||||
trx,
|
||||
);
|
||||
});
|
||||
|
||||
this.eventEmitter.emit('collab.page.updated', {
|
||||
page: {
|
||||
...page,
|
||||
lastUpdatedById: context.user.id,
|
||||
updatedAt: new Date(),
|
||||
content: tiptapJson,
|
||||
},
|
||||
pageId,
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to update page ${pageId}`, err);
|
||||
}
|
||||
|
||||
44
apps/server/src/collaboration/listeners/history.listener.ts
Normal file
44
apps/server/src/collaboration/listeners/history.listener.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
export class UpdatedPageEvent {
|
||||
page: Page;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HistoryListener {
|
||||
private readonly logger = new Logger(HistoryListener.name);
|
||||
|
||||
constructor(private readonly pageHistoryRepo: PageHistoryRepo) {}
|
||||
|
||||
@OnEvent('collab.page.updated')
|
||||
async handleCreatePageHistory(event: UpdatedPageEvent) {
|
||||
const { page } = event;
|
||||
|
||||
const pageCreationTime = new Date(page.createdAt).getTime();
|
||||
const currentTime = Date.now();
|
||||
const TEN_MINUTES = 10 * 60 * 1000;
|
||||
|
||||
if (currentTime - pageCreationTime < TEN_MINUTES) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id);
|
||||
|
||||
if (
|
||||
!lastHistory ||
|
||||
(!isDeepStrictEqual(lastHistory.content, page.content) &&
|
||||
currentTime - new Date(lastHistory.createdAt).getTime() >= TEN_MINUTES)
|
||||
) {
|
||||
try {
|
||||
await this.pageHistoryRepo.saveHistory(page);
|
||||
this.logger.debug(`New history created for: ${page.id}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to create history for: ${page.id}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user