Compare commits

...

20 Commits

Author SHA1 Message Date
Philipinho 5eb3416b5c namespace 2026-01-17 14:51:56 +00:00
Philipinho c1cfe158cd smart scene sync 2026-01-17 14:39:27 +00:00
Philipinho ab81903299 ignore ts error 2026-01-17 13:44:04 +00:00
Philipinho 14698ebb05 fix room exit 2026-01-17 13:42:58 +00:00
Philipinho efa52ea4c8 Live cursor 2026-01-17 13:36:35 +00:00
Philipinho a4750bff56 WIP - POC 2026-01-17 13:24:54 +00:00
Philipinho 5c9eed53c0 websocket - WIP 2026-01-17 03:15:58 +00:00
Philipinho bcb004af21 update lockfile 2026-01-16 13:22:41 +00:00
Philipinho ac675e7d74 update dockerfile 2026-01-16 13:21:42 +00:00
Philipinho bf89eff5e7 sync 2026-01-16 13:20:31 +00:00
Philip Okugbe 183787fa0c fix: update dependencies (#1843) 2026-01-14 16:36:47 +00:00
Philipinho 15aa04a5f7 sync 2026-01-14 11:49:39 +00:00
Philipinho 79343a5d52 fix: prevent text overflow in group and space list tables 2026-01-13 16:25:42 +00:00
Philipinho 61e252918e fix length 2026-01-13 16:13:52 +00:00
Philipinho e98fa7f69a sync
* fix form length
2026-01-13 16:13:04 +00:00
Philip Okugbe 6d148a35eb New Crowdin updates (#1830)
* New translations translation.json (Japanese)

* New translations translation.json (Japanese)
2026-01-13 16:01:08 +00:00
Philip Okugbe 0bbc1c35de fix: public sharing performance improvements (#1841) 2026-01-13 16:00:22 +00:00
Philip Okugbe 47097969a0 fix: use subquery (#1833)
- enhance file tasks list endpoint
2026-01-13 15:58:26 +00:00
Philip Okugbe 13f529e064 fix anchor scroll in same page (#1834) 2026-01-13 15:35:53 +00:00
Philip Okugbe 8fc8422fbc fix: increase max length for groups and spaces (#1840) 2026-01-13 15:31:03 +00:00
37 changed files with 3047 additions and 987 deletions
+3 -2
View File
@@ -1,5 +1,6 @@
node_modules node_modules
.git .git
.gitignore
dist dist
data /data
.env*
.nx
+3 -3
View File
@@ -1,13 +1,14 @@
FROM node:22-slim AS base FROM node:22-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost" LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@10.4.0
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN npm install -g pnpm@10.4.0
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
RUN pnpm build RUN pnpm build
@@ -31,12 +32,11 @@ COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-e
# Copy root package files # Copy root package files
COPY --from=builder /app/package.json /app/package.json COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/ COPY --from=builder /app/pnpm*.yaml /app/
COPY --from=builder /app/.npmrc /app/.npmrc
# Copy patches # Copy patches
COPY --from=builder /app/patches /app/patches COPY --from=builder /app/patches /app/patches
RUN npm install -g pnpm@10.4.0
RUN chown -R node:node /app RUN chown -R node:node /app
USER node USER node
+21 -22
View File
@@ -10,51 +10,50 @@
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"" "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
}, },
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.2",
"@casl/react": "^4.0.0", "@casl/react": "^4.0.0",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-864353b", "@excalidraw/excalidraw": "0.18.0-c158187",
"@mantine/core": "^8.1.3", "@mantine/core": "^8.3.12",
"@mantine/dates": "^8.3.2", "@mantine/dates": "^8.3.12",
"@mantine/form": "^8.1.3", "@mantine/form": "^8.3.12",
"@mantine/hooks": "^8.1.3", "@mantine/hooks": "^8.3.12",
"@mantine/modals": "^8.1.3", "@mantine/modals": "^8.3.12",
"@mantine/notifications": "^8.1.3", "@mantine/notifications": "^8.3.12",
"@mantine/spotlight": "^8.1.3", "@mantine/spotlight": "^8.3.12",
"@tabler/icons-react": "^3.34.0", "@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.90.17",
"@tiptap/extension-character-count": "^2.10.3", "@tiptap/extension-character-count": "^2.27.1",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0", "highlightjs-sap-abap": "^0.3.0",
"i18next": "^23.14.0", "i18next": "^23.16.8",
"i18next-http-backend": "^2.6.1", "i18next-http-backend": "^2.7.3",
"jotai": "^2.12.5", "jotai": "^2.16.2",
"jotai-optics": "^0.4.0", "jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"katex": "0.16.22", "katex": "0.16.27",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.11.0", "mermaid": "^11.12.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"posthog-js": "^1.255.1", "posthog-js": "^1.255.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "3.4.0", "react-arborist": "3.4.0",
"react-clear-modal": "^2.0.15", "react-clear-modal": "^2.0.17",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-drawio": "^1.0.1", "react-drawio": "^1.0.7",
"react-error-boundary": "^4.1.2", "react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-i18next": "^15.0.1", "react-i18next": "^15.0.1",
"react-router-dom": "^7.0.1", "react-router-dom": "^7.12.0",
"semver": "^7.7.2", "semver": "^7.7.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.3",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18", "tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.76" "zod": "^3.25.76"
+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 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 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.": "このバージョンを復元してもよろしいですか? バージョン管理されていない変更は失われます。", "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 become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになます",
"Can create and edit pages in space.": "スペース内のページを作成および編集できます", "Can create and edit pages in space.": "スペース内のページを作成編集できます",
"Can edit": "編集可能", "Can edit": "編集可能",
"Can manage workspace": "ワークスペースを管理できます", "Can manage workspace": "ワークスペースを管理できます",
"Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません", "Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません",
"Can view": "閲覧可能", "Can view": "閲覧可能",
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません", "Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません",
"Cancel": "キャンセル", "Cancel": "キャンセル",
"Change email": "メールアドレスの変更", "Change email": "メールアドレスの変更",
"Change password": "パスワードの変更", "Change password": "パスワードの変更",
"Change photo": "画像の変更", "Change photo": "画像の変更",
"Choose a role": "ロールを選んでください", "Choose a role": "ロールを選んでください",
"Choose your preferred color scheme.": "お好みのカラースキームを選択してください", "Choose your preferred color scheme.": "お好みのカラースキームを選択してください",
"Choose your preferred interface language.": "お好みのインターフェース言語を選択してください", "Choose your preferred interface language.": "お好みの言語を選択してください",
"Choose your preferred page width.": "左右の余白を縮小する場合はオンにしてください", "Choose your preferred page width.": "お好みのページ幅を選択してください",
"Confirm": "確認", "Confirm": "確認",
"Copy link": "リンクをコピー", "Copy link": "リンクをコピー",
"Create": "新規作成", "Create": "新規作成",
@@ -40,24 +40,24 @@
"Date": "日付", "Date": "日付",
"Delete": "削除", "Delete": "削除",
"Delete group": "グループを削除", "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": "説明", "Description": "説明",
"Details": "詳細", "Details": "詳細",
"e.g ACME": "例: 山田太郎", "e.g ACME": "例: 山田太郎",
"e.g ACME Inc": "例: 株式会社サンプル", "e.g ACME Inc": "例: 株式会社サンプル",
"e.g Developers": "例: エンジニア", "e.g Developers": "例: エンジニア",
"e.g Group for developers": "例: エンジニアグループ", "e.g Group for developers": "例: 開発チーム",
"e.g product": "例: product", "e.g product": "例: product",
"e.g Product Team": "例: 製品チーム", "e.g Product Team": "例: プロダクトチーム",
"e.g Sales": "例: 営業", "e.g Sales": "例: 営業",
"e.g Space for product team": "例: 製品チームスペース", "e.g Space for product team": "例: プロダクトチームスペース",
"e.g Space for sales team to collaborate": "例: 営業チーム連携用スペース", "e.g Space for sales team to collaborate": "例: 営業チーム用スペース",
"Edit": "編集", "Edit": "編集",
"Read": "読む", "Read": "閲覧",
"Edit group": "グループを編集", "Edit group": "グループを編集",
"Email": "メールアドレス", "Email": "メールアドレス",
"Enter a strong password": "強力なパスワードを入力してください", "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 valid emails addresses": "有効なメールアドレスを入力してください",
"Enter your current password": "現在のパスワードを入力してください", "Enter your current password": "現在のパスワードを入力してください",
"enter your full name": "氏名を入力してください", "enter your full name": "氏名を入力してください",
@@ -81,18 +81,18 @@
"Group description": "グループ説明", "Group description": "グループ説明",
"Group name": "グループ名", "Group name": "グループ名",
"Groups": "グループ", "Groups": "グループ",
"Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます", "Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます",
"Home": "ホーム", "Home": "ホーム",
"Import pages": "ページをインポート", "Import pages": "ページをインポート",
"Import pages & space settings": "ページとスペース設定をインポート", "Import pages & space settings": "ページとスペース設定をインポート",
"Importing pages": "ページをインポートしています", "Importing pages": "ページをインポートしています",
"invalid invitation link": "招待リンクが間違っています", "invalid invitation link": "無効な招待リンクす",
"Invitation signup": "招待登録", "Invitation signup": "招待登録",
"Invite by email": "メールアドレスで招待する", "Invite by email": "メールアドレスで招待する",
"Invite members": "メンバーを招待する", "Invite members": "メンバーを招待する",
"Invite new members": "新しいメンバーを招待する", "Invite new members": "新しいメンバーを招待する",
"Invited members who are yet to accept their invitation will appear here.": "招待をまだ承諾していないメンバーここに表示されます", "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 will be granted access to spaces the groups can access": "招待されたメンバーはグループがアクセスできるスペースにアクセスできます",
"Join the workspace": "ワークスペースに参加", "Join the workspace": "ワークスペースに参加",
"Language": "言語", "Language": "言語",
"Light": "ライト", "Light": "ライト",
@@ -113,20 +113,20 @@
"New page": "新規ページ", "New page": "新規ページ",
"New password": "新しいパスワード", "New password": "新しいパスワード",
"No group found": "グループが見つかりません", "No group found": "グループが見つかりません",
"No page history saved yet.": "まだページ履歴が保存されていません", "No page history saved yet.": "ページ履歴がありません",
"No pages yet": "ページがありません", "No pages yet": "ページがありません",
"No results found...": "結果が見つかりませんでした...", "No results found...": "結果が見つかりません",
"No user found": "ユーザがいません", "No user found": "ユーザーが見つかりません",
"Overview": "概要", "Overview": "概要",
"Owner": "所有者", "Owner": "所有者",
"page": "ページ", "page": "ページ",
"Page deleted successfully": "ページが正常に削除されました", "Page deleted successfully": "ページを削除しました",
"Page history": "ページ履歴", "Page history": "ページ履歴",
"Page import is in progress. Please do not close this tab.": "ページインポートが進行中です。このタブを閉じないでください", "Page import is in progress. Please do not close this tab.": "ページインポート中です。このタブを閉じないでください",
"Pages": "ページ", "Pages": "ページ",
"pages": "ページ", "pages": "ページ",
"Password": "パスワード", "Password": "パスワード",
"Password changed successfully": "パスワードが正常に変更されました", "Password changed successfully": "パスワードを変更しました",
"Pending": "保留中", "Pending": "保留中",
"Please confirm your action": "アクションを確認してください", "Please confirm your action": "アクションを確認してください",
"Preferences": "設定", "Preferences": "設定",
@@ -143,95 +143,95 @@
"Search for groups": "グループを検索", "Search for groups": "グループを検索",
"Search for users": "ユーザーを検索", "Search for users": "ユーザーを検索",
"Search for users and groups": "ユーザーとグループを検索", "Search for users and groups": "ユーザーとグループを検索",
"Search...": "検索する語句を入力", "Search...": "検索",
"Select language": "言語を選択", "Select language": "言語を選択",
"Select role": "ロールを選択", "Select role": "ロールを選択",
"Select role to assign to all invited members": "招待されたすべてのメンバーに割り当てるロールを選択してください", "Select role to assign to all invited members": "招待するメンバーに割り当てるロールを選択",
"Select theme": "テーマを選択", "Select theme": "テーマを選択",
"Send invitation": "招待を送る", "Send invitation": "招待を送る",
"Invitation sent": "招待送信されました", "Invitation sent": "招待送信ました",
"Settings": "設定", "Settings": "設定",
"Setup workspace": "ワークスペースを設定する", "Setup workspace": "ワークスペースを設定する",
"Sign In": "サインイン", "Sign In": "サインイン",
"Sign Up": "アカウント登録", "Sign Up": "新規登録",
"Slug": "Slug (URL用文字列)", "Slug": "スラッグ(URL識別子)",
"Space": "スペース", "Space": "スペース",
"Space description": "スペース説明", "Space description": "スペース説明",
"Space menu": "スペースメニュー", "Space menu": "スペースメニュー",
"Space name": "スペース名", "Space name": "スペース名",
"Space settings": "スペース設定", "Space settings": "スペース設定",
"Space slug": "スペースのSlug (URL用文字列)", "Space slug": "スペースのスラッグ(URL識別子)",
"Spaces": "スペース", "Spaces": "スペース",
"Spaces you belong to": "所属しているスペース", "Spaces you belong to": "所属しているスペース",
"No space found": "スペースが見つかりません", "No space found": "スペースが見つかりません",
"Search for spaces": "スペースを検索", "Search for spaces": "スペースを検索",
"Start typing to search...": "検索を開始するには入力してください...", "Start typing to search...": "入力して検索",
"Status": "ステータス", "Status": "ステータス",
"Successfully imported": "インポートに成功しました", "Successfully imported": "インポートしました",
"Successfully restored": "正常に復元されました", "Successfully restored": "復元しました",
"System settings": "システム設定", "System settings": "システム設定",
"Theme": "テーマ", "Theme": "テーマ",
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力する必要があります。", "To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力してください",
"Toggle full page width": "ページ幅を切り替え", "Toggle full page width": "ページ幅を切り替え",
"Unable to import pages. Please try again.": "ページをインポートできません。もう一度お試しください", "Unable to import pages. Please try again.": "ページをインポートできませんでした。もう一度お試しください",
"untitled": "無題", "untitled": "無題",
"Untitled": "無題", "Untitled": "無題",
"Updated successfully": "正常に更新されました", "Updated successfully": "更新しました",
"User": "ユーザー", "User": "ユーザー",
"Workspace": "ワークスペース", "Workspace": "ワークスペース",
"Workspace Name": "ワークスペース名", "Workspace Name": "ワークスペース名",
"Workspace settings": "ワークスペース設定", "Workspace settings": "ワークスペース設定",
"You can change your password here.": "パスワードを変更できます", "You can change your password here.": "パスワードを変更できます",
"Your Email": "メールアドレス", "Your Email": "メールアドレス",
"Your import is complete.": "インポートが完了しました", "Your import is complete.": "インポートが完了しました",
"Your name": "名前", "Your name": "名前",
"Your Name": "名前", "Your Name": "名前",
"Your password": "パスワード", "Your password": "パスワード",
"Your password must be a minimum of 8 characters.": "パスワードは最低 8 文字必要です。", "Your password must be a minimum of 8 characters.": "パスワードは8文字以上にしてください",
"Sidebar toggle": "サイドバー切り替え", "Sidebar toggle": "サイドバー切り替え",
"Comments": "コメント", "Comments": "コメント",
"404 page not found": "404 ページが見つかりません", "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": "ホームに戻る", "Take me back to homepage": "ホームに戻る",
"Forgot password": "パスワードを忘れた", "Forgot password": "パスワードを忘れた",
"Forgot your password?": "パスワードを忘れましたか?", "Forgot your password?": "パスワードを忘れましたか?",
"A password reset link has been sent to your email. Please check your inbox.": "パスワードリセットリンクがあなたのメールアドレスに送信されました。受信を確認してください", "A password reset link has been sent to your email. Please check your inbox.": "パスワードリセット用のリンクをメールに送信ました。受信トレイを確認してください",
"Send reset link": "リセットリンクを送", "Send reset link": "リセットリンクを送",
"Password reset": "パスワードリセット", "Password reset": "パスワードリセット",
"Your new password": "新しいパスワード", "Your new password": "新しいパスワード",
"Set password": "パスワードを設定", "Set password": "パスワードを設定",
"Write a comment": "コメントを書く", "Write a comment": "コメントを書く",
"Reply...": "返信...", "Reply...": "返信...",
"Error loading comments.": "コメントの読み込み中にエラーが発生しました", "Error loading comments.": "コメントの読み込みに失敗しました",
"No comments yet.": "コメントがありません", "No comments yet.": "コメントがありません",
"Edit comment": "コメントを編集する", "Edit comment": "コメントを編集する",
"Delete comment": "コメントを削除する", "Delete comment": "コメントを削除する",
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?", "Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
"Comment created successfully": "コメント作成されました", "Comment created successfully": "コメント作成ました",
"Error creating comment": "コメントの作成中にエラーが発生しました", "Error creating comment": "コメントの作成に失敗しました",
"Comment updated successfully": "コメント更新されました", "Comment updated successfully": "コメント更新ました",
"Failed to update comment": "コメントの更新に失敗しました", "Failed to update comment": "コメントの更新に失敗しました",
"Comment deleted successfully": "コメント削除されました", "Comment deleted successfully": "コメント削除ました",
"Failed to delete comment": "コメントの削除に失敗しました", "Failed to delete comment": "コメントの削除に失敗しました",
"Comment resolved successfully": "コメント解決されました", "Comment resolved successfully": "コメント解決ました",
"Comment re-opened successfully": "コメント再開されました", "Comment re-opened successfully": "コメント再開ました",
"Comment unresolved successfully": "コメントが再解決されました", "Comment unresolved successfully": "コメントを未解決に戻しました",
"Failed to resolve comment": "コメントの解決に失敗しました", "Failed to resolve comment": "コメントの解決に失敗しました",
"Resolve comment": "コメントを解決", "Resolve comment": "コメントを解決",
"Unresolve comment": "コメントを解決", "Unresolve comment": "コメントを解決に戻す",
"Resolve Comment Thread": "コメントスレッドを解決", "Resolve Comment Thread": "コメントスレッドを解決",
"Unresolve 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 resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか完了としてマークされます",
"Are you sure you want to unresolve this comment thread?": "このコメントスレッドを解決しますか?", "Are you sure you want to unresolve this comment thread?": "このコメントスレッドを解決に戻しますか?",
"Resolved": "解決済", "Resolved": "解決済",
"No active comments.": "アクティブなコメントはありません", "No active comments.": "アクティブなコメントはありません",
"No resolved comments.": "解決されたコメントはありません", "No resolved comments.": "解決済みのコメントはありません",
"Revoke invitation": "招待を取り消す", "Revoke invitation": "招待を取り消す",
"Revoke": "取り消す", "Revoke": "取り消す",
"Don't": "取り消さない", "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": "招待を再度送る", "Resend invitation": "招待を再度送る",
"Anyone with this link can join this workspace.": "このリンクをっている人は誰でもこのワークスペースに参加できます", "Anyone with this link can join this workspace.": "このリンクをっている人は誰でもワークスペースに参加できます",
"Invite link": "招待リンク", "Invite link": "招待リンク",
"Copy": "コピー", "Copy": "コピー",
"Copy to space": "スペースにコピー", "Copy to space": "スペースにコピー",
@@ -239,13 +239,13 @@
"Duplicate": "複製", "Duplicate": "複製",
"Select a user": "ユーザを選択", "Select a user": "ユーザを選択",
"Select a group": "グループを選択", "Select a group": "グループを選択",
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします", "Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします",
"Delete space": "スペースを削除", "Delete space": "スペースを削除",
"Are you sure you want to delete this space?": "このスペースを削除してもよろしいですか?", "Are you sure you want to delete this space?": "このスペースを削除してもよろしいですか?",
"Delete this space with all its pages and data.": "このスペースおよびスペース内のすべてのページデータを削除します", "Delete this space with all its pages and data.": "このスペースすべてのページデータを削除します",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "このスペース内のすべてのページ、コメント、添付ファイル、および権限完全に削除されます", "All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "スペース内のすべてのページ、コメント、添付ファイル、権限完全に削除されます",
"Confirm space name": "スペース名を確認する", "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": "フォーマット", "Format": "フォーマット",
"Include subpages": "サブページを含める", "Include subpages": "サブページを含める",
"Include attachments": "添付ファイルを含める", "Include attachments": "添付ファイルを含める",
@@ -273,12 +273,12 @@
"Success": "成功", "Success": "成功",
"Warning": "警告", "Warning": "警告",
"Danger": "危険", "Danger": "危険",
"Mermaid diagram error:": "Mermaid コードエラー", "Mermaid diagram error:": "Mermaid ダイアグラムエラー:",
"Invalid Mermaid diagram": "無効な Mermaid コードです", "Invalid Mermaid diagram": "無効な Mermaid ダイアグラムです",
"Double-click to edit Draw.io diagram": "ダブルクリックしてDraw.io図を編集", "Double-click to edit Draw.io diagram": "ダブルクリックして Draw.io 図を編集",
"Exit": "終了", "Exit": "終了",
"Save & Exit": "保存して終了", "Save & Exit": "保存して終了",
"Double-click to edit Excalidraw diagram": "ダブルクリックしてExcalidraw図を編集", "Double-click to edit Excalidraw diagram": "ダブルクリックして Excalidraw 図を編集",
"Paste link": "リンクを貼り付け", "Paste link": "リンクを貼り付け",
"Edit link": "リンクを編集", "Edit link": "リンクを編集",
"Remove link": "リンクを削除", "Remove link": "リンクを削除",
@@ -315,22 +315,22 @@
"Bullet List": "箇条書きリスト", "Bullet List": "箇条書きリスト",
"Numbered List": "番号付きリスト", "Numbered List": "番号付きリスト",
"Blockquote": "引用", "Blockquote": "引用",
"Just start typing with plain text.": "すぐに文章を書き始められます", "Just start typing with plain text.": "プレーンテキストを入力します",
"Track tasks with a to-do list.": "Todoリストでタスクを追跡します", "Track tasks with a to-do list.": "Todo リストでタスクを管理します",
"Big section heading.": "大きいフォントのセクション見出しです。", "Big section heading.": "大見出し",
"Medium section heading.": "中くらいのフォントのセクション見出しです。", "Medium section heading.": "中見出し",
"Small section heading.": "小さいフォントのセクション見出しです。", "Small section heading.": "小見出し",
"Create a simple bullet list.": "シンプルな箇条書きリストを作成します", "Create a simple bullet list.": "箇条書きリストを作成します",
"Create a list with numbering.": "番号付きリストを作成します", "Create a list with numbering.": "番号付きリストを作成します",
"Create block quote.": "引用を作成します", "Create block quote.": "引用ブロックを作成します",
"Insert code snippet.": "コードスニペットを入します", "Insert code snippet.": "コードスニペットを入します",
"Insert horizontal rule divider": "水平線を挿入します", "Insert horizontal rule divider": "区切り線を挿入します",
"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.": "デバイスからファイルをアップロードします",
"Table": "テーブル", "Table": "テーブル",
"Insert a table.": "を挿入します", "Insert a table.": "テーブルを挿入します",
"Insert collapsible block.": "折りたたみ可能なブロックを挿入します", "Insert collapsible block.": "折りたたみブロックを挿入します",
"Video": "動画", "Video": "動画",
"Divider": "区切り線", "Divider": "区切り線",
"Quote": "引用", "Quote": "引用",
@@ -338,16 +338,16 @@
"File attachment": "ファイル添付", "File attachment": "ファイル添付",
"Toggle block": "ブロックを切り替える", "Toggle block": "ブロックを切り替える",
"Callout": "コールアウト", "Callout": "コールアウト",
"Insert callout notice.": "コールアウトブロックを挿入します", "Insert callout notice.": "コールアウトを挿入します",
"Math inline": "インライン数式", "Math inline": "インライン数式",
"Insert inline math equation.": "インライン数式を挿入します", "Insert inline math equation.": "インライン数式を挿入します",
"Math block": "数式ブロック", "Math block": "数式ブロック",
"Insert math equation": "数式を挿入します", "Insert math equation": "数式を挿入します",
"Mermaid diagram": "Mermaidコード", "Mermaid diagram": "Mermaid ダイアグラム",
"Insert mermaid diagram": "Mermaidコードを記述して図を挿入します", "Insert mermaid diagram": "Mermaid ダイアグラムを挿入します",
"Insert and design Drawio diagrams": "Drawio図を挿入してデザインします", "Insert and design Drawio diagrams": "Draw.io 図を挿入・編集します",
"Insert current date": "今日の日付を挿入します", "Insert current date": "現在の日付を挿入します",
"Draw and sketch excalidraw diagrams": "Excalidraw図を埋め込みます", "Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
"Multiple": "複数", "Multiple": "複数",
"Heading {{level}}": "見出し {{level}}", "Heading {{level}}": "見出し {{level}}",
"Toggle title": "タイトルの表示/非表示を切り替える", "Toggle title": "タイトルの表示/非表示を切り替える",
@@ -357,29 +357,29 @@
"Yesterday, {{time}}": "昨日、{{time}}", "Yesterday, {{time}}": "昨日、{{time}}",
"Space created successfully": "スペースを作成しました", "Space created successfully": "スペースを作成しました",
"Space updated successfully": "スペースを更新しました", "Space updated successfully": "スペースを更新しました",
"Space deleted successfully": "スペース削除されました", "Space deleted successfully": "スペース削除ました",
"Members added successfully": "メンバーを追加しました", "Members added successfully": "メンバーを追加しました",
"Member removed successfully": "メンバー削除されました", "Member removed successfully": "メンバー削除ました",
"Member role updated successfully": "メンバーのロールを更新しました", "Member role updated successfully": "メンバーのロールを更新しました",
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>", "Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
"Created at: {{time}}": "作成しました:{{time}}", "Created at: {{time}}": "作成日: {{time}}",
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}", "Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
"Word count: {{wordCount}}": "ワード数: {{wordCount}}", "Word count: {{wordCount}}": "単語数: {{wordCount}}",
"Character count: {{characterCount}}": "文字数: {{characterCount}}", "Character count: {{characterCount}}": "文字数: {{characterCount}}",
"New update": "新規更新", "New update": "新規更新",
"{{latestVersion}} is available": "{{latestVersion}}利用可能です", "{{latestVersion}} is available": "{{latestVersion}}利用可能です",
"Default page edit mode": "デフォルトのページ編集モード", "Default page edit mode": "デフォルトのページ編集モード",
"Choose your preferred page edit mode. Avoid accidental edits.": "希望のページ編集モードを選択してください。誤って編集を防ます", "Choose your preferred page edit mode. Avoid accidental edits.": "お好みのページ編集モードを選択してください(誤編集を防止します",
"Reading": "読み取り", "Reading": "読み取り",
"Delete member": "メンバーを削除する", "Delete member": "メンバーを削除する",
"Member deleted successfully": "メンバー削除されました", "Member deleted successfully": "メンバー削除ました",
"Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません", "Are you sure you want to delete this workspace member? This action is irreversible.": "このメンバーを削除してもよろしいですか?この操作は取り消せません",
"Move": "移動", "Move": "移動",
"Move page": "ページを移動", "Move page": "ページを移動",
"Move page to a different space.": "ページを別のスペースに移動します", "Move page to a different space.": "ページを別のスペースに移動します",
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…", "Real-time editor connection lost. Retrying...": "リアルタイム編集の接続が切断されました。再接続中...",
"Table of contents": "目次", "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": "共有", "Share": "共有",
"Public sharing": "公開共有", "Public sharing": "公開共有",
"Shared by": "共有者", "Shared by": "共有者",
@@ -398,13 +398,13 @@
"Delete share": "共有を削除", "Delete share": "共有を削除",
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?", "Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます", "Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
"Share deleted successfully": "共有が正常に削除されました", "Share deleted successfully": "共有を削除しました",
"Share not found": "共有が見つかりません", "Share not found": "共有が見つかりません",
"Failed to share page": "ページの共有に失敗しました", "Failed to share page": "ページの共有に失敗しました",
"Copy page": "ページをコピー", "Copy page": "ページをコピー",
"Copy page to a different space.": "ページを別のスペースにコピーします", "Copy page to a different space.": "ページを別のスペースにコピーします",
"Page copied successfully": "ページコピーに成功しました", "Page copied successfully": "ページコピーしました",
"Page duplicated successfully": "ページが正常に複製されました", "Page duplicated successfully": "ページを複製しました",
"Find": "検索", "Find": "検索",
"Not found": "見つかりません", "Not found": "見つかりません",
"Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)", "Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)",
@@ -419,26 +419,26 @@
"Error": "エラー", "Error": "エラー",
"Failed to disable MFA": "MFAの無効化に失敗しました", "Failed to disable MFA": "MFAの無効化に失敗しました",
"Disable two-factor authentication": "二要素認証を無効化", "Disable two-factor authentication": "二要素認証を無効化",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります", "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:": "二要素認証を無効するにはパスワードを入力してください:", "Please enter your password to disable two-factor authentication:": "二要素認証を無効するにはパスワードを入力してください",
"Two-factor authentication has been enabled": "二要素認証有効になりました", "Two-factor authentication has been enabled": "二要素認証有効にました",
"Two-factor authentication has been disabled": "二要素認証無効になりました", "Two-factor authentication has been disabled": "二要素認証無効にました",
"2-step verification": "2段階認", "2-step verification": "2段階認",
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証レイヤーでアカウントを保護します", "Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証でアカウントを保護します",
"Two-factor authentication is active on your account.": "二要素認証がアカウントで有効です", "Two-factor authentication is active on your account.": "二要素認証が有効です",
"Add 2FA method": "2FAメソッドを追加", "Add 2FA method": "2FAメソッドを追加",
"Backup codes": "バックアップコード", "Backup codes": "バックアップコード",
"Disable": "無効にする", "Disable": "無効にする",
"Invalid verification code": "無効な認証コード", "Invalid verification code": "無効な認証コード",
"New backup codes have been generated": "新しいバックアップコード生成されました", "New backup codes have been generated": "新しいバックアップコード生成ました",
"Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました", "Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました",
"About 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.": "バックアップコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。", "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.": "いつでも新しいバックアップコード再生成できます。これにより、既存のすべてのコードが無効になります", "You can regenerate new backup codes at any time. This will invalidate all existing codes.": "新しいバックアップコードはいつでも再生成できます。既存のコードはすべて無効になります",
"Confirm password": "パスワードを確認", "Confirm password": "パスワードを確認",
"Generate new backup codes": "新しいバックアップコードを生成", "Generate new backup codes": "新しいバックアップコードを生成",
"Save your 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": "新しいバックアップコード", "Your new backup codes": "新しいバックアップコード",
"I've saved my backup codes": "バックアップコードを保存しました", "I've saved my backup codes": "バックアップコードを保存しました",
"Failed to setup MFA": "MFAの設定に失敗しました", "Failed to setup MFA": "MFAの設定に失敗しました",
@@ -449,51 +449,51 @@
"Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:", "Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:",
"2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください", "2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください",
"Verify and enable": "確認と有効化", "Verify and enable": "確認と有効化",
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。再試行してください", "Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。もう一度お試しください",
"Backup": "バックアップ", "Backup": "バックアップ",
"Save codes": "コードを保存", "Save codes": "コードを保存",
"Save your backup 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.": "これらのコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。", "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回のみ使用可能です",
"Print": "印刷", "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": "二要素認証が必要です", "Two-Factor authentication required": "二要素認証が必要です",
"Your workspace requires two-factor authentication for all users": "ワークスペースではすべてのユーザーに二要素認証が必要です", "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.": "ワークスペースへのアクセスを続けるには二要素認証を設定する必要があります。これにより、アカウントに追加のセキュリティ層が追加されます", "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": "二要素認証を設定", "Set up two-factor authentication": "二要素認証を設定",
"Cancel and logout": "キャンセルしてログアウト", "Cancel and logout": "キャンセルしてログアウト",
"Your workspace requires two-factor authentication. Please set it up to continue.": "ワークスペースでは二要素認証が必要です。続行するには設定してください", "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.": "これにより、認証アプリからの確認コードが必要となり、アカウントに追加のセキュリティ層が追加されます", "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "認証アプリからの確認コードアカウントのセキュリティが強化されます",
"Password is required": "パスワードが必要です", "Password is required": "パスワードが必要です",
"Password must be at least 8 characters": "パスワードは8文字以上必要です", "Password must be at least 8 characters": "パスワードは8文字以上必要です",
"Please enter a 6-digit code": "6桁のコードを入力してください", "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桁のコードを入力してください", "Enter the 6-digit code found in your authenticator app": "認証アプリに表示された6桁のコードを入力してください",
"Need help authenticating?": "認証に関するヘルプが必要ですか?", "Need help authenticating?": "認証に関するヘルプが必要ですか?",
"MFA QR Code": "MFA QRコード", "MFA QR Code": "MFA QRコード",
"Account created successfully. Please log in to set up two-factor authentication.": "アカウントが正常に作成されました。二要素認証を設定するためにログインしてください", "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 and complete two-factor authentication.": "パスワードリセットしました。新しいパスワードでログインし二要素認証を完了してください",
"Password reset successful. Please log in with your new password to set up 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.": "パスワードリセットが成功しました。新しいパスワードでログインしてください", "Password reset was successful. Please log in with your new password.": "パスワードリセットしました。新しいパスワードでログインしてください",
"Two-factor authentication": "二要素認証", "Two-factor authentication": "二要素認証",
"Use authenticator app instead": "代わりに認証アプリを使用", "Use authenticator app instead": "代わりに認証アプリを使用",
"Verify backup code": "バックアップコードを確認", "Verify backup code": "バックアップコードを確認",
"Use backup code": "バックアップコードを使用", "Use backup code": "バックアップコードを使用",
"Enter one of your backup codes": "バックアップコードのいずれかを入力してください", "Enter one of your backup codes": "バックアップコードのいずれかを入力してください",
"Backup code": "バックアップコード", "Backup code": "バックアップコード",
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードのいずれかを入力してください。各バックアップコードは一度しか使用できません。", "Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードを入力してください。各コードは1回のみ使用可能です",
"Verify": "確認", "Verify": "確認",
"Trash": "ごみ箱", "Trash": "ごみ箱",
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます", "Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます",
"Deleted": "削除", "Deleted": "削除",
"No pages in trash": "ごみ箱にページがありません", "No pages in trash": "ごみ箱にページがありません",
"Permanently delete page?": "ページを完全に削除しますか?", "Permanently delete page?": "ページを完全に削除しますか?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}を完全に削除しますか? この操作は元に戻せません", "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}を完全に削除しますかこの操作は取り消せません",
"Restore '{{title}}' and its sub-pages?": "{{title}}とそのサブページを復元しますか?", "Restore '{{title}}' and its sub-pages?": "{{title}}とそのサブページを復元しますか?",
"Move to trash": "ごみ箱に移動", "Move to trash": "ごみ箱に移動",
"Move this page to trash?": "このページをごみ箱に移動しますか?", "Move this page to trash?": "このページをごみ箱に移動しますか?",
"Restore page": "ページを復元", "Restore page": "ページを復元",
"Page moved to trash": "ページごみ箱に移動されました", "Page moved to trash": "ページごみ箱に移動ました",
"Page restored successfully": "ページが正常に復元されました", "Page restored successfully": "ページを復元しました",
"Deleted by": "削除者", "Deleted by": "削除者",
"Deleted at": "削除日時", "Deleted at": "削除日時",
"Preview": "プレビュー", "Preview": "プレビュー",
@@ -511,10 +511,10 @@
"Enterprise": "エンタープライズ", "Enterprise": "エンタープライズ",
"Download attachment": "添付ファイルをダウンロード", "Download attachment": "添付ファイルをダウンロード",
"Allowed email domains": "許可されたメールドメイン", "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": "コンマまたはスペースで区切って有効なドメイン名を入力してください", "Enter valid domain names separated by comma or space": "コンマまたはスペースで区切って有効なドメイン名を入力してください",
"Enforce two-factor authentication": "二要素認証を強制する", "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の強制を切り替える", "Toggle MFA enforcement": "MFAの強制を切り替える",
"Display name": "表示名", "Display name": "表示名",
"Allow signup": "登録を許可する", "Allow signup": "登録を許可する",
@@ -532,10 +532,10 @@
"Upload image": "画像をアップロード", "Upload image": "画像をアップロード",
"Remove image": "画像を削除", "Remove image": "画像を削除",
"Failed to remove image": "画像の削除に失敗しました", "Failed to remove image": "画像の削除に失敗しました",
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています", "Image exceeds 10MB limit.": "画像が10MBの制限を超えています",
"Image removed successfully": "画像が正常に削除されました", "Image removed successfully": "画像を削除しました",
"API key": "APIキー", "API key": "APIキー",
"API key created successfully": "APIキーが正常に作成されました", "API key created successfully": "APIキーを作成しました",
"API keys": "APIキー", "API keys": "APIキー",
"API management": "API管理", "API management": "API管理",
"Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか", "Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか",
@@ -550,9 +550,9 @@
"No API keys found": "APIキーが見つかりません", "No API keys found": "APIキーが見つかりません",
"No expiration": "期限なし", "No expiration": "期限なし",
"Revoke API key": "APIキーを無効にする", "Revoke API key": "APIキーを無効にする",
"Revoked successfully": "正常に無効化されました", "Revoked successfully": "無効にしました",
"Select expiration date": "有効期限を選択してください", "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キーを更新", "Update API key": "APIキーを更新",
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理", "Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理",
"AI settings": "AI設定", "AI settings": "AI設定",
@@ -562,7 +562,7 @@
"AI is thinking...": "AIが考え中...", "AI is thinking...": "AIが考え中...",
"Ask a question...": "質問を入力...", "Ask a question...": "質問を入力...",
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)", "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検索を切り替え", "Toggle AI search": "AI検索を切り替え",
"Sources": "ソース", "Sources": "ソース",
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません", "Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,5 @@
import { ENCRYPTION_KEY_BITS } from "@excalidraw/common";
type LibraryItems = any; type LibraryItems = any;
type LibraryPersistedData = { type LibraryPersistedData = {
@@ -8,8 +10,8 @@ export interface LibraryPersistenceAdapter {
load(metadata: { source: "load" | "save" }): load(metadata: { source: "load" | "save" }):
| Promise<{ libraryItems: LibraryItems } | null> | Promise<{ libraryItems: LibraryItems } | null>
| { | {
libraryItems: LibraryItems; libraryItems: LibraryItems;
} }
| null; | null;
save(libraryData: LibraryPersistedData): Promise<void> | void; save(libraryData: LibraryPersistedData): Promise<void> | void;
@@ -25,7 +27,10 @@ export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
return JSON.parse(data); return JSON.parse(data);
} }
} catch (e) { } catch (e) {
console.error("Error downloading Excalidraw library from localStorage", e); console.error(
"Error downloading Excalidraw library from localStorage",
e,
);
} }
return null; return null;
}, },
@@ -40,3 +45,124 @@ export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
} }
}, },
}; };
export const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
if ("arrayBuffer" in blob) {
return blob.arrayBuffer();
}
// Safari
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
if (!event.target?.result) {
return reject(new Error("Couldn't convert blob to ArrayBuffer"));
}
resolve(event.target.result as ArrayBuffer);
};
reader.readAsArrayBuffer(blob);
});
};
export const IV_LENGTH_BYTES = 12;
// Pre-transform error: No known conditions for "./data/encryption" specifier in "@excalidraw/excalidraw" package
// Plugin: vite:import-analysis
// File: /Users/lite/WebstormProjects/docmost-ee/apps/client/src/features/editor/components/excalidraw/use-excalidraw-collab.ts:11:7
// 7 | decryptData,
// 8 | encryptData
// 9 | } from "@excalidraw/excalidraw/data/encryption";
//@ts-ignore
export const createIV = (): Uint8Array<ArrayBuffer> => {
const arr = new Uint8Array(IV_LENGTH_BYTES);
return window.crypto.getRandomValues(arr);
};
export const generateEncryptionKey = async <
T extends "string" | "cryptoKey" = "string",
>(
returnAs?: T,
): Promise<T extends "cryptoKey" ? CryptoKey : string> => {
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: ENCRYPTION_KEY_BITS,
},
true, // extractable
["encrypt", "decrypt"],
);
return (
returnAs === "cryptoKey"
? key
: (await window.crypto.subtle.exportKey("jwk", key)).k
) as T extends "cryptoKey" ? CryptoKey : string;
};
export const getCryptoKey = (key: string, usage: KeyUsage) =>
window.crypto.subtle.importKey(
"jwk",
{
alg: "A128GCM",
ext: true,
k: key,
key_ops: ["encrypt", "decrypt"],
kty: "oct",
},
{
name: "AES-GCM",
length: ENCRYPTION_KEY_BITS,
},
false, // extractable
[usage],
);
export const encryptData = async (
key: string | CryptoKey,
//@ts-ignore
data: Uint8Array<ArrayBuffer> | ArrayBuffer | Blob | File | string,
//@ts-ignore
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array<ArrayBuffer> }> => {
const importedKey =
typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
const iv = createIV();
//@ts-ignore
const buffer: ArrayBuffer | Uint8Array<ArrayBuffer> =
typeof data === "string"
? new TextEncoder().encode(data)
: data instanceof Uint8Array
? data
: data instanceof Blob
? await blobToArrayBuffer(data)
: data;
// We use symmetric encryption. AES-GCM is the recommended algorithm and
// includes checks that the ciphertext has not been modified by an attacker.
const encryptedBuffer = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
buffer,
);
return { encryptedBuffer, iv };
};
export const decryptData = async (
//@ts-ignore
iv: Uint8Array<ArrayBuffer>,
//@ts-ignore
encrypted: Uint8Array<ArrayBuffer> | ArrayBuffer,
privateKey: string,
): Promise<ArrayBuffer> => {
const key = await getCryptoKey(privateKey, "decrypt");
return window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
key,
encrypted,
);
};
@@ -8,13 +8,14 @@ import {
Text, Text,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { useState } from "react"; import { useState, useCallback } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib"; import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import "@excalidraw/excalidraw/index.css"; import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; import type { ExcalidrawImperativeAPI, Gesture } from "@excalidraw/excalidraw/types";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal"; import ReactClearModal from "react-clear-modal";
import clsx from "clsx"; import clsx from "clsx";
@@ -22,8 +23,9 @@ import { IconEdit } from "@tabler/icons-react";
import { lazy } from "react"; import { lazy } from "react";
import { Suspense } from "react"; import { Suspense } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHandleLibrary } from "@excalidraw/excalidraw"; import { useHandleLibrary, LiveCollaborationTrigger } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { useExcalidrawCollab } from "./use-excalidraw-collab";
const Excalidraw = lazy(() => const Excalidraw = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({ import("@excalidraw/excalidraw").then((module) => ({
@@ -46,6 +48,16 @@ export default function ExcalidrawView(props: NodeViewProps) {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const pageId = editor.storage?.pageId;
const { broadcastScene, broadcastPointer, isCollaborating } = useExcalidrawCollab(excalidrawAPI, pageId, opened);
const handleChange = useCallback(
(elements: readonly ExcalidrawElement[]) => {
broadcastScene(elements);
},
[broadcastScene],
);
const handleOpen = async () => { const handleOpen = async () => {
if (!editor.isEditable) { if (!editor.isEditable) {
return; return;
@@ -157,6 +169,14 @@ export default function ExcalidrawView(props: NodeViewProps) {
scrollToContent: true, scrollToContent: true,
}} }}
theme={computedColorScheme} theme={computedColorScheme}
onChange={handleChange}
onPointerUpdate={broadcastPointer}
renderTopRightUI={() => (
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() => {}}
/>
)}
/> />
</Suspense> </Suspense>
</div> </div>
@@ -0,0 +1,257 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
import { newElementWith } from "@excalidraw/element";
import throttle from "lodash.throttle";
import type { UserIdleState } from "@excalidraw/common";
import type { OrderedExcalidrawElement } from "@excalidraw/element/types";
import type {
OnUserFollowedPayload,
SocketId,
} from "@excalidraw/excalidraw/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import { isSyncableElement } from "../data";
import type {
SocketUpdateData,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import type { TCollabClass } from "./Collab";
import type { Socket } from "socket.io-client";
class Portal {
collab: TCollabClass;
socket: Socket | null = null;
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
roomId: string | null = null;
roomKey: string | null = null;
broadcastedElementVersions: Map<string, number> = new Map();
constructor(collab: TCollabClass) {
this.collab = collab;
}
open(socket: Socket, id: string, key: string) {
this.socket = socket;
this.roomId = id;
this.roomKey = key;
// Initialize socket listeners
this.socket.on("init-room", () => {
if (this.socket) {
this.socket.emit("join-room", this.roomId);
trackEvent("share", "room joined");
}
});
this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene(
WS_SUBTYPES.INIT,
this.collab.getSceneElementsIncludingDeleted(),
/* syncAll */ true,
);
});
this.socket.on("room-user-change", (clients: SocketId[]) => {
this.collab.setCollaborators(clients);
});
return socket;
}
close() {
if (!this.socket) {
return;
}
this.queueFileUpload.flush();
this.socket.close();
this.socket = null;
this.roomId = null;
this.roomKey = null;
this.socketInitialized = false;
this.broadcastedElementVersions = new Map();
}
isOpen() {
return !!(
this.socketInitialized &&
this.socket &&
this.roomId &&
this.roomKey
);
}
async _broadcastSocketData(
data: SocketUpdateData,
volatile: boolean = false,
roomId?: string,
) {
if (this.isOpen()) {
const json = JSON.stringify(data);
const encoded = new TextEncoder().encode(json);
const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
this.socket?.emit(
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
roomId ?? this.roomId,
encryptedBuffer,
iv,
);
}
}
queueFileUpload = throttle(async () => {
try {
await this.collab.fileManager.saveFiles({
elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(),
files: this.collab.excalidrawAPI.getFiles(),
});
} catch (error: any) {
if (error.name !== "AbortError") {
this.collab.excalidrawAPI.updateScene({
appState: {
errorMessage: error.message,
},
});
}
}
let isChanged = false;
const newElements = this.collab.excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
isChanged = true;
// this will signal collaborators to pull image data from server
// (using mutation instead of newElementWith otherwise it'd break
// in-progress dragging)
return newElementWith(element, { status: "saved" });
}
return element;
});
if (isChanged) {
this.collab.excalidrawAPI.updateScene({
elements: newElements,
captureUpdate: CaptureUpdateAction.NEVER,
});
}
}, FILE_UPLOAD_TIMEOUT);
broadcastScene = async (
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
elements: readonly OrderedExcalidrawElement[],
syncAll: boolean,
) => {
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
throw new Error("syncAll must be true when sending SCENE.INIT");
}
// sync out only the elements we think we need to to save bandwidth.
// periodically we'll resync the whole thing to make sure no one diverges
// due to a dropped message (server goes down etc).
const syncableElements = elements.reduce((acc, element) => {
if (
(syncAll ||
!this.broadcastedElementVersions.has(element.id) ||
element.version > this.broadcastedElementVersions.get(element.id)!) &&
isSyncableElement(element)
) {
acc.push(element);
}
return acc;
}, [] as SyncableExcalidrawElement[]);
const data: SocketUpdateDataSource[typeof updateType] = {
type: updateType,
payload: {
elements: syncableElements,
},
};
for (const syncableElement of syncableElements) {
this.broadcastedElementVersions.set(
syncableElement.id,
syncableElement.version,
);
}
this.queueFileUpload();
await this._broadcastSocketData(data as SocketUpdateData);
};
broadcastIdleChange = (userState: UserIdleState) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
type: WS_SUBTYPES.IDLE_STATUS,
payload: {
socketId: this.socket.id as SocketId,
userState,
username: this.collab.state.username,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
);
}
};
broadcastMouseLocation = (payload: {
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
}) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
type: WS_SUBTYPES.MOUSE_LOCATION,
payload: {
socketId: this.socket.id as SocketId,
pointer: payload.pointer,
button: payload.button || "up",
selectedElementIds:
this.collab.excalidrawAPI.getAppState().selectedElementIds,
username: this.collab.state.username,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
);
}
};
broadcastVisibleSceneBounds = (
payload: {
sceneBounds: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"]["payload"]["sceneBounds"];
},
roomId: string,
) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"] = {
type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS,
payload: {
socketId: this.socket.id as SocketId,
username: this.collab.state.username,
sceneBounds: payload.sceneBounds,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
roomId,
);
}
};
broadcastUserFollowed = (payload: OnUserFollowedPayload) => {
if (this.socket?.id) {
this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload);
}
};
}
export default Portal;
@@ -0,0 +1,266 @@
import { useEffect, useRef, useCallback, useMemo, useState } from "react";
import { useAtom } from "jotai";
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import type {
ExcalidrawImperativeAPI,
Collaborator,
Gesture,
} from "@excalidraw/excalidraw/types";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { reconcileElements, getSceneVersion } from "@excalidraw/excalidraw";
import throttle from "lodash.throttle";
// Message types for collaboration
type SceneUpdateMessage = {
type: "SCENE_UPDATE";
payload: { elements: readonly ExcalidrawElement[] };
};
type PointerUpdateMessage = {
type: "POINTER_UPDATE";
payload: {
socketId: string;
pointer: { x: number; y: number };
button: "down" | "up";
username: string;
selectedElementIds: Record<string, boolean>;
};
};
type CollabMessage = SceneUpdateMessage | PointerUpdateMessage;
export function useExcalidrawCollab(
excalidrawAPI: ExcalidrawImperativeAPI | null,
pageId: string | undefined,
isOpen: boolean,
) {
const [socket] = useAtom(socketAtom);
const [currentUser] = useAtom(currentUserAtom);
const lastBroadcastedVersion = useRef(-1);
const isInitialized = useRef(false);
const collaboratorsRef = useRef<Map<string, Collaborator>>(new Map());
const [isCollaborating, setIsCollaborating] = useState(false);
// Track broadcasted element versions for bandwidth optimization
const broadcastedElementVersions = useRef<Map<string, number>>(new Map());
const roomId = pageId ? `excalidraw-${pageId}` : null;
const username = currentUser?.user?.name || "Anonymous";
// Broadcast pointer/cursor updates (volatile - can be dropped)
const broadcastPointer = useMemo(
() =>
throttle(
(payload: {
pointer: { x: number; y: number };
button: "down" | "up";
pointersMap: Gesture["pointers"];
}) => {
if (!socket || !roomId || !isInitialized.current) return;
if (payload.pointersMap.size >= 2) return; // Skip multi-touch
const data: PointerUpdateMessage = {
type: "POINTER_UPDATE",
payload: {
socketId: socket.id!,
pointer: payload.pointer,
button: payload.button,
username,
selectedElementIds:
excalidrawAPI?.getAppState().selectedElementIds || {},
},
};
const json = JSON.stringify(data);
socket.emit("ex-server-volatile-broadcast", [roomId, json, null]);
},
50,
),
[socket, roomId, username, excalidrawAPI],
);
// Broadcast scene changes with bandwidth optimization
const broadcastScene = useCallback(
(elements: readonly ExcalidrawElement[], syncAll = false) => {
if (!socket || !roomId || !isInitialized.current) {
return;
}
const sceneVersion = getSceneVersion(elements);
if (sceneVersion <= lastBroadcastedVersion.current) {
return;
}
// Filter to only send elements that changed since last broadcast
const changedElements = elements.filter((element) => {
const lastVersion = broadcastedElementVersions.current.get(element.id);
return syncAll || lastVersion === undefined || element.version > lastVersion;
});
if (changedElements.length === 0) {
return;
}
const data: SceneUpdateMessage = {
type: "SCENE_UPDATE",
payload: { elements: changedElements },
};
// Update tracking map
for (const element of changedElements) {
broadcastedElementVersions.current.set(element.id, element.version);
}
const json = JSON.stringify(data);
socket.emit("ex-server-broadcast", [roomId, json, null]);
lastBroadcastedVersion.current = sceneVersion;
},
[socket, roomId],
);
// Throttled version for onChange handler
const throttledBroadcastScene = useMemo(
() => throttle((elements: readonly ExcalidrawElement[]) => broadcastScene(elements, false), 100),
[broadcastScene],
);
// Handle incoming broadcasts
const handleClientBroadcast = useCallback(
(jsonData: string, _iv: Uint8Array | null) => {
if (!excalidrawAPI || !socket) return;
try {
const data: CollabMessage = JSON.parse(jsonData);
if (data.type === "SCENE_UPDATE" && data.payload?.elements) {
const remoteElements = data.payload.elements;
const localElements =
excalidrawAPI.getSceneElementsIncludingDeleted();
const reconciledElements = reconcileElements(
localElements,
// @ts-ignore
remoteElements,
excalidrawAPI.getAppState(),
);
excalidrawAPI.updateScene({
elements: reconciledElements,
});
lastBroadcastedVersion.current = getSceneVersion(reconciledElements);
} else if (data.type === "POINTER_UPDATE") {
const { socketId, pointer, button, username, selectedElementIds } =
data.payload;
// Don't update our own cursor
if (socketId === socket.id) return;
// Update collaborator with pointer info
const collaborator = collaboratorsRef.current.get(socketId) || {};
collaboratorsRef.current.set(socketId, {
...collaborator,
// @ts-ignore
pointer,
button,
username,
// @ts-ignore
selectedElementIds,
isCurrentUser: false,
});
excalidrawAPI.updateScene({
// @ts-ignore
collaborators: collaboratorsRef.current,
});
}
} catch (err) {
console.error("Failed to process broadcast:", err);
}
},
[excalidrawAPI, socket],
);
// Handle room user changes
const handleRoomUserChange = useCallback(
(socketIds: string[]) => {
if (!excalidrawAPI || !socket) return;
// Update collaborators map, preserving existing data
const newCollaborators = new Map<string, Collaborator>();
for (const id of socketIds) {
const existing = collaboratorsRef.current.get(id);
newCollaborators.set(id, {
...existing,
isCurrentUser: id === socket.id,
username:
existing?.username || (id === socket.id ? username : "User"),
});
}
collaboratorsRef.current = newCollaborators;
// @ts-ignore
excalidrawAPI.updateScene({ collaborators: newCollaborators });
// We're collaborating if there are other users
setIsCollaborating(socketIds.length > 1);
},
[excalidrawAPI, socket, username],
);
// Join/leave room based on modal state
useEffect(() => {
if (!socket || !roomId || !isOpen) {
setIsCollaborating(false);
return;
}
console.log("Joining room:", roomId);
socket.emit("ex-join-room", roomId);
isInitialized.current = true;
// Set up listeners
socket.on("ex-client-broadcast", handleClientBroadcast);
socket.on("ex-room-user-change", handleRoomUserChange);
socket.on("ex-first-in-room", () => {
console.log("First in excalidraw room");
});
socket.on("ex-new-user", (socketId: string) => {
console.log("New user joined:", socketId);
if (excalidrawAPI) {
// Send full scene to new user (syncAll = true)
broadcastScene(excalidrawAPI.getSceneElements(), true);
}
});
return () => {
console.log("Leaving room:", roomId);
socket.emit("ex-leave-room", roomId);
socket.off("ex-client-broadcast", handleClientBroadcast);
socket.off("ex-room-user-change", handleRoomUserChange);
socket.off("ex-first-in-room");
socket.off("ex-new-user");
isInitialized.current = false;
lastBroadcastedVersion.current = -1;
broadcastedElementVersions.current = new Map();
collaboratorsRef.current = new Map();
setIsCollaborating(false);
};
}, [
socket,
roomId,
isOpen,
handleClientBroadcast,
handleRoomUserChange,
broadcastScene,
excalidrawAPI,
]);
return {
broadcastScene: throttledBroadcastScene,
broadcastPointer,
isCollaborating,
};
}
@@ -1,19 +1,21 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Anchor, Text } from "@mantine/core"; import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react"; import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useParams } from "react-router-dom"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { import {
buildPageUrl, buildPageUrl,
buildSharedPageUrl, buildSharedPageUrl,
} from "@/features/page/page.utils.ts"; } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import classes from "./mention.module.css"; import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) { export default function MentionView(props: NodeViewProps) {
const { node } = props; const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs; const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const { spaceSlug } = useParams(); const { spaceSlug, pageSlug } = useParams();
const { shareId } = useParams(); const { shareId } = useParams();
const navigate = useNavigate();
const { const {
data: page, data: page,
isLoading, isLoading,
@@ -23,6 +25,20 @@ export default function MentionView(props: NodeViewProps) {
const location = useLocation(); const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share"); 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({ const shareSlugUrl = buildSharedPageUrl({
shareId, shareId,
pageSlugId: slugId, pageSlugId: slugId,
@@ -45,6 +61,7 @@ export default function MentionView(props: NodeViewProps) {
to={ to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId) isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
} }
onClick={handleClick}
underline="never" underline="never"
className={classes.pageMentionLink} className={classes.pageMentionLink}
> >
@@ -1,5 +1,7 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { IFileTask } from "@/features/file-task/types/file-task.types.ts"; 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> { export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
const req = await api.post<IFileTask>("/file-tasks/info", { const req = await api.post<IFileTask>("/file-tasks/info", {
@@ -8,7 +10,10 @@ export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
return req.data; return req.data;
} }
export async function getFileTasks(): Promise<IFileTask[]> { export async function getFileTasks(
const req = await api.post<IFileTask[]>("/file-tasks"); params?: QueryParams,
): Promise<IPagination<IFileTask>> {
const req = await api.post("/file-tasks", { ...params });
return req.data; return req.data;
} }
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { zodResolver } from 'mantine-form-zod-resolver'; import { zodResolver } from 'mantine-form-zod-resolver';
const formSchema = z.object({ const formSchema = z.object({
name: z.string().trim().min(2).max(50), name: z.string().trim().min(2).max(100),
description: z.string().max(500), description: z.string().max(500),
}); });
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import { zodResolver } from "mantine-form-zod-resolver"; import { zodResolver } from "mantine-form-zod-resolver";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().min(2).max(100),
description: z.string().max(500), description: z.string().max(500),
}); });
@@ -50,7 +50,7 @@ export default function GroupList() {
> >
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<IconGroupCircle /> <IconGroupCircle />
<div> <div style={{ minWidth: 0, overflow: "hidden" }}>
<Text fz="sm" fw={500} lineClamp={1}> <Text fz="sm" fw={500} lineClamp={1}>
{group.name} {group.name}
</Text> </Text>
@@ -269,12 +269,15 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const prefetchPage = () => { const prefetchPage = () => {
timerRef.current = setTimeout(() => { timerRef.current = setTimeout(async () => {
queryClient.prefetchQuery({ const page = await queryClient.fetchQuery({
queryKey: ["pages", node.data.slugId], queryKey: ["pages", node.data.id],
queryFn: () => getPageById({ pageId: node.data.slugId }), queryFn: () => getPageById({ pageId: node.data.id }),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
if (page?.slugId) {
queryClient.setQueryData(["pages", page.slugId], page);
}
}, 150); }, 150);
}; };
@@ -8,7 +8,6 @@ import {
Switch, Switch,
Text, Text,
TextInput, TextInput,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react"; import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
@@ -21,12 +20,12 @@ import {
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { extractPageSlugId, getPageIcon } from "@/lib"; import { extractPageSlugId, getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import CopyTextButton from "@/components/common/copy.tsx"; import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl, isCloud } from "@/lib/config.ts"; import { getAppUrl, isCloud } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css"; import classes from "@/features/share/components/share.module.css";
import useTrial from "@/ee/hooks/use-trial.tsx"; import useTrial from "@/ee/hooks/use-trial.tsx";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
interface ShareModalProps { interface ShareModalProps {
readOnly: boolean; readOnly: boolean;
@@ -35,7 +34,9 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const pageId = extractPageSlugId(pageSlug); const pageSlugId = extractPageSlugId(pageSlug);
const { data: page } = usePageQuery({ pageId: pageSlugId });
const pageId = page?.id;
const { data: share } = useShareForPageQuery(pageId); const { data: share } = useShareForPageQuery(pageId);
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { isTrial } = useTrial(); const { isTrial } = useTrial();
@@ -27,9 +27,7 @@ import {
getShares, getShares,
updateShare, updateShare,
} from "@/features/share/services/share-service.ts"; } from "@/features/share/services/share-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts"; import { IPagination, QueryParams } from "@/lib/types.ts";
import { useEffect } from "react";
export function useGetSharesQuery( export function useGetSharesQuery(
params?: QueryParams, params?: QueryParams,
@@ -72,7 +70,7 @@ export function useShareForPageQuery(
queryKey: ["share-for-page", pageId], queryKey: ["share-for-page", pageId],
queryFn: () => getShareForPage(pageId), queryFn: () => getShareForPage(pageId),
enabled: !!pageId, enabled: !!pageId,
staleTime: 0, staleTime: 60 * 1000,
retry: false, retry: false,
}); });
@@ -1,6 +1,7 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core"; import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useForm, zodResolver } from "@mantine/form"; import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import * as z from "zod"; import * as z from "zod";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts"; import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
@@ -9,12 +10,12 @@ import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().trim().min(2).max(50), name: z.string().trim().min(2).max(100),
slug: z slug: z
.string() .string()
.trim() .trim()
.min(2) .min(2)
.max(50) .max(100)
.regex( .regex(
/^[a-zA-Z0-9]+$/, /^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters", "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"; import { useTranslation } from "react-i18next";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().min(2).max(100),
description: z.string().max(250), description: z.string().max(500),
slug: z slug: z
.string() .string()
.min(2) .min(2)
.max(50) .max(100)
.regex( .regex(
/^[a-zA-Z0-9]+$/, /^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters", "Space slug must be alphanumeric. No special characters",
@@ -48,7 +48,7 @@ export default function SpaceList() {
variant="filled" variant="filled"
name={space.name} name={space.name}
/> />
<div> <div style={{ minWidth: 0, overflow: "hidden" }}>
<Text fz="sm" fw={500} lineClamp={1}> <Text fz="sm" fw={500} lineClamp={1}>
{space.name} {space.name}
</Text> </Text>
+25 -25
View File
@@ -30,48 +30,48 @@
"test:e2e": "jest --config test/jest-e2e.json" "test:e2e": "jest --config test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/azure": "^2.0.47", "@ai-sdk/google": "^3.0.9",
"@ai-sdk/google": "^2.0.18", "@ai-sdk/openai": "^3.0.11",
"@ai-sdk/openai": "^2.0.46", "@ai-sdk/openai-compatible": "^2.0.12",
"@aws-sdk/client-s3": "3.701.0", "@aws-sdk/client-s3": "3.701.0",
"@aws-sdk/lib-storage": "3.701.0", "@aws-sdk/lib-storage": "3.701.0",
"@aws-sdk/s3-request-presigner": "3.701.0", "@aws-sdk/s3-request-presigner": "3.701.0",
"@casl/ability": "^6.7.3",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.0.3", "@fastify/multipart": "^9.3.0",
"@fastify/static": "^8.2.0", "@fastify/static": "^8.3.0",
"@langchain/textsplitters": "^0.1.0", "@langchain/core": "1.1.13",
"@langchain/textsplitters": "1.0.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4", "@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.9", "@nestjs/common": "^11.1.11",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.9", "@nestjs/core": "^11.1.11",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.0", "@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.9", "@nestjs/platform-fastify": "^11.1.11",
"@nestjs/platform-socket.io": "^11.1.9", "@nestjs/platform-socket.io": "^11.1.11",
"@nestjs/schedule": "^6.0.1", "@nestjs/schedule": "^6.1.0",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.9", "@nestjs/websockets": "^11.1.11",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "0.0.28", "@react-email/components": "0.0.28",
"@react-email/render": "1.0.2", "@react-email/render": "1.0.2",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"ai": "^5.0.65", "ai": "^6.0.37",
"ai-sdk-ollama": "^0.12.0", "ai-sdk-ollama": "^3.1.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bullmq": "^5.65.0", "bullmq": "^5.65.0",
"cache-manager": "^6.4.3", "cache-manager": "^6.4.3",
"cheerio": "^1.1.0", "cheerio": "^1.1.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"cookie": "^1.0.2", "cookie": "^1.1.1",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.3",
"happy-dom": "20.0.10", "happy-dom": "20.1.0",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.3",
"kysely": "^0.28.2", "kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
"ldapts": "^7.4.0", "ldapts": "^7.4.0",
@@ -79,9 +79,9 @@
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"nanoid": "3.3.11", "nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0", "nestjs-kysely": "^1.2.0",
"nodemailer": "^7.0.11", "nodemailer": "^7.0.12",
"openid-client": "^5.7.1", "openid-client": "^5.7.1",
"otpauth": "^9.4.0", "otpauth": "^9.4.1",
"p-limit": "^6.2.0", "p-limit": "^6.2.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@@ -95,11 +95,11 @@
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"sanitize-filename-ts": "1.0.2", "sanitize-filename-ts": "1.0.2",
"sharp": "0.34.3", "sharp": "0.34.3",
"socket.io": "^4.8.1", "socket.io": "^4.8.3",
"stripe": "^17.5.0", "stripe": "^17.5.0",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"typesense": "^2.1.0", "typesense": "^2.1.0",
"ws": "^8.18.3", "ws": "^8.19.0",
"yauzl": "^3.2.0" "yauzl": "^3.2.0"
}, },
"devDependencies": { "devDependencies": {
@@ -124,7 +124,7 @@
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"globals": "^15.15.0", "globals": "^15.15.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"kysely-codegen": "^0.17.0", "kysely-codegen": "^0.19.0",
"prettier": "^3.5.1", "prettier": "^3.5.1",
"react-email": "3.0.2", "react-email": "3.0.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
@@ -11,7 +11,7 @@ import {Transform, TransformFnParams} from "class-transformer";
export class CreateGroupDto { export class CreateGroupDto {
@MinLength(2) @MinLength(2)
@MaxLength(50) @MaxLength(100)
@IsString() @IsString()
@Transform(({ value }: TransformFnParams) => value?.trim()) @Transform(({ value }: TransformFnParams) => value?.trim())
name: string; name: string;
+7 -10
View File
@@ -74,16 +74,13 @@ export class SearchService {
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId); queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
} else if (opts.userId && !searchParams.spaceId) { } else if (opts.userId && !searchParams.spaceId) {
// only search spaces the user is a member of // only search spaces the user is a member of
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds( queryResults = queryResults
opts.userId, .where(
); 'spaceId',
if (userSpaceIds.length > 0) { 'in',
queryResults = queryResults this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId),
.where('spaceId', 'in', userSpaceIds) )
.where('workspaceId', '=', opts.workspaceId); .where('workspaceId', '=', opts.workspaceId);
} else {
return [];
}
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) { } else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
// search in shares // search in shares
const shareId = searchParams.shareId; const shareId = searchParams.shareId;
+51 -49
View File
@@ -123,80 +123,82 @@ export class ShareService {
.withRecursive('page_hierarchy', (cte) => .withRecursive('page_hierarchy', (cte) =>
cte cte
.selectFrom('pages') .selectFrom('pages')
.leftJoin('shares', 'shares.pageId', 'pages.id')
.select([ .select([
'id', 'pages.id',
'slugId', 'pages.slugId',
'pages.title', 'pages.title',
'pages.icon', 'pages.icon',
'parentPageId', 'pages.parentPageId',
sql`0`.as('level'), 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) ? 'id' : 'slugId', '=', pageId) .where(isValidUUID(pageId) ? 'pages.id' : 'pages.slugId', '=', pageId)
.where('deletedAt', 'is', null) .where('pages.deletedAt', 'is', null)
.unionAll((union) => .unionAll(
union (union) =>
.selectFrom('pages as p') union
.select([ .selectFrom('pages as p')
'p.id', .innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
'p.slugId', .leftJoin('shares as s', 's.pageId', 'p.id')
'p.title', .select([
'p.icon', 'p.id',
'p.parentPageId', 'p.slugId',
// Increase the level by 1 for each ancestor. 'p.title',
sql`ph.level + 1`.as('level'), 'p.icon',
]) 'p.parentPageId',
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id') sql`ph.level + 1`.as('level'),
.where('p.deletedAt', 'is', null), '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
), ),
) )
.selectFrom('page_hierarchy') .selectFrom('page_hierarchy')
.leftJoin('shares', 'shares.pageId', 'page_hierarchy.id') .selectAll()
.select([ .where('shareId', 'is not', null)
'page_hierarchy.id as sharedPageId', .limit(1)
'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(); .executeTakeFirst();
if (!share || share.workspaceId != workspaceId) { if (!share || share.workspaceId !== workspaceId) {
return undefined; return undefined;
} }
if (share.level === 1 && !share.includeSubPages) { if ((share.level as number) > 0 && !share.includeSubPages) {
// we can only show a page if its shared ancestor permits it
return undefined; return undefined;
} }
return { return {
id: share.id, id: share.shareId,
key: share.key, key: share.shareKey,
includeSubPages: share.includeSubPages, includeSubPages: share.includeSubPages,
searchIndexing: share.searchIndexing, searchIndexing: share.searchIndexing,
pageId: share.pageId, pageId: share.id,
creatorId: share.creatorId, creatorId: share.creatorId,
spaceId: share.spaceId, spaceId: share.spaceId,
workspaceId: share.workspaceId, workspaceId: share.workspaceId,
createdAt: share.createdAt, createdAt: share.createdAt,
level: share.level, level: share.level,
sharedPage: { sharedPage: {
id: share.sharedPageId, id: share.id,
slugId: share.sharedPageSlugId, slugId: share.slugId,
title: share.sharedPageTitle, title: share.title,
icon: share.sharedPageIcon, icon: share.icon,
}, },
}; };
} }
@@ -9,7 +9,7 @@ import {Transform, TransformFnParams} from "class-transformer";
export class CreateSpaceDto { export class CreateSpaceDto {
@MinLength(2) @MinLength(2)
@MaxLength(50) @MaxLength(100)
@IsString() @IsString()
@Transform(({ value }: TransformFnParams) => value?.trim()) @Transform(({ value }: TransformFnParams) => value?.trim())
name: string; name: string;
@@ -19,7 +19,7 @@ export class CreateSpaceDto {
description?: string; description?: string;
@MinLength(2) @MinLength(2)
@MaxLength(50) @MaxLength(100)
@IsAlphanumeric() @IsAlphanumeric()
slug: string; slug: string;
} }
@@ -293,24 +293,18 @@ export class PageRepo {
} }
async getRecentPages(userId: string, pagination: PaginationOptions) { async getRecentPages(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const query = this.db const query = this.db
.selectFrom('pages') .selectFrom('pages')
.select(this.baseFields) .select(this.baseFields)
.select((eb) => this.withSpace(eb)) .select((eb) => this.withSpace(eb))
.where('spaceId', 'in', userSpaceIds) .where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.orderBy('updatedAt', 'desc'); .orderBy('updatedAt', 'desc');
const hasEmptyIds = userSpaceIds.length === 0; return executeWithPagination(query, {
const result = executeWithPagination(query, {
page: pagination.page, page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,
hasEmptyIds,
}); });
return result;
} }
async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) { async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
@@ -137,25 +137,19 @@ export class ShareRepo {
} }
async getShares(userId: string, pagination: PaginationOptions) { async getShares(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const query = this.db const query = this.db
.selectFrom('shares') .selectFrom('shares')
.select(this.baseFields) .select(this.baseFields)
.select((eb) => this.withPage(eb)) .select((eb) => this.withPage(eb))
.select((eb) => this.withSpace(eb, userId)) .select((eb) => this.withSpace(eb, userId))
.select((eb) => this.withCreator(eb)) .select((eb) => this.withCreator(eb))
.where('spaceId', 'in', userSpaceIds) .where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
.orderBy('updatedAt', 'desc'); .orderBy('updatedAt', 'desc');
const hasEmptyIds = userSpaceIds.length === 0; return executeWithPagination(query, {
const result = executeWithPagination(query, {
page: pagination.page, page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,
hasEmptyIds,
}); });
return result;
} }
withPage(eb: ExpressionBuilder<DB, 'shares'>) { withPage(eb: ExpressionBuilder<DB, 'shares'>) {
@@ -209,34 +209,33 @@ export class SpaceMemberRepo {
return roles; return roles;
} }
async getUserSpaceIds(userId: string): Promise<string[]> { getUserSpaceIdsQuery(userId: string) {
const membership = await this.db return this.db
.selectFrom('spaceMembers') .selectFrom('spaceMembers')
.innerJoin('spaces', 'spaces.id', 'spaceMembers.spaceId') .innerJoin('spaces', 'spaces.id', 'spaceMembers.spaceId')
.select(['spaces.id']) .select('spaces.id')
.where('userId', '=', userId) .where('userId', '=', userId)
.union( .union(
this.db this.db
.selectFrom('spaceMembers') .selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId') .innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.innerJoin('spaces', 'spaces.id', 'spaceMembers.spaceId') .innerJoin('spaces', 'spaces.id', 'spaceMembers.spaceId')
.select(['spaces.id']) .select('spaces.id')
.where('groupUsers.userId', '=', userId), .where('groupUsers.userId', '=', userId),
) );
.execute(); }
async getUserSpaceIds(userId: string): Promise<string[]> {
const membership = await this.getUserSpaceIdsQuery(userId).execute();
return membership.map((space) => space.id); return membership.map((space) => space.id);
} }
async getUserSpaces(userId: string, pagination: PaginationOptions) { async getUserSpaces(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.getUserSpaceIds(userId);
let query = this.db let query = this.db
.selectFrom('spaces') .selectFrom('spaces')
.selectAll() .selectAll()
.select((eb) => [this.spaceRepo.withMemberCount(eb)]) .select((eb) => [this.spaceRepo.withMemberCount(eb)])
//.where('workspaceId', '=', workspaceId) .where('id', 'in', this.getUserSpaceIdsQuery(userId))
.where('id', 'in', userSpaceIds)
.orderBy('createdAt', 'asc'); .orderBy('createdAt', 'asc');
if (pagination.query) { if (pagination.query) {
@@ -253,14 +252,9 @@ export class SpaceMemberRepo {
); );
} }
const hasEmptyIds = userSpaceIds.length === 0; return executeWithPagination(query, {
const result = executeWithPagination(query, {
page: pagination.page, page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,
hasEmptyIds,
}); });
return result;
} }
} }
@@ -105,7 +105,7 @@ export class EnvironmentVariables {
@IsOptional() @IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER) @ValidateIf((obj) => obj.AI_DRIVER)
@IsIn(['openai', 'gemini', 'ollama']) @IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])
@IsString() @IsString()
AI_DRIVER: string; AI_DRIVER: string;
@@ -117,11 +117,10 @@ export class EnvironmentVariables {
@IsOptional() @IsOptional()
@ValidateIf((obj) => obj.AI_EMBEDDING_DIMENSION) @ValidateIf((obj) => obj.AI_EMBEDDING_DIMENSION)
@IsIn(['768', '1024', '1536', '2000']) @IsIn(['768', '1024', '1536', '2000', '3072'])
@IsString() @IsString()
AI_EMBEDDING_DIMENSION: string; AI_EMBEDDING_DIMENSION: string;
@IsOptional() @IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER) @ValidateIf((obj) => obj.AI_DRIVER)
@IsString() @IsString()
@@ -129,13 +128,20 @@ export class EnvironmentVariables {
AI_COMPLETION_MODEL: string; AI_COMPLETION_MODEL: string;
@IsOptional() @IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'openai') @ValidateIf(
(obj) =>
obj.AI_DRIVER && ['openai', 'openai-compatible'].includes(obj.AI_DRIVER),
)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
OPENAI_API_KEY: string; OPENAI_API_KEY: string;
@IsOptional() @IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.OPENAI_API_URL && obj.AI_DRIVER === 'openai') @ValidateIf(
(obj) =>
obj.AI_DRIVER === 'openai-compatible' ||
(obj.AI_DRIVER === 'openai' && obj.OPENAI_API_URL),
)
@IsUrl({ protocols: ['http', 'https'], require_tld: false }) @IsUrl({ protocols: ['http', 'https'], require_tld: false })
OPENAI_API_URL: string; OPENAI_API_URL: string;
@@ -10,46 +10,59 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory'; import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User } from '@docmost/db/types/entity.types'; import { User, Workspace } from '@docmost/db/types/entity.types';
import { import {
SpaceCaslAction, SpaceCaslAction,
SpaceCaslSubject, SpaceCaslSubject,
} from '../../core/casl/interfaces/space-ability.type'; } 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 { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { FileTaskIdDto } from './dto/file-task-dto'; import { FileTaskIdDto } from './dto/file-task-dto';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; 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') @Controller('file-tasks')
export class FileTaskController { export class FileTaskController {
constructor( constructor(
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly spaceAbility: SpaceAbilityFactory, private readonly spaceAbility: SpaceAbilityFactory,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceMemberRepo: SpaceMemberRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
) {} ) {}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post() @Post()
async getFileTasks(@AuthUser() user: User) { async getFileTasks(
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(user.id); @Body() pagination: PaginationOptions,
@AuthUser() user: User,
if (!userSpaceIds || userSpaceIds.length === 0) { @AuthWorkspace() workspace: Workspace,
return []; ) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings)
) {
throw new ForbiddenException();
} }
const fileTasks = await this.db const query = this.db
.selectFrom('fileTasks') .selectFrom('fileTasks')
.selectAll() .selectAll()
.where('spaceId', 'in', userSpaceIds) .where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(user.id))
.execute(); .orderBy('createdAt', 'desc');
if (!fileTasks) { return executeWithPagination(query, {
throw new NotFoundException('File task not found'); page: pagination.page,
} perPage: pagination.limit,
});
return fileTasks;
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -0,0 +1,127 @@
import { Injectable } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { ExcalidrawFollowPayload } from '../types/excalidraw.types';
@Injectable()
export class ExcalidrawCollabService {
// Track socket -> rooms mapping for disconnect handling
// (Socket.IO clears client.rooms before handleDisconnect runs)
private socketRooms = new Map<string, Set<string>>();
async handleJoinRoom(
client: Socket,
server: Server,
roomId: string,
): Promise<void> {
await client.join(roomId);
// Track room membership
if (!this.socketRooms.has(client.id)) {
this.socketRooms.set(client.id, new Set());
}
this.socketRooms.get(client.id).add(roomId);
const sockets = await server.in(roomId).fetchSockets();
if (sockets.length <= 1) {
server.to(client.id).emit('ex-first-in-room');
} else {
client.broadcast.to(roomId).emit('ex-new-user', client.id);
}
server.in(roomId).emit(
'ex-room-user-change',
sockets.map((socket) => socket.id),
);
}
async handleLeaveRoom(
client: Socket,
server: Server,
roomId: string,
): Promise<void> {
await client.leave(roomId);
// Remove from tracking
this.socketRooms.get(client.id)?.delete(roomId);
// Notify remaining users
const sockets = await server.in(roomId).fetchSockets();
if (sockets.length > 0) {
server.in(roomId).emit(
'ex-room-user-change',
sockets.map((socket) => socket.id),
);
}
}
handleServerBroadcast(
client: Socket,
roomId: string,
encryptedData: ArrayBuffer,
iv: Uint8Array,
): void {
client.broadcast.to(roomId).emit('ex-client-broadcast', encryptedData, iv);
}
handleServerVolatileBroadcast(
client: Socket,
roomId: string,
encryptedData: ArrayBuffer,
iv: Uint8Array,
): void {
client.volatile.broadcast
.to(roomId)
.emit('ex-client-broadcast', encryptedData, iv);
}
async handleUserFollow(
client: Socket,
server: Server,
payload: ExcalidrawFollowPayload,
): Promise<void> {
const roomId = `follow@${payload.userToFollow.socketId}`;
if (payload.action === 'FOLLOW') {
await client.join(roomId);
} else {
await client.leave(roomId);
}
const sockets = await server.in(roomId).fetchSockets();
const followedBy = sockets.map((socket) => socket.id);
server.to(payload.userToFollow.socketId).emit(
'ex-user-follow-room-change',
followedBy,
);
}
async handleDisconnecting(client: Socket, server: Server): Promise<void> {
// Use tracked rooms since client.rooms is empty by this point
const rooms = this.socketRooms.get(client.id) || new Set();
for (const roomId of rooms) {
const otherClients = (await server.in(roomId).fetchSockets()).filter(
(socket) => socket.id !== client.id,
);
const isFollowRoom = roomId.startsWith('follow@');
if (!isFollowRoom && otherClients.length > 0) {
server.to(roomId).emit(
'ex-room-user-change',
otherClients.map((socket) => socket.id),
);
}
if (isFollowRoom && otherClients.length === 0) {
const socketId = roomId.replace('follow@', '');
server.to(socketId).emit('ex-broadcast-unfollow');
}
}
// Clean up tracking
this.socketRooms.delete(client.id);
}
}
@@ -0,0 +1,9 @@
export type ExcalidrawUserToFollow = {
socketId: string;
username: string;
};
export type ExcalidrawFollowPayload = {
userToFollow: ExcalidrawUserToFollow;
action: 'FOLLOW' | 'UNFOLLOW';
};
+80 -1
View File
@@ -1,6 +1,8 @@
import { import {
ConnectedSocket,
MessageBody, MessageBody,
OnGatewayConnection, OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage, SubscribeMessage,
WebSocketGateway, WebSocketGateway,
WebSocketServer, WebSocketServer,
@@ -11,17 +13,23 @@ import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
import { OnModuleDestroy } from '@nestjs/common'; import { OnModuleDestroy } from '@nestjs/common';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
import { ExcalidrawCollabService } from './services/excalidraw-collab.service';
import { ExcalidrawFollowPayload } from './types/excalidraw.types';
@WebSocketGateway({ @WebSocketGateway({
cors: { origin: '*' }, cors: { origin: '*' },
transports: ['websocket'], transports: ['websocket'],
}) })
export class WsGateway implements OnGatewayConnection, OnModuleDestroy { export class WsGateway
implements OnGatewayConnection, OnGatewayDisconnect, OnModuleDestroy
{
@WebSocketServer() @WebSocketServer()
server: Server; server: Server;
constructor( constructor(
private tokenService: TokenService, private tokenService: TokenService,
private spaceMemberRepo: SpaceMemberRepo, private spaceMemberRepo: SpaceMemberRepo,
private excalidrawCollabService: ExcalidrawCollabService,
) {} ) {}
async handleConnection(client: Socket, ...args: any[]): Promise<void> { async handleConnection(client: Socket, ...args: any[]): Promise<void> {
@@ -41,6 +49,8 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id)); const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id));
client.join([workspaceRoom, ...spaceRooms]); client.join([workspaceRoom, ...spaceRooms]);
this.server.to(client.id).emit('init-room');
} catch (err) { } catch (err) {
client.emit('Unauthorized'); client.emit('Unauthorized');
client.disconnect(); client.disconnect();
@@ -76,6 +86,75 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
client.leave(roomName); client.leave(roomName);
} }
// Excalidraw Sync
@SubscribeMessage('ex-join-room')
async handleExJoinRoom(
@ConnectedSocket() client: Socket,
@MessageBody() roomId: string,
): Promise<void> {
await this.excalidrawCollabService.handleJoinRoom(
client,
this.server,
roomId,
);
}
@SubscribeMessage('ex-leave-room')
async handleExLeaveRoom(
@ConnectedSocket() client: Socket,
@MessageBody() roomId: string,
): Promise<void> {
await this.excalidrawCollabService.handleLeaveRoom(
client,
this.server,
roomId,
);
}
@SubscribeMessage('ex-server-broadcast')
handleServerBroadcast(
@ConnectedSocket() client: Socket,
@MessageBody() [roomId, encryptedData, iv]: [string, ArrayBuffer, Uint8Array],
): void {
this.excalidrawCollabService.handleServerBroadcast(
client,
roomId,
encryptedData,
iv,
);
}
@SubscribeMessage('ex-server-volatile-broadcast')
handleServerVolatileBroadcast(
@ConnectedSocket() client: Socket,
@MessageBody() [roomId, encryptedData, iv]: [string, ArrayBuffer, Uint8Array],
): void {
this.excalidrawCollabService.handleServerVolatileBroadcast(
client,
roomId,
encryptedData,
iv,
);
}
@SubscribeMessage('ex-user-follow')
async handleUserFollow(
@ConnectedSocket() client: Socket,
@MessageBody() payload: ExcalidrawFollowPayload,
): Promise<void> {
await this.excalidrawCollabService.handleUserFollow(
client,
this.server,
payload,
);
}
async handleDisconnect(client: Socket): Promise<void> {
await this.excalidrawCollabService.handleDisconnecting(client, this.server);
}
onModuleDestroy() { onModuleDestroy() {
if (this.server) { if (this.server) {
this.server.close(); this.server.close();
+2 -1
View File
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { WsGateway } from './ws.gateway'; import { WsGateway } from './ws.gateway';
import { TokenModule } from '../core/auth/token.module'; import { TokenModule } from '../core/auth/token.module';
import { ExcalidrawCollabService } from './services/excalidraw-collab.service';
@Module({ @Module({
imports: [TokenModule], imports: [TokenModule],
providers: [WsGateway], providers: [WsGateway, ExcalidrawCollabService],
}) })
export class WsModule {} export class WsModule {}
+1
View File
@@ -19,6 +19,7 @@
}, },
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "^7.1.0", "@braintree/sanitize-url": "^7.1.0",
"@casl/ability": "^6.7.5",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "^1.7.3", "@floating-ui/dom": "^1.7.3",
"@hocuspocus/extension-redis": "^2.15.3", "@hocuspocus/extension-redis": "^2.15.3",
+744 -644
View File
File diff suppressed because it is too large Load Diff