Просмотр исходного кода

Merge branch 'master' into feat/page-bulk-export

Futa Arai 1 год назад
Родитель
Сommit
27a098db5b
68 измененных файлов с 593 добавлено и 437 удалено
  1. 1 0
      .devcontainer/devcontainer.json
  2. 1 1
      .github/workflows/reusable-app-prod.yml
  3. 35 1
      CHANGELOG.md
  4. 1 1
      apps/app/docker/README.md
  5. 1 1
      apps/app/package.json
  6. 0 0
      apps/app/playwright/23-editor/assets/example.txt
  7. 113 0
      apps/app/playwright/23-editor/with-navigation.spec.ts
  8. 4 2
      apps/app/public/static/locales/en_US/translation.json
  9. 4 2
      apps/app/public/static/locales/fr_FR/translation.json
  10. 4 2
      apps/app/public/static/locales/ja_JP/translation.json
  11. 4 2
      apps/app/public/static/locales/zh_CN/translation.json
  12. 2 2
      apps/app/resource/locales/ja_JP/welcome.md
  13. 6 5
      apps/app/src/client/components/Admin/App/V5PageMigration.tsx
  14. 1 3
      apps/app/src/client/components/Admin/Common/LabeledProgressBar.tsx
  15. 2 15
      apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  16. 20 0
      apps/app/src/client/components/Admin/UserGroup/UserGroupTable.module.scss
  17. 23 6
      apps/app/src/client/components/Admin/UserGroup/UserGroupTable.tsx
  18. 3 3
      apps/app/src/client/components/Bookmarks/BookmarkFolderItem.tsx
  19. 1 0
      apps/app/src/client/components/Bookmarks/BookmarkFolderTree.module.scss
  20. 5 0
      apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.module.scss
  21. 4 2
      apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.tsx
  22. 24 7
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  23. 0 1
      apps/app/src/client/components/PageControls/PageControls.tsx
  24. 1 1
      apps/app/src/client/components/PageList/PageListItemL.tsx
  25. 1 0
      apps/app/src/client/components/PageList/PageListItemS.module.scss
  26. 1 1
      apps/app/src/client/components/ReactMarkdownComponents/RichAttachment.tsx
  27. 1 1
      apps/app/src/client/components/Sidebar/Bookmarks.tsx
  28. 2 2
      apps/app/src/client/components/Sidebar/Custom/CustomSidebar.tsx
  29. 2 2
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx
  30. 1 1
      apps/app/src/client/components/Sidebar/PageTree/PageTree.tsx
  31. 4 0
      apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.module.scss
  32. 1 1
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChanges.tsx
  33. 4 0
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss
  34. 2 2
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  35. 1 1
      apps/app/src/client/components/Sidebar/Sidebar.module.scss
  36. 1 1
      apps/app/src/client/components/Sidebar/SidebarNav/SecondaryItems.tsx
  37. 2 2
      apps/app/src/client/components/Sidebar/Tag.tsx
  38. 1 1
      apps/app/src/client/components/TreeItem/TreeItemLayout.tsx
  39. 4 3
      apps/app/src/client/services/page-operation.ts
  40. 1 1
      apps/app/src/components/PageView/PageAlerts/PageGrantAlert.tsx
  41. 5 0
      apps/app/src/interfaces/yjs.ts
  42. 2 4
      apps/app/src/server/crowi/index.js
  43. 2 0
      apps/app/src/server/models/revision.ts
  44. 9 4
      apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts
  45. 1 1
      apps/app/src/server/service/config-manager.ts
  46. 28 23
      apps/app/src/server/service/file-uploader/aws/index.ts
  47. 25 20
      apps/app/src/server/service/file-uploader/azure.ts
  48. 4 0
      apps/app/src/server/service/file-uploader/file-uploader.ts
  49. 24 19
      apps/app/src/server/service/file-uploader/gcs/index.ts
  50. 30 24
      apps/app/src/server/service/file-uploader/gridfs.ts
  51. 9 1
      apps/app/src/server/service/file-uploader/local.ts
  52. 24 24
      apps/app/src/server/service/g2g-transfer.ts
  53. 2 1
      apps/app/src/server/service/in-app-notification.ts
  54. 29 0
      apps/app/src/server/service/normalize-data/convert-revision-page-id-to-string.ts
  55. 2 0
      apps/app/src/server/service/normalize-data/index.ts
  56. 3 2
      apps/app/src/server/service/page/index.ts
  57. 1 1
      apps/app/src/server/service/page/page-service.ts
  58. 27 6
      apps/app/src/server/service/search-delegator/aggregate-to-index.ts
  59. 0 0
      apps/app/src/server/service/socket-io/helper.ts
  60. 1 0
      apps/app/src/server/service/socket-io/index.ts
  61. 13 23
      apps/app/src/server/service/socket-io/socket-io.ts
  62. 3 3
      apps/app/src/server/service/system-events/sync-page-status.ts
  63. 15 6
      apps/app/src/server/service/yjs/yjs.ts
  64. 0 175
      apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts
  65. 1 1
      apps/slackbot-proxy/package.json
  66. 1 1
      package.json
  67. 3 1
      packages/core/src/interfaces/page.ts
  68. 40 21
      packages/editor/src/client/services/use-codemirror-editor/utils/fold-drawio.ts

+ 1 - 0
.devcontainer/devcontainer.json

@@ -19,6 +19,7 @@
     "eamodio.gitlens",
     "eamodio.gitlens",
     "github.vscode-pull-request-github",
     "github.vscode-pull-request-github",
     "cschleiden.vscode-github-actions",
     "cschleiden.vscode-github-actions",
+    "cweijan.vscode-database-client2",
     "mongodb.mongodb-vscode",
     "mongodb.mongodb-vscode",
     "msjsdiag.debugger-for-chrome",
     "msjsdiag.debugger-for-chrome",
     "firefox-devtools.vscode-firefox-debug",
     "firefox-devtools.vscode-firefox-debug",

+ 1 - 1
.github/workflows/reusable-app-prod.yml

@@ -213,7 +213,7 @@ jobs:
       fail-fast: false
       fail-fast: false
       matrix:
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['21', '23', '30', '50']
+        spec-group: ['21', '30', '50']
 
 
     services:
     services:
       mongodb:
       mongodb:

+ 35 - 1
CHANGELOG.md

@@ -1,9 +1,43 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.13...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.0.15...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v7.0.15](https://github.com/weseek/growi/compare/v7.0.14...v7.0.15) - 2024-07-23
+
+### 🐛 Bug Fixes
+
+* fix: The $size query when aggregation to rebuild the index (#8987) @yuki-takei
+* fix: Regaining lost backward compatibility for MongoDB 4.4 (#8985) @yuki-takei
+* fix: Activate express-session middlewares for all sockets in SocketIoService (#8981) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Chage text size in sidebar (#8965) @satof3
+
+## [v7.0.14](https://github.com/weseek/growi/compare/v7.0.13...v7.0.14) - 2024-07-19
+
+### 🐛 Bug Fixes
+
+### 💎 Features
+
+* feat: Alerts when trying to sync with latest revision when yjs data is corrupt (#8971) @miya
+
+### 🚀 Improvement
+
+* imprv: Restrict use of the editing UI from View if there is at least one user currently editing (#8966) @miya
+
+### 🐛 Bug Fixes
+
+* fix: Handle error when folding drawio blocks (#8977) @yuki-takei
+* fix: Sync the editor text with the latest revision menu (1) (#8975) @yuki-takei
+* fix: Sync the editor text with the latest revision menu (2) (#8978) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Normalize Revision.pageId (for #8954) (#8973) @miya
+
 ## [v7.0.13](https://github.com/weseek/growi/compare/v7.0.12...v7.0.13) - 2024-07-16
 ## [v7.0.13](https://github.com/weseek/growi/compare/v7.0.12...v7.0.13) - 2024-07-16
 
 
 ### 💎 Features
 ### 💎 Features

+ 1 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`7.0.13`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.13/apps/app/docker/Dockerfile)
+* [`7.0.15`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.15/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 * [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)

+ 1 - 1
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.0.14-RC.0",
+  "version": "7.0.16-RC.0",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
   "scripts": {
   "scripts": {

+ 0 - 0
apps/app/test/cypress/e2e/23-editor/assets/example.txt → apps/app/playwright/23-editor/assets/example.txt


+ 113 - 0
apps/app/playwright/23-editor/with-navigation.spec.ts

@@ -0,0 +1,113 @@
+import { readFileSync } from 'fs';
+import path from 'path';
+
+import { test, expect, type Page } from '@playwright/test';
+
+/**
+ * for the issues:
+ * @see https://redmine.weseek.co.jp/issues/122040
+ * @see https://redmine.weseek.co.jp/issues/124281
+ */
+test('should not be cleared and should prevent GrantSelector from modified', async({ page }) => {
+  await page.goto('/Sandbox/for-122040');
+
+  // Open Editor
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  // Open GrantSelector and select "only me"
+  await page.getByTestId('grw-grant-selector').click();
+  const dropdownMenu = page.getByTestId('grw-grant-selector-dropdown-menu');
+  await expect(dropdownMenu).toBeVisible();
+  await dropdownMenu.locator('.dropdown-item').nth(2).click();
+  await expect(page.getByTestId('grw-grant-selector')).toContainText('Only me');
+
+  // Upload attachment
+  const filePath = path.resolve(__dirname, '../23-editor/assets/example.txt');
+  const buffer = readFileSync(filePath).toString('base64');
+  const dataTransfer = await page.evaluateHandle(
+    async({ bufferData, localFileName, localFileType }) => {
+      const dt = new DataTransfer();
+
+      const blobData = await fetch(bufferData).then(res => res.blob());
+
+      const file = new File([blobData], localFileName, {
+        type: localFileType,
+      });
+      dt.items.add(file);
+      return dt;
+    },
+    {
+      bufferData: `data:application/octet-stream;base64,${buffer}`,
+      localFileName: 'sample.tst',
+      localFileType: 'application/octet-stream',
+    },
+  );
+  await page.locator('.dropzone').first().dispatchEvent('drop', { dataTransfer });
+  await expect(page.getByTestId('page-editor-preview-body').getByTestId('rich-attachment')).toBeVisible();
+
+  // Save page
+  await page.getByTestId('save-page-btn').click();
+
+  // Expect grant not to be reset after uploading an attachment
+  await expect(page.getByTestId('page-grant-alert')).toContainText('Browsing of this page is restricted');
+});
+
+const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+  await page.locator('.cm-content').fill(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+};
+
+/**
+ * for the issue:
+ * @see https://redmine.weseek.co.jp/issues/115285
+ */
+test('Successfully updating the page body', async({ page }) => {
+  const page1Path = '/Sandbox/for-115285/page1';
+  const page2Path = '/Sandbox/for-115285/page2';
+
+  const page1Body = 'Hello';
+  const page2Body = 'World';
+
+
+  await page.goto(page1Path);
+
+  // Open Editor (page1)
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  // Append text
+  await appendTextToEditorUntilContains(page, page1Body);
+
+  // Save page
+  await page.getByTestId('save-page-btn').click();
+
+  await expect(page.locator('.main')).toContainText(page1Body);
+
+  // Duplicate page1
+  await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+  await page.getByTestId('open-page-duplicate-modal-btn').click();
+  await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
+  await page.locator('.form-control').fill(page2Path);
+  await page.getByTestId('btn-duplicate').click();
+
+  // Open Editor (page2)
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  // Expect to see the text from which you are duplicating
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(page1Body);
+
+  // Append text
+  await appendTextToEditorUntilContains(page, page1Body + page2Body);
+
+
+  await page.goto(page1Path);
+
+  // Open Editor (page1)
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(page1Body);
+
+});

+ 4 - 2
apps/app/public/static/locales/en_US/translation.json

@@ -9,7 +9,6 @@
   "delete_all": "Delete all",
   "delete_all": "Delete all",
   "Duplicate": "Duplicate",
   "Duplicate": "Duplicate",
   "PathRecovery": "Path recovery",
   "PathRecovery": "Path recovery",
-  "SyncLatestRevisionBody": "Sync editor with latest body",
   "Copy": "Copy",
   "Copy": "Copy",
   "preview": "Preview",
   "preview": "Preview",
   "desktop": "Desktop",
   "desktop": "Desktop",
@@ -857,9 +856,12 @@
   "create_page": {
   "create_page": {
     "untitled": "Untitled"
     "untitled": "Untitled"
   },
   },
-  "sync-latest-reevision-body": {
+  "sync-latest-revision-body": {
+    "menuitem": "Sync the editor text with the latest revision body",
     "confirm": "Delete the draft data being entered into the editor and synchronize the latest text. Are you sure you want to run it?",
     "confirm": "Delete the draft data being entered into the editor and synchronize the latest text. Are you sure you want to run it?",
+    "alert": "The latest text may not have been synchronized. Please reload and check again.",
     "success-toaster": "Latest text synchronized",
     "success-toaster": "Latest text synchronized",
+    "skipped-toaster": "Skipped synchronizing since the editor is not activated. Please open the editor and try again.",
     "error-toaster": "Synchronization of the latest text failed"
     "error-toaster": "Synchronization of the latest text failed"
   }
   }
 }
 }

+ 4 - 2
apps/app/public/static/locales/fr_FR/translation.json

@@ -9,7 +9,6 @@
   "delete_all": "Tout supprimer",
   "delete_all": "Tout supprimer",
   "Duplicate": "Dupliquer",
   "Duplicate": "Dupliquer",
   "PathRecovery": "Récupération de chemin",
   "PathRecovery": "Récupération de chemin",
-  "SyncLatestRevisionBody": "Synchroniser l'éditeur avec le dernier corps",
   "Copy": "Copier",
   "Copy": "Copier",
   "preview": "Prévisualiser",
   "preview": "Prévisualiser",
   "desktop": "Ordinateur",
   "desktop": "Ordinateur",
@@ -848,9 +847,12 @@
     "size_s": "Taille: P",
     "size_s": "Taille: P",
     "size_l": "Taille: G"
     "size_l": "Taille: G"
   },
   },
-  "sync-latest-reevision-body": {
+  "sync-latest-revision-body": {
+    "menuitem": "Synchroniser le texte de l'éditeur avec le corps de la dernière révision",
     "confirm": "Delete the draft data being entered into the editor and synchronize the latest text. Are you sure you want to run it?",
     "confirm": "Delete the draft data being entered into the editor and synchronize the latest text. Are you sure you want to run it?",
+    "alert": "Il se peut que le texte le plus récent n'ait pas été synchronisé. Veuillez recharger et vérifier à nouveau.",
     "success-toaster": "Dernier texte synchronisé",
     "success-toaster": "Dernier texte synchronisé",
+    "skipped-toaster": "Synchronisation ignorée car l'éditeur n'est pas activé. Ouvrir l'éditeur et réessayer.",
     "error-toaster": "La synchronisation du dernier texte a échoué"
     "error-toaster": "La synchronisation du dernier texte a échoué"
   }
   }
 }
 }

+ 4 - 2
apps/app/public/static/locales/ja_JP/translation.json

@@ -9,7 +9,6 @@
   "delete_all": "全て削除",
   "delete_all": "全て削除",
   "Duplicate": "複製",
   "Duplicate": "複製",
   "PathRecovery": "パスを修復",
   "PathRecovery": "パスを修復",
-  "SyncLatestRevisionBody": "エディターを最新の本文に同期",
   "Copy": "コピー",
   "Copy": "コピー",
   "preview": "プレビュー",
   "preview": "プレビュー",
   "desktop": "パソコン",
   "desktop": "パソコン",
@@ -890,9 +889,12 @@
   "create_page": {
   "create_page": {
     "untitled": "無題のページ"
     "untitled": "無題のページ"
   },
   },
-  "sync-latest-reevision-body": {
+  "sync-latest-revision-body": {
+    "menuitem": "最新のリビジョンの本文とエディタのテキストを同期",
     "confirm": "エディターに入力中のドラフトデータを削除して最新の本文を同期します。実行しますか?",
     "confirm": "エディターに入力中のドラフトデータを削除して最新の本文を同期します。実行しますか?",
+    "alert": "最新の本文が同期されていない可能性があります。リロードして再度ご確認ください。",
     "success-toaster": "最新の本文を同期しました",
     "success-toaster": "最新の本文を同期しました",
+    "skipped-toaster": "エディターがアクティブではないため、同期をスキップしました。エディターを開いて再度お試しください。",
     "error-toaster": "最新の本文の同期に失敗しました"
     "error-toaster": "最新の本文の同期に失敗しました"
   }
   }
 }
 }

+ 4 - 2
apps/app/public/static/locales/zh_CN/translation.json

@@ -9,7 +9,6 @@
   "delete_all": "删除所有",
   "delete_all": "删除所有",
   "Duplicate": "复制",
   "Duplicate": "复制",
   "PathRecovery": "路径恢复",
   "PathRecovery": "路径恢复",
-  "SyncLatestRevisionBody": "将编辑器与最新机身同步",
   "Copy": "复制",
   "Copy": "复制",
   "preview": "预览",
   "preview": "预览",
   "desktop": "电脑",
   "desktop": "电脑",
@@ -860,9 +859,12 @@
   "create_page": {
   "create_page": {
     "untitled": "Untitled"
     "untitled": "Untitled"
   },
   },
-  "sync-latest-reevision-body": {
+  "sync-latest-revision-body": {
+    "menuitem": "同步编辑器文本与最新修订正文",
     "confirm": "删除输入编辑器的草稿数据,同步最新文本。 您真的想运行它吗?",
     "confirm": "删除输入编辑器的草稿数据,同步最新文本。 您真的想运行它吗?",
+    "alert": "最新文本可能尚未同步。 请重新加载并再次检查。",
     "success-toaster": "同步最新文本",
     "success-toaster": "同步最新文本",
+    "skipped-toaster": "由于编辑器未激活,因此跳过同步。 请打开编辑器并重试。",
     "error-toaster": "同步最新文本失败"
     "error-toaster": "同步最新文本失败"
   }
   }
 }
 }

+ 2 - 2
apps/app/resource/locales/ja_JP/welcome.md

@@ -43,8 +43,8 @@ GROWI は法人・個人向けの wiki | ナレッジベースツールです。
     - [GROWI に新しいメンバーを追加・招待する](https://docs.growi.org/ja/admin-guide/management-cookbook/user-management.html#%E6%96%B0%E8%A6%8F%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%AE%E4%BB%AE%E7%99%BA%E8%A1%8C)
     - [GROWI に新しいメンバーを追加・招待する](https://docs.growi.org/ja/admin-guide/management-cookbook/user-management.html#%E6%96%B0%E8%A6%8F%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%AE%E4%BB%AE%E7%99%BA%E8%A1%8C)
 
 
 ### :arrow_right: GROWI の見た目はこのままで満足ですか?
 ### :arrow_right: GROWI の見た目はこのままで満足ですか?
-- :heavy_check_mark: GROWI の見た目をカスタマイズしましょう!
-    - [GROWI のテーマをカスタマイズする](/admin/customize)
+- :heavy_check_mark: GROWI の見た目をカスタマイズしましょう!
+    - [GROWI のテーマをカスタマイズする](/admin/customize)
 
 
 ### :arrow_right: GROWI のセキュリティ設定は完了していますか?
 ### :arrow_right: GROWI のセキュリティ設定は完了していますか?
 - :heavy_check_mark: GROWI のセキュリティ設定を更新しましょう!
 - :heavy_check_mark: GROWI のセキュリティ設定を更新しましょう!

+ 6 - 5
apps/app/src/client/components/Admin/App/V5PageMigration.tsx

@@ -1,12 +1,14 @@
-import React, {
-  FC, useCallback, useEffect, useState,
-} from 'react';
+import type { FC } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
+import type {
+  PMStartedData, PMMigratingData, PMErrorCountData, PMEndedData,
+} from '~/interfaces/websocket';
 import {
 import {
-  SocketEventName, PMStartedData, PMMigratingData, PMErrorCountData, PMEndedData,
+  SocketEventName,
 } from '~/interfaces/websocket';
 } from '~/interfaces/websocket';
 import { useGlobalAdminSocket } from '~/stores/websocket';
 import { useGlobalAdminSocket } from '~/stores/websocket';
 
 
@@ -64,7 +66,6 @@ const V5PageMigration: FC<Props> = (props: Props) => {
         <LabeledProgressBar
         <LabeledProgressBar
           header={t('admin:v5_page_migration.header_upgrading_progress')}
           header={t('admin:v5_page_migration.header_upgrading_progress')}
           currentCount={current}
           currentCount={current}
-          errorsCount={skip}
           totalCount={total}
           totalCount={total}
           isInProgress={isInProgress}
           isInProgress={isInProgress}
         />
         />

+ 1 - 3
apps/app/src/client/components/Admin/Common/LabeledProgressBar.tsx

@@ -6,13 +6,12 @@ type Props = {
   header: string,
   header: string,
   currentCount: number,
   currentCount: number,
   totalCount: number,
   totalCount: number,
-  errorsCount?: number,
   isInProgress?: boolean,
   isInProgress?: boolean,
 }
 }
 
 
 const LabeledProgressBar = (props: Props): JSX.Element => {
 const LabeledProgressBar = (props: Props): JSX.Element => {
   const {
   const {
-    header, currentCount, totalCount, errorsCount, isInProgress,
+    header, currentCount, totalCount, isInProgress,
   } = props;
   } = props;
 
 
   const progressingColor = isInProgress ? 'info' : 'success';
   const progressingColor = isInProgress ? 'info' : 'success';
@@ -25,7 +24,6 @@ const LabeledProgressBar = (props: Props): JSX.Element => {
       </h6>
       </h6>
       <Progress multi>
       <Progress multi>
         <Progress bar max={totalCount} color={progressingColor} striped={isInProgress} animated={isInProgress} value={currentCount} />
         <Progress bar max={totalCount} color={progressingColor} striped={isInProgress} animated={isInProgress} value={currentCount} />
-        <Progress bar max={totalCount} color="danger" striped={isInProgress} animated={isInProgress} value={errorsCount} />
       </Progress>
       </Progress>
     </>
     </>
   );
   );

+ 2 - 15
apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -16,7 +16,6 @@ class RebuildIndexControls extends React.Component {
     this.state = {
     this.state = {
       total: 0,
       total: 0,
       current: 0,
       current: 0,
-      skip: 0,
     };
     };
   }
   }
 
 
@@ -32,7 +31,6 @@ class RebuildIndexControls extends React.Component {
         this.setState({
         this.setState({
           total: data.totalCount,
           total: data.totalCount,
           current: data.count,
           current: data.count,
-          skip: data.skipped,
         });
         });
       });
       });
 
 
@@ -40,7 +38,6 @@ class RebuildIndexControls extends React.Component {
         this.setState({
         this.setState({
           total: data.totalCount,
           total: data.totalCount,
           current: data.count,
           current: data.count,
-          skip: data.skipped,
         });
         });
       });
       });
     }
     }
@@ -51,7 +48,7 @@ class RebuildIndexControls extends React.Component {
       isRebuildingProcessing, isRebuildingCompleted,
       isRebuildingProcessing, isRebuildingCompleted,
     } = this.props;
     } = this.props;
     const {
     const {
-      total, current, skip,
+      total, current,
     } = this.state;
     } = this.state;
     const showProgressBar = isRebuildingProcessing || isRebuildingCompleted;
     const showProgressBar = isRebuildingProcessing || isRebuildingCompleted;
 
 
@@ -59,23 +56,13 @@ class RebuildIndexControls extends React.Component {
       return null;
       return null;
     }
     }
 
 
-    function getCompletedLabel() {
-      const completedLabel = skip === 0 ? 'Completed' : `Done (${skip} skips)`;
-      return completedLabel;
-    }
-
-    function getSkipLabel() {
-      return `Processing.. (${skip} skips)`;
-    }
-
-    const header = isRebuildingCompleted ? getCompletedLabel() : getSkipLabel();
+    const header = isRebuildingCompleted ? 'Completed' : 'Processing..';
 
 
     return (
     return (
       <div className="mb-3">
       <div className="mb-3">
         <LabeledProgressBar
         <LabeledProgressBar
           header={header}
           header={header}
           currentCount={current}
           currentCount={current}
-          errorsCount={skip}
           totalCount={total}
           totalCount={total}
         />
         />
       </div>
       </div>

+ 20 - 0
apps/app/src/client/components/Admin/UserGroup/UserGroupTable.module.scss

@@ -0,0 +1,20 @@
+.user-group-edit-link {
+  text-decoration: underline;
+}
+
+// switch visibility of the edit icon
+.user-group-edit-link {
+  :global {
+    .grw-edit-icon {
+      visibility: hidden;
+    }
+  }
+
+  &:global {
+    &:hover {
+      .grw-edit-icon {
+        visibility: visible;
+      }
+    }
+  }
+}

+ 23 - 6
apps/app/src/client/components/Admin/UserGroup/UserGroupTable.tsx

@@ -9,6 +9,11 @@ import Link from 'next/link';
 import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 
 
 
 
+import styles from './UserGroupTable.module.scss';
+
+const userGroupEditLinkStyle = styles['user-group-edit-link'] ?? '';
+
+
 type Props = {
 type Props = {
   headerLabel?: string,
   headerLabel?: string,
   userGroups: IUserGroupHasId[],
   userGroups: IUserGroupHasId[],
@@ -54,6 +59,23 @@ const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Re
   return map;
   return map;
 };
 };
 
 
+type UserGroupEditLinkProps = {
+  group:IUserGroupHasId,
+  isExternalGroup:boolean,
+}
+
+const UserGroupEditLink = (props: UserGroupEditLinkProps): JSX.Element => {
+  return (
+    <Link
+      href={`/admin/user-group-detail/${props.group._id}?isExternalGroup=${props.isExternalGroup}`}
+      className={`${userGroupEditLinkStyle} link-secondary link-offset-2 link-underline-opacity-25 link-underline-opacity-100-hover`}
+    >
+      <span className="material-symbols-outlined pe-2 pt-2">group</span>
+      <span>{props.group.name}</span>
+      <span className="grw-edit-icon material-symbols-outlined px-2 py-0">edit</span>
+    </Link>
+  );
+};
 
 
 export const UserGroupTable: FC<Props> = ({
 export const UserGroupTable: FC<Props> = ({
   headerLabel,
   headerLabel,
@@ -163,12 +185,7 @@ export const UserGroupTable: FC<Props> = ({
                 {isAclEnabled
                 {isAclEnabled
                   ? (
                   ? (
                     <td>
                     <td>
-                      <Link
-                        className="link-opacity-75-hover"
-                        href={`/admin/user-group-detail/${group._id}?isExternalGroup=${isExternalGroup}`}
-                      >
-                        {group.name}
-                      </Link>
+                      <UserGroupEditLink group={group} isExternalGroup={isExternalGroup} />
                     </td>
                     </td>
                   )
                   )
                   : (
                   : (

+ 3 - 3
apps/app/src/client/components/Bookmarks/BookmarkFolderItem.tsx

@@ -4,11 +4,11 @@ import { useCallback, useState } from 'react';
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { DropdownToggle } from 'reactstrap';
 import { DropdownToggle } from 'reactstrap';
 
 
+import { FolderIcon } from '~/client/components/Icons/FolderIcon';
 import {
 import {
   addBookmarkToFolder, addNewFolder, hasChildren, updateBookmarkFolder,
   addBookmarkToFolder, addNewFolder, hasChildren, updateBookmarkFolder,
 } from '~/client/util/bookmark-utils';
 } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
-import { FolderIcon } from '~/client/components/Icons/FolderIcon';
 import type { BookmarkFolderItems, DragItemDataType, DragItemType } from '~/interfaces/bookmark-info';
 import type { BookmarkFolderItems, DragItemDataType, DragItemType } from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
 import type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
@@ -250,7 +250,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
               onClick={loadChildFolder}
               onClick={loadChildFolder}
             >
             >
               <div className="d-flex justify-content-center">
               <div className="d-flex justify-content-center">
-                <span className="material-symbols-outlined">arrow_right</span>
+                <span className="material-symbols-outlined fs-5">arrow_right</span>
               </div>
               </div>
             </button>
             </button>
           </div>
           </div>
@@ -268,7 +268,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
           ) : (
           ) : (
             <>
             <>
               <div className="grw-foldertree-title-anchor ps-1">
               <div className="grw-foldertree-title-anchor ps-1">
-                <p className="text-truncate m-auto ">{name}</p>
+                <p className="text-truncate m-auto">{name}</p>
               </div>
               </div>
             </>
             </>
           )}
           )}

+ 1 - 0
apps/app/src/client/components/Bookmarks/BookmarkFolderTree.module.scss

@@ -51,6 +51,7 @@ $grw-bookmark-item-padding-left: 35px;
     .grw-foldertree-title-anchor {
     .grw-foldertree-title-anchor {
       width: 100%;
       width: 100%;
       overflow: hidden;
       overflow: hidden;
+      font-size: 14px;
       text-decoration: none;
       text-decoration: none;
     }
     }
   }
   }

+ 5 - 0
apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.module.scss

@@ -0,0 +1,5 @@
+.modal-notification :global {
+  .page-title {
+    font-size: 14px;
+  }
+}

+ 4 - 2
apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.tsx

@@ -8,6 +8,8 @@ import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
 
 import FormattedDistanceDate from '../../FormattedDistanceDate';
 import FormattedDistanceDate from '../../FormattedDistanceDate';
 
 
+import styles from './ModelNotification.module.scss';
+
 type Props = {
 type Props = {
   notification: IInAppNotification & HasObjectId
   notification: IInAppNotification & HasObjectId
   actionMsg: string
   actionMsg: string
@@ -27,8 +29,8 @@ export const ModelNotification: FC<Props> = ({
 }: Props) => {
 }: Props) => {
 
 
   return (
   return (
-    <div className="p-2 overflow-hidden">
-      <div className="text-truncate">
+    <div className={`${styles['modal-notification']} p-2 overflow-hidden`}>
+      <div className="text-truncate page-title">
         {hideActionUsers ? <></> : <b>{actionUsers}</b>}
         {hideActionUsers ? <></> : <b>{actionUsers}</b>}
         {` ${actionMsg}`}
         {` ${actionMsg}`}
         <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
         <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />

+ 24 - 7
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -7,6 +7,8 @@ import type {
   IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
   IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
 } from '@growi/core';
 } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
+import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
 import Link from 'next/link';
@@ -15,7 +17,7 @@ import Sticky from 'react-stickynode';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
 import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
 import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import { usePageBulkExportSelectModal } from '~/features/page-bulk-export/client/stores/modal';
 import { usePageBulkExportSelectModal } from '~/features/page-bulk-export/client/stores/modal';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
@@ -79,19 +81,34 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
   const { open: openPageBulkExportSelectModal } = usePageBulkExportSelectModal();
   const { open: openPageBulkExportSelectModal } = usePageBulkExportSelectModal();
 
 
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+
   const syncLatestRevisionBodyHandler = useCallback(async() => {
   const syncLatestRevisionBodyHandler = useCallback(async() => {
     // eslint-disable-next-line no-alert
     // eslint-disable-next-line no-alert
-    const answer = window.confirm(t('sync-latest-reevision-body.confirm'));
+    const answer = window.confirm(t('sync-latest-revision-body.confirm'));
     if (answer) {
     if (answer) {
       try {
       try {
-        await syncLatestRevisionBody(pageId);
-        toastSuccess(t('sync-latest-reevision-body.success-toaster'));
+        const editingMarkdownLength = codeMirrorEditor?.getDoc().length;
+        const res = await syncLatestRevisionBody(pageId, editingMarkdownLength);
+
+        if (!res.synced) {
+          toastWarning(t('sync-latest-revision-body.skipped-toaster'));
+          return;
+        }
+
+        if (res?.isYjsDataBroken) {
+          // eslint-disable-next-line no-alert
+          window.alert(t('sync-latest-revision-body.alert'));
+          return;
+        }
+
+        toastSuccess(t('sync-latest-revision-body.success-toaster'));
       }
       }
       catch {
       catch {
-        toastError(t('sync-latest-reevision-body.error-toaster'));
+        toastError(t('sync-latest-revision-body.error-toaster'));
       }
       }
     }
     }
-  }, [pageId, t]);
+  }, [codeMirrorEditor, pageId, t]);
 
 
   return (
   return (
     <>
     <>
@@ -100,7 +117,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         className="grw-page-control-dropdown-item"
         className="grw-page-control-dropdown-item"
       >
       >
         <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">sync</span>
         <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">sync</span>
-        {t('SyncLatestRevisionBody')}
+        {t('sync-latest-revision-body.menuitem')}
       </DropdownItem>
       </DropdownItem>
 
 
       {/* Presentation */}
       {/* Presentation */}

+ 0 - 1
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -1,4 +1,3 @@
-import type { MouseEventHandler } from 'react';
 import React, {
 import React, {
   memo, useCallback, useEffect, useMemo, useRef,
   memo, useCallback, useEffect, useMemo, useRef,
 } from 'react';
 } from 'react';

+ 1 - 1
apps/app/src/client/components/PageList/PageListItemL.tsx

@@ -31,10 +31,10 @@ import {
 } from '~/stores/modal';
 } from '~/stores/modal';
 import { useIsDeviceLargerThanLg } from '~/stores/ui';
 import { useIsDeviceLargerThanLg } from '~/stores/ui';
 
 
+import { PagePathHierarchicalLink } from '../../../components/Common/PagePathHierarchicalLink';
 import { useSWRMUTxPageInfo, useSWRxPageInfo } from '../../../stores/page';
 import { useSWRMUTxPageInfo, useSWRxPageInfo } from '../../../stores/page';
 import type { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import type { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { PageItemControl } from '../Common/Dropdown/PageItemControl';
 import { PageItemControl } from '../Common/Dropdown/PageItemControl';
-import { PagePathHierarchicalLink } from '../../../components/Common/PagePathHierarchicalLink';
 
 
 type Props = {
 type Props = {
   page: IPageWithSearchMeta | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>,
   page: IPageWithSearchMeta | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>,

+ 1 - 0
apps/app/src/client/components/PageList/PageListItemS.module.scss

@@ -1,4 +1,5 @@
 .page-title {
 .page-title {
   flex: 1;
   flex: 1;
+  font-size: 14px;
   line-height: 1.2;
   line-height: 1.2;
 }
 }

+ 1 - 1
apps/app/src/client/components/ReactMarkdownComponents/RichAttachment.tsx

@@ -53,7 +53,7 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
   }
   }
 
 
   return (
   return (
-    <div className={`${styles.attachment} d-inline-block`}>
+    <div data-testid="rich-attachment" className={`${styles.attachment} d-inline-block`}>
       <div className="my-2 p-2 card">
       <div className="my-2 p-2 card">
         <div className="p-1 card-body d-flex align-items-center">
         <div className="p-1 card-body d-flex align-items-center">
           <div className="me-2 px-0 d-flex align-items-center justify-content-center">
           <div className="me-2 px-0 d-flex align-items-center justify-content-center">

+ 1 - 1
apps/app/src/client/components/Sidebar/Bookmarks.tsx

@@ -14,7 +14,7 @@ export const Bookmarks = () : JSX.Element => {
   return (
   return (
     <div className="px-3">
     <div className="px-3">
       <div className="grw-sidebar-content-header">
       <div className="grw-sidebar-content-header">
-        <h4 className="mb-0 py-4">{t('Bookmarks')}</h4>
+        <h3 className="fs-6 fw-bold mb-0 py-4">{t('Bookmarks')}</h3>
       </div>
       </div>
       {isGuestUser ? (
       {isGuestUser ? (
         <h4 className="fs-6">
         <h4 className="fs-6">

+ 2 - 2
apps/app/src/client/components/Sidebar/Custom/CustomSidebar.tsx

@@ -20,10 +20,10 @@ export const CustomSidebar = (): JSX.Element => {
   return (
   return (
     <div className="pt-4 pb-3 px-3">
     <div className="pt-4 pb-3 px-3">
       <div className="grw-sidebar-content-header d-flex">
       <div className="grw-sidebar-content-header d-flex">
-        <h4 className="mb-0">
+        <h3 className="fs-6 fw-bold mb-0">
           {t('CustomSidebar')}
           {t('CustomSidebar')}
           { !isLoading && <Link href="/Sidebar#edit" className="h6 ms-2"><span className="material-symbols-outlined">edit</span></Link> }
           { !isLoading && <Link href="/Sidebar#edit" className="h6 ms-2"><span className="material-symbols-outlined">edit</span></Link> }
-        </h4>
+        </h3>
         { !isLoading && <SidebarHeaderReloadButton onClick={() => mutate()} /> }
         { !isLoading && <SidebarHeaderReloadButton onClick={() => mutate()} /> }
       </div>
       </div>
 
 

+ 2 - 2
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx

@@ -17,9 +17,9 @@ export const InAppNotification = (): JSX.Element => {
   return (
   return (
     <div className="px-3">
     <div className="px-3">
       <div className="grw-sidebar-content-header py-4 d-flex">
       <div className="grw-sidebar-content-header py-4 d-flex">
-        <h4 className="mb-0">
+        <h3 className="fs-6 fw-bold mb-0">
           {t('In-App Notification')}
           {t('In-App Notification')}
-        </h4>
+        </h3>
       </div>
       </div>
 
 
       <InAppNotificationForms
       <InAppNotificationForms

+ 1 - 1
apps/app/src/client/components/Sidebar/PageTree/PageTree.tsx

@@ -23,7 +23,7 @@ export const PageTree = (): JSX.Element => {
   return (
   return (
     <div className="pt-4 pb-3 px-3">
     <div className="pt-4 pb-3 px-3">
       <div className="grw-sidebar-content-header d-flex">
       <div className="grw-sidebar-content-header d-flex">
-        <h4 className="mb-0">{t('Page Tree')}</h4>
+        <h3 className="fs-6 fw-bold mb-0">{t('Page Tree')}</h3>
         <Suspense>
         <Suspense>
           <PageTreeHeader
           <PageTreeHeader
             isWipPageShown={isWipPageShown}
             isWipPageShown={isWipPageShown}

+ 4 - 0
apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.module.scss

@@ -8,6 +8,10 @@
   }
   }
 }
 }
 
 
+// font size
+.page-tree-item :global {
+    font-size: 14px;
+}
 
 
 // == Colors
 // == Colors
 
 

+ 1 - 1
apps/app/src/client/components/Sidebar/RecentChanges/RecentChanges.tsx

@@ -21,7 +21,7 @@ export const RecentChanges = (): JSX.Element => {
   return (
   return (
     <div className="px-3" data-testid="grw-recent-changes">
     <div className="px-3" data-testid="grw-recent-changes">
       <div className="grw-sidebar-content-header py-4 d-flex">
       <div className="grw-sidebar-content-header py-4 d-flex">
-        <h4 className="mb-0 text-nowrap">{t('Recent Changes')}</h4>
+        <h3 className="fs-6 fw-bold mb-0 text-nowrap">{t('Recent Changes')}</h3>
         <Suspense>
         <Suspense>
           <RecentChangesHeader
           <RecentChangesHeader
             isSmall={isSmall}
             isSmall={isSmall}

+ 4 - 0
apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss

@@ -8,6 +8,10 @@
 .list-group-item :global {
 .list-group-item :global {
   font-size: 12px;
   font-size: 12px;
 
 
+  h6 {
+    font-size: 14px;
+  }
+
   .grw-recent-changes-skeleton-small {
   .grw-recent-changes-skeleton-small {
     @include grw-skeleton-text($font-size: 14px, $line-height: 16px);
     @include grw-skeleton-text($font-size: 14px, $line-height: 16px);
 
 

+ 2 - 2
apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -9,10 +9,10 @@ import { DevidedPagePath } from '@growi/core/dist/models';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import { useKeywordManager } from '~/client/services/search-operation';
-import { PagePathHierarchicalLink } from '~/components/Common/PagePathHierarchicalLink';
 import FormattedDistanceDate from '~/client/components/FormattedDistanceDate';
 import FormattedDistanceDate from '~/client/components/FormattedDistanceDate';
 import InfiniteScroll from '~/client/components/InfiniteScroll';
 import InfiniteScroll from '~/client/components/InfiniteScroll';
+import { useKeywordManager } from '~/client/services/search-operation';
+import { PagePathHierarchicalLink } from '~/components/Common/PagePathHierarchicalLink';
 import LinkedPagePath from '~/models/linked-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
 import { useSWRINFxRecentlyUpdated } from '~/stores/page-listing';
 import { useSWRINFxRecentlyUpdated } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';

+ 1 - 1
apps/app/src/client/components/Sidebar/Sidebar.module.scss

@@ -11,7 +11,7 @@
 .grw-sidebar :global {
 .grw-sidebar :global {
   .grw-sidebar-content-header {
   .grw-sidebar-content-header {
     .grw-btn-reload {
     .grw-btn-reload {
-      font-size: 18px;
+      font-size: 16px;
     }
     }
   }
   }
 }
 }

+ 1 - 1
apps/app/src/client/components/Sidebar/SidebarNav/SecondaryItems.tsx

@@ -47,10 +47,10 @@ export const SecondaryItems: FC = memo(() => {
 
 
   return (
   return (
     <div className={styles['grw-secondary-items']}>
     <div className={styles['grw-secondary-items']}>
-      {!isGuestUser && <PersonalDropdown />}
       <SecondaryItem label="Help" iconName="help" href={growiCloudUri != null ? 'https://growi.cloud/help/' : 'https://docs.growi.org'} isBlank />
       <SecondaryItem label="Help" iconName="help" href={growiCloudUri != null ? 'https://growi.cloud/help/' : 'https://docs.growi.org'} isBlank />
       {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
       {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
       <SecondaryItem label="Trash" href="/trash" iconName="delete" />
       <SecondaryItem label="Trash" href="/trash" iconName="delete" />
+      {!isGuestUser && <PersonalDropdown />}
     </div>
     </div>
   );
   );
 });
 });

+ 2 - 2
apps/app/src/client/components/Sidebar/Tag.tsx

@@ -44,8 +44,8 @@ const Tag: FC = () => {
   // todo: adjust design by XD
   // todo: adjust design by XD
   return (
   return (
     <div className="container-lg px-3 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
     <div className="container-lg px-3 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
-      <div className="grw-sidebar-content-header py-3 d-flex">
-        <h4 className="mb-0">{t('Tags')}</h4>
+      <div className="grw-sidebar-content-header pt-4 pb-3 d-flex">
+        <h3 className="fs-6 fw-bold mb-0">{t('Tags')}</h3>
         <SidebarHeaderReloadButton onClick={() => onReload()} />
         <SidebarHeaderReloadButton onClick={() => onReload()} />
       </div>
       </div>
 
 

+ 1 - 1
apps/app/src/client/components/TreeItem/TreeItemLayout.tsx

@@ -152,7 +152,7 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
               onClick={onClickLoadChildren}
               onClick={onClickLoadChildren}
             >
             >
               <div className="d-flex justify-content-center">
               <div className="d-flex justify-content-center">
-                <span className="material-symbols-outlined">arrow_right</span>
+                <span className="material-symbols-outlined fs-5">arrow_right</span>
               </div>
               </div>
             </button>
             </button>
           )}
           )}

+ 4 - 3
apps/app/src/client/services/page-operation.ts

@@ -4,6 +4,7 @@ import type { IPageHasId } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
+import type { SyncLatestRevisionBody } from '~/interfaces/yjs';
 import { useEditingMarkdown, usePageTagsForEditors } from '~/stores/editor';
 import { useEditingMarkdown, usePageTagsForEditors } from '~/stores/editor';
 import {
 import {
   useCurrentPageId, useSWRMUTxCurrentPage, useSWRxApplicableGrant, useSWRxTagsInfo,
   useCurrentPageId, useSWRMUTxCurrentPage, useSWRxApplicableGrant, useSWRxTagsInfo,
@@ -175,7 +176,7 @@ export const unpublish = async(pageId: string): Promise<IPageHasId> => {
   return res.data;
   return res.data;
 };
 };
 
 
-export const syncLatestRevisionBody = async(pageId: string): Promise<void> => {
-  await apiv3Put(`/page/${pageId}/sync-latest-revision-body-to-yjs-draft`);
-  return;
+export const syncLatestRevisionBody = async(pageId: string, editingMarkdownLength?: number): Promise<SyncLatestRevisionBody> => {
+  const res = await apiv3Put(`/page/${pageId}/sync-latest-revision-body-to-yjs-draft`, { editingMarkdownLength });
+  return res.data;
 };
 };

+ 1 - 1
apps/app/src/components/PageView/PageAlerts/PageGrantAlert.tsx

@@ -55,7 +55,7 @@ export const PageGrantAlert = (): JSX.Element => {
 
 
 
 
   return (
   return (
-    <p className="alert alert-primary py-3 px-4">
+    <p data-testid="page-grant-alert" className="alert alert-primary py-3 px-4">
       {renderAlertContent()}
       {renderAlertContent()}
     </p>
     </p>
   );
   );

+ 5 - 0
apps/app/src/interfaces/yjs.ts

@@ -2,3 +2,8 @@ export type CurrentPageYjsData = {
   hasYdocsNewerThanLatestRevision?: boolean,
   hasYdocsNewerThanLatestRevision?: boolean,
   awarenessStateSize?: number,
   awarenessStateSize?: number,
 }
 }
+
+export type SyncLatestRevisionBody = {
+  synced: boolean,
+  isYjsDataBroken?: boolean,
+}

+ 2 - 4
apps/app/src/server/crowi/index.js

@@ -37,6 +37,7 @@ import PageOperationService from '../service/page-operation';
 import PassportService from '../service/passport';
 import PassportService from '../service/passport';
 import SearchService from '../service/search';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { SlackIntegrationService } from '../service/slack-integration';
+import { SocketIoService } from '../service/socket-io';
 import UserGroupService from '../service/user-group';
 import UserGroupService from '../service/user-group';
 import { UserNotificationService } from '../service/user-notification';
 import { UserNotificationService } from '../service/user-notification';
 import { initializeYjsService } from '../service/yjs';
 import { initializeYjsService } from '../service/yjs';
@@ -304,10 +305,7 @@ Crowi.prototype.setupS2sMessagingService = async function() {
 };
 };
 
 
 Crowi.prototype.setupSocketIoService = async function() {
 Crowi.prototype.setupSocketIoService = async function() {
-  const SocketIoService = require('../service/socket-io');
-  if (this.socketIoService == null) {
-    this.socketIoService = new SocketIoService(this);
-  }
+  this.socketIoService = new SocketIoService(this);
 };
 };
 
 
 Crowi.prototype.setupModels = async function() {
 Crowi.prototype.setupModels = async function() {

+ 2 - 0
apps/app/src/server/models/revision.ts

@@ -34,6 +34,8 @@ export interface IRevisionModel extends Model<IRevisionDocument> {
 Schema.Types.String.checkRequired(v => typeof v === 'string');
 Schema.Types.String.checkRequired(v => typeof v === 'string');
 
 
 const revisionSchema = new Schema<IRevisionDocument, IRevisionModel>({
 const revisionSchema = new Schema<IRevisionDocument, IRevisionModel>({
+  // The type of pageId is always converted to String at server startup
+  // Refer to this method (/src/server/service/normalize-data/convert-revision-page-id-to-string.ts) to change the pageId type
   pageId: {
   pageId: {
     type: String, required: true, index: true,
     type: String, required: true, index: true,
   },
   },

+ 9 - 4
apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts

@@ -2,7 +2,7 @@ import type { IPage, IUserHasId } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import type { ValidationChain } from 'express-validator';
-import { param } from 'express-validator';
+import { param, body } from 'express-validator';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
@@ -21,7 +21,10 @@ type SyncLatestRevisionBodyToYjsDraftHandlerFactory = (crowi: Crowi) => RequestH
 type ReqParams = {
 type ReqParams = {
   pageId: string,
   pageId: string,
 }
 }
-interface Req extends Request<ReqParams, ApiV3Response> {
+type ReqBody = {
+  editingMarkdownLength?: number,
+}
+interface Req extends Request<ReqParams, ApiV3Response, ReqBody> {
   user: IUserHasId,
   user: IUserHasId,
 }
 }
 export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionBodyToYjsDraftHandlerFactory = (crowi) => {
 export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionBodyToYjsDraftHandlerFactory = (crowi) => {
@@ -32,6 +35,7 @@ export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionB
   // define validators for req.params
   // define validators for req.params
   const validator: ValidationChain[] = [
   const validator: ValidationChain[] = [
     param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
     param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+    body('editingMarkdownLength').optional().isInt().withMessage('The body "editingMarkdownLength" must be integer'),
   ];
   ];
 
 
   return [
   return [
@@ -39,6 +43,7 @@ export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionB
     validator, apiV3FormValidator,
     validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
     async(req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;
       const { pageId } = req.params;
+      const { editingMarkdownLength } = req.body;
 
 
       // check whether accessible
       // check whether accessible
       if (!(await Page.isAccessiblePageByViewer(pageId, req.user))) {
       if (!(await Page.isAccessiblePageByViewer(pageId, req.user))) {
@@ -47,8 +52,8 @@ export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionB
 
 
       try {
       try {
         const yjsService = getYjsService();
         const yjsService = getYjsService();
-        await yjsService.syncWithTheLatestRevisionForce(pageId);
-        return res.apiv3({ });
+        const result = await yjsService.syncWithTheLatestRevisionForce(pageId, editingMarkdownLength);
+        return res.apiv3(result);
       }
       }
       catch (err) {
       catch (err) {
         logger.error(err);
         logger.error(err);

+ 1 - 1
apps/app/src/server/service/config-manager.ts

@@ -214,7 +214,7 @@ class ConfigManagerImpl implements ConfigManager, S2sMessageHandlable {
     }
     }
   }
   }
 
 
-  async removeConfigsInTheSameNamespace(namespace, configKeys: string[], withoutPublishingS2sMessage?) {
+  async removeConfigsInTheSameNamespace(namespace, configKeys: readonly string[], withoutPublishingS2sMessage?) {
     const queries: any[] = [];
     const queries: any[] = [];
     for (const key of configKeys) {
     for (const key of configKeys) {
       queries.push({
       queries.push({

+ 28 - 23
apps/app/src/server/service/file-uploader/aws/index.ts

@@ -1,3 +1,5 @@
+import type { ReadStream } from 'fs';
+
 import type { GetObjectCommandInput, HeadObjectCommandInput } from '@aws-sdk/client-s3';
 import type { GetObjectCommandInput, HeadObjectCommandInput } from '@aws-sdk/client-s3';
 import {
 import {
   S3Client,
   S3Client,
@@ -151,6 +153,32 @@ class AwsFileUploader extends AbstractFileUploader {
       : ResponseMode.REDIRECT;
       : ResponseMode.REDIRECT;
   }
   }
 
 
+  /**
+   * @inheritdoc
+   */
+  override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
+    if (!this.getIsUploadable()) {
+      throw new Error('AWS is not configured.');
+    }
+
+    logger.debug(`File uploading: fileName=${attachment.fileName}`);
+
+    const s3 = S3Factory();
+
+    const filePath = getFilePathOnStorage(attachment);
+    const contentHeaders = new ContentHeaders(attachment);
+
+    await s3.send(new PutObjectCommand({
+      Bucket: getS3Bucket(),
+      Key: filePath,
+      Body: readStream,
+      ACL: getS3PutObjectCannedAcl(),
+      // put type and the file name for reference information when uploading
+      ContentType: contentHeaders.contentType?.value.toString(),
+      ContentDisposition: contentHeaders.contentDisposition?.value.toString(),
+    }));
+  }
+
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
@@ -294,29 +322,6 @@ module.exports = (crowi) => {
     return s3.send(new DeleteObjectCommand(params));
     return s3.send(new DeleteObjectCommand(params));
   };
   };
 
 
-  (lib as any).uploadAttachment = async function(fileStream, attachment) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('AWS is not configured.');
-    }
-
-    logger.debug(`File uploading: fileName=${attachment.fileName}`);
-
-    const s3 = S3Factory();
-
-    const filePath = getFilePathOnStorage(attachment);
-    const contentHeaders = new ContentHeaders(attachment);
-
-    return s3.send(new PutObjectCommand({
-      Bucket: getS3Bucket(),
-      Key: filePath,
-      Body: fileStream,
-      ACL: getS3PutObjectCannedAcl(),
-      // put type and the file name for reference information when uploading
-      ContentType: contentHeaders.contentType?.value.toString(),
-      ContentDisposition: contentHeaders.contentDisposition?.value.toString(),
-    }));
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
   lib.saveFile = async function({ filePath, contentType, data }) {
     const s3 = S3Factory();
     const s3 = S3Factory();
 
 

+ 25 - 20
apps/app/src/server/service/file-uploader/azure.ts

@@ -1,3 +1,5 @@
+import type { ReadStream } from 'fs';
+
 import type { TokenCredential } from '@azure/identity';
 import type { TokenCredential } from '@azure/identity';
 import { ClientSecretCredential } from '@azure/identity';
 import { ClientSecretCredential } from '@azure/identity';
 import type {
 import type {
@@ -97,6 +99,29 @@ class AzureFileUploader extends AbstractFileUploader {
     throw new Error('Method not implemented.');
     throw new Error('Method not implemented.');
   }
   }
 
 
+  /**
+   * @inheritdoc
+   */
+  override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
+    if (!this.getIsUploadable()) {
+      throw new Error('Azure is not configured.');
+    }
+
+    logger.debug(`File uploading: fileName=${attachment.fileName}`);
+    const filePath = getFilePathOnStorage(attachment);
+    const containerClient = await getContainerClient();
+    const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
+    const contentHeaders = new ContentHeaders(attachment);
+
+    await blockBlobClient.uploadStream(readStream, undefined, undefined, {
+      blobHTTPHeaders: {
+        // put type and the file name for reference information when uploading
+        blobContentType: contentHeaders.contentType?.value.toString(),
+        blobContentDisposition: contentHeaders.contentDisposition?.value.toString(),
+      },
+    });
+  }
+
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
@@ -221,26 +246,6 @@ module.exports = (crowi) => {
     }
     }
   };
   };
 
 
-  (lib as any).uploadAttachment = async function(readStream, attachment) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('Azure is not configured.');
-    }
-
-    logger.debug(`File uploading: fileName=${attachment.fileName}`);
-    const filePath = getFilePathOnStorage(attachment);
-    const containerClient = await getContainerClient();
-    const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
-    const contentHeaders = new ContentHeaders(attachment);
-
-    return blockBlobClient.uploadStream(readStream, undefined, undefined, {
-      blobHTTPHeaders: {
-        // put type and the file name for reference information when uploading
-        blobContentType: contentHeaders.contentType?.value.toString(),
-        blobContentDisposition: contentHeaders.contentDisposition?.value.toString(),
-      },
-    });
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
   lib.saveFile = async function({ filePath, contentType, data }) {
     const containerClient = await getContainerClient();
     const containerClient = await getContainerClient();
     const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
     const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);

+ 4 - 0
apps/app/src/server/service/file-uploader/file-uploader.ts

@@ -1,4 +1,5 @@
 import { randomUUID } from 'crypto';
 import { randomUUID } from 'crypto';
+import type { ReadStream } from 'fs';
 
 
 import type { Response } from 'express';
 import type { Response } from 'express';
 
 
@@ -38,6 +39,7 @@ export interface FileUploader {
   getTotalFileSize(): Promise<number>,
   getTotalFileSize(): Promise<number>,
   doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<ICheckLimitResult>,
   doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<ICheckLimitResult>,
   determineResponseMode(): ResponseMode,
   determineResponseMode(): ResponseMode,
+  uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void>,
   respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void,
   respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void,
   findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>,
   findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>,
   generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl>,
   generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl>,
@@ -158,6 +160,8 @@ export abstract class AbstractFileUploader implements FileUploader {
     throw new Error('Multipart upload not available for file upload type');
     throw new Error('Multipart upload not available for file upload type');
   }
   }
 
 
+ abstract uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void>;
+
   /**
   /**
    * Respond to the HTTP request.
    * Respond to the HTTP request.
    */
    */

+ 24 - 19
apps/app/src/server/service/file-uploader/gcs/index.ts

@@ -1,3 +1,5 @@
+import type { ReadStream } from 'fs';
+
 import { Storage } from '@google-cloud/storage';
 import { Storage } from '@google-cloud/storage';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
@@ -104,6 +106,28 @@ class GcsFileUploader extends AbstractFileUploader {
       : ResponseMode.REDIRECT;
       : ResponseMode.REDIRECT;
   }
   }
 
 
+  /**
+   * @inheritdoc
+   */
+  override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
+    if (!this.getIsUploadable()) {
+      throw new Error('GCS is not configured.');
+    }
+
+    logger.debug(`File uploading: fileName=${attachment.fileName}`);
+
+    const gcs = getGcsInstance();
+    const myBucket = gcs.bucket(getGcsBucket());
+    const filePath = getFilePathOnStorage(attachment);
+    const contentHeaders = new ContentHeaders(attachment);
+
+    await myBucket.upload(readStream.path.toString(), {
+      destination: filePath,
+      // put type and the file name for reference information when uploading
+      contentType: contentHeaders.contentType?.value.toString(),
+    });
+  }
+
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
@@ -217,25 +241,6 @@ module.exports = function(crowi: Crowi) {
     });
     });
   };
   };
 
 
-  (lib as any).uploadAttachment = function(fileStream, attachment) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('GCS is not configured.');
-    }
-
-    logger.debug(`File uploading: fileName=${attachment.fileName}`);
-
-    const gcs = getGcsInstance();
-    const myBucket = gcs.bucket(getGcsBucket());
-    const filePath = getFilePathOnStorage(attachment);
-    const contentHeaders = new ContentHeaders(attachment);
-
-    return myBucket.upload(fileStream.path, {
-      destination: filePath,
-      // put type and the file name for reference information when uploading
-      contentType: contentHeaders.contentType?.value.toString(),
-    });
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
   lib.saveFile = async function({ filePath, contentType, data }) {
     const gcs = getGcsInstance();
     const gcs = getGcsInstance();
     const myBucket = gcs.bucket(getGcsBucket());
     const myBucket = gcs.bucket(getGcsBucket());

+ 30 - 24
apps/app/src/server/service/file-uploader/gridfs.ts

@@ -1,3 +1,4 @@
+import type { ReadStream } from 'fs';
 import { Readable } from 'stream';
 import { Readable } from 'stream';
 import util from 'util';
 import util from 'util';
 
 
@@ -16,6 +17,17 @@ import { ContentHeaders } from './utils';
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
 
 
 
 
+const COLLECTION_NAME = 'attachmentFiles';
+const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
+
+// instantiate mongoose-gridfs
+const AttachmentFile = createModel({
+  modelName: COLLECTION_NAME,
+  bucketName: COLLECTION_NAME,
+  connection: mongoose.connection,
+});
+
+
 // TODO: rewrite this module to be a type-safe implementation
 // TODO: rewrite this module to be a type-safe implementation
 class GridfsFileUploader extends AbstractFileUploader {
 class GridfsFileUploader extends AbstractFileUploader {
 
 
@@ -47,6 +59,24 @@ class GridfsFileUploader extends AbstractFileUploader {
     throw new Error('Method not implemented.');
     throw new Error('Method not implemented.');
   }
   }
 
 
+  /**
+   * @inheritdoc
+   */
+  override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
+    logger.debug(`File uploading: fileName=${attachment.fileName}`);
+
+    const contentHeaders = new ContentHeaders(attachment);
+
+    return AttachmentFile.promisifiedWrite(
+      {
+        // put type and the file name for reference information when uploading
+        filename: attachment.fileName,
+        contentType: contentHeaders.contentType?.value.toString(),
+      },
+      readStream,
+    );
+  }
+
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
@@ -73,15 +103,6 @@ class GridfsFileUploader extends AbstractFileUploader {
 
 
 module.exports = function(crowi) {
 module.exports = function(crowi) {
   const lib = new GridfsFileUploader(crowi);
   const lib = new GridfsFileUploader(crowi);
-  const COLLECTION_NAME = 'attachmentFiles';
-  const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
-
-  // instantiate mongoose-gridfs
-  const AttachmentFile = createModel({
-    modelName: COLLECTION_NAME,
-    bucketName: COLLECTION_NAME,
-    connection: mongoose.connection,
-  });
 
 
   // get Collection instance of chunk
   // get Collection instance of chunk
   const chunkCollection = mongoose.connection.collection(CHUNK_COLLECTION_NAME);
   const chunkCollection = mongoose.connection.collection(CHUNK_COLLECTION_NAME);
@@ -150,21 +171,6 @@ module.exports = function(crowi) {
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
   };
   };
 
 
-  (lib as any).uploadAttachment = async function(fileStream, attachment) {
-    logger.debug(`File uploading: fileName=${attachment.fileName}`);
-
-    const contentHeaders = new ContentHeaders(attachment);
-
-    return AttachmentFile.promisifiedWrite(
-      {
-        // put type and the file name for reference information when uploading
-        filename: attachment.fileName,
-        contentType: contentHeaders.contentType?.value.toString(),
-      },
-      fileStream,
-    );
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
   lib.saveFile = async function({ filePath, contentType, data }) {
     const readable = new Readable();
     const readable = new Readable();
     readable.push(data);
     readable.push(data);

+ 9 - 1
apps/app/src/server/service/file-uploader/local.ts

@@ -1,3 +1,4 @@
+import type { ReadStream } from 'fs';
 import { Readable } from 'stream';
 import { Readable } from 'stream';
 
 
 import type { Response } from 'express';
 import type { Response } from 'express';
@@ -71,6 +72,13 @@ class LocalFileUploader extends AbstractFileUploader {
       : ResponseMode.RELAY;
       : ResponseMode.RELAY;
   }
   }
 
 
+  /**
+   * @inheritdoc
+   */
+  override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
+    throw new Error('Method not implemented.');
+  }
+
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
@@ -146,7 +154,7 @@ module.exports = function(crowi) {
     return fs.unlinkSync(filePath);
     return fs.unlinkSync(filePath);
   };
   };
 
 
-  (lib as any).uploadAttachment = async function(fileStream, attachment) {
+  lib.uploadAttachment = async function(fileStream, attachment) {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
 
     const filePath = getFilePathOnStorage(attachment);
     const filePath = getFilePathOnStorage(attachment);

+ 24 - 24
apps/app/src/server/service/g2g-transfer.ts

@@ -1,6 +1,7 @@
-import { createReadStream, ReadStream } from 'fs';
+import type { ReadStream } from 'fs';
+import { createReadStream } from 'fs';
 import { basename } from 'path';
 import { basename } from 'path';
-import { Readable } from 'stream';
+import type { Readable } from 'stream';
 
 
 // eslint-disable-next-line no-restricted-imports
 // eslint-disable-next-line no-restricted-imports
 import rawAxios, { type AxiosRequestConfig } from 'axios';
 import rawAxios, { type AxiosRequestConfig } from 'axios';
@@ -202,10 +203,10 @@ interface Receiver {
   updateFileUploadConfigs(fileUploadConfigs: FileUploadConfigs): Promise<void>
   updateFileUploadConfigs(fileUploadConfigs: FileUploadConfigs): Promise<void>
   /**
   /**
    * Upload attachment file
    * Upload attachment file
-   * @param {Readable} content Pushed attachment data from source GROWI
+   * @param {ReadStream} content Pushed attachment data from source GROWI
    * @param {any} attachmentMap Map-ped Attachment instance
    * @param {any} attachmentMap Map-ped Attachment instance
    */
    */
-  receiveAttachment(content: Readable, attachmentMap: any): Promise<void>
+  receiveAttachment(content: ReadStream, attachmentMap: any): Promise<void>
 }
 }
 
 
 /**
 /**
@@ -326,7 +327,7 @@ export class G2GTransferPusherService implements Pusher {
   public async transferAttachments(tk: TransferKey): Promise<void> {
   public async transferAttachments(tk: TransferKey): Promise<void> {
     const BATCH_SIZE = 100;
     const BATCH_SIZE = 100;
     const { fileUploadService, socketIoService } = this.crowi;
     const { fileUploadService, socketIoService } = this.crowi;
-    const socket = socketIoService.getAdminSocket();
+    const socket = socketIoService?.getAdminSocket();
     const filesFromSrcGROWI = await this.listFilesInStorage(tk);
     const filesFromSrcGROWI = await this.listFilesInStorage(tk);
 
 
     /**
     /**
@@ -391,7 +392,7 @@ export class G2GTransferPusherService implements Pusher {
         }
         }
         catch (err) {
         catch (err) {
           logger.warn(`Error occured when getting Attachment(ID=${attachment.id}), skipping: `, err);
           logger.warn(`Error occured when getting Attachment(ID=${attachment.id}), skipping: `, err);
-          socket.emit('admin:g2gError', {
+          socket?.emit('admin:g2gError', {
             message: `Error occured when uploading Attachment(ID=${attachment.id})`,
             message: `Error occured when uploading Attachment(ID=${attachment.id})`,
             key: `Error occured when uploading Attachment(ID=${attachment.id})`,
             key: `Error occured when uploading Attachment(ID=${attachment.id})`,
             // TODO: emit error with params
             // TODO: emit error with params
@@ -405,7 +406,7 @@ export class G2GTransferPusherService implements Pusher {
         }
         }
         catch (err) {
         catch (err) {
           logger.error(`Error occured when uploading attachment(ID=${attachment.id})`, err);
           logger.error(`Error occured when uploading attachment(ID=${attachment.id})`, err);
-          socket.emit('admin:g2gError', {
+          socket?.emit('admin:g2gError', {
             message: `Error occured when uploading Attachment(ID=${attachment.id})`,
             message: `Error occured when uploading Attachment(ID=${attachment.id})`,
             key: `Error occured when uploading Attachment(ID=${attachment.id})`,
             key: `Error occured when uploading Attachment(ID=${attachment.id})`,
             // TODO: emit error with params
             // TODO: emit error with params
@@ -418,9 +419,9 @@ export class G2GTransferPusherService implements Pusher {
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
   public async startTransfer(tk: TransferKey, user: any, collections: string[], optionsMap: any, destGROWIInfo: IDataGROWIInfo): Promise<void> {
   public async startTransfer(tk: TransferKey, user: any, collections: string[], optionsMap: any, destGROWIInfo: IDataGROWIInfo): Promise<void> {
-    const socket = this.crowi.socketIoService.getAdminSocket();
+    const socket = this.crowi.socketIoService?.getAdminSocket();
 
 
-    socket.emit('admin:g2gProgress', {
+    socket?.emit('admin:g2gProgress', {
       mongo: G2G_PROGRESS_STATUS.IN_PROGRESS,
       mongo: G2G_PROGRESS_STATUS.IN_PROGRESS,
       attachments: G2G_PROGRESS_STATUS.PENDING,
       attachments: G2G_PROGRESS_STATUS.PENDING,
     });
     });
@@ -442,11 +443,11 @@ export class G2GTransferPusherService implements Pusher {
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
-      socket.emit('admin:g2gProgress', {
+      socket?.emit('admin:g2gProgress', {
         mongo: G2G_PROGRESS_STATUS.ERROR,
         mongo: G2G_PROGRESS_STATUS.ERROR,
         attachments: G2G_PROGRESS_STATUS.PENDING,
         attachments: G2G_PROGRESS_STATUS.PENDING,
       });
       });
-      socket.emit('admin:g2gError', { message: 'Failed to generate GROWI archive file', key: 'admin:g2g:error_generate_growi_archive' });
+      socket?.emit('admin:g2gError', { message: 'Failed to generate GROWI archive file', key: 'admin:g2g:error_generate_growi_archive' });
       throw err;
       throw err;
     }
     }
 
 
@@ -465,15 +466,15 @@ export class G2GTransferPusherService implements Pusher {
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
-      socket.emit('admin:g2gProgress', {
+      socket?.emit('admin:g2gProgress', {
         mongo: G2G_PROGRESS_STATUS.ERROR,
         mongo: G2G_PROGRESS_STATUS.ERROR,
         attachments: G2G_PROGRESS_STATUS.PENDING,
         attachments: G2G_PROGRESS_STATUS.PENDING,
       });
       });
-      socket.emit('admin:g2gError', { message: 'Failed to send GROWI archive file to the destination GROWI', key: 'admin:g2g:error_send_growi_archive' });
+      socket?.emit('admin:g2gError', { message: 'Failed to send GROWI archive file to the destination GROWI', key: 'admin:g2g:error_send_growi_archive' });
       throw err;
       throw err;
     }
     }
 
 
-    socket.emit('admin:g2gProgress', {
+    socket?.emit('admin:g2gProgress', {
       mongo: G2G_PROGRESS_STATUS.COMPLETED,
       mongo: G2G_PROGRESS_STATUS.COMPLETED,
       attachments: G2G_PROGRESS_STATUS.IN_PROGRESS,
       attachments: G2G_PROGRESS_STATUS.IN_PROGRESS,
     });
     });
@@ -483,15 +484,15 @@ export class G2GTransferPusherService implements Pusher {
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
-      socket.emit('admin:g2gProgress', {
+      socket?.emit('admin:g2gProgress', {
         mongo: G2G_PROGRESS_STATUS.COMPLETED,
         mongo: G2G_PROGRESS_STATUS.COMPLETED,
         attachments: G2G_PROGRESS_STATUS.ERROR,
         attachments: G2G_PROGRESS_STATUS.ERROR,
       });
       });
-      socket.emit('admin:g2gError', { message: 'Failed to transfer attachments', key: 'admin:g2g:error_upload_attachment' });
+      socket?.emit('admin:g2gError', { message: 'Failed to transfer attachments', key: 'admin:g2g:error_upload_attachment' });
       throw err;
       throw err;
     }
     }
 
 
-    socket.emit('admin:g2gProgress', {
+    socket?.emit('admin:g2gProgress', {
       mongo: G2G_PROGRESS_STATUS.COMPLETED,
       mongo: G2G_PROGRESS_STATUS.COMPLETED,
       attachments: G2G_PROGRESS_STATUS.COMPLETED,
       attachments: G2G_PROGRESS_STATUS.COMPLETED,
     });
     });
@@ -519,9 +520,9 @@ export class G2GTransferPusherService implements Pusher {
  */
  */
 export class G2GTransferReceiverService implements Receiver {
 export class G2GTransferReceiverService implements Receiver {
 
 
-  crowi: any;
+  crowi: Crowi;
 
 
-  constructor(crowi: any) {
+  constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
@@ -542,7 +543,7 @@ export class G2GTransferReceiverService implements Receiver {
   }
   }
 
 
   public async answerGROWIInfo(): Promise<IDataGROWIInfo> {
   public async answerGROWIInfo(): Promise<IDataGROWIInfo> {
-    const { version, configManager, fileUploadService } = this.crowi;
+    const { version, fileUploadService } = this.crowi;
     const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
     const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
     const fileUploadDisabled = configManager.getConfig('crowi', 'app:fileUploadDisabled');
     const fileUploadDisabled = configManager.getConfig('crowi', 'app:fileUploadDisabled');
     const fileUploadTotalLimit = fileUploadService.getFileUploadTotalLimit();
     const fileUploadTotalLimit = fileUploadService.getFileUploadTotalLimit();
@@ -639,7 +640,7 @@ export class G2GTransferReceiverService implements Receiver {
       importSettingsMap: { [key: string]: ImportSettings; },
       importSettingsMap: { [key: string]: ImportSettings; },
       sourceGROWIUploadConfigs: FileUploadConfigs,
       sourceGROWIUploadConfigs: FileUploadConfigs,
   ): Promise<void> {
   ): Promise<void> {
-    const { configManager, importService, appService } = this.crowi;
+    const { importService, appService } = this.crowi;
     /** whether to keep current file upload configs */
     /** whether to keep current file upload configs */
     const shouldKeepUploadConfigs = configManager.getConfig('crowi', 'app:fileUploadType') !== 'none';
     const shouldKeepUploadConfigs = configManager.getConfig('crowi', 'app:fileUploadType') !== 'none';
 
 
@@ -667,7 +668,6 @@ export class G2GTransferReceiverService implements Receiver {
   }
   }
 
 
   public async getFileUploadConfigs(): Promise<FileUploadConfigs> {
   public async getFileUploadConfigs(): Promise<FileUploadConfigs> {
-    const { configManager } = this.crowi;
     const fileUploadConfigs = Object.fromEntries(UPLOAD_CONFIG_KEYS.map((key) => {
     const fileUploadConfigs = Object.fromEntries(UPLOAD_CONFIG_KEYS.map((key) => {
       return [key, configManager.getConfigFromDB('crowi', key)];
       return [key, configManager.getConfigFromDB('crowi', key)];
     })) as FileUploadConfigs;
     })) as FileUploadConfigs;
@@ -676,7 +676,7 @@ export class G2GTransferReceiverService implements Receiver {
   }
   }
 
 
   public async updateFileUploadConfigs(fileUploadConfigs: FileUploadConfigs): Promise<void> {
   public async updateFileUploadConfigs(fileUploadConfigs: FileUploadConfigs): Promise<void> {
-    const { appService, configManager } = this.crowi;
+    const { appService } = this.crowi;
 
 
     await configManager.removeConfigsInTheSameNamespace('crowi', Object.keys(fileUploadConfigs));
     await configManager.removeConfigsInTheSameNamespace('crowi', Object.keys(fileUploadConfigs));
     await configManager.updateConfigsInTheSameNamespace('crowi', fileUploadConfigs);
     await configManager.updateConfigsInTheSameNamespace('crowi', fileUploadConfigs);
@@ -684,7 +684,7 @@ export class G2GTransferReceiverService implements Receiver {
     await appService.setupAfterInstall();
     await appService.setupAfterInstall();
   }
   }
 
 
-  public async receiveAttachment(content: Readable, attachmentMap): Promise<void> {
+  public async receiveAttachment(content: ReadStream, attachmentMap): Promise<void> {
     const { fileUploadService } = this.crowi;
     const { fileUploadService } = this.crowi;
     return fileUploadService.uploadAttachment(content, attachmentMap);
     return fileUploadService.uploadAttachment(content, attachmentMap);
   }
   }

+ 2 - 1
apps/app/src/server/service/in-app-notification.ts

@@ -19,10 +19,11 @@ import Subscription from '~/server/models/subscription';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../crowi';
 import type Crowi from '../crowi';
-import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
+
 
 
 import { generateSnapshot } from './in-app-notification/in-app-notification-utils';
 import { generateSnapshot } from './in-app-notification/in-app-notification-utils';
 import { preNotifyService, type PreNotify } from './pre-notify';
 import { preNotifyService, type PreNotify } from './pre-notify';
+import { RoomPrefix, getRoomNameWithId } from './socket-io/helper';
 
 
 
 
 const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;

+ 29 - 0
apps/app/src/server/service/normalize-data/convert-revision-page-id-to-string.ts

@@ -0,0 +1,29 @@
+// see: https://redmine.weseek.co.jp/issues/150649
+
+import { type IRevisionHasId } from '@growi/core';
+import mongoose from 'mongoose';
+
+import { type IRevisionModel } from '~/server/models/revision';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:NormalizeData:convert-revision-page-id-to-string');
+
+export const convertRevisionPageIdToString = async(): Promise<void> => {
+  const Revision = mongoose.model<IRevisionHasId, IRevisionModel>('Revision');
+
+  const filter = { pageId: { $type: 'objectId' } };
+  const update = [
+    {
+      $set: {
+        pageId: {
+          $toString: '$pageId',
+        },
+      },
+    },
+  ];
+
+  await Revision.updateMany(filter, update);
+
+  const explain = await Revision.updateMany(filter, update).explain('queryPlanner');
+  logger.debug(explain);
+};

+ 2 - 0
apps/app/src/server/service/normalize-data/index.ts

@@ -1,11 +1,13 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { convertRevisionPageIdToString } from './convert-revision-page-id-to-string';
 import { renameDuplicateRootPages } from './rename-duplicate-root-pages';
 import { renameDuplicateRootPages } from './rename-duplicate-root-pages';
 
 
 const logger = loggerFactory('growi:service:NormalizeData');
 const logger = loggerFactory('growi:service:NormalizeData');
 
 
 export const normalizeData = async(): Promise<void> => {
 export const normalizeData = async(): Promise<void> => {
   await renameDuplicateRootPages();
   await renameDuplicateRootPages();
+  await convertRevisionPageIdToString();
 
 
   logger.info('normalizeData has been executed');
   logger.info('normalizeData has been executed');
   return;
   return;

+ 3 - 2
apps/app/src/server/service/page/index.ts

@@ -2542,7 +2542,7 @@ class PageService implements IPageService {
     });
     });
   }
   }
 
 
-  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
+  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | Omit<IPageInfoForEntity, 'bookmarkCount'> {
     const isMovable = isGuestUser ? false : isMovablePage(page.path);
     const isMovable = isGuestUser ? false : isMovablePage(page.path);
     const isDeletable = !(isGuestUser || isTopPage(page.path) || isUsersTopPage(page.path));
     const isDeletable = !(isGuestUser || isTopPage(page.path) || isUsersTopPage(page.path));
 
 
@@ -2560,7 +2560,7 @@ class PageService implements IPageService {
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
 
 
-    return {
+    const infoForEntity: Omit<IPageInfoForEntity, 'bookmarkCount'> = {
       isV5Compatible: isTopPage(page.path) || page.parent != null,
       isV5Compatible: isTopPage(page.path) || page.parent != null,
       isEmpty: false,
       isEmpty: false,
       sumOfLikers: page.liker.length,
       sumOfLikers: page.liker.length,
@@ -2576,6 +2576,7 @@ class PageService implements IPageService {
       commentCount: page.commentCount,
       commentCount: page.commentCount,
     };
     };
 
 
+    return infoForEntity;
   }
   }
 
 
   async shortBodiesMapByPageIds(pageIds: ObjectId[] = [], user?): Promise<Record<string, string | null>> {
   async shortBodiesMapByPageIds(pageIds: ObjectId[] = [], user?): Promise<Record<string, string | null>> {

+ 1 - 1
apps/app/src/server/service/page/page-service.ts

@@ -23,7 +23,7 @@ export interface IPageService {
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>,
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>,
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>,
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>,
   shortBodiesMapByPageIds(pageIds?: ObjectId[], user?): Promise<Record<string, string | null>>,
   shortBodiesMapByPageIds(pageIds?: ObjectId[], user?): Promise<Record<string, string | null>>,
-  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity,
+  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | Omit<IPageInfoForEntity, 'bookmarkCount'>,
   canDelete(page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean): boolean,
   canDelete(page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean): boolean,
   canDeleteCompletely(
   canDeleteCompletely(
     page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]
     page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]

+ 27 - 6
apps/app/src/server/service/search-delegator/aggregate-to-index.ts

@@ -51,11 +51,32 @@ export const aggregatePipelineToIndex = (maxBodyLengthToIndex: number, query?: Q
 
 
     // join Comment
     // join Comment
     {
     {
+      // MongoDB 5.0 or later can use concise syntax
+      // https://www.mongodb.com/docs/v6.0/reference/operator/aggregation/lookup/#correlated-subqueries-using-concise-syntax
+      // $lookup: {
+      //   from: 'comments',
+      //   localField: '_id',
+      //   foreignField: 'page',
+      //   pipeline: [
+      //     {
+      //       $addFields: {
+      //         commentLength: { $strLenCP: '$comment' },
+      //       },
+      //     },
+      //   ],
+      //   as: 'comments',
+      // },
       $lookup: {
       $lookup: {
         from: 'comments',
         from: 'comments',
-        localField: '_id',
-        foreignField: 'page',
+        let: { pageId: '$_id' },
         pipeline: [
         pipeline: [
+          {
+            $match: {
+              $expr: {
+                $eq: ['$page', '$$pageId'],
+              },
+            },
+          },
           {
           {
             $addFields: {
             $addFields: {
               commentLength: { $strLenCP: '$comment' },
               commentLength: { $strLenCP: '$comment' },
@@ -67,7 +88,7 @@ export const aggregatePipelineToIndex = (maxBodyLengthToIndex: number, query?: Q
     },
     },
     {
     {
       $addFields: {
       $addFields: {
-        commentsCount: { $size: '$comments' },
+        commentsCount: { $size: { $ifNull: ['$comments', []] } },
       },
       },
     },
     },
 
 
@@ -82,19 +103,19 @@ export const aggregatePipelineToIndex = (maxBodyLengthToIndex: number, query?: Q
     },
     },
     {
     {
       $addFields: {
       $addFields: {
-        bookmarksCount: { $size: '$bookmarks' },
+        bookmarksCount: { $size: { $ifNull: ['$bookmarks', []] } },
       },
       },
     },
     },
 
 
     // add counts for embedded arrays
     // add counts for embedded arrays
     {
     {
       $addFields: {
       $addFields: {
-        likeCount: { $size: '$liker' },
+        likeCount: { $size: { $ifNull: ['$liker', []] } },
       },
       },
     },
     },
     {
     {
       $addFields: {
       $addFields: {
-        seenUsersCount: { $size: '$seenUsers' },
+        seenUsersCount: { $size: { $ifNull: ['$seenUsers', []] } },
       },
       },
     },
     },
 
 

+ 0 - 0
apps/app/src/server/util/socket-io-helpers.ts → apps/app/src/server/service/socket-io/helper.ts


+ 1 - 0
apps/app/src/server/service/socket-io/index.ts

@@ -0,0 +1 @@
+export * from './socket-io';

+ 13 - 23
apps/app/src/server/service/socket-io.ts → apps/app/src/server/service/socket-io/socket-io.ts

@@ -9,10 +9,10 @@ import { Server } from 'socket.io';
 import { SocketEventName } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import type Crowi from '../crowi';
-import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
+import type Crowi from '../../crowi';
+import { configManager } from '../config-manager';
 
 
-import { configManager } from './config-manager';
+import { RoomPrefix, getRoomNameWithId } from './helper';
 
 
 
 
 const logger = loggerFactory('growi:service:socket-io');
 const logger = loggerFactory('growi:service:socket-io');
@@ -23,7 +23,7 @@ type RequestWithUser = IncomingMessage & { user: IUserHasId };
 /**
 /**
  * Serve socket.io for server-to-client messaging
  * Serve socket.io for server-to-client messaging
  */
  */
-class SocketIoService {
+export class SocketIoService {
 
 
   crowi: Crowi;
   crowi: Crowi;
 
 
@@ -34,12 +34,12 @@ class SocketIoService {
   adminNamespace: Namespace;
   adminNamespace: Namespace;
 
 
 
 
-  constructor(crowi) {
+  constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
     this.guestClients = new Set();
     this.guestClients = new Set();
   }
   }
 
 
-  get isInitialized() {
+  get isInitialized(): boolean {
     return (this.io != null);
     return (this.io != null);
   }
   }
 
 
@@ -83,27 +83,19 @@ class SocketIoService {
 
 
   /**
   /**
    * use passport session
    * use passport session
-   * @see https://socket.io/docs/v4/middlewares/#Compatibility-with-Express-middleware
+   * @see https://socket.io/docs/v4/middlewares/#compatibility-with-express-middleware
    */
    */
-  setupSessionMiddleware() {
-    const wrap = middleware => (socket, next) => middleware(socket.request, {}, next);
-
-    this.io.use(wrap(expressSession(this.crowi.sessionConfig)));
-    this.io.use(wrap(passport.initialize()));
-    this.io.use(wrap(passport.session()));
-
-    // express and passport session on main socket doesn't shared to child namespace socket
-    // need to define the session for specific namespace
-    this.getAdminSocket().use(wrap(expressSession(this.crowi.sessionConfig)));
-    this.getAdminSocket().use(wrap(passport.initialize()));
-    this.getAdminSocket().use(wrap(passport.session()));
+  setupSessionMiddleware(): void {
+    this.io.engine.use(expressSession(this.crowi.sessionConfig));
+    this.io.engine.use(passport.initialize());
+    this.io.engine.use(passport.session());
   }
   }
 
 
   /**
   /**
    * use loginRequired middleware
    * use loginRequired middleware
    */
    */
   setupLoginRequiredMiddleware() {
   setupLoginRequiredMiddleware() {
-    const loginRequired = require('../middlewares/login-required')(this.crowi, true, (req, res, next) => {
+    const loginRequired = require('../../middlewares/login-required')(this.crowi, true, (req, res, next) => {
       next(new Error('Login is required to connect.'));
       next(new Error('Login is required to connect.'));
     });
     });
 
 
@@ -117,7 +109,7 @@ class SocketIoService {
    * use adminRequired middleware
    * use adminRequired middleware
    */
    */
   setupAdminRequiredMiddleware() {
   setupAdminRequiredMiddleware() {
-    const adminRequired = require('../middlewares/admin-required')(this.crowi, (req, res, next) => {
+    const adminRequired = require('../../middlewares/admin-required')(this.crowi, (req, res, next) => {
       next(new Error('Admin priviledge is required to connect.'));
       next(new Error('Admin priviledge is required to connect.'));
     });
     });
 
 
@@ -243,5 +235,3 @@ class SocketIoService {
   }
   }
 
 
 }
 }
-
-module.exports = SocketIoService;

+ 3 - 3
apps/app/src/server/service/system-events/sync-page-status.ts

@@ -2,9 +2,9 @@ import loggerFactory from '~/utils/logger';
 
 
 import { S2cMessagePageUpdated } from '../../models/vo/s2c-message';
 import { S2cMessagePageUpdated } from '../../models/vo/s2c-message';
 import S2sMessage from '../../models/vo/s2s-message';
 import S2sMessage from '../../models/vo/s2s-message';
-import { RoomPrefix, getRoomNameWithId } from '../../util/socket-io-helpers';
-import { S2sMessagingService } from '../s2s-messaging/base';
-import { S2sMessageHandlable } from '../s2s-messaging/handlable';
+import type { S2sMessagingService } from '../s2s-messaging/base';
+import type { S2sMessageHandlable } from '../s2s-messaging/handlable';
+import { RoomPrefix, getRoomNameWithId } from '../socket-io/helper';
 
 
 const logger = loggerFactory('growi:service:system-events:SyncPageStatusService');
 const logger = loggerFactory('growi:service:system-events:SyncPageStatusService');
 
 

+ 15 - 6
apps/app/src/server/service/yjs/yjs.ts

@@ -1,5 +1,6 @@
 import type { IncomingMessage } from 'http';
 import type { IncomingMessage } from 'http';
 
 
+
 import type { IPage, IUserHasId } from '@growi/core';
 import type { IPage, IUserHasId } from '@growi/core';
 import { YDocStatus } from '@growi/core/dist/consts';
 import { YDocStatus } from '@growi/core/dist/consts';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
@@ -8,7 +9,8 @@ import type { Document } from 'y-socket.io/dist/server';
 import { YSocketIO, type Document as Ydoc } from 'y-socket.io/dist/server';
 import { YSocketIO, type Document as Ydoc } from 'y-socket.io/dist/server';
 
 
 import { SocketEventName } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
-import { RoomPrefix, getRoomNameWithId } from '~/server/util/socket-io-helpers';
+import type { SyncLatestRevisionBody } from '~/interfaces/yjs';
+import { RoomPrefix, getRoomNameWithId } from '~/server/service/socket-io/helper';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type { PageModel } from '../../models/page';
 import type { PageModel } from '../../models/page';
@@ -32,7 +34,7 @@ type RequestWithUser = IncomingMessage & { user: IUserHasId };
 
 
 export interface IYjsService {
 export interface IYjsService {
   getYDocStatus(pageId: string): Promise<YDocStatus>;
   getYDocStatus(pageId: string): Promise<YDocStatus>;
-  syncWithTheLatestRevisionForce(pageId: string): Promise<void>;
+  syncWithTheLatestRevisionForce(pageId: string, editingMarkdownLength?: number): Promise<SyncLatestRevisionBody>
   getCurrentYdoc(pageId: string): Ydoc | undefined;
   getCurrentYdoc(pageId: string): Ydoc | undefined;
 }
 }
 
 
@@ -72,9 +74,8 @@ class YjsService implements IYjsService {
     // create indexes
     // create indexes
     createIndexes(MONGODB_PERSISTENCE_COLLECTION_NAME);
     createIndexes(MONGODB_PERSISTENCE_COLLECTION_NAME);
 
 
-    // TODO: https://redmine.weseek.co.jp/issues/150529
     // register middlewares
     // register middlewares
-    // this.registerAccessiblePageChecker(ysocketio);
+    this.registerAccessiblePageChecker(ysocketio);
 
 
     ysocketio.on('document-loaded', async(doc: Document) => {
     ysocketio.on('document-loaded', async(doc: Document) => {
       const pageId = doc.name;
       const pageId = doc.name;
@@ -181,14 +182,22 @@ class YjsService implements IYjsService {
     return YDocStatus.OUTDATED;
     return YDocStatus.OUTDATED;
   }
   }
 
 
-  public async syncWithTheLatestRevisionForce(pageId: string): Promise<void> {
+  public async syncWithTheLatestRevisionForce(pageId: string, editingMarkdownLength?: number): Promise<SyncLatestRevisionBody> {
     const doc = this.ysocketio.documents.get(pageId);
     const doc = this.ysocketio.documents.get(pageId);
 
 
     if (doc == null) {
     if (doc == null) {
-      return;
+      return { synced: false };
     }
     }
 
 
+    const ytextLength = doc?.getText('codemirror').length;
     syncYDoc(this.mdb, doc, true);
     syncYDoc(this.mdb, doc, true);
+
+    return {
+      synced: true,
+      isYjsDataBroken: editingMarkdownLength != null
+        ? editingMarkdownLength !== ytextLength
+        : undefined,
+    };
   }
   }
 
 
   public getCurrentYdoc(pageId: string): Ydoc | undefined {
   public getCurrentYdoc(pageId: string): Ydoc | undefined {

+ 0 - 175
apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts

@@ -1,175 +0,0 @@
-import path from 'path-browserify';
-
-function openEditor() {
-  cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
-  cy.getByTestid('editor-button').click();
-  cy.getByTestid('grw-editor-navbar-bottom').should('be.visible');
-  cy.get('.cm-content').should('be.visible');
-}
-
-context('Editor while uploading to a new page', () => {
-
-  const ssPrefix = 'editor-while-uploading-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  /**
-   * for the issues:
-   * @see https://redmine.weseek.co.jp/issues/122040
-   * @see https://redmine.weseek.co.jp/issues/124281
-   */
-  it('should not be cleared and should prevent GrantSelector from modified', { scrollBehavior: false }, () => {
-    cy.visit('/Sandbox/for-122040');
-
-    openEditor();
-
-    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-1`);
-
-    // input the body
-    const body = 'Hello World!';
-    cy.get('.cm-content').should('be.visible').type(body, { force: true });
-    cy.getByTestid('page-editor-preview-body').should('contain.text', body);
-
-    // open GrantSelector
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('grw-grant-selector').within(() => {
-        cy.get('button.dropdown-toggle').click({force: true});
-      });
-      // wait until
-      return cy.getByTestid('grw-grant-selector-dropdown-menu').then($elem => $elem.is(':visible'))
-    });
-
-    // Select "Only me"
-    cy.getByTestid('grw-grant-selector-dropdown-menu').find('.dropdown-item').should('have.length', 4).then((menuItems) => {
-      // click "Only me"
-      menuItems[2].click();
-    })
-
-    cy.getByTestid('grw-grant-selector').find('.dropdown-toggle').should('contain.text', 'Only me');
-    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-2`);
-
-    // intercept API req/res for fixing labels
-    const dummyAttachmentId = '64b000000000000000000000';
-    let uploadedAttachmentId = '';
-    cy.intercept('POST', '/_api/v3/attachment', (req) => {
-      req.continue((res) => {
-        // store the attachment id
-        uploadedAttachmentId = res.body.attachment._id;
-        // overwrite filePathProxied
-        res.body.attachment.filePathProxied = `/attachment/${dummyAttachmentId}`;
-      });
-    }).as('attachmentsAdd');
-    cy.intercept('GET', `/_api/v3/attachment?attachmentId=${dummyAttachmentId}`, (req) => {
-      // replace attachmentId query
-      req.url = req.url.replace(dummyAttachmentId, uploadedAttachmentId);
-      req.continue((res) => {
-        // overwrite the attachment createdAt
-        res.body.attachment.createdAt = new Date('2023-07-01T00:00:00');
-      });
-    });
-
-    // drag-drop a file
-    const filePath = path.relative('/', path.resolve(Cypress.spec.relative, '../assets/example.txt'));
-    cy.get('.dropzone').eq(0).selectFile(filePath, { action: 'drag-drop' });
-    cy.wait('@attachmentsAdd');
-
-    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-3`);
-
-    // Update page using shortcut keys
-    cy.get('.cm-content').click({force: true}).type('{ctrl+s}');
-
-    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-4`);
-
-    // expect
-    cy.get('.Toastify__toast').should('contain.text', 'Saved successfully');
-    cy.get('.cm-content').should('contain.text', body);
-    cy.get('.cm-content').should('contain.text', '[example.txt](/attachment/64b000000000000000000000');
-    cy.getByTestid('grw-grant-selector').find('.dropdown-toggle').should('contain.text', 'Only me');
-    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-5`);
-  });
-
-});
-
-context('Editor while navigation', () => {
-
-  const ssPrefix = 'editor-while-navigation-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  /**
-   * for the issue:
-   * @see https://redmine.weseek.co.jp/issues/115285
-   */
-  it('Successfully updating the page body', { scrollBehavior: false }, () => {
-    const page1Path = '/Sandbox/for-115285/page1';
-    const page2Path = '/Sandbox/for-115285/page2';
-
-    cy.visit(page1Path);
-
-    openEditor();
-
-    // page1
-    const bodyHello = 'hello';
-    cy.get('.cm-content').should('be.visible').type(bodyHello, { force: true });
-    cy.getByTestid('page-editor-preview-body').should('contain.text', bodyHello);
-    cy.get('.cm-content').screenshot(`${ssPrefix}-editor-for-page1`);
-
-    // save page1
-    cy.getByTestid('save-page-btn').click();
-
-    // open duplicate modal
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('grw-contextual-sub-nav').within(() => {
-        cy.getByTestid('open-page-item-control-btn').find('button').click({force: true});
-      });
-      // wait until
-      return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible'))
-    });
-    cy.getByTestid('open-page-duplicate-modal-btn').filter(':visible').click({force: true});
-
-    // duplicate and navigate to page1
-    cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
-      cy.get('input.form-control').clear();
-      cy.get('input.form-control').type(page2Path);
-      cy.getByTestid('btn-duplicate').click();
-    })
-
-    openEditor();
-    cy.get('.cm-content').screenshot(`${ssPrefix}-editor-for-page2`);
-
-    // type (without save)
-    const bodyWorld = ' world!!'
-    cy.get('.cm-content').should('be.visible').type(`{moveToEnd}${bodyWorld}`, { force: true });
-    cy.getByTestid('page-editor-preview-body').should('contain.text', `${bodyHello}${bodyWorld}`);
-    cy.get('.cm-content').screenshot(`${ssPrefix}-editor-for-page2-modified`);
-
-    // create a link to page1
-    cy.get('.cm-content').type('\n\n[page1](./page1)');
-
-    // go to page1
-    cy.getByTestid('page-editor-preview-body').within(() => {
-      cy.get("a:contains('page1')").click();
-    });
-
-    openEditor();
-
-    cy.get('.cm-content').screenshot(`${ssPrefix}-editor-for-page1-returned`);
-
-    // expect
-    cy.get('.cm-content').should('contain.text', bodyHello);
-    cy.get('.cm-content').should('not.contain.text', bodyWorld); // text that added to page2
-    cy.get('.cm-content').should('not.contain.text', 'page1'); // text that added to page2
-  });
-});

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "7.0.14-slackbot-proxy.0",
+  "version": "7.0.16-slackbot-proxy.0",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
   "scripts": {
   "scripts": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "7.0.14-RC.0",
+  "version": "7.0.16-RC.0",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",

+ 3 - 1
packages/core/src/interfaces/page.ts

@@ -102,7 +102,9 @@ export type IPageInfoAll = IPageInfo | IPageInfoForEntity | IPageInfoForOperatio
 
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export const isIPageInfoForEntity = (pageInfo: any | undefined): pageInfo is IPageInfoForEntity => {
 export const isIPageInfoForEntity = (pageInfo: any | undefined): pageInfo is IPageInfoForEntity => {
-  return pageInfo != null;
+  return pageInfo != null && pageInfo instanceof Object
+    && ('isEmpty' in pageInfo)
+    && pageInfo.isEmpty === false;
 };
 };
 
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 // eslint-disable-next-line @typescript-eslint/no-explicit-any

+ 40 - 21
packages/editor/src/client/services/use-codemirror-editor/utils/fold-drawio.ts

@@ -3,42 +3,61 @@ import { useEffect } from 'react';
 import { foldEffect } from '@codemirror/language';
 import { foldEffect } from '@codemirror/language';
 import type { EditorView } from '@codemirror/view';
 import type { EditorView } from '@codemirror/view';
 
 
+
 export type FoldDrawio = void;
 export type FoldDrawio = void;
 
 
 const findAllDrawioSection = (view?: EditorView) => {
 const findAllDrawioSection = (view?: EditorView) => {
   if (view == null) {
   if (view == null) {
     return;
     return;
   }
   }
-  const lineBeginPartOfDrawioRE = /^```(\s.*)drawio$/;
-  const lineNumbers: number[] = [];
-  // repeat the process in each line from the top to the bottom in the editor
-  for (let i = 1, e = view.state.doc.lines; i <= e; i++) {
-    // get each line text
-    const lineTxt = view.state.doc.line(i).text;
-    const match = lineBeginPartOfDrawioRE.exec(lineTxt);
-    if (match) {
-      lineNumbers.push(i);
+
+  try {
+    const lineBeginPartOfDrawioRE = /^```(\s.*)drawio$/;
+    const lineNumbers: number[] = [];
+    // repeat the process in each line from the top to the bottom in the editor
+    for (let i = 1, e = view.state.doc.lines; i <= e; i++) {
+      // get each line text
+      const lineTxt = view.state.doc.line(i).text;
+      const match = lineBeginPartOfDrawioRE.exec(lineTxt);
+      if (match) {
+        lineNumbers.push(i);
+      }
+    }
+    return lineNumbers;
+  }
+  catch (err) {
+    if (err instanceof Error) {
+      // eslint-disable-next-line no-console
+      console.warn(err.toString());
     }
     }
   }
   }
-  return lineNumbers;
 };
 };
 
 
 const foldDrawioSection = (lineNumbers?: number[], view?: EditorView) => {
 const foldDrawioSection = (lineNumbers?: number[], view?: EditorView) => {
   if (view == null || lineNumbers == null) {
   if (view == null || lineNumbers == null) {
     return;
     return;
   }
   }
-  lineNumbers.forEach((lineNumber) => {
-    // get the end of the lines containing '''drawio
-    const from = view.state.doc.line(lineNumber).to;
-    // get the end of the lines containing '''
-    const to = view.state.doc.line(lineNumber + 2).to;
-    view?.dispatch({
-      effects: foldEffect.of({
-        from,
-        to,
-      }),
+
+  try {
+    lineNumbers.forEach((lineNumber) => {
+      // get the end of the lines containing '''drawio
+      const from = view.state.doc.line(lineNumber).to;
+      // get the end of the lines containing '''
+      const to = view.state.doc.line(lineNumber + 2).to;
+      view?.dispatch({
+        effects: foldEffect.of({
+          from,
+          to,
+        }),
+      });
     });
     });
-  });
+  }
+  catch (err) {
+    if (err instanceof Error) {
+    // eslint-disable-next-line no-console
+      console.warn(err.toString());
+    }
+  }
 };
 };
 
 
 export const useFoldDrawio = (view?: EditorView): FoldDrawio => {
 export const useFoldDrawio = (view?: EditorView): FoldDrawio => {