Compare commits

..

1 Commits

Author SHA1 Message Date
Philipinho 5f8a567aa0 fix svg content legth 2026-02-09 18:18:29 -08:00
30 changed files with 4194 additions and 4938 deletions
+12 -12
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.25.3",
"version": "0.25.2",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -14,18 +14,18 @@
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "^8.3.14",
"@mantine/dates": "^8.3.14",
"@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.14",
"@mantine/modals": "^8.3.14",
"@mantine/notifications": "^8.3.14",
"@mantine/spotlight": "^8.3.14",
"@excalidraw/excalidraw": "0.18.0-c158187",
"@mantine/core": "^8.3.12",
"@mantine/dates": "^8.3.12",
"@mantine/form": "^8.3.12",
"@mantine/hooks": "^8.3.12",
"@mantine/modals": "^8.3.12",
"@mantine/notifications": "^8.3.12",
"@mantine/spotlight": "^8.3.12",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.17",
"alfaaz": "^1.1.0",
"axios": "^1.13.5",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
@@ -41,7 +41,7 @@
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.12.2",
"mitt": "^3.0.1",
"posthog-js": "1.345.5",
"posthog-js": "^1.255.1",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.17",
@@ -66,7 +66,7 @@
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.2",
"eslint": "^9.15.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
@@ -160,7 +160,7 @@ export function TitleEditor({
// guard against Cannot access view['hasFocus'] error
if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end");
}, 300);
}, 500);
}, [titleEditor]);
useEffect(() => {
@@ -1,4 +1,4 @@
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { Text, Group, UnstyledButton } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css";
@@ -6,8 +6,6 @@ import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react";
const MAX_VISIBLE_AVATARS = 5;
interface HistoryItemProps {
historyItem: IPageHistory;
index: number;
@@ -33,9 +31,6 @@ const HistoryItem = memo(function HistoryItem({
onHover?.(historyItem.id, index);
}, [onHover, historyItem.id, index]);
const contributors = historyItem.contributors;
const hasContributors = contributors && contributors.length > 0;
return (
<UnstyledButton
p="xs"
@@ -44,54 +39,25 @@ const HistoryItem = memo(function HistoryItem({
onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })}
>
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
<Group wrap="nowrap">
<div>
<Text size="sm">
{formattedDate(new Date(historyItem.createdAt))}
</Text>
<Group gap={6} wrap="nowrap" mt={4}>
{hasContributors ? (
<>
<Tooltip.Group openDelay={300} closeDelay={100}>
<Avatar.Group spacing={8}>
{contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
<Tooltip key={contributor.id} label={contributor.name} withArrow>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
))}
{contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip
withArrow
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
<div key={c.id}>{c.name}</div>
))}
>
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
<div style={{ flex: 1 }}>
<Group gap={4} wrap="nowrap">
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{contributors[0].name}
{historyItem.lastUpdatedBy?.name}
</Text>
)}
</>
) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
</Text>
</>
)}
</Group>
</div>
</div>
</Group>
</UnstyledButton>
);
@@ -62,18 +62,11 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
const selectData = useMemo(
() =>
historyItems.map((item) => {
const contributors = item.contributors;
const hasContributors = contributors && contributors.length > 0;
const names = hasContributors
? contributors.map((c) => c.name).join(", ")
: item.lastUpdatedBy?.name;
return {
value: item.id,
label: formattedDate(new Date(item.createdAt)),
userName: names,
};
}),
historyItems.map((item) => ({
value: item.id,
label: formattedDate(new Date(item.createdAt)),
userName: item.lastUpdatedBy?.name,
})),
[historyItems],
);
@@ -18,5 +18,4 @@ export interface IPageHistory {
createdAt: string;
updatedAt: string;
lastUpdatedBy: IPageHistoryUser;
contributors?: IPageHistoryUser[];
}
+12 -12
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.25.3",
"version": "0.25.2",
"description": "",
"author": "",
"private": true,
@@ -56,8 +56,8 @@
"@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.13",
"@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "1.0.7",
"@react-email/render": "2.0.4",
"@react-email/components": "0.0.28",
"@react-email/render": "1.0.2",
"@socket.io/redis-adapter": "^8.3.0",
"ai": "^6.0.37",
"ai-sdk-ollama": "^3.1.1",
@@ -111,32 +111,32 @@
},
"devDependencies": {
"@eslint/js": "^9.20.0",
"@nestjs/cli": "^11.0.16",
"@nestjs/cli": "^11.0.4",
"@nestjs/schematics": "^11.0.1",
"@nestjs/testing": "^11.0.10",
"@types/bcrypt": "^5.0.2",
"@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^30.0.0",
"@types/jest": "^29.5.14",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.13.4",
"@types/nodemailer": "^6.4.17",
"@types/passport-google-oauth20": "^2.0.16",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.3",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.14",
"@types/yauzl": "^2.10.3",
"eslint": "^9.39.2",
"eslint": "^9.20.1",
"eslint-config-prettier": "^10.0.1",
"globals": "^15.15.0",
"jest": "^30.2.0",
"jest": "^29.7.0",
"kysely-codegen": "^0.19.0",
"prettier": "^3.5.1",
"react-email": "5.2.8",
"react-email": "3.0.2",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"ts-loader": "^9.5.4",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
@@ -1,12 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { Hocuspocus, Document } from '@hocuspocus/server';
import { TiptapTransformer } from '@hocuspocus/transformer';
import {
prosemirrorNodeToYElement,
tiptapExtensions,
} from './collaboration.util';
import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types';
export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers']
@@ -27,47 +20,6 @@ export class CollaborationHandler {
// const fragment = doc.getXmlFragment('default');
//});
},
updatePageContent: async (
documentName: string,
payload: {
pageId: string;
prosemirrorJson: any;
operation: string;
user: User;
},
) => {
const { pageId, prosemirrorJson, operation, user } = payload;
this.logger.debug(
'Updating page content via yjs',
documentName,
payload,
);
await this.withYdocConnection(
hocuspocus,
documentName,
{ user },
(doc) => {
const fragment = doc.getXmlFragment('default');
if (operation === 'replace') {
if (fragment.length > 0) {
fragment.delete(0, fragment.length);
}
const newDoc = TiptapTransformer.toYdoc(
prosemirrorJson,
'default',
tiptapExtensions,
);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newDoc));
} else {
const newContent = prosemirrorJson.content || [];
const yElements = newContent.map(prosemirrorNodeToYElement);
fragment.insert(fragment.length, yElements);
}
},
);
},
};
}
@@ -7,10 +7,9 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import { IncomingMessage } from 'http';
import { WebSocket } from 'ws';
import { TokenModule } from '../core/auth/token.module';
import { HistoryProcessor } from './processors/history.processor';
import { HistoryListener } from './listeners/history.listener';
import { LoggerExtension } from './extensions/logger.extension';
import { CollaborationHandler } from './collaboration.handler';
import { CollabHistoryService } from './services/collab-history.service';
@Module({
providers: [
@@ -18,8 +17,7 @@ import { CollabHistoryService } from './services/collab-history.service';
AuthenticationExtension,
PersistenceExtension,
LoggerExtension,
HistoryProcessor,
CollabHistoryService,
HistoryListener,
CollaborationHandler,
],
exports: [CollaborationGateway],
@@ -34,7 +34,6 @@ import {
Highlight,
UniqueID,
addUniqueIdsToDoc,
htmlToMarkdown,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
@@ -43,7 +42,6 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// see:https://github.com/ueberdosis/tiptap/issues/4089
//import { generateJSON } from '@tiptap/html';
import { Node, Schema } from '@tiptap/pm/model';
import * as Y from 'yjs';
import { Logger } from '@nestjs/common';
export const tiptapExtensions = [
@@ -163,37 +161,3 @@ function stripUnknownNodes(
return json;
}
export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText {
if (node.type === 'text') {
const ytext = new Y.XmlText();
ytext.insert(0, node.text || '');
if (node.marks?.length > 0) {
const attrs: Record<string, any> = {};
for (const mark of node.marks) {
attrs[mark.type] = mark.attrs || true;
}
ytext.format(0, node.text?.length || 0, attrs);
}
return ytext;
}
const element = new Y.XmlElement(node.type);
if (node.attrs) {
for (const [key, value] of Object.entries(node.attrs)) {
if (value !== null && value !== undefined) {
element.setAttribute(key, value as any);
}
}
}
if (node.content?.length > 0) {
const children = node.content.map(prosemirrorNodeToYElement);
element.insert(0, children);
}
return element;
}
export function jsonToMarkdown(tiptapJson: any): string {
const html = jsonToHtml(tiptapJson);
return htmlToMarkdown(html);
}
@@ -1,3 +0,0 @@
export const HISTORY_INTERVAL = 5 * 60 * 1000;
export const HISTORY_FAST_INTERVAL = 60 * 1000;
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
@@ -13,6 +13,7 @@ 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';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq';
@@ -21,17 +22,8 @@ import {
extractPageMentions,
} from '../../common/helpers/prosemirror/utils';
import { isDeepStrictEqual } from 'node:util';
import {
IPageBacklinkJob,
IPageHistoryJob,
} from '../../integrations/queue/constants/queue.interface';
import { IPageBacklinkJob } from '../../integrations/queue/constants/queue.interface';
import { Page } from '@docmost/db/types/entity.types';
import { CollabHistoryService } from '../services/collab-history.service';
import {
HISTORY_FAST_INTERVAL,
HISTORY_FAST_THRESHOLD,
HISTORY_INTERVAL,
} from '../constants';
@Injectable()
export class PersistenceExtension implements Extension {
@@ -41,10 +33,9 @@ export class PersistenceExtension implements Extension {
constructor(
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
private eventEmitter: EventEmitter2,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
private readonly collabHistory: CollabHistoryService,
) {}
async onLoadDocument(data: onLoadDocumentPayload) {
@@ -110,7 +101,6 @@ export class PersistenceExtension implements Extension {
}
let page: Page = null;
const editingUserIds = this.consumeContributors(documentName);
try {
await executeTx(this.db, async (trx) => {
@@ -133,9 +123,13 @@ export class PersistenceExtension implements Extension {
let contributorIds = undefined;
try {
const existingContributors = page.contributorIds || [];
const contributorSet = this.contributors.get(documentName);
contributorSet.add(page.creatorId);
const newContributors = [...contributorSet];
contributorIds = Array.from(
new Set([...existingContributors, ...editingUserIds, page.creatorId]),
new Set([...existingContributors, ...newContributors]),
);
this.contributors.delete(documentName);
} catch (err) {
//this.logger.debug('Contributors error:' + err?.['message']);
}
@@ -159,7 +153,13 @@ export class PersistenceExtension implements Extension {
}
if (page) {
await this.collabHistory.addContributors(pageId, editingUserIds);
this.eventEmitter.emit('collab.page.updated', {
page: {
...page,
content: tiptapJson,
lastUpdatedById: context.user.id,
},
});
const mentions = extractMentions(tiptapJson);
const pageMentions = extractPageMentions(mentions);
@@ -174,14 +174,12 @@ export class PersistenceExtension implements Extension {
pageIds: [pageId],
workspaceId: page.workspaceId,
});
await this.enqueuePageHistory(page);
}
}
async onChange(data: onChangePayload) {
const documentName = data.documentName;
const userId = data.context?.user?.id;
const userId = data.context?.user.id;
if (!userId) return;
if (!this.contributors.has(documentName)) {
@@ -195,26 +193,4 @@ export class PersistenceExtension implements Extension {
const documentName = data.documentName;
this.contributors.delete(documentName);
}
private consumeContributors(documentName: string): string[] {
const contributorSet = this.contributors.get(documentName);
if (!contributorSet) return [];
const userIds = [...contributorSet];
this.contributors.delete(documentName);
return userIds;
}
private async enqueuePageHistory(page: Page): Promise<void> {
const pageAge = Date.now() - new Date(page.createdAt).getTime();
const delay =
pageAge < HISTORY_FAST_THRESHOLD
? HISTORY_FAST_INTERVAL
: HISTORY_INTERVAL;
await this.historyQueue.add(
QueueJob.PAGE_HISTORY,
{ pageId: page.id } as IPageHistoryJob,
{ jobId: page.id, delay },
);
}
}
@@ -0,0 +1,52 @@
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';
import { EnvironmentService } from '../../integrations/environment/environment.service';
export class UpdatedPageEvent {
page: Page;
}
@Injectable()
export class HistoryListener {
private readonly logger = new Logger(HistoryListener.name);
constructor(
private readonly pageHistoryRepo: PageHistoryRepo,
private readonly environmentService: EnvironmentService,
) {}
@OnEvent('collab.page.updated')
async handleCreatePageHistory(event: UpdatedPageEvent) {
const { page } = event;
const pageCreationTime = new Date(page.createdAt).getTime();
const currentTime = Date.now();
const FIVE_MINUTES = this.environmentService.isDevelopment()
? 60 * 1000
: 5 * 60 * 1000;
if (currentTime - pageCreationTime < FIVE_MINUTES) {
return;
}
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id, {
includeContent: true,
});
if (
!lastHistory ||
(!isDeepStrictEqual(lastHistory.content, page.content) &&
currentTime - new Date(lastHistory.createdAt).getTime() >= FIVE_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: ${page.id}`, err);
}
}
}
}
@@ -1,84 +0,0 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { IPageHistoryJob } from '../../integrations/queue/constants/queue.interface';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { isDeepStrictEqual } from 'node:util';
import { CollabHistoryService } from '../services/collab-history.service';
@Processor(QueueName.HISTORY_QUEUE)
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(HistoryProcessor.name);
constructor(
private readonly pageHistoryRepo: PageHistoryRepo,
private readonly pageRepo: PageRepo,
private readonly collabHistory: CollabHistoryService,
) {
super();
}
async process(job: Job<IPageHistoryJob, void>): Promise<void> {
if (job.name !== QueueJob.PAGE_HISTORY) return;
try {
const { pageId } = job.data;
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
});
if (!page) {
this.logger.warn(`Page ${pageId} not found, skipping history`);
await this.collabHistory.clearContributors(pageId);
return;
}
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true },
);
if (
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content)
) {
const contributorIds =
await this.collabHistory.popContributors(pageId);
try {
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
this.logger.debug(`History created for page: ${pageId}`);
} catch (err) {
await this.collabHistory.addContributors(
pageId,
contributorIds,
);
throw err;
}
}
} catch (err) {
throw err;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.debug(`Processing ${job.name} for page: ${job.data.pageId}`);
}
@OnWorkerEvent('failed')
onError(job: Job) {
this.logger.error(
`Failed ${job.name} for page: ${job.data.pageId}. Reason: ${job.failedReason}`,
);
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
}
}
@@ -9,8 +9,6 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { HealthModule } from '../../integrations/health/health.module';
import { CollaborationController } from './collaboration.controller';
import { LoggerModule } from '../../common/logger/logger.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from '../../integrations/redis/redis-config.service';
@Module({
imports: [
@@ -21,9 +19,6 @@ import { RedisConfigService } from '../../integrations/redis/redis-config.servic
QueueModule,
HealthModule,
EventEmitterModule.forRoot(),
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
],
controllers: [
AppController,
@@ -1,30 +0,0 @@
import { Injectable } from '@nestjs/common';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
const REDIS_KEY_PREFIX = 'history:contributors:';
@Injectable()
export class CollabHistoryService {
private readonly redis: Redis;
constructor(private readonly redisService: RedisService) {
this.redis = this.redisService.getOrThrow();
}
async addContributors(pageId: string, userIds: string[]): Promise<void> {
if (userIds.length === 0) return;
await this.redis.sadd(REDIS_KEY_PREFIX + pageId, ...userIds);
}
async popContributors(pageId: string): Promise<string[]> {
const key = REDIS_KEY_PREFIX + pageId;
const count = await this.redis.scard(key);
if (count === 0) return [];
return await this.redis.spop(key, count);
}
async clearContributors(pageId: string): Promise<void> {
await this.redis.del(REDIS_KEY_PREFIX + pageId);
}
}
@@ -1,12 +1,4 @@
import {
IsIn,
IsOptional,
IsString,
IsUUID,
ValidateIf,
} from 'class-validator';
export type InputFormat = 'json' | 'markdown' | 'html';
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class CreatePageDto {
@IsOptional()
@@ -23,11 +15,4 @@ export class CreatePageDto {
@IsUUID()
spaceId: string;
@IsOptional()
content?: string | object;
@ValidateIf((o) => o.content !== undefined)
@IsIn(['json', 'markdown', 'html'])
input?: InputFormat;
}
+2 -9
View File
@@ -1,6 +1,5 @@
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
@@ -23,20 +22,14 @@ export class PageHistoryIdDto {
historyId: string;
}
export type OutputFormat = 'json' | 'markdown' | 'html';
export class PageInfoDto extends PageIdDto {
@IsOptional()
@IsBoolean()
includeSpace?: boolean;
includeSpace: boolean;
@IsOptional()
@IsBoolean()
includeContent?: boolean;
@IsOptional()
@IsIn(['json', 'markdown', 'html'])
output?: OutputFormat;
includeContent: boolean;
}
export class DeletePageDto extends PageIdDto {
@@ -1,21 +1,8 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreatePageDto, InputFormat } from './create-page.dto';
import { IsIn, IsOptional, IsString, ValidateIf } from 'class-validator';
export type ContentOperation = 'append' | 'replace';
import { CreatePageDto } from './create-page.dto';
import { IsString } from 'class-validator';
export class UpdatePageDto extends PartialType(CreatePageDto) {
@IsString()
pageId: string;
@IsOptional()
content?: string | object;
@ValidateIf((o) => o.content !== undefined)
@IsIn(['append', 'replace'])
operation?: ContentOperation;
@ValidateIf((o) => o.content !== undefined)
@IsIn(['json', 'markdown', 'html'])
input?: InputFormat;
}
+1 -16
View File
@@ -35,10 +35,6 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { RecentPageDto } from './dto/recent-page.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto';
import { DeletedPageDto } from './dto/deleted-page.dto';
import {
jsonToHtml,
jsonToMarkdown,
} from '../../collaboration/collaboration.util';
@UseGuards(JwtAuthGuard)
@Controller('pages')
@@ -70,17 +66,6 @@ export class PageController {
throw new ForbiddenException();
}
if (dto.output && dto.output !== 'json' && page.content) {
const contentOutput =
dto.output === 'markdown'
? jsonToMarkdown(page.content)
: jsonToHtml(page.content);
return {
...page,
content: contentOutput,
};
}
return page;
}
@@ -116,7 +101,7 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.update(page, updatePageDto, user);
return this.pageService.update(page, updatePageDto, user.id);
}
@HttpCode(HttpStatus.OK)
+1 -2
View File
@@ -4,12 +4,11 @@ import { PageController } from './page.controller';
import { PageHistoryService } from './services/page-history.service';
import { TrashCleanupService } from './services/trash-cleanup.service';
import { StorageModule } from '../../integrations/storage/storage.module';
import { CollaborationModule } from '../../collaboration/collaboration.module';
@Module({
controllers: [PageController],
providers: [PageService, PageHistoryService, TrashCleanupService],
exports: [PageService, PageHistoryService],
imports: [StorageModule, CollaborationModule],
imports: [StorageModule],
})
export class PageModule {}
@@ -4,8 +4,8 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { CreatePageDto, InputFormat } from '../dto/create-page.dto';
import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
import { CreatePageDto } from '../dto/create-page.dto';
import { UpdatePageDto } from '../dto/update-page.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
@@ -28,11 +28,7 @@ import {
isAttachmentNode,
removeMarkTypeFromDoc,
} from '../../../common/helpers/prosemirror/utils';
import {
htmlToJson,
jsonToNode,
jsonToText,
} from 'src/collaboration/collaboration.util';
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
import {
CopyPageMapEntry,
ICopyPageAttachment,
@@ -44,8 +40,6 @@ import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { EventName } from '../../../common/events/event.contants';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
import { markdownToHtml } from '@docmost/editor-ext';
@Injectable()
export class PageService {
@@ -59,7 +53,6 @@ export class PageService {
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
private eventEmitter: EventEmitter2,
private collaborationGateway: CollaborationGateway,
) {}
async findById(
@@ -95,42 +88,7 @@ export class PageService {
parentPageId = parentPage.id;
}
let content = undefined;
let textContent = undefined;
let ydoc = undefined;
if (createPageDto?.content && createPageDto?.input) {
let prosemirrorJson: any;
switch (createPageDto.input) {
case 'markdown': {
const html = await markdownToHtml(createPageDto.content as string);
prosemirrorJson = htmlToJson(html as string);
break;
}
case 'html': {
prosemirrorJson = htmlToJson(createPageDto.content as string);
break;
}
case 'json':
default: {
prosemirrorJson = createPageDto.content;
break;
}
}
try {
jsonToNode(prosemirrorJson);
} catch (err) {
throw new BadRequestException('Invalid content format');
}
content = prosemirrorJson;
textContent = jsonToText(prosemirrorJson);
ydoc = createYdocFromJson(prosemirrorJson);
}
return this.pageRepo.insertPage({
const createdPage = await this.pageRepo.insertPage({
slugId: generateSlugId(),
title: createPageDto.title,
position: await this.nextPagePosition(
@@ -143,10 +101,9 @@ export class PageService {
creatorId: userId,
workspaceId: workspaceId,
lastUpdatedById: userId,
content,
textContent,
ydoc,
});
return createdPage;
}
async nextPagePosition(spaceId: string, parentPageId?: string) {
@@ -193,37 +150,23 @@ export class PageService {
async update(
page: Page,
updatePageDto: UpdatePageDto,
user: User,
userId: string,
): Promise<Page> {
const contributors = new Set<string>(page.contributorIds);
contributors.add(user.id);
contributors.add(userId);
const contributorIds = Array.from(contributors);
await this.pageRepo.updatePage(
{
title: updatePageDto.title,
icon: updatePageDto.icon,
lastUpdatedById: user.id,
lastUpdatedById: userId,
updatedAt: new Date(),
contributorIds: contributorIds,
},
page.id,
);
if (
updatePageDto.content &&
updatePageDto.operation &&
updatePageDto.input
) {
await this.updatePageContent(
page.id,
updatePageDto.content,
updatePageDto.operation,
updatePageDto.input,
user,
);
}
return await this.pageRepo.findById(page.id, {
includeSpace: true,
includeContent: true,
@@ -233,46 +176,6 @@ export class PageService {
});
}
async updatePageContent(
pageId: string,
content: string | object,
operation: ContentOperation,
input: InputFormat,
user: User,
): Promise<void> {
let prosemirrorJson: any;
switch (input) {
case 'markdown': {
const html = await markdownToHtml(content as string);
prosemirrorJson = htmlToJson(html as string);
break;
}
case 'html': {
prosemirrorJson = htmlToJson(content as string);
break;
}
case 'json':
default: {
prosemirrorJson = content;
break;
}
}
try {
jsonToNode(prosemirrorJson);
} catch (err) {
throw new BadRequestException('Invalid content format');
}
const documentName = `page.${pageId}`;
await this.collaborationGateway.handleYjsEvent(
'updatePageContent',
documentName,
{ pageId, operation, prosemirrorJson, user },
);
}
async getSidebarPages(
spaceId: string,
pagination: PaginationOptions,
@@ -306,11 +209,7 @@ export class PageService {
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{
expression: 'position',
direction: 'asc',
orderModifier: (ob) => ob.collate('C').asc(),
},
{ expression: 'position', direction: 'asc', orderModifier: (ob) => ob.collate('C').asc() },
{ expression: 'id', direction: 'asc' },
],
parseCursor: (cursor) => ({
@@ -1,15 +0,0 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('page_history')
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo('{}'))
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('page_history')
.dropColumn('contributor_ids')
.execute();
}
@@ -9,8 +9,8 @@ import {
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder, sql } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
@Injectable()
@@ -25,7 +25,6 @@ export class PageHistoryRepo {
'icon',
'coverPhoto',
'lastUpdatedById',
'contributorIds',
'spaceId',
'workspaceId',
'createdAt',
@@ -45,7 +44,6 @@ export class PageHistoryRepo {
.select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
.select((eb) => this.withLastUpdatedBy(eb))
.select((eb) => this.withContributors(eb))
.where('id', '=', pageHistoryId)
.executeTakeFirst();
}
@@ -62,10 +60,7 @@ export class PageHistoryRepo {
.executeTakeFirst();
}
async saveHistory(
page: Page,
opts?: { contributorIds?: string[]; trx?: KyselyTransaction },
): Promise<void> {
async saveHistory(page: Page, trx?: KyselyTransaction): Promise<void> {
await this.insertPageHistory(
{
pageId: page.id,
@@ -75,11 +70,10 @@ export class PageHistoryRepo {
icon: page.icon,
coverPhoto: page.coverPhoto,
lastUpdatedById: page.lastUpdatedById ?? page.creatorId,
contributorIds: opts?.contributorIds,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
},
opts?.trx,
trx,
);
}
@@ -88,7 +82,6 @@ export class PageHistoryRepo {
.selectFrom('pageHistory')
.select(this.baseFields)
.select((eb) => this.withLastUpdatedBy(eb))
.select((eb) => this.withContributors(eb))
.where('pageId', '=', pageId);
return executeWithCursorPagination(query, {
@@ -127,17 +120,4 @@ export class PageHistoryRepo {
.whereRef('users.id', '=', 'pageHistory.lastUpdatedById'),
).as('lastUpdatedBy');
}
withContributors(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonArrayFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef(
'users.id',
'=',
sql`ANY(${eb.ref('pageHistory.contributorIds')})`,
),
).as('contributors');
}
}
-1
View File
@@ -199,7 +199,6 @@ export interface GroupUsers {
export interface PageHistory {
content: Json | null;
contributorIds: Generated<string[] | null>;
coverPhoto: string | null;
createdAt: Generated<Timestamp>;
icon: string | null;
@@ -6,7 +6,6 @@ export enum QueueName {
FILE_TASK_QUEUE = '{file-task-queue}',
SEARCH_QUEUE = '{search-queue}',
AI_QUEUE = '{ai-queue}',
HISTORY_QUEUE = '{history-queue}',
}
export enum QueueJob {
@@ -59,6 +58,4 @@ export enum QueueJob {
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
PAGE_HISTORY = 'page-history',
}
@@ -9,8 +9,4 @@ export interface IPageBacklinkJob {
export interface IStripeSeatsSyncJob {
workspaceId: string;
}
export interface IPageHistoryJob {
pageId: string;
}
@@ -73,14 +73,6 @@ import { BacklinksProcessor } from './processors/backlinks.processor';
attempts: 1,
},
}),
BullModule.registerQueue({
name: QueueName.HISTORY_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 2,
},
}),
],
exports: [BullModule],
providers: [BacklinksProcessor],
+5 -22
View File
@@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.25.3",
"version": "0.25.2",
"private": true,
"scripts": {
"build": "nx run-many -t build",
@@ -56,13 +56,12 @@
"@tiptap/react": "3.17.1",
"@tiptap/starter-kit": "3.17.1",
"@tiptap/suggestion": "3.17.1",
"@tiptap/y-tiptap": "^3.0.2",
"@types/qrcode": "^1.5.5",
"bytes": "^3.1.2",
"cross-env": "^7.0.3",
"date-fns": "^4.1.0",
"diff": "8.0.3",
"dompurify": "^3.3.1",
"dompurify": "^3.2.6",
"fractional-indexing-jittered": "^1.0.0",
"highlight.js": "^11.11.1",
"image-dimensions": "^2.5.0",
@@ -79,12 +78,12 @@
"yjs": "^13.6.29"
},
"devDependencies": {
"@nx/js": "22.5.0",
"@nx/js": "20.4.5",
"@types/bytes": "^3.1.5",
"@types/turndown": "^5.0.6",
"@types/uuid": "^10.0.0",
"concurrently": "^9.1.2",
"nx": "22.5.0",
"nx": "20.4.5",
"tsx": "^4.19.3"
},
"workspaces": {
@@ -102,23 +101,7 @@
"jsdom": "25.0.1",
"jsonwebtoken": "9.0.3",
"prosemirror-changeset": "2.3.1",
"y-prosemirror": "1.3.7",
"glob": "10.5.0",
"lodash": "4.17.23",
"ws": "8.19.0",
"cross-spawn": "7.0.5",
"dompurify": "3.3.1",
"tmp": "0.2.5",
"lodash-es": "4.17.23",
"@tiptap/core": "3.17.1",
"@tiptap/pm": "3.17.1",
"@tiptap/starter-kit": "3.17.1",
"@tiptap/extension-blockquote": "3.17.1",
"@tiptap/extension-bold": "3.17.0",
"@tiptap/extension-bubble-menu": "3.17.1",
"@tiptap/extension-bullet-list": "3.17.1",
"@tiptap/extension-list": "3.17.1",
"@tiptap/extension-code": "3.17.1"
"y-prosemirror": "1.3.7"
},
"neverBuiltDependencies": []
}
@@ -10,7 +10,6 @@ const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
const successIcon = `<svg xmlns="http://www.w3.org/2000/svg" style="color: forestgreen;" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="m10.6 16.6l7.05-7.05l-1.4-1.4l-5.65 5.65l-2.85-2.85l-1.4 1.4zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>`;
export const Heading = TiptapHeading.extend<TiptapHeadingOptions>({
// @ts-ignore
addProseMirrorPlugins() {
return [
new Plugin({
+4051 -4352
View File
File diff suppressed because it is too large Load Diff