Compare commits

..

1 Commits

Author SHA1 Message Date
Philipinho 52b34bc6f4 WIP - audit logs 2026-01-10 12:57:27 +00:00
136 changed files with 4506 additions and 5293 deletions
+2 -3
View File
@@ -1,6 +1,5 @@
node_modules
.git
.gitignore
dist
/data
.env*
.nx
data
+1 -7
View File
@@ -46,10 +46,4 @@ DRAWIO_URL=
DISABLE_TELEMETRY=false
# Enable debug logging in production (default: false)
DEBUG_MODE=false
# Log database queries
DEBUG_DB=false
# Log http requests
LOG_HTTP=false
DEBUG_MODE=false
+3 -3
View File
@@ -1,14 +1,13 @@
FROM node:22-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@10.4.0
FROM base AS builder
WORKDIR /app
COPY . .
RUN npm install -g pnpm@10.4.0
RUN pnpm install --frozen-lockfile
RUN pnpm build
@@ -32,11 +31,12 @@ COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-e
# Copy root package files
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/
COPY --from=builder /app/.npmrc /app/.npmrc
# Copy patches
COPY --from=builder /app/patches /app/patches
RUN npm install -g pnpm@10.4.0
RUN chown -R node:node /app
USER node
+32 -29
View File
@@ -10,49 +10,52 @@
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
},
"dependencies": {
"@casl/ability": "^6.7.2",
"@casl/react": "^4.0.0",
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@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",
"@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^8.1.3",
"@mantine/dates": "^8.3.2",
"@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.1.3",
"@mantine/notifications": "^8.1.3",
"@mantine/spotlight": "^8.1.3",
"@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.80.6",
"@tiptap/extension-character-count": "^2.10.3",
"alfaaz": "^1.1.0",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0",
"i18next": "^23.16.8",
"i18next-http-backend": "^2.7.3",
"jotai": "^2.16.2",
"i18next": "^23.14.0",
"i18next-http-backend": "^2.6.1",
"jotai": "^2.12.5",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"katex": "0.16.27",
"katex": "0.16.22",
"lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.12.2",
"mermaid": "^11.11.0",
"mitt": "^3.0.1",
"posthog-js": "^1.255.1",
"react": "^19.2.3",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.17",
"react-dom": "^19.2.3",
"react-drawio": "^1.0.7",
"react-error-boundary": "^6.1.0",
"react-clear-modal": "^2.0.15",
"react-dom": "^18.3.1",
"react-drawio": "^1.0.1",
"react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5",
"react-i18next": "^15.0.1",
"react-router-dom": "^7.13.0",
"semver": "^7.7.3",
"socket.io-client": "^4.8.3",
"react-router-dom": "^7.0.1",
"semver": "^7.7.2",
"socket.io-client": "^4.8.1",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.76"
},
@@ -63,13 +66,13 @@
"@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7",
"@types/node": "22.19.1",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.15.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.13.0",
"optics-ts": "^2.4.1",
"postcss": "^8.4.49",
@@ -328,8 +328,6 @@
"Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.",
"Upload any file from your device.": "Upload any file from your device.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "Table",
"Insert a table.": "Insert a table.",
"Insert collapsible block.": "Insert collapsible block.",
+144 -144
View File
@@ -13,21 +13,21 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "このユーザをグループから削除してもよろしいですか? ユーザはこのグループがアクセス権を持つリソースにアクセスできなくなります。",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "このユーザをスペースから削除してもよろしいですか? ユーザはこのスペースへのアクセス権をすべて失います。",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "このバージョンを復元してもよろしいですか? バージョン管理されていない変更は失われます。",
"Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになます",
"Can create and edit pages in space.": "スペース内のページを作成編集できます",
"Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになることができます",
"Can create and edit pages in space.": "スペース内のページを作成および編集できます",
"Can edit": "編集可能",
"Can manage workspace": "ワークスペースを管理できます",
"Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません",
"Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません",
"Can view": "閲覧可能",
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません",
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません",
"Cancel": "キャンセル",
"Change email": "メールアドレスの変更",
"Change password": "パスワードの変更",
"Change photo": "画像の変更",
"Choose a role": "ロールを選んでください",
"Choose your preferred color scheme.": "お好みのカラースキームを選択してください",
"Choose your preferred interface language.": "お好みの言語を選択してください",
"Choose your preferred page width.": "お好みのページ幅を選択してください",
"Choose your preferred color scheme.": "お好みのカラースキームを選択してください",
"Choose your preferred interface language.": "お好みのインターフェース言語を選択してください",
"Choose your preferred page width.": "左右の余白を縮小する場合はオンにしてください",
"Confirm": "確認",
"Copy link": "リンクをコピー",
"Create": "新規作成",
@@ -40,24 +40,24 @@
"Date": "日付",
"Delete": "削除",
"Delete group": "グループを削除",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?子ページページ履歴削除されます。この操作は取り消せません。",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?この操作により、子ページおよびページ履歴削除されます。この操作は元に戻せません。",
"Description": "説明",
"Details": "詳細",
"e.g ACME": "例: 山田太郎",
"e.g ACME Inc": "例: 株式会社サンプル",
"e.g Developers": "例: エンジニア",
"e.g Group for developers": "例: 開発チーム",
"e.g Group for developers": "例: エンジニアグループ",
"e.g product": "例: product",
"e.g Product Team": "例: プロダクトチーム",
"e.g Sales": "例: 営業",
"e.g Space for product team": "例: プロダクトチームスペース",
"e.g Space for sales team to collaborate": "例: 営業チーム用スペース",
"e.g Product Team": "例: 製品チーム",
"e.g Sales": "例: 営業",
"e.g Space for product team": "例: 製品チームスペース",
"e.g Space for sales team to collaborate": "例: 営業チーム連携用スペース",
"Edit": "編集",
"Read": "閲覧",
"Read": "読む",
"Edit group": "グループを編集",
"Email": "メールアドレス",
"Enter a strong password": "強力なパスワードを入力してください",
"Enter valid email addresses separated by comma or space max_50": "メールアドレスをカンマまたはスペース区切りで入力(最大50",
"Enter valid email addresses separated by comma or space max_50": "有効なメールアドレスをカンマまたはスペース区切って入力してください(最大 50",
"enter valid emails addresses": "有効なメールアドレスを入力してください",
"Enter your current password": "現在のパスワードを入力してください",
"enter your full name": "氏名を入力してください",
@@ -81,18 +81,18 @@
"Group description": "グループ説明",
"Group name": "グループ名",
"Groups": "グループ",
"Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます",
"Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます",
"Home": "ホーム",
"Import pages": "ページをインポート",
"Import pages & space settings": "ページとスペース設定をインポート",
"Importing pages": "ページをインポートしています",
"invalid invitation link": "無効な招待リンクす",
"invalid invitation link": "招待リンクが間違っています",
"Invitation signup": "招待登録",
"Invite by email": "メールアドレスで招待する",
"Invite members": "メンバーを招待する",
"Invite new members": "新しいメンバーを招待する",
"Invited members who are yet to accept their invitation will appear here.": "招待を承諾していないメンバーここに表示されます",
"Invited members will be granted access to spaces the groups can access": "招待されたメンバーはグループがアクセスできるスペースにアクセスできます",
"Invited members who are yet to accept their invitation will appear here.": "招待をまだ承諾していないメンバーここに表示されます",
"Invited members will be granted access to spaces the groups can access": "招待されたメンバーはグループがアクセスできるスペースにアクセス権が付与されます",
"Join the workspace": "ワークスペースに参加",
"Language": "言語",
"Light": "ライト",
@@ -113,20 +113,20 @@
"New page": "新規ページ",
"New password": "新しいパスワード",
"No group found": "グループが見つかりません",
"No page history saved yet.": "ページ履歴がありません",
"No page history saved yet.": "まだページ履歴が保存されていません",
"No pages yet": "ページがありません",
"No results found...": "結果が見つかりません",
"No user found": "ユーザーが見つかりません",
"No results found...": "結果が見つかりませんでした...",
"No user found": "ユーザがいません",
"Overview": "概要",
"Owner": "所有者",
"page": "ページ",
"Page deleted successfully": "ページを削除しました",
"Page history": "ページ履歴",
"Page import is in progress. Please do not close this tab.": "ページインポート中です。このタブを閉じないでください",
"Page deleted successfully": "ページが正常に削除されました",
"Page history": "ページ履歴",
"Page import is in progress. Please do not close this tab.": "ページインポートが進行中です。このタブを閉じないでください",
"Pages": "ページ",
"pages": "ページ",
"Password": "パスワード",
"Password changed successfully": "パスワードを変更しました",
"Password changed successfully": "パスワードが正常に変更されました",
"Pending": "保留中",
"Please confirm your action": "アクションを確認してください",
"Preferences": "設定",
@@ -143,95 +143,95 @@
"Search for groups": "グループを検索",
"Search for users": "ユーザーを検索",
"Search for users and groups": "ユーザーとグループを検索",
"Search...": "検索",
"Search...": "検索する語句を入力",
"Select language": "言語を選択",
"Select role": "ロールを選択",
"Select role to assign to all invited members": "招待するメンバーに割り当てるロールを選択",
"Select role to assign to all invited members": "招待されたすべてのメンバーに割り当てるロールを選択してください",
"Select theme": "テーマを選択",
"Send invitation": "招待を送る",
"Invitation sent": "招待送信ました",
"Invitation sent": "招待送信されました",
"Settings": "設定",
"Setup workspace": "ワークスペースを設定する",
"Sign In": "サインイン",
"Sign Up": "新規登録",
"Slug": "スラッグ(URL識別子)",
"Sign Up": "アカウント登録",
"Slug": "Slug (URL用文字列)",
"Space": "スペース",
"Space description": "スペース説明",
"Space menu": "スペースメニュー",
"Space name": "スペース名",
"Space settings": "スペース設定",
"Space slug": "スペースのスラッグ(URL識別子)",
"Space slug": "スペースのSlug (URL用文字列)",
"Spaces": "スペース",
"Spaces you belong to": "所属しているスペース",
"No space found": "スペースが見つかりません",
"Search for spaces": "スペースを検索",
"Start typing to search...": "入力して検索",
"Start typing to search...": "検索を開始するには入力してください...",
"Status": "ステータス",
"Successfully imported": "インポートしました",
"Successfully restored": "復元しました",
"Successfully imported": "インポートに成功しました",
"Successfully restored": "正常に復元されました",
"System settings": "システム設定",
"Theme": "テーマ",
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力してください",
"Toggle full page width": "ページ幅を切り替え",
"Unable to import pages. Please try again.": "ページをインポートできませんでした。もう一度お試しください",
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力する必要があります。",
"Toggle full page width": "ページ幅を切り替え",
"Unable to import pages. Please try again.": "ページをインポートできません。もう一度お試しください",
"untitled": "無題",
"Untitled": "無題",
"Updated successfully": "更新しました",
"Updated successfully": "正常に更新されました",
"User": "ユーザー",
"Workspace": "ワークスペース",
"Workspace Name": "ワークスペース名",
"Workspace settings": "ワークスペース設定",
"You can change your password here.": "パスワードを変更できます",
"You can change your password here.": "パスワードを変更できます",
"Your Email": "メールアドレス",
"Your import is complete.": "インポートが完了しました",
"Your import is complete.": "インポートが完了しました",
"Your name": "名前",
"Your Name": "名前",
"Your password": "パスワード",
"Your password must be a minimum of 8 characters.": "パスワードは8文字以上にしてください",
"Your password must be a minimum of 8 characters.": "パスワードは最低 8 文字必要です。",
"Sidebar toggle": "サイドバー切り替え",
"Comments": "コメント",
"404 page not found": "404 ページが見つかりません",
"Sorry, we can't find the page you are looking for.": "お探しのページが見つかりません",
"Sorry, we can't find the page you are looking for.": "お探しのページが見つかりません",
"Take me back to homepage": "ホームに戻る",
"Forgot password": "パスワードを忘れた",
"Forgot your password?": "パスワードを忘れましたか?",
"A password reset link has been sent to your email. Please check your inbox.": "パスワードリセット用のリンクをメールに送信ました。受信トレイを確認してください",
"Send reset link": "リセットリンクを送",
"A password reset link has been sent to your email. Please check your inbox.": "パスワードリセットリンクがあなたのメールアドレスに送信されました。受信を確認してください",
"Send reset link": "リセットリンクを送",
"Password reset": "パスワードリセット",
"Your new password": "新しいパスワード",
"Set password": "パスワードを設定",
"Write a comment": "コメントを書く",
"Reply...": "返信...",
"Error loading comments.": "コメントの読み込みに失敗しました",
"No comments yet.": "コメントがありません",
"Error loading comments.": "コメントの読み込み中にエラーが発生しました",
"No comments yet.": "コメントがありません",
"Edit comment": "コメントを編集する",
"Delete comment": "コメントを削除する",
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
"Comment created successfully": "コメント作成ました",
"Error creating comment": "コメントの作成に失敗しました",
"Comment updated successfully": "コメント更新ました",
"Comment created successfully": "コメント作成されました",
"Error creating comment": "コメントの作成中にエラーが発生しました",
"Comment updated successfully": "コメント更新されました",
"Failed to update comment": "コメントの更新に失敗しました",
"Comment deleted successfully": "コメント削除ました",
"Comment deleted successfully": "コメント削除されました",
"Failed to delete comment": "コメントの削除に失敗しました",
"Comment resolved successfully": "コメント解決ました",
"Comment re-opened successfully": "コメント再開ました",
"Comment unresolved successfully": "コメントを未解決に戻しました",
"Comment resolved successfully": "コメント解決されました",
"Comment re-opened successfully": "コメント再開されました",
"Comment unresolved successfully": "コメントが再解決されました",
"Failed to resolve comment": "コメントの解決に失敗しました",
"Resolve comment": "コメントを解決",
"Unresolve comment": "コメントを解決に戻す",
"Unresolve comment": "コメントを解決",
"Resolve Comment Thread": "コメントスレッドを解決",
"Unresolve Comment Thread": "コメントスレッドを解決に戻す",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか完了としてマークされます",
"Are you sure you want to unresolve this comment thread?": "このコメントスレッドを解決に戻しますか?",
"Unresolve Comment Thread": "コメントスレッドを解決",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか? これにより完了としてマークされます",
"Are you sure you want to unresolve this comment thread?": "このコメントスレッドを解決しますか?",
"Resolved": "解決済",
"No active comments.": "アクティブなコメントはありません",
"No resolved comments.": "解決済みのコメントはありません",
"No active comments.": "アクティブなコメントはありません",
"No resolved comments.": "解決されたコメントはありません",
"Revoke invitation": "招待を取り消す",
"Revoke": "取り消す",
"Don't": "取り消さない",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "この招待を取り消してもよろしいですかユーザはワークスペースに参加できなくなります",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "この招待を取り消してもよろしいですか? ユーザはワークスペースに参加できなくなります",
"Resend invitation": "招待を再度送る",
"Anyone with this link can join this workspace.": "このリンクをっている人は誰でもワークスペースに参加できます",
"Anyone with this link can join this workspace.": "このリンクをっている人は誰でもこのワークスペースに参加できます",
"Invite link": "招待リンク",
"Copy": "コピー",
"Copy to space": "スペースにコピー",
@@ -239,13 +239,13 @@
"Duplicate": "複製",
"Select a user": "ユーザを選択",
"Select a group": "グループを選択",
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします",
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします",
"Delete space": "スペースを削除",
"Are you sure you want to delete this space?": "このスペースを削除してもよろしいですか?",
"Delete this space with all its pages and data.": "このスペースすべてのページデータを削除します",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "スペース内のすべてのページ、コメント、添付ファイル、権限完全に削除されます",
"Delete this space with all its pages and data.": "このスペースおよびスペース内のすべてのページデータを削除します",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "このスペース内のすべてのページ、コメント、添付ファイル、および権限完全に削除されます",
"Confirm space name": "スペース名を確認する",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "確認のためスペース名 <b>{{spaceName}}</b> を入力してください",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "アクションを確認するためスペース名 <b>{{spaceName}}</b> を入力してください",
"Format": "フォーマット",
"Include subpages": "サブページを含める",
"Include attachments": "添付ファイルを含める",
@@ -273,12 +273,12 @@
"Success": "成功",
"Warning": "警告",
"Danger": "危険",
"Mermaid diagram error:": "Mermaid ダイアグラムエラー:",
"Invalid Mermaid diagram": "無効な Mermaid ダイアグラムです",
"Double-click to edit Draw.io diagram": "ダブルクリックして Draw.io 図を編集",
"Mermaid diagram error:": "Mermaid コードエラー",
"Invalid Mermaid diagram": "無効な Mermaid コードです",
"Double-click to edit Draw.io diagram": "ダブルクリックしてDraw.io図を編集",
"Exit": "終了",
"Save & Exit": "保存して終了",
"Double-click to edit Excalidraw diagram": "ダブルクリックして Excalidraw 図を編集",
"Double-click to edit Excalidraw diagram": "ダブルクリックしてExcalidraw図を編集",
"Paste link": "リンクを貼り付け",
"Edit link": "リンクを編集",
"Remove link": "リンクを削除",
@@ -315,22 +315,22 @@
"Bullet List": "箇条書きリスト",
"Numbered List": "番号付きリスト",
"Blockquote": "引用",
"Just start typing with plain text.": "プレーンテキストを入力します",
"Track tasks with a to-do list.": "Todo リストでタスクを管理します",
"Big section heading.": "大見出し",
"Medium section heading.": "中見出し",
"Small section heading.": "小見出し",
"Create a simple bullet list.": "箇条書きリストを作成します",
"Create a list with numbering.": "番号付きリストを作成します",
"Create block quote.": "引用ブロックを作成します",
"Insert code snippet.": "コードスニペットを入します",
"Insert horizontal rule divider": "区切り線を挿入します",
"Upload any image from your device.": "デバイスから画像をアップロードします",
"Upload any video from your device.": "デバイスから動画をアップロードします",
"Upload any file from your device.": "デバイスからファイルをアップロードします",
"Just start typing with plain text.": "すぐに文章を書き始められます",
"Track tasks with a to-do list.": "Todoリストでタスクを追跡します",
"Big section heading.": "大きいフォントのセクション見出しです。",
"Medium section heading.": "中くらいのフォントのセクション見出しです。",
"Small section heading.": "小さいフォントのセクション見出しです。",
"Create a simple bullet list.": "シンプルな箇条書きリストを作成します",
"Create a list with numbering.": "番号付きリストを作成します",
"Create block quote.": "引用を作成します",
"Insert code snippet.": "コードスニペットを入します",
"Insert horizontal rule divider": "水平線を挿入します",
"Upload any image from your device.": "画像をアップロードします",
"Upload any video from your device.": "動画をアップロードします",
"Upload any file from your device.": "ファイルをアップロードします",
"Table": "テーブル",
"Insert a table.": "テーブルを挿入します",
"Insert collapsible block.": "折りたたみブロックを挿入します",
"Insert a table.": "を挿入します",
"Insert collapsible block.": "折りたたみ可能なブロックを挿入します",
"Video": "動画",
"Divider": "区切り線",
"Quote": "引用",
@@ -338,16 +338,16 @@
"File attachment": "ファイル添付",
"Toggle block": "ブロックを切り替える",
"Callout": "コールアウト",
"Insert callout notice.": "コールアウトを挿入します",
"Insert callout notice.": "コールアウトブロックを挿入します",
"Math inline": "インライン数式",
"Insert inline math equation.": "インライン数式を挿入します",
"Insert inline math equation.": "インライン数式を挿入します",
"Math block": "数式ブロック",
"Insert math equation": "数式を挿入します",
"Mermaid diagram": "Mermaid ダイアグラム",
"Insert mermaid diagram": "Mermaid ダイアグラムを挿入します",
"Insert and design Drawio diagrams": "Draw.io 図を挿入・編集します",
"Insert current date": "現在の日付を挿入します",
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
"Mermaid diagram": "Mermaidコード",
"Insert mermaid diagram": "Mermaidコードを記述して図を挿入します",
"Insert and design Drawio diagrams": "Drawio図を挿入してデザインします",
"Insert current date": "今日の日付を挿入します",
"Draw and sketch excalidraw diagrams": "Excalidraw図を埋め込みます",
"Multiple": "複数",
"Heading {{level}}": "見出し {{level}}",
"Toggle title": "タイトルの表示/非表示を切り替える",
@@ -357,29 +357,29 @@
"Yesterday, {{time}}": "昨日、{{time}}",
"Space created successfully": "スペースを作成しました",
"Space updated successfully": "スペースを更新しました",
"Space deleted successfully": "スペース削除ました",
"Space deleted successfully": "スペース削除されました",
"Members added successfully": "メンバーを追加しました",
"Member removed successfully": "メンバー削除ました",
"Member removed successfully": "メンバー削除されました",
"Member role updated successfully": "メンバーのロールを更新しました",
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
"Created at: {{time}}": "作成日: {{time}}",
"Created at: {{time}}": "作成しました:{{time}}",
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
"Word count: {{wordCount}}": "単語数: {{wordCount}}",
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
"Character count: {{characterCount}}": "文字数: {{characterCount}}",
"New update": "新規更新",
"{{latestVersion}} is available": "{{latestVersion}}利用可能です",
"{{latestVersion}} is available": "{{latestVersion}}利用可能です",
"Default page edit mode": "デフォルトのページ編集モード",
"Choose your preferred page edit mode. Avoid accidental edits.": "お好みのページ編集モードを選択してください(誤編集を防止します",
"Choose your preferred page edit mode. Avoid accidental edits.": "希望のページ編集モードを選択してください。誤って編集を防ます",
"Reading": "読み取り",
"Delete member": "メンバーを削除する",
"Member deleted successfully": "メンバー削除ました",
"Are you sure you want to delete this workspace member? This action is irreversible.": "このメンバーを削除してもよろしいですか?この操作は取り消せません",
"Member deleted successfully": "メンバー削除されました",
"Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません",
"Move": "移動",
"Move page": "ページを移動",
"Move page to a different space.": "ページを別のスペースに移動します",
"Real-time editor connection lost. Retrying...": "リアルタイム編集の接続が切断されました。再接続中...",
"Move page to a different space.": "ページを別のスペースに移動します",
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
"Table of contents": "目次",
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加すると目次生成されます",
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加して目次生成ます",
"Share": "共有",
"Public sharing": "公開共有",
"Shared by": "共有者",
@@ -398,13 +398,13 @@
"Delete share": "共有を削除",
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
"Share deleted successfully": "共有を削除しました",
"Share deleted successfully": "共有が正常に削除されました",
"Share not found": "共有が見つかりません",
"Failed to share page": "ページの共有に失敗しました",
"Copy page": "ページをコピー",
"Copy page to a different space.": "ページを別のスペースにコピーします",
"Page copied successfully": "ページコピーしました",
"Page duplicated successfully": "ページを複製しました",
"Copy page to a different space.": "ページを別のスペースにコピーします",
"Page copied successfully": "ページコピーに成功しました",
"Page duplicated successfully": "ページが正常に複製されました",
"Find": "検索",
"Not found": "見つかりません",
"Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)",
@@ -419,26 +419,26 @@
"Error": "エラー",
"Failed to disable MFA": "MFAの無効化に失敗しました",
"Disable two-factor authentication": "二要素認証を無効化",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります",
"Please enter your password to disable two-factor authentication:": "二要素認証を無効するにはパスワードを入力してください",
"Two-factor authentication has been enabled": "二要素認証有効にました",
"Two-factor authentication has been disabled": "二要素認証無効にました",
"2-step verification": "2段階認",
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証でアカウントを保護します",
"Two-factor authentication is active on your account.": "二要素認証が有効です",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります",
"Please enter your password to disable two-factor authentication:": "二要素認証を無効するにはパスワードを入力してください:",
"Two-factor authentication has been enabled": "二要素認証有効になりました",
"Two-factor authentication has been disabled": "二要素認証無効になりました",
"2-step verification": "2段階認",
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証レイヤーでアカウントを保護します",
"Two-factor authentication is active on your account.": "二要素認証がアカウントで有効です",
"Add 2FA method": "2FAメソッドを追加",
"Backup codes": "バックアップコード",
"Disable": "無効にする",
"Invalid verification code": "無効な認証コード",
"New backup codes have been generated": "新しいバックアップコード生成ました",
"New backup codes have been generated": "新しいバックアップコード生成されました",
"Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました",
"About backup codes": "バックアップコードについて",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "認証アプリアクセスできない場合、バックアップコードでアカウントにアクセスできます。各コードは1回のみ使用可能です",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "新しいバックアップコードはいつでも再生成できます。既存のコードはすべて無効になります",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "バックアップコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "いつでも新しいバックアップコード再生成できます。これにより、既存のすべてのコードが無効になります",
"Confirm password": "パスワードを確認",
"Generate new backup codes": "新しいバックアップコードを生成",
"Save your new backup codes": "新しいバックアップコードを保存",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効になりました",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効です。",
"Your new backup codes": "新しいバックアップコード",
"I've saved my backup codes": "バックアップコードを保存しました",
"Failed to setup MFA": "MFAの設定に失敗しました",
@@ -449,51 +449,51 @@
"Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:",
"2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください",
"Verify and enable": "確認と有効化",
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。もう一度お試しください",
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。再試行してください",
"Backup": "バックアップ",
"Save codes": "コードを保存",
"Save your backup codes": "バックアップコードを保存",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "認証アプリアクセスできない場合、これらのコードでアカウントにアクセスできます。各コードは1回のみ使用可能です",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "これらのコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
"Print": "印刷",
"Two-factor authentication has been set up. Please log in again.": "二要素認証設定ました。再度ログインしてください",
"Two-factor authentication has been set up. Please log in again.": "二要素認証設定されました。再度ログインしてください",
"Two-Factor authentication required": "二要素認証が必要です",
"Your workspace requires two-factor authentication for all users": "このワークスペースではすべてのユーザーに二要素認証が必要です",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースアクセスるには二要素認証を設定してください。アカウントのセキュリティが強化されます",
"Your workspace requires two-factor authentication for all users": "ワークスペースではすべてのユーザーに二要素認証が必要です",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースへのアクセスを続けるには二要素認証を設定する必要があります。これにより、アカウントに追加のセキュリティ層が追加されます",
"Set up two-factor authentication": "二要素認証を設定",
"Cancel and logout": "キャンセルしてログアウト",
"Your workspace requires two-factor authentication. Please set it up to continue.": "このワークスペースでは二要素認証が必要です。続行するには設定してください",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "認証アプリからの確認コードアカウントのセキュリティが強化されます",
"Your workspace requires two-factor authentication. Please set it up to continue.": "ワークスペースでは二要素認証が必要です。続行するには設定してください",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "これにより、認証アプリからの確認コードが必要となり、アカウントに追加のセキュリティ層が追加されます",
"Password is required": "パスワードが必要です",
"Password must be at least 8 characters": "パスワードは8文字以上必要です",
"Please enter a 6-digit code": "6桁のコードを入力してください",
"Code must be exactly 6 digits": "コードは6桁で入力してください",
"Code must be exactly 6 digits": "コードは正確に6桁である必要があります",
"Enter the 6-digit code found in your authenticator app": "認証アプリに表示された6桁のコードを入力してください",
"Need help authenticating?": "認証に関するヘルプが必要ですか?",
"MFA QR Code": "MFA QRコード",
"Account created successfully. Please log in to set up two-factor authentication.": "アカウントを作成しました。二要素認証を設定するためにログインしてください",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードリセットしました。新しいパスワードでログインし二要素認証を完了してください",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードリセットしました。新しいパスワードでログインして二要素認証を設定してください",
"Password reset was successful. Please log in with your new password.": "パスワードリセットしました。新しいパスワードでログインしてください",
"Account created successfully. Please log in to set up two-factor authentication.": "アカウントが正常に作成されました。二要素認証を設定するためにログインしてください",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードリセットが成功しました。新しいパスワードでログインし二要素認証を完了してください",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードリセットが成功しました。二要素認証を設定するために新しいパスワードでログインしてください",
"Password reset was successful. Please log in with your new password.": "パスワードリセットが成功しました。新しいパスワードでログインしてください",
"Two-factor authentication": "二要素認証",
"Use authenticator app instead": "代わりに認証アプリを使用",
"Verify backup code": "バックアップコードを確認",
"Use backup code": "バックアップコードを使用",
"Enter one of your backup codes": "バックアップコードのいずれかを入力してください",
"Backup code": "バックアップコード",
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードを入力してください。各コードは1回のみ使用可能です",
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードのいずれかを入力してください。各バックアップコードは一度しか使用できません。",
"Verify": "確認",
"Trash": "ごみ箱",
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます",
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます",
"Deleted": "削除",
"No pages in trash": "ごみ箱にページがありません",
"Permanently delete page?": "ページを完全に削除しますか?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}を完全に削除しますかこの操作は取り消せません",
"Restore '{{title}}' and its sub-pages?": "{{title}}とそのサブページを復元しますか?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}を完全に削除しますか? この操作は元に戻せません",
"Restore '{{title}}' and its sub-pages?": "{{title}}とそのサブページを復元しますか?",
"Move to trash": "ごみ箱に移動",
"Move this page to trash?": "このページをごみ箱に移動しますか?",
"Restore page": "ページを復元",
"Page moved to trash": "ページごみ箱に移動ました",
"Page restored successfully": "ページを復元しました",
"Page moved to trash": "ページごみ箱に移動されました",
"Page restored successfully": "ページが正常に復元されました",
"Deleted by": "削除者",
"Deleted at": "削除日時",
"Preview": "プレビュー",
@@ -511,10 +511,10 @@
"Enterprise": "エンタープライズ",
"Download attachment": "添付ファイルをダウンロード",
"Allowed email domains": "許可されたメールドメイン",
"Only users with email addresses from these domains can signup via SSO.": "これらのドメインのメールアドレスを持つユーザーのみSSO経由で登録できます",
"Only users with email addresses from these domains can signup via SSO.": "これらのドメインからのメールアドレスを持つユーザーのみSSOで登録できます",
"Enter valid domain names separated by comma or space": "コンマまたはスペースで区切って有効なドメイン名を入力してください",
"Enforce two-factor authentication": "二要素認証を強制する",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "有効にすると、すべてのメンバーが二要素認証を設定しないとワークスペースにアクセスできなくなります",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "一度強制されると、すべてのメンバーはワークスペースにアクセスするために二要素認証を有効にする必要があります",
"Toggle MFA enforcement": "MFAの強制を切り替える",
"Display name": "表示名",
"Allow signup": "登録を許可する",
@@ -532,10 +532,10 @@
"Upload image": "画像をアップロード",
"Remove image": "画像を削除",
"Failed to remove image": "画像の削除に失敗しました",
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています",
"Image removed successfully": "画像を削除しました",
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています",
"Image removed successfully": "画像が正常に削除されました",
"API key": "APIキー",
"API key created successfully": "APIキーを作成しました",
"API key created successfully": "APIキーが正常に作成されました",
"API keys": "APIキー",
"API management": "API管理",
"Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか",
@@ -550,9 +550,9 @@
"No API keys found": "APIキーが見つかりません",
"No expiration": "期限なし",
"Revoke API key": "APIキーを無効にする",
"Revoked successfully": "無効にしました",
"Revoked successfully": "正常に無効化されました",
"Select expiration date": "有効期限を選択してください",
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は取り消せません。このAPIキーを使用しているアプリケーションは動作しなくなります",
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は元に戻せません。このAPIキーを使用しているアプリケーションは動作を停止します",
"Update API key": "APIキーを更新",
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理",
"AI settings": "AI設定",
@@ -562,7 +562,7 @@
"AI is thinking...": "AIが考え中...",
"Ask a question...": "質問を入力...",
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペースコンテンツ全体にわたって意味検索機能を提供します",
"Toggle AI search": "AI検索を切り替え",
"Sources": "ソース",
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
@@ -5,27 +5,26 @@ import {
Badge,
Table,
ActionIcon,
} from "@mantine/core";
import { Link } from "react-router-dom";
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { IconFileDescription } from "@tabler/icons-react";
import { getSpaceUrl } from "@/lib/config.ts";
} from '@mantine/core';
import {Link} from 'react-router-dom';
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
import { buildPageUrl } from '@/features/page/page.utils.ts';
import { formattedDate } from '@/lib/time.ts';
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts';
import { IconFileDescription } from '@tabler/icons-react';
import { getSpaceUrl } from '@/lib/config.ts';
import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color.ts";
interface Props {
spaceId?: string;
}
export default function RecentChanges({ spaceId }: Props) {
export default function RecentChanges({spaceId}: Props) {
const { t } = useTranslation();
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
if (isLoading) {
return <PageListSkeleton />;
return <PageListSkeleton/>;
}
if (isError) {
@@ -45,8 +44,8 @@ export default function RecentChanges({ spaceId }: Props) {
>
<Group wrap="nowrap">
{page.icon || (
<ActionIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
<ActionIcon variant='transparent' color='gray' size={18}>
<IconFileDescription size={18}/>
</ActionIcon>
)}
@@ -59,23 +58,18 @@ export default function RecentChanges({ spaceId }: Props) {
{!spaceId && (
<Table.Td>
<Badge
color={getInitialsColor(page?.space.name)}
color="blue"
variant="light"
component={Link}
to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }}
style={{cursor: 'pointer'}}
>
{page?.space.name}
</Badge>
</Table.Td>
)}
<Table.Td>
<Text
c="dimmed"
style={{ whiteSpace: "nowrap" }}
size="xs"
fw={500}
>
<Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
{formattedDate(page.updatedAt)}
</Text>
</Table.Td>
@@ -1,49 +0,0 @@
import { useRef, useState, ReactNode } from "react";
import { Text, TextProps, Tooltip } from "@mantine/core";
type AutoTooltipTextProps = TextProps & {
children: ReactNode;
tooltipLabel?: string;
tooltipProps?: Omit<
React.ComponentProps<typeof Tooltip>,
"children" | "label"
>;
};
export function AutoTooltipText({
children,
tooltipLabel,
tooltipProps,
...textProps
}: AutoTooltipTextProps) {
const textRef = useRef<HTMLParagraphElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const handleMouseEnter = () => {
const element = textRef.current;
if (element) {
setIsTruncated(element.scrollWidth > element.clientWidth);
}
};
const label = tooltipLabel ?? (typeof children === "string" ? children : "");
return (
<Tooltip
label={label}
disabled={!isTruncated || !label}
multiline
withArrow
{...tooltipProps}
>
<Text
ref={textRef}
truncate
onMouseEnter={handleMouseEnter}
{...textProps}
>
{children}
</Text>
</Tooltip>
);
}
@@ -1,13 +1,11 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
import { Group, Text, Paper, ActionIcon } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts";
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
import { useHover } from "@mantine/hooks";
import { formatBytes } from "@/lib";
import { useTranslation } from "react-i18next";
export default function AttachmentView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected } = props;
const { url, name, size } = node.attrs;
const { hovered, ref } = useHover();
@@ -22,28 +20,26 @@ export default function AttachmentView(props: NodeViewProps) {
wrap="nowrap"
h={25}
>
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
{url ? (
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
) : (
<Loader size={20} style={{ flexShrink: 0 }} />
)}
<Group justify="space-between" wrap="nowrap">
<IconPaperclip size={20} />
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
{url ? name : t("Uploading {{name}}", { name })}
<Text component="span" size="md" truncate="end">
{name}
</Text>
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
<Text component="span" size="sm" c="dimmed" inline>
{formatBytes(size)}
</Text>
</Group>
{url && (selected || hovered) && (
{selected || hovered ? (
<a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} />
</ActionIcon>
</a>
) : (
""
)}
</Group>
</Paper>
@@ -1,6 +1,10 @@
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
import { isNodeSelection, useEditorState } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import {
BubbleMenu,
BubbleMenuProps,
isNodeSelection,
useEditor,
useEditorState,
} from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react";
import {
IconBold,
@@ -34,7 +38,7 @@ export interface BubbleMenuItem {
}
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
editor: Editor | null;
editor: ReturnType<typeof useEditor>;
};
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
@@ -129,9 +133,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
}
return isTextSelected(editor);
},
options: {
placement: "top",
offset: 8,
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
onCreate: (instance) => {
instance.popper.firstChild?.addEventListener("blur", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
});
},
onHide: () => {
setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false);
@@ -147,7 +156,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
return (
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
<BubbleMenu {...bubbleMenuProps}>
<div className={classes.bubbleMenu}>
<NodeSelector
editor={props.editor}
@@ -1,5 +1,9 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import {
@@ -49,26 +53,17 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
},
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return dom.getBoundingClientRect();
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const setCalloutType = useCallback(
@@ -117,12 +112,14 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`callout-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
tippyOptions={{
getReferenceClientRect,
offset: [0, 10],
placement: "bottom",
// offset: 233, // // offset: [0, 10],
// zIndex: 99,
flip: false,
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
}}
shouldShow={shouldShow}
>
@@ -90,7 +90,6 @@ export default function CodeBlockView(props: NodeViewProps) {
node.textContent.length > 0
}
>
{/* @ts-ignore */}
<NodeViewContent as="code" className={`language-${language}`} />
</pre>
@@ -1,12 +1,13 @@
import type { EditorView } from "@tiptap/pm/view";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { Slice } from "@tiptap/pm/model";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
export const handlePaste = (
editor: Editor,
view: EditorView,
event: ClipboardEvent,
pageId: string,
creatorId?: string,
@@ -17,7 +18,7 @@ export const handlePaste = (
// we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault();
const url = clipboardData.trim();
const { from: pos, empty } = editor.state.selection;
const { from: pos, empty } = view.state.selection;
const match = INTERNAL_LINK_REGEX.exec(url);
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
@@ -33,27 +34,19 @@ export const handlePaste = (
return false;
}
const anchorId = match[6] ? match[6].split("#")[0] : undefined;
const urlWithoutAnchor = anchorId
? url.substring(0, url.indexOf("#"))
: url;
createMentionAction(
urlWithoutAnchor,
editor.view,
pos,
creatorId,
anchorId,
);
const anchorId = match[6] ? match[6].split('#')[0] : undefined;
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
return true;
}
if (event.clipboardData?.files.length) {
event.preventDefault();
for (const file of event.clipboardData.files) {
const pos = editor.state.selection.from;
uploadImageAction(file, editor, pos, pageId);
uploadVideoAction(file, editor, pos, pageId);
uploadAttachmentAction(file, editor, pos, pageId);
const pos = view.state.selection.from;
uploadImageAction(file, view, pos, pageId);
uploadVideoAction(file, view, pos, pageId);
uploadAttachmentAction(file, view, pos, pageId);
}
return true;
}
@@ -61,7 +54,7 @@ export const handlePaste = (
};
export const handleFileDrop = (
editor: Editor,
view: EditorView,
event: DragEvent,
moved: boolean,
pageId: string,
@@ -70,14 +63,14 @@ export const handleFileDrop = (
event.preventDefault();
for (const file of event.dataTransfer.files) {
const coordinates = editor.view.posAtCoords({
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
}
return true;
}
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
@@ -35,26 +40,17 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
},
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return dom.getBoundingClientRect();
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const onWidthChange = useCallback(
@@ -69,11 +65,15 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`drawio-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
@@ -66,7 +66,6 @@ export default function DrawioView(props: NodeViewProps) {
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
//@ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
@@ -1,41 +1,16 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
import EmojiList from "./emoji-list";
import tippy from "tippy.js";
import { init } from "emoji-mart";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
const renderEmojiItems = () => {
let component: ReactRenderer | null = null;
let popup: HTMLDivElement | null = null;
let cleanup: (() => void) | null = null;
let getReferenceClientRect: (() => DOMRect) | null = null;
const destroy = () => {
if (cleanup) {
cleanup();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
}
if (component) {
component.destroy();
component = null;
}
};
let popup: any | null = null;
return {
onBeforeStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
clientRect: DOMRect;
}) => {
init({
data: async () => (await import("@emoji-mart/data")).default,
@@ -50,61 +25,51 @@ const renderEmojiItems = () => {
return;
}
getReferenceClientRect = props.clientRect;
popup = document.createElement("div");
popup.style.zIndex = "9999";
popup.style.position = "absolute";
popup.style.top = "0";
popup.style.left = "0";
popup.appendChild(component.element);
document.body.appendChild(popup);
const virtualElement = {
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect(0, 0, 0, 0);
},
};
cleanup = autoUpdate(virtualElement, popup, () => {
if (!popup) return;
computePosition(virtualElement, popup, {
placement: "bottom-start",
middleware: [offset(10), flip(), shift()],
}).then(({ x, y }) => {
if (!popup) return;
Object.assign(popup.style, {
transform: `translate(${x}px, ${y}px)`,
});
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom",
});
},
onStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
clientRect: DOMRect;
}) => {
component?.updateProps({ ...props, isLoading: false });
component?.updateProps({...props, isLoading: false});
if (props.clientRect) {
getReferenceClientRect = props.clientRect;
if (!props.clientRect) {
return;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
clientRect: DOMRect;
}) => {
component?.updateProps(props);
if (props.clientRect) {
getReferenceClientRect = props.clientRect;
if (!props.clientRect) {
return;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
destroy();
popup?.[0].hide();
component?.destroy()
return true;
}
@@ -113,7 +78,13 @@ const renderEmojiItems = () => {
return component?.ref?.onKeyDown(props);
},
onExit: () => {
destroy();
if (popup && !popup[0]?.state.isDestroyed) {
popup[0]?.destroy();
}
if (component) {
component?.destroy();
}
},
};
};
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
@@ -37,26 +42,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
},
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return dom.getBoundingClientRect();
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const onWidthChange = useCallback(
@@ -69,13 +65,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`excalidraw-menu`}
pluginKey={`excalidraw-menu}`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
@@ -98,7 +98,6 @@ export default function ExcalidrawView(props: NodeViewProps) {
const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
@@ -17,6 +22,16 @@ import { useTranslation } from "react-i18next";
export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("image");
},
[editor],
);
const editorState = useEditorState({
editor,
@@ -37,37 +52,17 @@ export function ImageMenu({ editor }: EditorMenuProps) {
},
});
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("image") && editor.getAttributes("image").src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return dom.getBoundingClientRect();
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const alignImageLeft = useCallback(() => {
@@ -110,11 +105,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`image-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
@@ -1,27 +0,0 @@
.imageWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
overflow: hidden;
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
@@ -1,70 +1,30 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Image, Loader, Text } from "@mantine/core";
import { useMemo } from "react";
import { Image } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx";
import classes from "./image-view.module.css";
import { useTranslation } from "react-i18next";
export default function ImageView(props: NodeViewProps) {
const { t } = useTranslation();
const { editor, node, selected } = props;
const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
const { node, selected } = props;
const { src, width, align, title } = node.attrs;
const alignClass = useMemo(() => {
if (align === "left") return "alignLeft";
if (align === "right") return "alignRight";
if (align === "center") return "alignCenter";
return "alignCenter";
}, [align]);
const previewSrc = useMemo(() => {
editor.storage.shared.imagePreviews =
editor.storage.shared.imagePreviews || {};
if (placeholder?.id) {
return editor.storage.shared.imagePreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return (
<NodeViewWrapper data-drag-handle>
<div
className={clsx(
selected && "ProseMirror-selectednode",
classes.imageWrapper,
alignClass,
)}
style={{
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
width,
}}
>
{src && (
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} />
)}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<Image
radius="md"
fit="contain"
src={previewSrc}
alt={placeholder?.name}
/>
<Loader size={20} pos="absolute" bottom={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
<Image
radius="md"
fit="contain"
w={width}
src={getFileUrl(src)}
alt={title}
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
/>
</NodeViewWrapper>
);
}
@@ -1,10 +1,9 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
import React, { useCallback, useState } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
import { Card } from "@mantine/core";
import { useEditorState } from "@tiptap/react";
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
const [showEdit, setShowEdit] = useState(false);
@@ -60,15 +59,18 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`link-menu`}
pluginKey={`link-menu}`}
updateDelay={0}
options={{
onHide: () => {
tippyOptions={{
appendTo: () => {
return appendTo?.current;
},
onHidden: () => {
setShowEdit(false);
},
placement: "bottom",
offset: 5,
// zIndex: 101,
offset: [0, 5],
zIndex: 101,
}}
shouldShow={shouldShow}
>
@@ -106,7 +106,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
setRenderItems(items);
// update editor storage
//@ts-ignore
props.editor.storage.mentionItems = items;
}
}, [suggestion, isLoading]);
@@ -1,11 +1,5 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import tippy from "tippy.js";
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
function getWhitespaceCount(query: string) {
@@ -15,27 +9,16 @@ function getWhitespaceCount(query: string) {
const mentionRenderItems = () => {
let component: ReactRenderer | null = null;
let activeClientRect: (() => DOMRect) | null = null;
let updatePositionCleanup: (() => void) | null = null;
const destroy = () => {
updatePositionCleanup?.();
updatePositionCleanup = null;
component?.destroy();
if (component?.element?.parentNode) {
component.element.parentNode.removeChild(component.element);
}
component = null;
};
let popup: any | null = null;
return {
onStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
clientRect: DOMRect;
query: string;
}) => {
// query must not start with a whitespace
if (props.query.charAt(0) === " ") {
if (props.query.charAt(0) === ' '){
return;
}
@@ -54,88 +37,75 @@ const mentionRenderItems = () => {
return;
}
activeClientRect = props.clientRect;
const { element } = component;
document.body.appendChild(element);
updatePositionCleanup = autoUpdate(
{
getBoundingClientRect: () =>
activeClientRect ? activeClientRect() : new DOMRect(),
},
element,
() => {
if (!component?.element) return;
computePosition(
{
getBoundingClientRect: () => {
return activeClientRect ? activeClientRect() : new DOMRect();
},
},
element,
{
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
}
).then(({ x, y }) => {
Object.assign(element.style, {
left: `${x}px`,
top: `${y}px`,
position: "absolute",
zIndex: "9999",
});
});
}
);
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
clientRect: DOMRect;
query: string;
}) => {
// query must not start with a whitespace
if (props.query.charAt(0) === " ") {
destroy();
if (props.query.charAt(0) === ' '){
component?.destroy();
return;
}
// only update component if popup is not destroyed
if (component) {
component.updateProps(props);
if (!popup?.[0].state.isDestroyed) {
component?.updateProps(props);
}
if (!props || !props.clientRect) {
return;
}
activeClientRect = props.clientRect;
const whitespaceCount = getWhitespaceCount(props.query);
// destroy component if space is greater 3 without a match
if (
whitespaceCount > 3 &&
//@ts-ignore
props.editor.storage.mentionItems.length === 0
) {
destroy();
popup?.[0]?.destroy();
component?.destroy();
return;
}
popup &&
!popup?.[0].state.isDestroyed &&
popup?.[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key)
if (
props.event.key === "Escape" ||
(props.event.key === "Enter" && !component)
(props.event.key === "Enter" && !popup?.[0].state.isShown)
) {
destroy();
popup?.[0].destroy();
component?.destroy();
return false;
}
return (component?.ref as any)?.onKeyDown(props);
},
onExit: () => {
destroy();
if (popup && !popup?.[0].state.isDestroyed) {
popup[0].destroy();
}
if (component) {
component.destroy();
}
},
};
};
@@ -1,21 +1,19 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { Link, useLocation, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import {
buildPageUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) {
const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const { spaceSlug, pageSlug } = useParams();
const { spaceSlug } = useParams();
const { shareId } = useParams();
const navigate = useNavigate();
const {
data: page,
isLoading,
@@ -25,20 +23,6 @@ export default function MentionView(props: NodeViewProps) {
const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share");
const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId;
const handleClick = (e: React.MouseEvent) => {
if (isSamePage && anchorId) {
e.preventDefault();
const element = document.querySelector(`[id="${anchorId}"]`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
navigate(`#${anchorId}`, { replace: true });
}
}
};
const shareSlugUrl = buildSharedPageUrl({
shareId,
pageSlugId: slugId,
@@ -61,7 +45,6 @@ export default function MentionView(props: NodeViewProps) {
to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
@@ -73,8 +73,6 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
if (!editor) return;
const { results, resultIndex } = editor.storage.searchAndReplace;
//TODO: check type error
//@ts-ignore
const position: Range = results[resultIndex];
if (!position) return;
@@ -161,7 +161,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
@@ -174,13 +173,9 @@ const CommandGroups: SlashMenuGroupedItemsType = {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadImageAction(file, editor, pos, pageId);
uploadImageAction(file, editor.view, pos, pageId);
}
}
// Reset the input value to allow uploading the same file again if needed
input.value = "";
};
input.click();
},
@@ -193,7 +188,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
@@ -201,18 +195,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*";
input.multiple = true;
input.onchange = async () => {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadVideoAction(file, editor, pos, pageId);
}
const file = input.files[0];
const pos = editor.view.state.selection.from;
uploadVideoAction(file, editor.view, pos, pageId);
}
// Reset the input value to allow uploading the same file again if needed
input.value = "";
};
input.click();
},
@@ -225,7 +213,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
@@ -233,18 +220,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input");
input.type = "file";
input.accept = "";
input.multiple = true;
input.onchange = async () => {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadAttachmentAction(file, editor, pos, pageId, true);
}
const file = input.files[0];
const pos = editor.view.state.selection.from;
uploadAttachmentAction(file, editor.view, pos, pageId, true);
}
// Reset the input value to allow uploading the same file again if needed
input.value = "";
};
input.click();
},
@@ -1,35 +1,10 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
import CommandList from "@/features/editor/components/slash-menu/command-list";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import tippy from "tippy.js";
const renderItems = () => {
let component: ReactRenderer | null = null;
let popup: HTMLElement | null = null;
let cleanup: (() => void) | null = null;
let getReferenceClientRect: (() => DOMRect) | null = null;
const updatePosition = () => {
if (!popup || !getReferenceClientRect) return;
// @ts-ignore
const rect = getReferenceClientRect();
computePosition({ getBoundingClientRect: () => rect }, popup, {
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
}).then(({ x, y }) => {
if (popup) {
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
}
});
};
let popup: any | null = null;
return {
onStart: (props: {
@@ -46,29 +21,15 @@ const renderItems = () => {
}
// @ts-ignore
getReferenceClientRect = props.clientRect;
popup = document.createElement("div");
popup.style.zIndex = "9999";
popup.style.position = "absolute";
popup.style.top = "0";
popup.style.left = "0";
document.body.appendChild(popup);
popup.appendChild(component.element);
cleanup = autoUpdate(
// @ts-ignore
{
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect();
},
},
popup,
updatePosition
);
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
@@ -80,15 +41,14 @@ const renderItems = () => {
return;
}
// @ts-ignore
getReferenceClientRect = props.clientRect;
updatePosition();
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
if (popup) {
popup.style.display = "none";
}
popup?.[0].hide();
return true;
}
@@ -97,19 +57,12 @@ const renderItems = () => {
return component?.ref?.onKeyDown(props);
},
onExit: () => {
if (cleanup) {
cleanup();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
if (popup && !popup[0].state.isDestroyed) {
popup[0].destroy();
}
if (component) {
component.destroy();
component = null;
}
},
};
@@ -1,11 +1,15 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { posToDOMRect, findParentNode } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { JSX, useCallback } from "react";
import React, { useCallback } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core";
import { sticky } from "tippy.js";
interface SubpagesMenuProps {
editor: Editor;
@@ -29,7 +33,7 @@ export const SubpagesMenu = React.memo(
return editor.isActive("subpages");
},
[editor]
[editor],
);
const getReferenceClientRect = useCallback(() => {
@@ -58,8 +62,18 @@ export const SubpagesMenu = React.memo(
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`subpages-menu`}
pluginKey={`subpages-menu}`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
<Tooltip position="top" label={t("Delete")}>
@@ -75,7 +89,7 @@ export const SubpagesMenu = React.memo(
</Tooltip>
</BaseBubbleMenu>
);
}
},
);
export default SubpagesMenu;
@@ -19,7 +19,6 @@ export default function SubpagesView(props: NodeViewProps) {
const { spaceSlug, shareId } = useParams();
const { t } = useTranslation();
//@ts-ignore
const currentPageId = editor.storage.pageId;
// Get subpages from shared tree if we're in a shared context
@@ -1,4 +1,6 @@
import React, { JSX, useCallback } from "react";
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
import React, { useCallback } from "react";
import {
EditorMenuProps,
ShouldShowProps,
@@ -15,7 +17,6 @@ import {
import { useTranslation } from "react-i18next";
import { TableBackgroundColor } from "./table-background-color";
import { TableTextAlignment } from "./table-text-alignment";
import { BubbleMenu } from "@tiptap/react/menus";
export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
@@ -28,7 +29,7 @@ export const TableCellMenu = React.memo(
return isCellSelection(state.selection);
},
[editor]
[editor],
);
const mergeCells = useCallback(() => {
@@ -52,27 +53,23 @@ export const TableCellMenu = React.memo(
}, [editor]);
return (
<BubbleMenu
<BaseBubbleMenu
editor={editor}
pluginKey="table-cell-menu"
updateDelay={0}
appendTo={() => {
return appendTo?.current;
}}
ref={(element) => {
element.style.zIndex = "99";
}}
options={{
offset: {
mainAxis: 15,
tippyOptions={{
appendTo: () => {
return appendTo?.current;
},
offset: [0, 15],
zIndex: 99,
}}
shouldShow={shouldShow}
>
<ActionIcon.Group>
<TableBackgroundColor editor={editor} />
<TableTextAlignment editor={editor} />
<Tooltip position="top" label={t("Merge cells")}>
<ActionIcon
onClick={mergeCells}
@@ -128,9 +125,9 @@ export const TableCellMenu = React.memo(
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</BubbleMenu>
</BaseBubbleMenu>
);
}
},
);
export default TableCellMenu;
@@ -1,6 +1,11 @@
import { posToDOMRect, findParentNode } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { JSX, useCallback } from "react";
import React, { useCallback } from "react";
import {
EditorMenuProps,
ShouldShowProps,
@@ -12,12 +17,9 @@ import {
IconColumnRemove,
IconRowInsertBottom,
IconRowInsertTop,
IconRowRemove,
IconTableColumn,
IconTableRow,
IconRowRemove, IconTableColumn, IconTableRow,
IconTrashX,
} from "@tabler/icons-react";
import { BubbleMenu } from "@tiptap/react/menus";
} from '@tabler/icons-react';
import { isCellSelection } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
@@ -32,28 +34,20 @@ export const TableMenu = React.memo(
return editor.isActive("table") && !isCellSelection(state.selection);
},
[editor]
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "table";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const rect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
return dom.getBoundingClientRect();
}
const rect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const toggleHeaderColumn = useCallback(() => {
@@ -93,33 +87,42 @@ export const TableMenu = React.memo(
}, [editor]);
return (
<BubbleMenu
<BaseBubbleMenu
editor={editor}
pluginKey="table-menu"
resizeDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
ref={(element) => {
element.style.zIndex = "99";
}}
options={{
placement: "top",
offset: {
mainAxis: 15,
},
flip: {
fallbackPlacements: ["top", "bottom"],
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
boundary: editor.options.element as HTMLElement,
},
shift: {
padding: 8 + 15,
crossAxis: true,
updateDelay={0}
tippyOptions={{
getReferenceClientRect: getReferenceClientRect,
offset: [0, 15],
zIndex: 99,
popperOptions: {
modifiers: [
{
name: "preventOverflow",
enabled: true,
options: {
altAxis: true,
boundary: "clippingParents",
padding: 8,
},
},
{
name: "flip",
enabled: true,
options: {
boundary: editor.options.element,
fallbackPlacements: ["top", "bottom"],
padding: { top: 35, left: 8, right: 8, bottom: -Infinity },
},
},
],
},
}}
shouldShow={shouldShow}
>
<ActionIcon.Group>
<Tooltip position="top" label={t("Add left column")}>
<Tooltip position="top" label={t("Add left column")}
>
<ActionIcon
onClick={addColumnLeft}
variant="default"
@@ -185,7 +188,8 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Toggle header row")}>
<Tooltip position="top" label={t("Toggle header row")}
>
<ActionIcon
onClick={toggleHeaderRow}
variant="default"
@@ -196,7 +200,8 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Toggle header column")}>
<Tooltip position="top" label={t("Toggle header column")}
>
<ActionIcon
onClick={toggleHeaderColumn}
variant="default"
@@ -219,9 +224,9 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</BubbleMenu>
</BaseBubbleMenu>
);
}
},
);
export default TableMenu;
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
@@ -17,6 +22,16 @@ import { useTranslation } from "react-i18next";
export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("video");
},
[editor],
);
const editorState = useEditorState({
editor,
@@ -37,37 +52,17 @@ export function VideoMenu({ editor }: EditorMenuProps) {
},
});
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("video") && editor.getAttributes("video").src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return dom.getBoundingClientRect();
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const alignVideoLeft = useCallback(() => {
@@ -110,11 +105,15 @@ export function VideoMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`video-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
@@ -1,33 +0,0 @@
.videoWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
overflow: hidden;
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
.video {
display: block;
width: 100%;
height: 100%;
border-radius: 8px;
}
@@ -1,75 +1,29 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Loader, Text } from "@mantine/core";
import { useMemo } from "react";
import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx";
import classes from "./video-view.module.css";
import { useTranslation } from "react-i18next";
export default function VideoView(props: NodeViewProps) {
const { t } = useTranslation();
const { editor, node, selected } = props;
const { src, width, align, aspectRatio, placeholder } = node.attrs;
const { node, selected } = props;
const { src, width, align } = node.attrs;
const alignClass = useMemo(() => {
if (align === "left") return "alignLeft";
if (align === "right") return "alignRight";
if (align === "center") return "alignCenter";
return "alignCenter";
}, [align]);
const previewSrc = useMemo(() => {
editor.storage.shared.videoPreviews =
editor.storage.shared.videoPreviews || {};
if (placeholder?.id) {
return editor.storage.shared.videoPreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return (
<NodeViewWrapper data-drag-handle>
<div
className={clsx(
selected && "ProseMirror-selectednode",
classes.videoWrapper,
alignClass,
)}
style={{
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
width,
}}
>
{src && (
<video
className={classes.video}
preload="metadata"
controls
src={getFileUrl(src)}
/>
)}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<video
className={classes.video}
preload="metadata"
controls
src={previewSrc}
/>
<Loader size={20} pos="absolute" top={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
<video
preload="metadata"
width={width}
controls
src={getFileUrl(src)}
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
style={{ display: "block" }}
/>
</NodeViewWrapper>
);
}
@@ -1,7 +1,11 @@
import { StarterKit } from "@tiptap/starter-kit";
import { Placeholder } from "@tiptap/extension-placeholder";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
import { Placeholder, CharacterCount } from "@tiptap/extensions";
import { CharacterCount } from "@tiptap/extension-character-count";
import { TaskList } from "@tiptap/extension-task-list";
import { ListKeymap } from "@tiptap/extension-list-keymap";
import { TaskItem } from "@tiptap/extension-task-item";
import { Underline } from "@tiptap/extension-underline";
import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography";
@@ -11,7 +15,7 @@ import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube";
import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
import { HocuspocusProvider } from "@hocuspocus/provider";
import {
Comment,
@@ -37,12 +41,11 @@ import {
Embed,
SearchAndReplace,
Mention,
TableDndExtension,
Subpages,
TableDndExtension,
Heading,
Highlight,
UniqueID,
SharedStorage,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -94,9 +97,7 @@ lowlight.register("scala", scala);
export const mainExtensions = [
StarterKit.configure({
heading: false,
undoRedo: false,
link: false,
trailingNode: false,
history: false,
dropcursor: {
width: 3,
color: "#70CFF8",
@@ -108,7 +109,6 @@ export const mainExtensions = [
},
},
}),
SharedStorage,
Heading,
UniqueID.configure({
types: ["heading", "paragraph"],
@@ -134,6 +134,8 @@ export const mainExtensions = [
TaskItem.configure({
nested: true,
}),
ListKeymap,
Underline,
LinkExtension.configure({
openOnClick: false,
}),
@@ -168,9 +170,6 @@ export const mainExtensions = [
},
}).extend({
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(MentionView);
},
}),
@@ -209,7 +208,6 @@ export const mainExtensions = [
}),
CustomCodeBlock.configure({
view: CodeBlockView,
//@ts-ignore
lowlight,
HTMLAttributes: {
spellcheck: false,
@@ -260,9 +258,8 @@ type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
export const collabExtensions: CollabExtensions = (provider, user) => [
Collaboration.configure({
document: provider.document,
provider,
}),
CollaborationCaret.configure({
CollaborationCursor.configure({
provider,
user: {
name: user.name,
+113 -101
View File
@@ -1,22 +1,13 @@
import "@/features/editor/styles/index.css";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
HocuspocusProvider,
onStatusParameters,
onAuthenticationFailedParameters,
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
} from "@hocuspocus/provider";
import {
Editor,
EditorContent,
EditorProvider,
useEditor,
@@ -78,140 +69,161 @@ export default function PageEditor({
editable,
content,
}: PageEditorProps) {
const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null);
const editorCreated = useRef(false);
useEffect(() => {
isComponentMounted.current = true;
}, []);
const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(pageEditorAtom);
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const ydocRef = useRef<Y.Doc | null>(null);
if (!ydocRef.current) {
ydocRef.current = new Y.Doc();
}
const ydoc = ydocRef.current;
const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`;
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted],
);
const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
const localProvider = providersRef.current?.local;
const remoteProvider = providersRef.current?.remote;
// Track when collaborative provider is ready and synced
const [collabReady, setCollabReady] = useState(false);
useEffect(() => {
if (
remoteProvider?.status === WebSocketStatus.Connected &&
isLocalSynced &&
isRemoteSynced
) {
setCollabReady(true);
}
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
useEffect(() => {
if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setIsLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state);
};
const onAuthenticationFailedHandler = () => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
}
};
local.on("synced", () => setLocalSynced(true));
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
url: collaborationURL,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
connect: true,
preserveConnection: false,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
remote.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
remote.connect();
}, 100);
}
});
}
},
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
});
local.on("synced", onLocalSyncedHandler);
providersRef.current = { socket, local, remote };
remote.on("synced", () => setRemoteSynced(true));
remote.on("disconnect", () => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
});
providersRef.current = { local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
};
}, [pageId]);
/*
useEffect(() => {
// Handle token updates by reconnecting with new token
if (providersRef.current?.remote && collabQuery?.token) {
const currentToken = providersRef.current.remote.configuration.token;
if (currentToken !== collabQuery.token) {
// Token has changed, need to reconnect with new token
providersRef.current.remote.disconnect();
providersRef.current.remote.configuration.token = collabQuery.token;
providersRef.current.remote.connect();
}
}
}, [collabQuery?.token]);
*/
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket;
const remoteProvider = providersRef.current.remote;
if (
isIdle &&
documentState === "hidden" &&
yjsConnectionStatus === WebSocketStatus.Connected
remoteProvider.status === WebSocketStatus.Connected
) {
socket.disconnect();
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
yjsConnectionStatus === WebSocketStatus.Disconnected
remoteProvider.status === WebSocketStatus.Disconnected
) {
resetIdle();
socket.connect();
remoteProvider.connect();
setTimeout(() => setIsCollabReady(true), 500);
}
}, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
const extensions = useMemo(() => {
if (!providersReady || !providersRef.current || !currentUser?.user) {
return mainExtensions;
}
const remoteProvider = providersRef.current.remote;
if (!remoteProvider || !currentUser?.user) return mainExtensions;
return [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
];
}, [providersReady, currentUser?.user]);
}, [remoteProvider, currentUser?.user]);
const editor = useEditor(
{
@@ -254,30 +266,18 @@ export default function PageEditor({
}
},
},
handlePaste: (_view, event) => {
if (!editorRef.current) return false;
return handlePaste(
editorRef.current,
event,
pageId,
currentUser?.user.id,
);
},
handleDrop: (_view, event, _slice, moved) => {
if (!editorRef.current) return false;
return handleFileDrop(editorRef.current, event, moved, pageId);
},
handlePaste: (view, event, slice) =>
handlePaste(view, event, pageId, currentUser?.user.id),
handleDrop: (view, event, _slice, moved) =>
handleFileDrop(view, event, moved, pageId),
},
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setEditor(editor);
// @ts-ignore
editor.storage.pageId = pageId;
handleScrollTo(editor);
editorRef.current = editor;
editorCreated.current = true;
}
},
onUpdate({ editor }) {
@@ -287,7 +287,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson);
},
},
[pageId, editable, extensions],
[pageId, editable, remoteProvider],
);
const editorIsEditable = useEditorState({
@@ -343,17 +343,30 @@ export default function PageEditor({
setAsideState({ tab: "", isAsideOpen: false });
}, [pageId]);
useEffect(() => {
if (remoteProvider?.status === WebSocketStatus.Connecting) {
const timeout = setTimeout(() => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
}, 5000);
return () => clearTimeout(timeout);
}
}, [remoteProvider?.status]);
const isSynced = isLocalSynced && isRemoteSynced;
useEffect(() => {
const timeout = setTimeout(() => {
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
const collabReadyTimeout = setTimeout(() => {
if (
!isCollabReady &&
isSynced &&
remoteProvider?.status === WebSocketStatus.Connected
) {
setIsCollabReady(true);
}
}, 7500);
}, 500);
return () => clearTimeout(collabReadyTimeout);
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
return () => clearTimeout(timeout);
}, [yjsConnectionStatus, isSynced]);
useEffect(() => {
// Only honor user default page edit mode preference and permissions
if (editor) {
@@ -375,13 +388,12 @@ export default function PageEditor({
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
yjsConnectionStatus === WebSocketStatus.Connected &&
isSynced
remoteProvider?.status === WebSocketStatus.Connected
) {
hasConnectedOnceRef.current = true;
setShowStatic(false);
}
}, [yjsConnectionStatus, isSynced]);
}, [remoteProvider?.status]);
if (showStatic) {
return (
@@ -81,7 +81,6 @@ export default function ReadonlyPageEditor({
onCreate={({ editor }) => {
if (editor) {
if (pageId) {
// @ts-ignore
editor.storage.pageId = pageId;
}
// @ts-ignore
@@ -1,5 +1,5 @@
/* Give a remote user a caret */
.collaboration-carets__caret {
.collaboration-cursor__caret {
border-left: 1px solid #0d0d0d;
border-right: 1px solid #0d0d0d;
margin-left: -1px;
@@ -10,7 +10,7 @@
}
/* Render the username above the caret */
.collaboration-carets__label {
.collaboration-cursor__label {
border-radius: 3px 3px 3px 0;
color: #0d0d0d;
font-size: 0.75rem;
@@ -1,7 +1,5 @@
import api from "@/lib/api-client";
import { IFileTask } from "@/features/file-task/types/file-task.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { IApiKey } from "@/ee/api-key";
export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
const req = await api.post<IFileTask>("/file-tasks/info", {
@@ -10,10 +8,7 @@ export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
return req.data;
}
export async function getFileTasks(
params?: QueryParams,
): Promise<IPagination<IFileTask>> {
const req = await api.post("/file-tasks", { ...params });
export async function getFileTasks(): Promise<IFileTask[]> {
const req = await api.post<IFileTask[]>("/file-tasks");
return req.data;
}
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { zodResolver } from 'mantine-form-zod-resolver';
const formSchema = z.object({
name: z.string().trim().min(2).max(100),
name: z.string().trim().min(2).max(50),
description: z.string().max(500),
});
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import { zodResolver } from "mantine-form-zod-resolver";
const formSchema = z.object({
name: z.string().min(2).max(100),
name: z.string().min(2).max(50),
description: z.string().max(500),
});
@@ -10,7 +10,6 @@ import Paginate from "@/components/common/paginate.tsx";
import { queryClient } from "@/main.tsx";
import { getSpaces } from "@/features/space/services/space-service.ts";
import { getGroupMembers } from "@/features/group/services/group-service.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
export default function GroupList() {
const { t } = useTranslation();
@@ -51,10 +50,10 @@ export default function GroupList() {
>
<Group gap="sm" wrap="nowrap">
<IconGroupCircle />
<div style={{ minWidth: 0, overflow: "hidden" }}>
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{group.name}
</AutoTooltipText>
</Text>
<Text fz="xs" c="dimmed" lineClamp={2}>
{group.description}
</Text>
@@ -67,7 +67,7 @@ function HistoryList({ pageId }: Props) {
mainEditorTitle
.chain()
.clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true })
.setContent(activeHistoryData.title, true)
.run();
mainEditor
.chain()
@@ -9,14 +9,20 @@ import {
IconList,
IconMessage,
IconPrinter,
IconSearch,
IconTrash,
IconWifiOff,
} from "@tabler/icons-react";
import React, { useEffect, useRef, useState } from "react";
import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom, useAtomValue } from "jotai";
import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
import {
getHotkeyHandler,
useClipboard,
useDisclosure,
useHotkeys,
} from "@mantine/hooks";
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -32,7 +38,8 @@ import {
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { formattedDate } from "@/lib/time.ts";
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
@@ -44,6 +51,7 @@ interface PageHeaderMenuProps {
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation();
const toggleAside = useToggleAside();
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
useHotkeys(
[
@@ -67,7 +75,17 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
return (
<>
<ConnectionWarning />
{yjsConnectionStatus === "disconnected" && (
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
<IconWifiOff size={20} stroke={2} />
</ActionIcon>
</Tooltip>
)}
{!readOnly && <PageStateSegmentedControl size="xs" />}
@@ -272,49 +290,3 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</>
);
}
function ConnectionWarning() {
const { t } = useTranslation();
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
const [showWarning, setShowWarning] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const isDisconnected = ["disconnected", "connecting"].includes(yjsConnectionStatus);
if (isDisconnected) {
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
}
} else {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setShowWarning(false);
}
}, [yjsConnectionStatus]);
// Cleanup only on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
if (!showWarning) return null;
return (
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
<IconWifiOff size={20} stroke={2} />
</ActionIcon>
</Tooltip>
);
}
@@ -94,9 +94,9 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
spaceId,
});
const [, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>(null);
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
const rootElement = useRef<HTMLDivElement>(null);
const rootElement = useRef<HTMLDivElement>();
const [isRootReady, setIsRootReady] = useState(false);
const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef((element) => {
@@ -269,15 +269,12 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const prefetchPage = () => {
timerRef.current = setTimeout(async () => {
const page = await queryClient.fetchQuery({
queryKey: ["pages", node.data.id],
queryFn: () => getPageById({ pageId: node.data.id }),
timerRef.current = setTimeout(() => {
queryClient.prefetchQuery({
queryKey: ["pages", node.data.slugId],
queryFn: () => getPageById({ pageId: node.data.slugId }),
staleTime: 5 * 60 * 1000,
});
if (page?.slugId) {
queryClient.setQueryData(["pages", page.slugId], page);
}
}, 150);
};
@@ -8,6 +8,7 @@ import {
Switch,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
import React, { useEffect, useMemo, useState } from "react";
@@ -20,12 +21,12 @@ import {
import { Link, useNavigate, useParams } from "react-router-dom";
import { extractPageSlugId, getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl, isCloud } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css";
import useTrial from "@/ee/hooks/use-trial.tsx";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
interface ShareModalProps {
readOnly: boolean;
@@ -34,9 +35,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { pageSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug);
const { data: page } = usePageQuery({ pageId: pageSlugId });
const pageId = page?.id;
const pageId = extractPageSlugId(pageSlug);
const { data: share } = useShareForPageQuery(pageId);
const { spaceSlug } = useParams();
const { isTrial } = useTrial();
@@ -36,7 +36,7 @@ export default function SharedTree({ sharedPageTree }: SharedTree) {
const [tree, setTree] = useState<
TreeApi<SharedPageTreeNode> | null | undefined
>(null);
const rootElement = useRef<HTMLDivElement>(null);
const rootElement = useRef<HTMLDivElement>();
const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef(rootElement, sizeRef);
const { pageSlug } = useParams();
@@ -27,7 +27,9 @@ import {
getShares,
updateShare,
} from "@/features/share/services/share-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { useEffect } from "react";
export function useGetSharesQuery(
params?: QueryParams,
@@ -70,7 +72,7 @@ export function useShareForPageQuery(
queryKey: ["share-for-page", pageId],
queryFn: () => getShareForPage(pageId),
enabled: !!pageId,
staleTime: 60 * 1000,
staleTime: 0,
retry: false,
});
@@ -1,7 +1,6 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useEffect } from "react";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod";
import { useNavigate } from "react-router-dom";
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
@@ -10,12 +9,12 @@ import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().trim().min(2).max(100),
name: z.string().trim().min(2).max(50),
slug: z
.string()
.trim()
.min(2)
.max(100)
.max(50)
.regex(
/^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters",
@@ -7,12 +7,12 @@ import { ISpace } from "@/features/space/types/space.types.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(2).max(100),
description: z.string().max(500),
name: z.string().min(2).max(50),
description: z.string().max(250),
slug: z
.string()
.min(2)
.max(100)
.max(50)
.regex(
/^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters",
@@ -8,7 +8,6 @@ import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
export default function SpaceList() {
const { t } = useTranslation();
@@ -49,10 +48,10 @@ export default function SpaceList() {
variant="filled"
name={space.name}
/>
<div style={{ minWidth: 0, overflow: "hidden" }}>
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{space.name}
</AutoTooltipText>
</Text>
<Text fz="xs" c="dimmed" lineClamp={2}>
{space.description}
</Text>
@@ -27,7 +27,6 @@ import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { SearchInput } from "@/components/common/search-input.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
type MemberType = "user" | "group";
@@ -139,10 +138,10 @@ export default function SpaceMembersList({
{member.type === "group" && <IconGroupCircle />}
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 260 }}>
<AutoTooltipText fz="sm" fw={500}>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{member?.name}
</AutoTooltipText>
</Text>
<Text fz="xs" c="dimmed">
{member.type == "user" && member?.email}
@@ -23,7 +23,6 @@ import SpaceSettingsModal from "@/features/space/components/settings-modal";
import classes from "./all-spaces-list.module.css";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
interface AllSpacesListProps {
spaces: any[];
@@ -97,10 +96,10 @@ export default function AllSpacesList({
variant="filled"
size="md"
/>
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 350 }}>
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{space.name}
</AutoTooltipText>
</Text>
{space.description && (
<Text fz="xs" c="dimmed" lineClamp={2}>
{space.description}
-34
View File
@@ -1,34 +0,0 @@
import { MantineColor } from "@mantine/core";
function hashCode(input: string) {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
const char = input.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0;
}
return hash;
}
const defaultColors: MantineColor[] = [
"blue",
"cyan",
"grape",
"green",
"indigo",
"lime",
"orange",
"pink",
"red",
"teal",
"violet",
];
export function getInitialsColor(
name: string,
colors: MantineColor[] = defaultColors,
) {
const hash = hashCode(name);
const index = Math.abs(hash) % colors.length;
return colors[index];
}
+28 -30
View File
@@ -30,80 +30,77 @@
"test:e2e": "jest --config test/jest-e2e.json"
},
"dependencies": {
"@ai-sdk/google": "^3.0.9",
"@ai-sdk/openai": "^3.0.11",
"@ai-sdk/openai-compatible": "^2.0.12",
"@ai-sdk/azure": "^2.0.47",
"@ai-sdk/google": "^2.0.18",
"@ai-sdk/openai": "^2.0.46",
"@aws-sdk/client-s3": "3.701.0",
"@aws-sdk/lib-storage": "3.701.0",
"@aws-sdk/s3-request-presigner": "3.701.0",
"@casl/ability": "^6.7.3",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.3.0",
"@fastify/static": "^8.3.0",
"@langchain/core": "1.1.13",
"@langchain/textsplitters": "1.0.1",
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.2.0",
"@langchain/textsplitters": "^0.1.0",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.11",
"@nestjs/common": "^11.1.9",
"nestjs-cls": "^4.5.0",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.11",
"@nestjs/core": "^11.1.9",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.11",
"@nestjs/platform-socket.io": "^11.1.11",
"@nestjs/schedule": "^6.1.0",
"@nestjs/platform-fastify": "^11.1.9",
"@nestjs/platform-socket.io": "^11.1.9",
"@nestjs/schedule": "^6.0.1",
"@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.11",
"@nestjs/websockets": "^11.1.9",
"@node-saml/passport-saml": "^5.1.0",
"@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",
"ai": "^5.0.65",
"ai-sdk-ollama": "^0.12.0",
"bcrypt": "^6.0.0",
"bullmq": "^5.65.0",
"cache-manager": "^6.4.3",
"cheerio": "^1.1.2",
"cheerio": "^1.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"cookie": "^1.1.1",
"fs-extra": "^11.3.3",
"happy-dom": "20.1.0",
"cookie": "^1.0.2",
"fs-extra": "^11.3.0",
"happy-dom": "20.0.10",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.3",
"jsonwebtoken": "^9.0.2",
"kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2",
"kysely-postgres-js": "^3.0.0",
"ldapts": "^7.4.0",
"mammoth": "^1.11.0",
"mime-types": "^2.1.35",
"nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0",
"nestjs-pino": "^4.5.0",
"nodemailer": "^7.0.12",
"nodemailer": "^7.0.11",
"openid-client": "^5.7.1",
"otpauth": "^9.4.1",
"otpauth": "^9.4.0",
"p-limit": "^6.2.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"pdfjs-dist": "^5.4.394",
"pg": "^8.16.3",
"pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1",
"postgres": "^3.4.8",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
"postmark": "^4.0.5",
"react": "^18.3.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sanitize-filename-ts": "1.0.2",
"sharp": "0.34.3",
"socket.io": "^4.8.3",
"socket.io": "^4.8.1",
"stripe": "^17.5.0",
"tmp-promise": "^3.0.3",
"typesense": "^2.1.0",
"ws": "^8.19.0",
"ws": "^8.18.3",
"yauzl": "^3.2.0"
},
"devDependencies": {
@@ -120,6 +117,7 @@
"@types/nodemailer": "^6.4.17",
"@types/passport-google-oauth20": "^2.0.16",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.11.11",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.14",
"@types/yauzl": "^2.10.3",
@@ -127,7 +125,7 @@
"eslint-config-prettier": "^10.0.1",
"globals": "^15.15.0",
"jest": "^29.7.0",
"kysely-codegen": "^0.19.0",
"kysely-codegen": "^0.17.0",
"prettier": "^3.5.1",
"react-email": "3.0.2",
"source-map-support": "^0.5.21",
+14 -3
View File
@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuditActorInterceptor } from './common/interceptors/audit-actor.interceptor';
import { CoreModule } from './core/core.module';
import { EnvironmentModule } from './integrations/environment/environment.module';
import { CollaborationModule } from './collaboration/collaboration.module';
@@ -18,7 +20,7 @@ import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service';
import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
const enterpriseModules = [];
try {
@@ -36,7 +38,10 @@ try {
@Module({
imports: [
LoggerModule,
ClsModule.forRoot({
global: true,
middleware: { mount: true },
}),
CoreModule,
DatabaseModule,
EnvironmentModule,
@@ -62,6 +67,12 @@ try {
...enterpriseModules,
],
controllers: [AppController],
providers: [AppService],
providers: [
AppService,
{
provide: APP_INTERCEPTOR,
useClass: AuditActorInterceptor,
},
],
})
export class AppModule {}
@@ -26,7 +26,7 @@ export class CollaborationGateway {
) {
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
this.hocuspocus = new Hocuspocus({
this.hocuspocus = HocuspocusServer.configure({
debounce: 10000,
maxDebounce: 45000,
unloadImmediately: false,
@@ -65,6 +65,6 @@ export class CollaborationGateway {
}
async destroy(): Promise<void> {
//await this.hocuspocus.destroy();
await this.hocuspocus.destroy();
}
}
@@ -1,12 +1,14 @@
import { StarterKit } from '@tiptap/starter-kit';
import { TextAlign } from '@tiptap/extension-text-align';
import { TaskList } from '@tiptap/extension-task-list';
import { TaskItem } from '@tiptap/extension-task-item';
import { Underline } from '@tiptap/extension-underline';
import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript';
import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube';
import { TaskList, TaskItem } from '@tiptap/extension-list';
import {
Heading,
Callout,
@@ -40,14 +42,11 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089
//import { generateJSON } from '@tiptap/html';
import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [
StarterKit.configure({
codeBlock: false,
link: false,
trailingNode: false,
heading: false,
}),
Heading,
@@ -60,6 +59,7 @@ export const tiptapExtensions = [
TaskItem.configure({
nested: true,
}),
Underline,
LinkExtension,
Superscript,
SubScript,
@@ -69,7 +69,7 @@ export class AuthenticationExtension implements Extension {
}
if (userSpaceRole === SpaceRole.READER) {
data.connectionConfig.readOnly = true;
data.connection.readOnly = true;
this.logger.debug(`User granted readonly access to page: ${pageId}`);
}
@@ -8,11 +8,9 @@ import { QueueModule } from '../../integrations/queue/queue.module';
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';
@Module({
imports: [
LoggerModule,
DatabaseModule,
EnvironmentModule,
CollaborationModule,
@@ -5,8 +5,8 @@ import {
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
import { InternalLogFilter } from '../../common/logger/internal-log-filter';
import { Logger } from '@nestjs/common';
import { Logger as PinoLogger } from 'nestjs-pino';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
@@ -17,12 +17,10 @@ async function bootstrap() {
maxParamLength: 500,
}),
{
bufferLogs: true,
logger: new InternalLogFilter(),
},
);
app.useLogger(app.get(PinoLogger));
app.setGlobalPrefix('api', { exclude: ['/'] });
app.enableCors();
@@ -0,0 +1,101 @@
export const AuditEvent = {
// Workspace Invitations
WORKSPACE_CREATED: 'workspace.created',
WORKSPACE_INVITE_CREATED: 'workspace.invite_created',
WORKSPACE_INVITE_REVOKED: 'workspace.invite_revoked',
WORKSPACE_INVITE_ACCEPTED: 'workspace.invite_accepted',
WORKSPACE_USER_CREATED: 'workspace.user_created',
WORKSPACE_USER_DEACTIVATED: 'workspace.user_deactivated',
WORKSPACE_ALLOWED_DOMAIN_UPDATED: 'workspace.allowed_domain_updated',
WORKSPACE_ICON_CHANGED: 'workspace.icon_changed',
WORKSPACE_NAME_CHANGED: 'workspace.name_changed',
WORKSPACE_AI_TOGGLED: 'workspace.ai_toggled',
USER_CREATED: 'user.created',
USER_DELETED: 'user.deleted',
USER_LOGIN: 'user.login',
USER_LOGOUT: 'user.logout',
USER_ROLE_CHANGED: 'user.user_role_changed',
USER_PASSWORD_CHANGED: 'user.password_changed',
USER_PASSWORD_RESET: 'user.reset_password',
USER_PHOTO_CHANGED: 'user.reset_password',
USER_NAME_CHANGED: 'user.name_changed',
USER_EMAIL_CHANGED: 'user.email_changed',
USER_MFA_SETUP: 'user.mfa_setup',
USER_MFA_BACKUP_CODE_GENERATED: 'user.mfa_backup_code_generated',
// API Keys
API_KEY_CREATED: 'api_key.created',
API_KEY_UPDATED: 'api_key.updated',
API_KEY_DELETED: 'api_key.deleted',
// Space
SPACE_CREATED: 'space.created',
SPACE_UPDATED: 'space.updated',
SPACE_DELETED: 'space.deleted',
SPACE_MEMBER_ADDED: 'space.member_added',
SPACE_MEMBER_REMOVED: 'space.member_removed',
SPACE_MEMBER_ROLE_CHANGED: 'space.member_role_changed',
// OR SPACE_USER_ADDED: 'space.user_added',
// SPACE_GROUP_ADDED: 'space.group_added',
// GROUP
GROUP_CREATED: 'group.created',
GROUP_UPDATED: 'group.updated',
GROUP_DELETED: 'group.deleted',
GROUP_MEMBER_ADDED: 'group.member_added',
GROUP_MEMBER_REMOVED: 'group.member_removed',
// Comments
COMMENT_CREATED: 'comment.created',
COMMENT_UPDATED: 'comment.updated',
COMMENT_DELETED: 'comment.deleted',
COMMENT_RESOLVED: 'comment.resolved',
COMMENT_REOPENED: 'comment.reopened',
// PAGE
PAGE_CREATED: 'page.created',
PAGE_UPDATED: 'page.updated',
PAGE_TRASHED: 'page.trash',
PAGE_DELETED: 'page.deleted',
PAGE_SHARED: 'page.shared',
ATTACHMENT_UPLOADED: 'attachment.uploaded',
PAGE_IMPORTED: 'page.imported',
PAGE_RESTORED: 'page.restored',
PAGE_EXPORTED: 'page.exported',
SPACE_EXPORTED: 'space.imported',
// SSO EVENTS
} as const;
export type AuditEventType = (typeof AuditEvent)[keyof typeof AuditEvent];
export type ActorType = 'user' | 'system' | 'api_key';
export interface AuditLogPayload {
event: AuditEventType;
resourceType: string;
resourceId?: string;
changes?: {
before?: Record<string, any>;
after?: Record<string, any>;
};
metadata?: Record<string, any>;
}
export interface AuditLogData extends AuditLogPayload {
workspaceId: string;
actorId?: string;
actorType: ActorType;
ipAddress?: string;
userAgent?: string;
}
@@ -1,71 +0,0 @@
// https://github.com/WebReflection/html-escaper
/**
* Copyright (C) 2017-present by Andrea Giammarchi - @WebReflection
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
const { replace } = '';
// escape
const es = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g;
const ca = /[&<>'"]/g;
const esca = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
};
const pe = (m) => esca[m];
/**
* Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`.
* @param {string} es the input to safely escape
* @returns {string} the escaped input, and it **throws** an error if
* the input type is unexpected, except for boolean and numbers,
* converted as string.
*/
export const htmlEscape = (es) => replace.call(es, ca, pe);
// unescape
const unes = {
'&amp;': '&',
'&#38;': '&',
'&lt;': '<',
'&#60;': '<',
'&gt;': '>',
'&#62;': '>',
'&apos;': "'",
'&#39;': "'",
'&quot;': '"',
'&#34;': '"',
};
const cape = (m) => unes[m];
/**
* Safely unescape previously escaped entities such as `&`, `<`, `>`, `"`,
* and `'`.
* @param {string} un a previously escaped string
* @returns {string} the unescaped input, and it **throws** an error if
* the input type is unexpected, except for boolean and numbers,
* converted as string.
*/
export const htmlUnescape = (un) => replace.call(un, es, cape);
-36
View File
@@ -2,7 +2,6 @@ import * as path from 'path';
import * as bcrypt from 'bcrypt';
import { sanitize } from 'sanitize-filename-ts';
import { FastifyRequest } from 'fastify';
import { Readable, Transform } from 'stream';
export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
@@ -99,38 +98,3 @@ export function hasLicenseOrEE(opts: {
const { licenseKey, plan, isCloud } = opts;
return Boolean(licenseKey) || (isCloud && plan === 'business');
}
/**
* Normalizes a database URL for postgres.js compatibility.
* - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values
* - Removes `schema` parameter (has no effect via connection string)
* Note: If we don't strip them, the connection will fail
*/
export function normalizePostgresUrl(url: string): string {
const parsed = new URL(url);
const newParams = new URLSearchParams();
for (const [key, value] of parsed.searchParams) {
if (key === 'sslmode' && value === 'no-verify') continue;
if (key === 'schema') continue;
newParams.append(key, value);
}
parsed.search = newParams.toString();
return parsed.toString();
}
export function createByteCountingStream(source: Readable) {
let bytesRead = 0;
const stream = new Transform({
transform(chunk, encoding, callback) {
bytesRead += chunk.length;
callback(null, chunk);
},
});
source.pipe(stream);
source.on('error', (err) => stream.emit('error', err));
return { stream, getBytesRead: () => bytesRead };
}
@@ -0,0 +1,29 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { ClsService } from 'nestjs-cls';
import { AuditContext, AUDIT_CONTEXT_KEY } from '../middlewares/audit-context.middleware';
@Injectable()
export class AuditActorInterceptor implements NestInterceptor {
constructor(private readonly cls: ClsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const user = request.user?.user;
if (user?.id) {
const auditContext = this.cls.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (auditContext) {
auditContext.actorId = user.id;
this.cls.set(AUDIT_CONTEXT_KEY, auditContext);
}
}
return next.handle();
}
}
@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { LoggerModule as PinoLoggerModule } from 'nestjs-pino';
import { createPinoConfig } from './pino.config';
@Module({
imports: [PinoLoggerModule.forRoot(createPinoConfig())],
exports: [PinoLoggerModule],
})
export class LoggerModule {}
@@ -1,77 +0,0 @@
import { Params } from 'nestjs-pino';
import { stdTimeFunctions } from 'pino';
const CONTEXTS_TO_IGNORE = [
'InstanceLoader',
'RoutesResolver',
'RouterExplorer',
'WebSocketsController',
];
export function createPinoConfig(): Params {
const isProduction = process.env.NODE_ENV === 'production';
const isDebugMode = process.env.DEBUG_MODE === 'true';
const logHttp = process.env.LOG_HTTP === 'true';
const level = isProduction && !isDebugMode ? 'info' : 'debug';
return {
pinoHttp: {
level,
timestamp: stdTimeFunctions.isoTime,
transport: !isProduction
? {
target: 'pino-pretty',
options: {
colorize: true,
singleLine: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
}
: undefined,
formatters: {
level: (label) => ({ level: label }),
log: (object: Record<string, unknown>) => {
if (isProduction && !isDebugMode) {
const context = object['context'] as string | undefined;
if (context && CONTEXTS_TO_IGNORE.includes(context)) {
return { filtered: true };
}
}
return object;
},
},
serializers: {
req: (req) => {
const forwardedFor = req.headers?.['x-forwarded-for'];
const ip =
req.headers?.['cf-connecting-ip'] ||
(typeof forwardedFor === 'string' ? forwardedFor.split(',')[0]?.trim() : undefined) ||
req.remoteAddress;
return {
method: req.method,
url: req.url,
ip,
userAgent: req.headers?.['user-agent'],
};
},
res: (res) => ({
statusCode: res.statusCode,
}),
},
customLogLevel: (_req, res, err) => {
if (res.statusCode >= 500 || err) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
autoLogging: logHttp
? {
ignore: (req) =>
req.url === '/api/health' || req.url === '/api/health/live',
}
: false,
},
};
}
@@ -0,0 +1,50 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';
import { ClsService } from 'nestjs-cls';
export interface AuditContext {
workspaceId: string | null;
actorId: string | null;
actorType: 'user' | 'system' | 'api_key';
ipAddress: string | null;
}
export const AUDIT_CONTEXT_KEY = 'auditContext';
@Injectable()
export class AuditContextMiddleware implements NestMiddleware {
constructor(private readonly cls: ClsService) {}
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void) {
const workspaceId = (req as any).workspaceId ?? null;
const ipAddress = this.extractIpAddress(req);
const auditContext: AuditContext = {
workspaceId,
actorId: null,
actorType: 'user',
ipAddress,
};
this.cls.set(AUDIT_CONTEXT_KEY, auditContext);
next();
}
private extractIpAddress(req: FastifyRequest['raw']): string | null {
const xForwardedFor = req.headers['x-forwarded-for'];
if (xForwardedFor) {
const ips = Array.isArray(xForwardedFor)
? xForwardedFor[0]
: xForwardedFor.split(',')[0];
return ips?.trim() ?? null;
}
const xRealIp = req.headers['x-real-ip'];
if (xRealIp) {
return Array.isArray(xRealIp) ? xRealIp[0] : xRealIp;
}
return (req as any).socket?.remoteAddress ?? null;
}
}
@@ -5,17 +5,15 @@ import { sanitizeFileName } from '../../common/helpers';
import * as sharp from 'sharp';
export interface PreparedFile {
buffer?: Buffer;
buffer: Buffer;
fileName: string;
fileSize: number;
fileExtension: string;
mimeType: string;
multiPartFile?: MultipartFile;
}
export async function prepareFile(
filePromise: Promise<MultipartFile>,
options: { skipBuffer?: boolean } = {},
): Promise<PreparedFile> {
const file = await filePromise;
@@ -24,16 +22,10 @@ export async function prepareFile(
}
try {
let buffer: Buffer | undefined;
let fileSize = 0;
if (!options.skipBuffer) {
buffer = await file.toBuffer();
fileSize = buffer.length;
}
const buffer = await file.toBuffer();
const sanitizedFilename = sanitizeFileName(file.filename);
const fileName = sanitizedFilename.slice(0, 255);
const fileSize = buffer.length;
const fileExtension = path.extname(file.filename).toLowerCase();
return {
@@ -42,7 +34,6 @@ export async function prepareFile(
fileSize,
fileExtension,
mimeType: file.mimetype,
multiPartFile: file,
};
} catch (error) {
throw error;
@@ -4,7 +4,6 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { Readable } from 'stream';
import { StorageService } from '../../../integrations/storage/storage.service';
import { MultipartFile } from '@fastify/multipart';
import {
@@ -27,7 +26,6 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { createByteCountingStream } from '../../../common/helpers/utils';
@Injectable()
export class AttachmentService {
@@ -51,9 +49,7 @@ export class AttachmentService {
attachmentId?: string;
}) {
const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
const preparedFile: PreparedFile = await prepareFile(filePromise, {
skipBuffer: true,
});
const preparedFile: PreparedFile = await prepareFile(filePromise);
let isUpdate = false;
let attachmentId = null;
@@ -85,14 +81,7 @@ export class AttachmentService {
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
const { stream, getBytesRead } = createByteCountingStream(
preparedFile.multiPartFile.file,
);
await this.uploadToDrive(filePath, stream);
// Update fileSize from the consumed stream
preparedFile.fileSize = getBytesRead();
await this.uploadToDrive(filePath, preparedFile.buffer);
let attachment: Attachment = null;
try {
@@ -153,10 +142,7 @@ export class AttachmentService {
const preparedFile: PreparedFile = await prepareFile(filePromise);
validateFileType(preparedFile.fileExtension, validImageExtensions);
const processedBuffer = await compressAndResizeIcon(
preparedFile.buffer,
type,
);
const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type);
preparedFile.buffer = processedBuffer;
preparedFile.fileSize = processedBuffer.length;
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
@@ -246,9 +232,9 @@ export class AttachmentService {
}
}
async uploadToDrive(filePath: string, fileContent: Buffer | Readable) {
async uploadToDrive(filePath: string, fileBuffer: any) {
try {
await this.storageService.upload(filePath, fileContent);
await this.storageService.upload(filePath, fileBuffer);
} catch (err) {
this.logger.error('Error uploading file to drive:', err);
throw new BadRequestException('Error uploading file to drive');
+26 -6
View File
@@ -15,7 +15,13 @@ import { SpaceModule } from './space/space.module';
import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware';
import { ShareModule } from './share/share.module';
import {
AUDIT_SERVICE,
NoopAuditService,
} from '../integrations/audit/audit.service';
import { ClsMiddleware } from 'nestjs-cls';
@Module({
imports: [
@@ -31,17 +37,31 @@ import { ShareModule } from './share/share.module';
CaslModule,
ShareModule,
],
providers: [
{
provide: AUDIT_SERVICE,
useClass: NoopAuditService,
},
],
exports: [AUDIT_SERVICE],
})
export class CoreModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
const excludedRoutes = [
{ path: 'auth/setup', method: RequestMethod.POST },
{ path: 'health', method: RequestMethod.GET },
{ path: 'health/live', method: RequestMethod.GET },
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
];
consumer
.apply(DomainMiddleware)
.exclude(
{ path: 'auth/setup', method: RequestMethod.POST },
{ path: 'health', method: RequestMethod.GET },
{ path: 'health/live', method: RequestMethod.GET },
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
)
.exclude(...excludedRoutes)
.forRoutes('*');
consumer
.apply(AuditContextMiddleware)
.exclude(...excludedRoutes)
.forRoutes('*');
}
}
@@ -11,7 +11,7 @@ import {Transform, TransformFnParams} from "class-transformer";
export class CreateGroupDto {
@MinLength(2)
@MaxLength(100)
@MaxLength(50)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
+10 -7
View File
@@ -74,13 +74,16 @@ export class SearchService {
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
} else if (opts.userId && !searchParams.spaceId) {
// only search spaces the user is a member of
queryResults = queryResults
.where(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId),
)
.where('workspaceId', '=', opts.workspaceId);
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(
opts.userId,
);
if (userSpaceIds.length > 0) {
queryResults = queryResults
.where('spaceId', 'in', userSpaceIds)
.where('workspaceId', '=', opts.workspaceId);
} else {
return [];
}
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
// search in shares
const shareId = searchParams.shareId;
@@ -7,7 +7,6 @@ import { validate as isValidUUID } from 'uuid';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { Workspace } from '@docmost/db/types/entity.types';
import { htmlEscape } from '../../common/helpers/html-escaper';
@Controller('share')
export class ShareSeoController {
@@ -69,7 +68,7 @@ export class ShareSeoController {
return this.sendIndex(indexFilePath, res);
}
const rawTitle = htmlEscape(share?.sharedPage.title ?? 'untitled');
const rawTitle = share.sharedPage.title ?? 'untitled';
const metaTitle =
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}` : rawTitle;
+49 -51
View File
@@ -123,82 +123,80 @@ export class ShareService {
.withRecursive('page_hierarchy', (cte) =>
cte
.selectFrom('pages')
.leftJoin('shares', 'shares.pageId', 'pages.id')
.select([
'pages.id',
'pages.slugId',
'id',
'slugId',
'pages.title',
'pages.icon',
'pages.parentPageId',
'parentPageId',
sql`0`.as('level'),
'shares.id as shareId',
'shares.key as shareKey',
'shares.includeSubPages',
'shares.searchIndexing',
'shares.creatorId',
'shares.spaceId',
'shares.workspaceId',
'shares.createdAt',
])
.where(isValidUUID(pageId) ? 'pages.id' : 'pages.slugId', '=', pageId)
.where('pages.deletedAt', 'is', null)
.unionAll(
(union) =>
union
.selectFrom('pages as p')
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
.leftJoin('shares as s', 's.pageId', 'p.id')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.parentPageId',
sql`ph.level + 1`.as('level'),
's.id as shareId',
's.key as shareKey',
's.includeSubPages',
's.searchIndexing',
's.creatorId',
's.spaceId',
's.workspaceId',
's.createdAt',
])
.where('p.deletedAt', 'is', null)
.where(sql`ph.share_id`, 'is', null) // stop if share found
.where(sql`ph.level`, '<', sql`25`), // prevent loop
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
.where('deletedAt', 'is', null)
.unionAll((union) =>
union
.selectFrom('pages as p')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.parentPageId',
// Increase the level by 1 for each ancestor.
sql`ph.level + 1`.as('level'),
])
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_hierarchy')
.selectAll()
.where('shareId', 'is not', null)
.limit(1)
.leftJoin('shares', 'shares.pageId', 'page_hierarchy.id')
.select([
'page_hierarchy.id as sharedPageId',
'page_hierarchy.slugId as sharedPageSlugId',
'page_hierarchy.title as sharedPageTitle',
'page_hierarchy.icon as sharedPageIcon',
'page_hierarchy.level as level',
'shares.id',
'shares.key',
'shares.pageId',
'shares.includeSubPages',
'shares.searchIndexing',
'shares.creatorId',
'shares.spaceId',
'shares.workspaceId',
'shares.createdAt',
'shares.updatedAt',
])
.where('shares.id', 'is not', null)
.orderBy('page_hierarchy.level', 'asc')
.executeTakeFirst();
if (!share || share.workspaceId !== workspaceId) {
if (!share || share.workspaceId != workspaceId) {
return undefined;
}
if ((share.level as number) > 0 && !share.includeSubPages) {
if (share.level === 1 && !share.includeSubPages) {
// we can only show a page if its shared ancestor permits it
return undefined;
}
return {
id: share.shareId,
key: share.shareKey,
id: share.id,
key: share.key,
includeSubPages: share.includeSubPages,
searchIndexing: share.searchIndexing,
pageId: share.id,
pageId: share.pageId,
creatorId: share.creatorId,
spaceId: share.spaceId,
workspaceId: share.workspaceId,
createdAt: share.createdAt,
level: share.level,
sharedPage: {
id: share.id,
slugId: share.slugId,
title: share.title,
icon: share.icon,
id: share.sharedPageId,
slugId: share.sharedPageSlugId,
title: share.sharedPageTitle,
icon: share.sharedPageIcon,
},
};
}
@@ -9,7 +9,7 @@ import {Transform, TransformFnParams} from "class-transformer";
export class CreateSpaceDto {
@MinLength(2)
@MaxLength(100)
@MaxLength(50)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@@ -19,7 +19,7 @@ export class CreateSpaceDto {
description?: string;
@MinLength(2)
@MaxLength(100)
@MaxLength(50)
@IsAlphanumeric()
slug: string;
}
@@ -1,5 +1,6 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -14,6 +15,11 @@ import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
import { SpaceRole } from '../../../common/helpers/types/permission';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { AuditEvent } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class SpaceMemberService {
@@ -21,6 +27,7 @@ export class SpaceMemberService {
private spaceMemberRepo: SpaceMemberRepo,
private spaceRepo: SpaceRepo,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async addUserToSpace(
@@ -161,8 +168,43 @@ export class SpaceMemberService {
if (membersToAdd.length > 0) {
await this.spaceMemberRepo.insertSpaceMember(membersToAdd);
} else {
// either they are already members or do not exist on the workspace
// Audit log for each member added
for (const user of validUsers) {
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_ADDED,
resourceType: 'space_members',
resourceId: dto.spaceId,
changes: {
after: { role: dto.role },
},
metadata: {
spaceId: dto.spaceId,
spaceName: space.name,
userId: user.id,
userName: user.name,
memberType: 'user',
},
});
}
for (const group of validGroups) {
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_ADDED,
resourceType: 'space_members',
resourceId: dto.spaceId,
changes: {
after: { role: dto.role },
},
metadata: {
spaceId: dto.spaceId,
spaceName: space.name,
groupId: group.id,
groupName: group.name,
memberType: 'group',
},
});
}
}
}
@@ -209,6 +251,22 @@ export class SpaceMemberService {
spaceMember.id,
dto.spaceId,
);
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_REMOVED,
resourceType: 'space_member',
resourceId: dto.spaceId,
changes: {
before: { role: spaceMember.role },
},
metadata: {
spaceId: dto.spaceId,
spaceName: space.name,
userId: spaceMember.userId,
groupId: spaceMember.groupId,
memberType: spaceMember.userId ? 'user' : 'group',
},
});
}
async updateSpaceMemberRole(
@@ -259,6 +317,23 @@ export class SpaceMemberService {
spaceMember.id,
dto.spaceId,
);
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
resourceType: 'space_members',
resourceId: dto.spaceId,
changes: {
before: { role: spaceMember.role },
after: { role: dto.role },
},
metadata: {
spaceId: dto.spaceId,
spaceName: space.name,
userId: spaceMember.userId,
groupId: spaceMember.groupId,
memberType: spaceMember.userId ? 'user' : 'group',
},
});
}
async validateLastAdmin(spaceId: string): Promise<void> {
@@ -1,5 +1,6 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
@@ -33,6 +34,11 @@ import {
validateAllowedEmail,
validateSsoEnforcement,
} from '../../auth/auth.util';
import { AuditEvent } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class WorkspaceInvitationService {
@@ -46,6 +52,7 @@ export class WorkspaceInvitationService {
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
private readonly environmentService: EnvironmentService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async getInvitations(workspaceId: string, pagination: PaginationOptions) {
@@ -179,6 +186,24 @@ export class WorkspaceInvitationService {
workspace.hostname,
);
});
// Audit log for each invitation created
for (const invitation of invites) {
this.auditService.log({
event: AuditEvent.WORKSPACE_INVITE_CREATED,
resourceType: 'workspace_invitation',
resourceId: invitation.id,
changes: {
after: {
email: invitation.email,
role: invitation.role,
},
},
metadata: {
groupIds: invitation.groupIds,
},
});
}
}
}
@@ -344,11 +369,32 @@ export class WorkspaceInvitationService {
invitationId: string,
workspaceId: string,
): Promise<void> {
const invitation = await this.db
.selectFrom('workspaceInvitations')
.select(['id', 'email', 'role'])
.where('id', '=', invitationId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
await this.db
.deleteFrom('workspaceInvitations')
.where('id', '=', invitationId)
.where('workspaceId', '=', workspaceId)
.execute();
if (invitation) {
this.auditService.log({
event: AuditEvent.WORKSPACE_INVITE_REVOKED,
resourceType: 'workspace_invitation',
resourceId: invitation.id,
changes: {
before: {
email: invitation.email,
role: invitation.role,
},
},
});
}
}
async getInvitationLinkById(
@@ -1,6 +1,7 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
@@ -34,6 +35,11 @@ import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { generateRandomSuffixNumbers } from '../../../common/helpers';
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
import { AuditEvent } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class WorkspaceService {
@@ -52,6 +58,7 @@ export class WorkspaceService {
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async findById(workspaceId: string) {
@@ -428,6 +435,20 @@ export class WorkspaceService {
user.id,
workspaceId,
);
this.auditService.log({
event: AuditEvent.USER_ROLE_CHANGED,
resourceType: 'users',
resourceId: user.id,
changes: {
before: { role: user.role },
after: { role: newRole },
},
metadata: {
userName: user.name,
userEmail: user.email,
},
});
}
async generateHostname(
@@ -531,6 +552,19 @@ export class WorkspaceService {
.execute();
});
this.auditService.log({
event: AuditEvent.USER_DELETED,
resourceType: 'users',
resourceId: user.id,
changes: {
before: {
name: user.name,
email: user.email,
role: user.role,
},
},
});
try {
await this.attachmentQueue.add(QueueJob.DELETE_USER_AVATARS, user);
} catch (err) {
+20 -23
View File
@@ -7,7 +7,8 @@ import {
} from '@nestjs/common';
import { InjectKysely, KyselyModule } from 'nestjs-kysely';
import { EnvironmentService } from '../integrations/environment/environment.service';
import { CamelCasePlugin, LogEvent, sql } from 'kysely';
import { CamelCasePlugin, LogEvent, PostgresDialect, sql } from 'kysely';
import { Pool, types } from 'pg';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
@@ -25,9 +26,9 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PageListener } from '@docmost/db/listeners/page.listener';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
import { normalizePostgresUrl } from '../common/helpers';
// https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@Global()
@Module({
@@ -36,30 +37,26 @@ import { normalizePostgresUrl } from '../common/helpers';
imports: [],
inject: [EnvironmentService],
useFactory: (environmentService: EnvironmentService) => ({
dialect: new PostgresJSDialect({
postgres: postgres(
normalizePostgresUrl(environmentService.getDatabaseURL()),
{
max: environmentService.getDatabaseMaxPool(),
onnotice: () => {},
types: {
bigint: {
to: 20,
from: [20, 1700],
serialize: (value: number) => value.toString(),
parse: (value: string) => Number.parseInt(value),
},
},
},
),
dialect: new PostgresDialect({
pool: new Pool({
connectionString: environmentService.getDatabaseURL(),
max: environmentService.getDatabaseMaxPool(),
}).on('error', (err) => {
console.error('Database error:', err.message);
}),
}),
plugins: [new CamelCasePlugin()],
log: (event: LogEvent) => {
if (environmentService.getNodeEnv() !== 'development') return;
const logger = new Logger(DatabaseModule.name);
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
logger.debug(event.query.sql);
logger.debug('query time: ' + event.queryDurationMillis + ' ms');
if (event.level) {
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
logger.debug(event.query.sql);
logger.debug('query time: ' + event.queryDurationMillis + ' ms');
//if (event.query.parameters.length > 0) {
// logger.debug('parameters: ' + event.query.parameters);
//}
}
}
},
}),
+12 -6
View File
@@ -1,19 +1,25 @@
import * as path from 'path';
import { promises as fs } from 'fs';
import { Kysely, Migrator, FileMigrationProvider } from 'kysely';
import pg from 'pg';
import {
Kysely,
Migrator,
PostgresDialect,
FileMigrationProvider,
} from 'kysely';
import { run } from 'kysely-migration-cli';
import * as dotenv from 'dotenv';
import { envPath, normalizePostgresUrl } from '../common/helpers';
import { PostgresJSDialect } from 'kysely-postgres-js';
import postgres from 'postgres';
import { envPath } from '../common/helpers/utils';
dotenv.config({ path: envPath });
const migrationFolder = path.join(__dirname, './migrations');
const db = new Kysely<any>({
dialect: new PostgresJSDialect({
postgres: postgres(normalizePostgresUrl(process.env.DATABASE_URL)),
dialect: new PostgresDialect({
pool: new pg.Pool({
connectionString: process.env.DATABASE_URL,
}) as any,
}),
});
@@ -0,0 +1,32 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('audit_logs')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('actor_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('actor_type', 'varchar', (col) =>
col.notNull().defaultTo('user'),
)
.addColumn('event', 'varchar', (col) => col.notNull())
.addColumn('resource_type', 'varchar', (col) => col.notNull())
.addColumn('resource_id', 'uuid')
.addColumn('changes', 'jsonb')
.addColumn('metadata', 'jsonb')
.addColumn('ip_address', sql `inet`)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('audit_logs').execute();
}
@@ -293,18 +293,24 @@ export class PageRepo {
}
async getRecentPages(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const query = this.db
.selectFrom('pages')
.select(this.baseFields)
.select((eb) => this.withSpace(eb))
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
.where('spaceId', 'in', userSpaceIds)
.where('deletedAt', 'is', null)
.orderBy('updatedAt', 'desc');
return executeWithPagination(query, {
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
@@ -137,19 +137,25 @@ export class ShareRepo {
}
async getShares(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const query = this.db
.selectFrom('shares')
.select(this.baseFields)
.select((eb) => this.withPage(eb))
.select((eb) => this.withSpace(eb, userId))
.select((eb) => this.withCreator(eb))
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
.where('spaceId', 'in', userSpaceIds)
.orderBy('updatedAt', 'desc');
return executeWithPagination(query, {
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
withPage(eb: ExpressionBuilder<DB, 'shares'>) {
@@ -209,33 +209,34 @@ export class SpaceMemberRepo {
return roles;
}
getUserSpaceIdsQuery(userId: string) {
return this.db
async getUserSpaceIds(userId: string): Promise<string[]> {
const membership = await this.db
.selectFrom('spaceMembers')
.innerJoin('spaces', 'spaces.id', 'spaceMembers.spaceId')
.select('spaces.id')
.select(['spaces.id'])
.where('userId', '=', userId)
.union(
this.db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.innerJoin('spaces', 'spaces.id', 'spaceMembers.spaceId')
.select('spaces.id')
.select(['spaces.id'])
.where('groupUsers.userId', '=', userId),
);
}
)
.execute();
async getUserSpaceIds(userId: string): Promise<string[]> {
const membership = await this.getUserSpaceIdsQuery(userId).execute();
return membership.map((space) => space.id);
}
async getUserSpaces(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.getUserSpaceIds(userId);
let query = this.db
.selectFrom('spaces')
.selectAll()
.select((eb) => [this.spaceRepo.withMemberCount(eb)])
.where('id', 'in', this.getUserSpaceIdsQuery(userId))
//.where('workspaceId', '=', workspaceId)
.where('id', 'in', userSpaceIds)
.orderBy('createdAt', 'asc');
if (pagination.query) {
@@ -252,9 +253,14 @@ export class SpaceMemberRepo {
);
}
return executeWithPagination(query, {
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
}
+28 -15
View File
@@ -3,18 +3,13 @@
* Please do not edit it manually.
*/
import type { ColumnType } from 'kysely';
import type { ColumnType } from "kysely";
export type Generated<T> =
T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<
string,
bigint | number | string,
bigint | number | string
>;
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
export type Json = JsonValue;
@@ -32,13 +27,13 @@ export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export interface ApiKeys {
createdAt: Generated<Timestamp>;
creatorId: string;
deletedAt: Timestamp | null;
expiresAt: Timestamp | null;
id: Generated<string>;
lastUsedAt: Timestamp | null;
name: string | null;
updatedAt: Generated<Timestamp>;
creatorId: string;
workspaceId: string;
}
@@ -61,6 +56,20 @@ export interface Attachments {
workspaceId: string;
}
export interface AuditLogs {
actorId: string | null;
actorType: Generated<string>;
changes: Json | null;
createdAt: Generated<Timestamp>;
event: string;
id: Generated<string>;
ipAddress: string | null;
metadata: Json | null;
resourceId: string | null;
resourceType: string;
workspaceId: string;
}
export interface AuthAccounts {
authProviderId: string | null;
createdAt: Generated<Timestamp>;
@@ -77,25 +86,25 @@ export interface AuthProviders {
createdAt: Generated<Timestamp>;
creatorId: string | null;
deletedAt: Timestamp | null;
groupSync: Generated<boolean>;
id: Generated<string>;
isEnabled: Generated<boolean>;
groupSync: Generated<boolean>;
ldapBaseDn: string | null;
ldapBindDn: string | null;
ldapBindPassword: string | null;
ldapConfig: Json | null;
ldapTlsCaCert: string | null;
ldapTlsEnabled: Generated<boolean | null>;
ldapUrl: string | null;
ldapUserAttributes: Json | null;
ldapUserSearchFilter: string | null;
ldapConfig: Json | null;
settings: Json | null;
name: string;
oidcClientId: string | null;
oidcClientSecret: string | null;
oidcIssuer: string | null;
samlCertificate: string | null;
samlUrl: string | null;
settings: Json | null;
type: string;
updatedAt: Generated<Timestamp>;
workspaceId: string;
@@ -225,9 +234,11 @@ export interface Pages {
icon: string | null;
id: Generated<string>;
isLocked: Generated<boolean>;
isRestricted: Generated<boolean>;
lastUpdatedById: string | null;
parentPageId: string | null;
position: string | null;
restrictedById: string | null;
slugId: string;
spaceId: string;
textContent: string | null;
@@ -298,12 +309,12 @@ export interface Users {
deletedAt: Timestamp | null;
email: string;
emailVerifiedAt: Timestamp | null;
hasGeneratedPassword: Generated<boolean | null>;
id: Generated<string>;
invitedById: string | null;
lastActiveAt: Timestamp | null;
lastLoginAt: Timestamp | null;
locale: string | null;
hasGeneratedPassword: Generated<boolean | null>;
name: string | null;
password: string | null;
role: string | null;
@@ -363,6 +374,7 @@ export interface Workspaces {
export interface DB {
apiKeys: ApiKeys;
attachments: Attachments;
auditLogs: AuditLogs;
authAccounts: AuthAccounts;
authProviders: AuthProviders;
backlinks: Backlinks;
@@ -372,6 +384,7 @@ export interface DB {
groups: Groups;
groupUsers: GroupUsers;
pageHistory: PageHistory;
AuditLog: PagePermissions;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
+2 -43
View File
@@ -1,47 +1,6 @@
import {
ApiKeys,
Attachments,
AuthAccounts,
AuthProviders,
Backlinks,
Billing,
Comments,
FileTasks,
Groups,
GroupUsers,
PageHistory,
Pages,
Shares,
SpaceMembers,
Spaces,
UserMfa,
Users,
UserTokens,
WorkspaceInvitations,
Workspaces,
} from '@docmost/db/types/db';
import { DB } from '@docmost/db/types/db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
export interface DbInterface {
attachments: Attachments;
authAccounts: AuthAccounts;
authProviders: AuthProviders;
backlinks: Backlinks;
billing: Billing;
comments: Comments;
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
export interface DbInterface extends DB {
pageEmbeddings: PageEmbeddings;
pageHistory: PageHistory;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;
userMfa: UserMfa;
users: Users;
userTokens: UserTokens;
workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces;
apiKeys: ApiKeys;
}
@@ -20,6 +20,7 @@ import {
FileTasks,
UserMfa as _UserMFA,
ApiKeys,
AuditLogs,
} from './db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
@@ -131,3 +132,8 @@ export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
export type PageEmbedding = Selectable<PageEmbeddings>;
export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
export type UpdatablePageEmbedding = Updateable<Omit<PageEmbeddings, 'id'>>;
// Audit Log
export type AuditLog = Selectable<AuditLogs>;
export type InsertableAuditLog = Insertable<AuditLogs>;
export type UpdatableAuditLog = Updateable<Omit<AuditLogs, 'id'>>;
@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { AuditLogPayload, ActorType } from '../../common/events/audit-events';
export type IAuditService = {
log(payload: AuditLogPayload): void | Promise<void>;
logWithContext(
payload: AuditLogPayload,
context: {
workspaceId: string;
actorId?: string;
actorType?: ActorType;
ipAddress?: string;
userAgent?: string;
},
): void | Promise<void>;
setActorId(actorId: string): void;
setActorType(actorType: ActorType): void;
};
export const AUDIT_SERVICE = Symbol('AUDIT_SERVICE');
@Injectable()
export class NoopAuditService implements IAuditService {
log(_payload: AuditLogPayload): void {
// No-op: swallow the log when EE module is not available
}
logWithContext(
_payload: AuditLogPayload,
_context: {
workspaceId: string;
actorId?: string;
actorType?: ActorType;
ipAddress?: string;
userAgent?: string;
},
): void {
// No-op: swallow the log when EE module is not available
}
setActorId(_actorId: string): void {
// No-op
}
setActorType(_actorType: ActorType): void {
// No-op
}
}
@@ -105,7 +105,7 @@ export class EnvironmentVariables {
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])
@IsIn(['openai', 'gemini', 'ollama'])
@IsString()
AI_DRIVER: string;
@@ -117,10 +117,11 @@ export class EnvironmentVariables {
@IsOptional()
@ValidateIf((obj) => obj.AI_EMBEDDING_DIMENSION)
@IsIn(['768', '1024', '1536', '2000', '3072'])
@IsIn(['768', '1024', '1536', '2000'])
@IsString()
AI_EMBEDDING_DIMENSION: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsString()
@@ -128,20 +129,13 @@ export class EnvironmentVariables {
AI_COMPLETION_MODEL: string;
@IsOptional()
@ValidateIf(
(obj) =>
obj.AI_DRIVER && ['openai', 'openai-compatible'].includes(obj.AI_DRIVER),
)
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'openai')
@IsString()
@IsNotEmpty()
OPENAI_API_KEY: string;
@IsOptional()
@ValidateIf(
(obj) =>
obj.AI_DRIVER === 'openai-compatible' ||
(obj.AI_DRIVER === 'openai' && obj.OPENAI_API_URL),
)
@ValidateIf((obj) => obj.AI_DRIVER && obj.OPENAI_API_URL && obj.AI_DRIVER === 'openai')
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
OPENAI_API_URL: string;
@@ -10,59 +10,46 @@ import {
} from '@nestjs/common';
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { User } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../core/casl/interfaces/space-ability.type';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../../core/casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../../core/casl/abilities/workspace-ability.factory';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { FileTaskIdDto } from './dto/file-task-dto';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
@Controller('file-tasks')
export class FileTaskController {
constructor(
private readonly spaceAbility: SpaceAbilityFactory,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly spaceAbility: SpaceAbilityFactory,
@InjectKysely() private readonly db: KyselyDB,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post()
async getFileTasks(
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings)
) {
throw new ForbiddenException();
async getFileTasks(@AuthUser() user: User) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(user.id);
if (!userSpaceIds || userSpaceIds.length === 0) {
return [];
}
const query = this.db
const fileTasks = await this.db
.selectFrom('fileTasks')
.selectAll()
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(user.id))
.orderBy('createdAt', 'desc');
.where('spaceId', 'in', userSpaceIds)
.execute();
return executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
if (!fileTasks) {
throw new NotFoundException('File task not found');
}
return fileTasks;
}
@UseGuards(JwtAuthGuard)
@@ -10,11 +10,7 @@ import {
} from '../../../collaboration/collaboration.util';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
generateSlugId,
sanitizeFileName,
createByteCountingStream,
} from '../../../common/helpers';
import { generateSlugId, sanitizeFileName } from '../../../common/helpers';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { TiptapTransformer } from '@hocuspocus/transformer';
import * as Y from 'yjs';
@@ -177,24 +173,15 @@ export class ImportService {
};
}
async getNewPagePosition(
spaceId: string,
parentPageId?: string,
): Promise<string> {
let query = this.db
async getNewPagePosition(spaceId: string): Promise<string> {
const lastPage = await this.db
.selectFrom('pages')
.select(['id', 'position'])
.where('spaceId', '=', spaceId)
.orderBy('position', (ob) => ob.collate('C').desc())
.limit(1);
if (parentPageId) {
query = query.where('parentPageId', '=', parentPageId);
} else {
query = query.where('parentPageId', 'is', null);
}
const lastPage = await query.executeTakeFirst();
.limit(1)
.where('parentPageId', 'is', null)
.executeTakeFirst();
if (lastPage) {
return generateJitteredKeyBetween(lastPage.position, null);
@@ -211,21 +198,20 @@ export class ImportService {
workspaceId: string,
) {
const file = await filePromise;
const fileBuffer = await file.toBuffer();
const fileExtension = path.extname(file.filename).toLowerCase();
const fileName = sanitizeFileName(
path.basename(file.filename, fileExtension),
);
const fileSize = fileBuffer.length;
const fileNameWithExt = fileName + fileExtension;
const fileTaskId = uuid7();
const filePath = `${getFileTaskFolderPath(FileTaskType.Import, workspaceId)}/${fileTaskId}/${fileNameWithExt}`;
// upload file
const { stream, getBytesRead } = createByteCountingStream(file.file);
await this.storageService.upload(filePath, stream);
const fileSize = getBytesRead();
await this.storageService.upload(filePath, fileBuffer);
const fileTask = await this.db
.insertInto('fileTasks')
@@ -6,6 +6,7 @@ export enum QueueName {
FILE_TASK_QUEUE = '{file-task-queue}',
SEARCH_QUEUE = '{search-queue}',
AI_QUEUE = '{ai-queue}',
AUDIT_QUEUE = '{audit-queue}',
}
export enum QueueJob {
@@ -58,4 +59,7 @@ export enum QueueJob {
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
AUDIT_LOG = 'audit-log',
AUDIT_CLEANUP = 'audit-cleanup',
}
@@ -73,6 +73,14 @@ import { BacklinksProcessor } from './processors/backlinks.processor';
attempts: 1,
},
}),
BullModule.registerQueue({
name: QueueName.AUDIT_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 3,
},
}),
],
exports: [BullModule],
providers: [BacklinksProcessor],
@@ -20,15 +20,9 @@ export class LocalDriver implements StorageDriver {
return join(this.config.storagePath, filePath);
}
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
async upload(filePath: string, file: Buffer): Promise<void> {
try {
const fullPath = this._fullPath(filePath);
if (file instanceof Buffer) {
await fs.outputFile(fullPath, file);
} else {
await fs.mkdir(dirname(fullPath), { recursive: true });
await pipeline(file, createWriteStream(fullPath));
}
await fs.outputFile(this._fullPath(filePath), file);
} catch (err) {
throw new Error(`Failed to upload file: ${(err as Error).message}`);
}
@@ -48,7 +42,7 @@ export class LocalDriver implements StorageDriver {
try {
const fromFullPath = this._fullPath(fromFilePath);
const toFullPath = this._fullPath(toFilePath);
if (await this.exists(fromFilePath)) {
await fs.copy(fromFullPath, toFullPath);
}
@@ -23,21 +23,19 @@ export class S3Driver implements StorageDriver {
this.s3Client = new S3Client(config as any);
}
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
async upload(filePath: string, file: Buffer): Promise<void> {
try {
const contentType = getMimeType(filePath);
const upload = new Upload({
client: this.s3Client,
params: {
Bucket: this.config.bucket,
Key: filePath,
Body: file,
ContentType: contentType,
},
const command = new PutObjectCommand({
Bucket: this.config.bucket,
Key: filePath,
Body: file,
ContentType: contentType,
// ACL: "public-read",
});
await upload.done();
await this.s3Client.send(command);
} catch (err) {
throw new Error(`Failed to upload file: ${(err as Error).message}`);
}
@@ -1,7 +1,7 @@
import { Readable } from 'stream';
export interface StorageDriver {
upload(filePath: string, file: Buffer | Readable): Promise<void>;
upload(filePath: string, file: Buffer): Promise<void>;
uploadStream(filePath: string, file: Readable, options?: { recreateClient?: boolean }): Promise<void>;
@@ -8,9 +8,9 @@ export class StorageService {
private readonly logger = new Logger(StorageService.name);
constructor(
@Inject(STORAGE_DRIVER_TOKEN) private storageDriver: StorageDriver,
) { }
) {}
async upload(filePath: string, fileContent: Buffer | Readable) {
async upload(filePath: string, fileContent: Buffer | any) {
await this.storageDriver.upload(filePath, fileContent);
this.logger.debug(`File uploaded successfully. Path: ${filePath}`);
}

Some files were not shown because too many files have changed in this diff Show More