Compare commits

..

4 Commits

Author SHA1 Message Date
Philipinho 3cd0fa1985 fix interval 2026-02-09 13:26:15 -08:00
Philipinho b0e9217a40 display contributor avatars in history list 2026-02-09 13:23:37 -08:00
Philipinho 7e0aa2adac feat: save multiple version contributors 2026-02-09 13:05:28 -08:00
Philipinho 5f0afd6f9f Refactor: use queue for page history 2026-02-09 09:04:18 -08:00
33 changed files with 4115 additions and 4808 deletions
+12 -12
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.25.3", "version": "0.25.2",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -14,18 +14,18 @@
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-3a5ef40", "@excalidraw/excalidraw": "0.18.0-c158187",
"@mantine/core": "^8.3.14", "@mantine/core": "^8.3.12",
"@mantine/dates": "^8.3.14", "@mantine/dates": "^8.3.12",
"@mantine/form": "^8.3.14", "@mantine/form": "^8.3.12",
"@mantine/hooks": "^8.3.14", "@mantine/hooks": "^8.3.12",
"@mantine/modals": "^8.3.14", "@mantine/modals": "^8.3.12",
"@mantine/notifications": "^8.3.14", "@mantine/notifications": "^8.3.12",
"@mantine/spotlight": "^8.3.14", "@mantine/spotlight": "^8.3.12",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.17", "@tanstack/react-query": "^5.90.17",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "^1.13.5", "axios": "^1.13.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
@@ -41,7 +41,7 @@
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.12.2", "mermaid": "^11.12.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"posthog-js": "1.345.5", "posthog-js": "^1.255.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "3.4.0", "react-arborist": "3.4.0",
"react-clear-modal": "^2.0.17", "react-clear-modal": "^2.0.17",
@@ -66,7 +66,7 @@
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.2", "eslint": "^9.15.0",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-react-refresh": "^0.4.16",
@@ -1,33 +0,0 @@
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT
// modified to use the polyfilled clipboard api
import React from "react";
import { useClipboard } from "@/hooks/use-clipboard";
import { useProps } from "@mantine/core";
interface CopyButtonProps {
/** Children callback, provides current status and copy function as an argument */
children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode;
/** Value that is copied to the clipboard when the button is clicked */
value: string;
/** Copied status timeout in ms @default `1000` */
timeout?: number;
}
const defaultProps = {
timeout: 1000,
} satisfies Partial<CopyButtonProps>;
export function CopyButton(props: CopyButtonProps) {
const { children, timeout, value, ...others } = useProps(
"CopyButton",
defaultProps,
props,
);
const clipboard = useClipboard({ timeout });
const copy = () => clipboard.copy(value);
return <>{children({ copy, copied: clipboard.copied, ...others })}</>;
}
CopyButton.displayName = "@mantine/core/CopyButton";
+1 -2
View File
@@ -1,5 +1,4 @@
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { IconCheck, IconCopy } from "@tabler/icons-react"; import { IconCheck, IconCopy } from "@tabler/icons-react";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -8,10 +8,10 @@ import {
Group, Group,
List, List,
Code, Code,
CopyButton,
Alert, Alert,
PasswordInput, PasswordInput,
} from "@mantine/core"; } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { import {
IconRefresh, IconRefresh,
IconCopy, IconCopy,
@@ -11,6 +11,7 @@ import {
PinInput, PinInput,
Alert, Alert,
List, List,
CopyButton,
ActionIcon, ActionIcon,
Tooltip, Tooltip,
Paper, Paper,
@@ -19,7 +20,6 @@ import {
Collapse, Collapse,
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { import {
IconQrcode, IconQrcode,
IconShieldCheck, IconShieldCheck,
@@ -84,14 +84,9 @@ const CommentEditor = forwardRef(
autofocus: (autofocus && "end") || false, autofocus: (autofocus && "end") || false,
}); });
// Sync content from props for read-only editors (e.g. when updated via
// websocket on another browser). Skip for editable editors to avoid
// resetting the cursor position on every keystroke.
useEffect(() => { useEffect(() => {
if (!editable && commentEditor && defaultContent) { commentEditor.commands.setContent(defaultContent);
commentEditor.commands.setContent(defaultContent); }, [defaultContent]);
}
}, [defaultContent, editable, commentEditor]);
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
@@ -1,5 +1,5 @@
import { Group, Text, Box, Badge } from "@mantine/core"; import { Group, Text, Box, Badge } from "@mantine/core";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useState } from "react";
import classes from "./comment.module.css"; import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
@@ -40,7 +40,6 @@ function CommentListItem({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const editor = useAtomValue(pageEditorAtom); const editor = useAtomValue(pageEditorAtom);
const [content, setContent] = useState<string>(comment.content); const [content, setContent] = useState<string>(comment.content);
const editContentRef = useRef<any>(null);
const updateCommentMutation = useUpdateCommentMutation(); const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation(); const resolveCommentMutation = useResolveCommentMutation();
@@ -57,13 +56,9 @@ function CommentListItem({
setIsLoading(true); setIsLoading(true);
const commentToUpdate = { const commentToUpdate = {
commentId: comment.id, commentId: comment.id,
content: JSON.stringify(editContentRef.current ?? content), content: JSON.stringify(content),
}; };
await updateCommentMutation.mutateAsync(commentToUpdate); await updateCommentMutation.mutateAsync(commentToUpdate);
if (editContentRef.current) {
setContent(editContentRef.current);
editContentRef.current = null;
}
setIsEditing(false); setIsEditing(false);
emit({ emit({
@@ -133,7 +128,6 @@ function CommentListItem({
setIsEditing(true); setIsEditing(true);
} }
function cancelEdit() { function cancelEdit() {
editContentRef.current = null;
setIsEditing(false); setIsEditing(false);
} }
@@ -200,7 +194,7 @@ function CommentListItem({
<CommentEditor <CommentEditor
defaultContent={content} defaultContent={content}
editable={true} editable={true}
onUpdate={(newContent: any) => { editContentRef.current = newContent; }} onUpdate={(newContent: any) => setContent(newContent)}
onSave={handleUpdateComment} onSave={handleUpdateComment}
autofocus={true} autofocus={true}
/> />
@@ -1,6 +1,5 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Group, Select, Tooltip } from "@mantine/core"; import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react"; import { IconCheck, IconCopy } from "@tabler/icons-react";
import classes from "./code-block.module.css"; import classes from "./code-block.module.css";
@@ -160,7 +160,7 @@ export function TitleEditor({
// guard against Cannot access view['hasFocus'] error // guard against Cannot access view['hasFocus'] error
if (!titleEditor?.isInitialized) return; if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end"); titleEditor?.commands?.focus("end");
}, 300); }, 500);
}, [titleEditor]); }, [titleEditor]);
useEffect(() => { useEffect(() => {
@@ -17,8 +17,7 @@ import React, { useEffect, useRef, useState } from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { useDisclosure, useHotkeys } from "@mantine/hooks"; import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -54,11 +54,11 @@ import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { OpenMap } from "react-arborist/dist/main/state/open-slice"; import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import { import {
useClipboard,
useDisclosure, useDisclosure,
useElementSize, useElementSize,
useMergedRef, useMergedRef,
} from "@mantine/hooks"; } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { dfs } from "react-arborist/dist/module/utils"; import { dfs } from "react-arborist/dist/module/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -13,7 +13,7 @@ import {
buildPageUrl, buildPageUrl,
buildSharedPageUrl, buildSharedPageUrl,
} from "@/features/page/page.utils.ts"; } from "@/features/page/page.utils.ts";
import { useClipboard } from "@/hooks/use-clipboard"; import { useClipboard } from "@mantine/hooks";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts"; import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts";
@@ -8,7 +8,7 @@ import {
} from "@/features/workspace/queries/workspace-query.ts"; } from "@/features/workspace/queries/workspace-query.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useClipboard } from "@/hooks/use-clipboard"; import { useClipboard } from "@mantine/hooks";
import { getInviteLink } from "@/features/workspace/services/workspace-service.ts"; import { getInviteLink } from "@/features/workspace/services/workspace-service.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
@@ -1,8 +1,7 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Group, Text, TextInput } from "@mantine/core"; import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function WorkspaceInviteSection() { export default function WorkspaceInviteSection() {
-60
View File
@@ -1,60 +0,0 @@
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
// polyfilled to support execCommand fallback
import { useState } from "react";
import { execCommandCopy } from "@docmost/editor-ext";
export type UseClipboardOptions = {
timeout?: number;
};
export type UseClipboardReturnValue = {
copy: (value: string) => void;
reset: () => void;
error: Error | null;
copied: boolean;
};
export function useClipboard(
options: UseClipboardOptions = { timeout: 2000 },
): UseClipboardReturnValue {
const [error, setError] = useState<Error | null>(null);
const [copied, setCopied] = useState(false);
const [copyTimeout, setCopyTimeout] = useState<number | null>(null);
const handleCopyResult = (value: boolean) => {
window.clearTimeout(copyTimeout!);
setCopyTimeout(window.setTimeout(() => setCopied(false), options.timeout));
setCopied(value);
};
const copy = (value: string) => {
if ("clipboard" in navigator) {
navigator.clipboard
.writeText(value)
.then(() => handleCopyResult(true))
.catch(() => {
try {
execCommandCopy(value);
handleCopyResult(true);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to copy"));
}
});
} else {
try {
execCommandCopy(value);
handleCopyResult(true);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to copy"));
}
}
};
const reset = () => {
setCopied(false);
setError(null);
window.clearTimeout(copyTimeout!);
};
return { copy, reset, error, copied };
}
+12 -12
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.25.3", "version": "0.25.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -56,8 +56,8 @@
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.13", "@nestjs/websockets": "^11.1.13",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "1.0.7", "@react-email/components": "0.0.28",
"@react-email/render": "2.0.4", "@react-email/render": "1.0.2",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"ai": "^6.0.37", "ai": "^6.0.37",
"ai-sdk-ollama": "^3.1.1", "ai-sdk-ollama": "^3.1.1",
@@ -111,32 +111,32 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.20.0", "@eslint/js": "^9.20.0",
"@nestjs/cli": "^11.0.16", "@nestjs/cli": "^11.0.4",
"@nestjs/schematics": "^11.0.1", "@nestjs/schematics": "^11.0.1",
"@nestjs/testing": "^11.0.10", "@nestjs/testing": "^11.0.10",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/debounce": "^1.2.4", "@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/jest": "^30.0.0", "@types/jest": "^29.5.14",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/node": "^22.13.4", "@types/node": "^22.13.4",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"@types/passport-google-oauth20": "^2.0.16", "@types/passport-google-oauth20": "^2.0.16",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.3", "@types/supertest": "^6.0.2",
"@types/ws": "^8.5.14", "@types/ws": "^8.5.14",
"@types/yauzl": "^2.10.3", "@types/yauzl": "^2.10.3",
"eslint": "^9.39.2", "eslint": "^9.20.1",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"globals": "^15.15.0", "globals": "^15.15.0",
"jest": "^30.2.0", "jest": "^29.7.0",
"kysely-codegen": "^0.19.0", "kysely-codegen": "^0.19.0",
"prettier": "^3.5.1", "prettier": "^3.5.1",
"react-email": "5.2.8", "react-email": "3.0.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.2.2", "supertest": "^7.0.0",
"ts-jest": "^29.4.6", "ts-jest": "^29.2.5",
"ts-loader": "^9.5.4", "ts-loader": "^9.5.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
@@ -1,12 +1,5 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Hocuspocus, Document } from '@hocuspocus/server'; 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< export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers'] CollaborationHandler['getHandlers']
@@ -27,47 +20,6 @@ export class CollaborationHandler {
// const fragment = doc.getXmlFragment('default'); // 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);
}
},
);
},
}; };
} }
@@ -34,7 +34,6 @@ import {
Highlight, Highlight,
UniqueID, UniqueID,
addUniqueIdsToDoc, addUniqueIdsToDoc,
htmlToMarkdown,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; 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 // see:https://github.com/ueberdosis/tiptap/issues/4089
//import { generateJSON } from '@tiptap/html'; //import { generateJSON } from '@tiptap/html';
import { Node, Schema } from '@tiptap/pm/model'; import { Node, Schema } from '@tiptap/pm/model';
import * as Y from 'yjs';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
export const tiptapExtensions = [ export const tiptapExtensions = [
@@ -163,37 +161,3 @@ function stripUnknownNodes(
return json; 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);
}
@@ -181,7 +181,7 @@ export class PersistenceExtension implements Extension {
async onChange(data: onChangePayload) { async onChange(data: onChangePayload) {
const documentName = data.documentName; const documentName = data.documentName;
const userId = data.context?.user?.id; const userId = data.context?.user.id;
if (!userId) return; if (!userId) return;
if (!this.contributors.has(documentName)) { if (!this.contributors.has(documentName)) {
@@ -9,8 +9,6 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { HealthModule } from '../../integrations/health/health.module'; import { HealthModule } from '../../integrations/health/health.module';
import { CollaborationController } from './collaboration.controller'; import { CollaborationController } from './collaboration.controller';
import { LoggerModule } from '../../common/logger/logger.module'; import { LoggerModule } from '../../common/logger/logger.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from '../../integrations/redis/redis-config.service';
@Module({ @Module({
imports: [ imports: [
@@ -21,9 +19,6 @@ import { RedisConfigService } from '../../integrations/redis/redis-config.servic
QueueModule, QueueModule,
HealthModule, HealthModule,
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
], ],
controllers: [ controllers: [
AppController, AppController,
@@ -464,8 +464,7 @@ export class AttachmentController {
'Cache-Control': `${cacheScope}, max-age=3600`, 'Cache-Control': `${cacheScope}, max-age=3600`,
}); });
const isSvg = attachment.fileExt === '.svg'; if (fileSize) {
if (fileSize && !isSvg) {
res.header('Content-Length', fileSize); res.header('Content-Length', fileSize);
} }
@@ -99,7 +99,6 @@ export class AttachmentService {
if (isUpdate) { if (isUpdate) {
attachment = await this.attachmentRepo.updateAttachment( attachment = await this.attachmentRepo.updateAttachment(
{ {
fileSize: preparedFile.fileSize,
updatedAt: new Date(), updatedAt: new Date(),
}, },
attachmentId, attachmentId,
@@ -1,12 +1,4 @@
import { import { IsOptional, IsString, IsUUID } from 'class-validator';
IsIn,
IsOptional,
IsString,
IsUUID,
ValidateIf,
} from 'class-validator';
export type InputFormat = 'json' | 'markdown' | 'html';
export class CreatePageDto { export class CreatePageDto {
@IsOptional() @IsOptional()
@@ -23,11 +15,4 @@ export class CreatePageDto {
@IsUUID() @IsUUID()
spaceId: string; 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 { import {
IsBoolean, IsBoolean,
IsIn,
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
@@ -23,20 +22,14 @@ export class PageHistoryIdDto {
historyId: string; historyId: string;
} }
export type OutputFormat = 'json' | 'markdown' | 'html';
export class PageInfoDto extends PageIdDto { export class PageInfoDto extends PageIdDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
includeSpace?: boolean; includeSpace: boolean;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
includeContent?: boolean; includeContent: boolean;
@IsOptional()
@IsIn(['json', 'markdown', 'html'])
output?: OutputFormat;
} }
export class DeletePageDto extends PageIdDto { export class DeletePageDto extends PageIdDto {
@@ -1,21 +1,8 @@
import { PartialType } from '@nestjs/mapped-types'; import { PartialType } from '@nestjs/mapped-types';
import { CreatePageDto, InputFormat } from './create-page.dto'; import { CreatePageDto } from './create-page.dto';
import { IsIn, IsOptional, IsString, ValidateIf } from 'class-validator'; import { IsString } from 'class-validator';
export type ContentOperation = 'append' | 'replace';
export class UpdatePageDto extends PartialType(CreatePageDto) { export class UpdatePageDto extends PartialType(CreatePageDto) {
@IsString() @IsString()
pageId: string; 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 { RecentPageDto } from './dto/recent-page.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto'; import { DuplicatePageDto } from './dto/duplicate-page.dto';
import { DeletedPageDto } from './dto/deleted-page.dto'; import { DeletedPageDto } from './dto/deleted-page.dto';
import {
jsonToHtml,
jsonToMarkdown,
} from '../../collaboration/collaboration.util';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('pages') @Controller('pages')
@@ -70,17 +66,6 @@ export class PageController {
throw new ForbiddenException(); 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; return page;
} }
@@ -116,7 +101,7 @@ export class PageController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
return this.pageService.update(page, updatePageDto, user); return this.pageService.update(page, updatePageDto, user.id);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
+1 -2
View File
@@ -4,12 +4,11 @@ import { PageController } from './page.controller';
import { PageHistoryService } from './services/page-history.service'; import { PageHistoryService } from './services/page-history.service';
import { TrashCleanupService } from './services/trash-cleanup.service'; import { TrashCleanupService } from './services/trash-cleanup.service';
import { StorageModule } from '../../integrations/storage/storage.module'; import { StorageModule } from '../../integrations/storage/storage.module';
import { CollaborationModule } from '../../collaboration/collaboration.module';
@Module({ @Module({
controllers: [PageController], controllers: [PageController],
providers: [PageService, PageHistoryService, TrashCleanupService], providers: [PageService, PageHistoryService, TrashCleanupService],
exports: [PageService, PageHistoryService], exports: [PageService, PageHistoryService],
imports: [StorageModule, CollaborationModule], imports: [StorageModule],
}) })
export class PageModule {} export class PageModule {}
@@ -4,8 +4,8 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { CreatePageDto, InputFormat } from '../dto/create-page.dto'; import { CreatePageDto } from '../dto/create-page.dto';
import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto'; import { UpdatePageDto } from '../dto/update-page.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types'; import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
@@ -28,11 +28,7 @@ import {
isAttachmentNode, isAttachmentNode,
removeMarkTypeFromDoc, removeMarkTypeFromDoc,
} from '../../../common/helpers/prosemirror/utils'; } from '../../../common/helpers/prosemirror/utils';
import { import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
htmlToJson,
jsonToNode,
jsonToText,
} from 'src/collaboration/collaboration.util';
import { import {
CopyPageMapEntry, CopyPageMapEntry,
ICopyPageAttachment, ICopyPageAttachment,
@@ -44,8 +40,6 @@ import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { EventName } from '../../../common/events/event.contants'; import { EventName } from '../../../common/events/event.contants';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
import { markdownToHtml } from '@docmost/editor-ext';
@Injectable() @Injectable()
export class PageService { export class PageService {
@@ -59,7 +53,6 @@ export class PageService {
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
private eventEmitter: EventEmitter2, private eventEmitter: EventEmitter2,
private collaborationGateway: CollaborationGateway,
) {} ) {}
async findById( async findById(
@@ -95,42 +88,7 @@ export class PageService {
parentPageId = parentPage.id; parentPageId = parentPage.id;
} }
let content = undefined; const createdPage = await this.pageRepo.insertPage({
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({
slugId: generateSlugId(), slugId: generateSlugId(),
title: createPageDto.title, title: createPageDto.title,
position: await this.nextPagePosition( position: await this.nextPagePosition(
@@ -143,10 +101,9 @@ export class PageService {
creatorId: userId, creatorId: userId,
workspaceId: workspaceId, workspaceId: workspaceId,
lastUpdatedById: userId, lastUpdatedById: userId,
content,
textContent,
ydoc,
}); });
return createdPage;
} }
async nextPagePosition(spaceId: string, parentPageId?: string) { async nextPagePosition(spaceId: string, parentPageId?: string) {
@@ -193,37 +150,23 @@ export class PageService {
async update( async update(
page: Page, page: Page,
updatePageDto: UpdatePageDto, updatePageDto: UpdatePageDto,
user: User, userId: string,
): Promise<Page> { ): Promise<Page> {
const contributors = new Set<string>(page.contributorIds); const contributors = new Set<string>(page.contributorIds);
contributors.add(user.id); contributors.add(userId);
const contributorIds = Array.from(contributors); const contributorIds = Array.from(contributors);
await this.pageRepo.updatePage( await this.pageRepo.updatePage(
{ {
title: updatePageDto.title, title: updatePageDto.title,
icon: updatePageDto.icon, icon: updatePageDto.icon,
lastUpdatedById: user.id, lastUpdatedById: userId,
updatedAt: new Date(), updatedAt: new Date(),
contributorIds: contributorIds, contributorIds: contributorIds,
}, },
page.id, 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, { return await this.pageRepo.findById(page.id, {
includeSpace: true, includeSpace: true,
includeContent: 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( async getSidebarPages(
spaceId: string, spaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
@@ -306,11 +209,7 @@ export class PageService {
cursor: pagination.cursor, cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor, beforeCursor: pagination.beforeCursor,
fields: [ 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' }, { expression: 'id', direction: 'asc' },
], ],
parseCursor: (cursor) => ({ parseCursor: (cursor) => ({
+5 -22
View File
@@ -1,7 +1,7 @@
{ {
"name": "docmost", "name": "docmost",
"homepage": "https://docmost.com", "homepage": "https://docmost.com",
"version": "0.25.3", "version": "0.25.2",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nx run-many -t build", "build": "nx run-many -t build",
@@ -56,13 +56,12 @@
"@tiptap/react": "3.17.1", "@tiptap/react": "3.17.1",
"@tiptap/starter-kit": "3.17.1", "@tiptap/starter-kit": "3.17.1",
"@tiptap/suggestion": "3.17.1", "@tiptap/suggestion": "3.17.1",
"@tiptap/y-tiptap": "^3.0.2",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"bytes": "^3.1.2", "bytes": "^3.1.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"diff": "8.0.3", "diff": "8.0.3",
"dompurify": "^3.3.1", "dompurify": "^3.2.6",
"fractional-indexing-jittered": "^1.0.0", "fractional-indexing-jittered": "^1.0.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"image-dimensions": "^2.5.0", "image-dimensions": "^2.5.0",
@@ -79,12 +78,12 @@
"yjs": "^13.6.29" "yjs": "^13.6.29"
}, },
"devDependencies": { "devDependencies": {
"@nx/js": "22.5.0", "@nx/js": "20.4.5",
"@types/bytes": "^3.1.5", "@types/bytes": "^3.1.5",
"@types/turndown": "^5.0.6", "@types/turndown": "^5.0.6",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"nx": "22.5.0", "nx": "20.4.5",
"tsx": "^4.19.3" "tsx": "^4.19.3"
}, },
"workspaces": { "workspaces": {
@@ -102,23 +101,7 @@
"jsdom": "25.0.1", "jsdom": "25.0.1",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
"prosemirror-changeset": "2.3.1", "prosemirror-changeset": "2.3.1",
"y-prosemirror": "1.3.7", "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"
}, },
"neverBuiltDependencies": [] "neverBuiltDependencies": []
} }
@@ -4,13 +4,11 @@ import TiptapHeading, {
import { mergeAttributes } from "@tiptap/react"; import { mergeAttributes } from "@tiptap/react";
import { Decoration, DecorationSet } from "prosemirror-view"; import { Decoration, DecorationSet } from "prosemirror-view";
import { Plugin } from "prosemirror-state"; import { Plugin } from "prosemirror-state";
import { copyToClipboard } from "../utils";
const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`; const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`;
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>`; 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>({ export const Heading = TiptapHeading.extend<TiptapHeadingOptions>({
// @ts-ignore
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [ return [
new Plugin({ new Plugin({
@@ -43,7 +41,7 @@ export const Heading = TiptapHeading.extend<TiptapHeadingOptions>({
const id = node.attrs.id; const id = node.attrs.id;
const baseUrl = window.location.href.split('#')[0]; const baseUrl = window.location.href.split('#')[0];
const url = `${baseUrl}#${id}`; const url = `${baseUrl}#${id}`;
copyToClipboard(url); navigator.clipboard.writeText(url);
linkBtnContent.innerHTML = successIcon; linkBtnContent.innerHTML = successIcon;
setTimeout( setTimeout(
() => (linkBtnContent.innerHTML = copyIcon), () => (linkBtnContent.innerHTML = copyIcon),
-22
View File
@@ -384,25 +384,3 @@ export function sanitizeUrl(url: string | undefined): string {
const alphabet = "abcdefghijklmnopqrstuvwxyz"; const alphabet = "abcdefghijklmnopqrstuvwxyz";
export const generateNodeId = customAlphabet(alphabet, 12); export const generateNodeId = customAlphabet(alphabet, 12);
export function copyToClipboard(text: string): void {
if ("clipboard" in navigator) {
navigator.clipboard.writeText(text).catch(() => {
execCommandCopy(text);
});
} else {
execCommandCopy(text);
}
}
export function execCommandCopy(text: string): void {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
+4051 -4352
View File
File diff suppressed because it is too large Load Diff