mirror of
https://github.com/docmost/docmost.git
synced 2025-11-14 09:11:15 +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 { useAtom } from "jotai";
|
||||||
import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms";
|
import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms";
|
||||||
import HistoryView from "@/features/page-history/components/history-view";
|
import HistoryView from "@/features/page-history/components/history-view";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HistoryModalBody({ pageId }: Props) {
|
export default function HistoryModalBody({ pageId }: Props) {
|
||||||
const [activeHistoryId] = useAtom(activeHistoryIdAtom);
|
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveHistoryId("");
|
||||||
|
}, [pageId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.sidebarFlex}>
|
<div className={classes.sidebarFlex}>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export function usePageHistoryListQuery(
|
|||||||
queryKey: ["page-history-list", pageId],
|
queryKey: ["page-history-list", pageId],
|
||||||
queryFn: () => getPageHistoryList(pageId),
|
queryFn: () => getPageHistoryList(pageId),
|
||||||
enabled: !!pageId,
|
enabled: !!pageId,
|
||||||
|
gcTime: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { StorageModule } from './integrations/storage/storage.module';
|
|||||||
import { MailModule } from './integrations/mail/mail.module';
|
import { MailModule } from './integrations/mail/mail.module';
|
||||||
import { QueueModule } from './integrations/queue/queue.module';
|
import { QueueModule } from './integrations/queue/queue.module';
|
||||||
import { StaticModule } from './integrations/static/static.module';
|
import { StaticModule } from './integrations/static/static.module';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -26,6 +27,7 @@ import { StaticModule } from './integrations/static/static.module';
|
|||||||
MailModule.forRootAsync({
|
MailModule.forRootAsync({
|
||||||
imports: [EnvironmentModule],
|
imports: [EnvironmentModule],
|
||||||
}),
|
}),
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
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 { IncomingMessage } from 'http';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
import { AuthenticationExtension } from './extensions/authentication.extension';
|
import { AuthenticationExtension } from './extensions/authentication.extension';
|
||||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||||
import { Injectable } from '@nestjs/common';
|
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()
|
@Injectable()
|
||||||
export class CollaborationGateway {
|
export class CollaborationGateway {
|
||||||
|
private hocuspocus: Hocuspocus;
|
||||||
|
private redisConfig: RedisConfig;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private authenticationExtension: AuthenticationExtension,
|
private authenticationExtension: AuthenticationExtension,
|
||||||
private persistenceExtension: PersistenceExtension,
|
private persistenceExtension: PersistenceExtension,
|
||||||
private historyExtension: HistoryExtension,
|
private environmentService: EnvironmentService,
|
||||||
) {}
|
) {
|
||||||
|
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
||||||
|
|
||||||
private hocuspocus = HocuspocusServer.configure({
|
this.hocuspocus = HocuspocusServer.configure({
|
||||||
debounce: 5000,
|
debounce: 5000,
|
||||||
maxDebounce: 10000,
|
maxDebounce: 10000,
|
||||||
unloadImmediately: false,
|
unloadImmediately: false,
|
||||||
extensions: [
|
extensions: [
|
||||||
this.authenticationExtension,
|
this.authenticationExtension,
|
||||||
this.persistenceExtension,
|
this.persistenceExtension,
|
||||||
this.historyExtension,
|
new Redis({
|
||||||
],
|
host: this.redisConfig.host,
|
||||||
});
|
port: this.redisConfig.port,
|
||||||
|
options: {
|
||||||
|
password: this.redisConfig.password,
|
||||||
|
retryStrategy: createRetryStrategy(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
handleConnection(client: WebSocket, request: IncomingMessage): any {
|
handleConnection(client: WebSocket, request: IncomingMessage): any {
|
||||||
this.hocuspocus.handleConnection(client, request);
|
this.hocuspocus.handleConnection(client, request);
|
||||||
|
|||||||
@ -6,15 +6,15 @@ import { HttpAdapterHost } from '@nestjs/core';
|
|||||||
import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
||||||
import { IncomingMessage } from 'http';
|
import { IncomingMessage } from 'http';
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
import { HistoryExtension } from './extensions/history.extension';
|
|
||||||
import { TokenModule } from '../core/auth/token.module';
|
import { TokenModule } from '../core/auth/token.module';
|
||||||
|
import { HistoryListener } from './listeners/history.listener';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
CollaborationGateway,
|
CollaborationGateway,
|
||||||
AuthenticationExtension,
|
AuthenticationExtension,
|
||||||
PersistenceExtension,
|
PersistenceExtension,
|
||||||
HistoryExtension,
|
HistoryListener,
|
||||||
],
|
],
|
||||||
imports: [TokenModule],
|
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 { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
import { getPageId, jsonToText, tiptapExtensions } from '../collaboration.util';
|
import { getPageId, jsonToText, tiptapExtensions } from '../collaboration.util';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
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()
|
@Injectable()
|
||||||
export class PersistenceExtension implements Extension {
|
export class PersistenceExtension implements Extension {
|
||||||
private readonly logger = new Logger(PersistenceExtension.name);
|
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) {
|
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||||
const { documentName, document } = data;
|
const { documentName, document } = data;
|
||||||
@ -72,16 +80,38 @@ export class PersistenceExtension implements Extension {
|
|||||||
const textContent = jsonToText(tiptapJson);
|
const textContent = jsonToText(tiptapJson);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.pageRepo.updatePage(
|
let page = null;
|
||||||
{
|
|
||||||
content: tiptapJson,
|
await executeTx(this.db, async (trx) => {
|
||||||
textContent: textContent,
|
page = await this.pageRepo.findById(pageId, {
|
||||||
ydoc: ydocState,
|
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,
|
lastUpdatedById: context.user.id,
|
||||||
updatedAt: new Date(),
|
content: tiptapJson,
|
||||||
},
|
},
|
||||||
pageId,
|
});
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to update page ${pageId}`, 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