Преглед изворни кода

Merge remote-tracking branch 'origin/master' into imprv/140677-145630-new-design

Yuki Takei пре 1 година
родитељ
комит
a9782d4d96
100 измењених фајлова са 578 додато и 142 уклоњено
  1. 1 0
      .devcontainer/devcontainer.json
  2. 0 1
      .eslintrc.js
  3. 1 1
      .github/workflows/reusable-app-prod.yml
  4. 88 1
      CHANGELOG.md
  5. 2 0
      apps/app/config/logger/config.dev.js
  6. 1 1
      apps/app/docker/README.md
  7. 0 1
      apps/app/next-env.d.ts
  8. 4 1
      apps/app/nodemon.json
  9. 6 6
      apps/app/package.json
  10. 129 1
      apps/app/playwright/20-basic-features/access-to-page.spec.ts
  11. 50 0
      apps/app/playwright/23-editor/saving.spec.ts
  12. 27 0
      apps/app/playwright/23-editor/template-modal.spec.ts
  13. BIN
      apps/app/public/favicon.ico
  14. 24 0
      apps/app/public/favicon.svg
  15. BIN
      apps/app/public/images/icons/favicon/android-icon-144x144.png
  16. BIN
      apps/app/public/images/icons/favicon/android-icon-192x192.png
  17. BIN
      apps/app/public/images/icons/favicon/android-icon-36x36.png
  18. BIN
      apps/app/public/images/icons/favicon/android-icon-48x48.png
  19. BIN
      apps/app/public/images/icons/favicon/android-icon-72x72.png
  20. BIN
      apps/app/public/images/icons/favicon/android-icon-96x96.png
  21. BIN
      apps/app/public/images/icons/favicon/apple-icon-114x114.png
  22. BIN
      apps/app/public/images/icons/favicon/apple-icon-120x120.png
  23. BIN
      apps/app/public/images/icons/favicon/apple-icon-144x144.png
  24. BIN
      apps/app/public/images/icons/favicon/apple-icon-152x152.png
  25. BIN
      apps/app/public/images/icons/favicon/apple-icon-180x180.png
  26. BIN
      apps/app/public/images/icons/favicon/apple-icon-57x57.png
  27. BIN
      apps/app/public/images/icons/favicon/apple-icon-60x60.png
  28. BIN
      apps/app/public/images/icons/favicon/apple-icon-72x72.png
  29. BIN
      apps/app/public/images/icons/favicon/apple-icon-76x76.png
  30. BIN
      apps/app/public/images/icons/favicon/apple-icon-precomposed.png
  31. BIN
      apps/app/public/images/icons/favicon/apple-icon.png
  32. 0 2
      apps/app/public/images/icons/favicon/browserconfig.xml
  33. BIN
      apps/app/public/images/icons/favicon/favicon-16x16.png
  34. BIN
      apps/app/public/images/icons/favicon/favicon-32x32.png
  35. BIN
      apps/app/public/images/icons/favicon/favicon-96x96.png
  36. BIN
      apps/app/public/images/icons/favicon/ms-icon-144x144.png
  37. BIN
      apps/app/public/images/icons/favicon/ms-icon-150x150.png
  38. BIN
      apps/app/public/images/icons/favicon/ms-icon-310x310.png
  39. BIN
      apps/app/public/images/icons/favicon/ms-icon-70x70.png
  40. 8 0
      apps/app/public/static/locales/en_US/translation.json
  41. 8 0
      apps/app/public/static/locales/fr_FR/translation.json
  42. 8 0
      apps/app/public/static/locales/ja_JP/translation.json
  43. 8 0
      apps/app/public/static/locales/zh_CN/translation.json
  44. 6 5
      apps/app/src/client/components/Admin/App/V5PageMigration.tsx
  45. 1 3
      apps/app/src/client/components/Admin/Common/LabeledProgressBar.tsx
  46. 2 15
      apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  47. 3 3
      apps/app/src/client/components/Bookmarks/BookmarkFolderItem.tsx
  48. 1 0
      apps/app/src/client/components/Bookmarks/BookmarkFolderTree.module.scss
  49. 0 1
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  50. 5 0
      apps/app/src/client/components/InAppNotification/PageNotification/ModelNotification.module.scss
  51. 4 2
      apps/app/src/client/components/InAppNotification/PageNotification/ModelNotification.tsx
  52. 41 1
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  53. 2 2
      apps/app/src/client/components/Navbar/PageEditorModeManager.tsx
  54. 2 0
      apps/app/src/client/components/Page/DisplaySwitcher.tsx
  55. 0 1
      apps/app/src/client/components/PageControls/PageControls.tsx
  56. 18 10
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  57. 6 6
      apps/app/src/client/components/PageEditor/ScrollSyncHelper.tsx
  58. 4 4
      apps/app/src/client/components/PageHistory/PageRevisionTable.tsx
  59. 3 3
      apps/app/src/client/components/PageHistory/RevisionDiff.tsx
  60. 1 1
      apps/app/src/client/components/PageList/PageListItemL.tsx
  61. 1 0
      apps/app/src/client/components/PageList/PageListItemS.module.scss
  62. 10 1
      apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx
  63. 9 2
      apps/app/src/client/components/ReactMarkdownComponents/Header.tsx
  64. 9 1
      apps/app/src/client/components/ReactMarkdownComponents/TableWithEditButton.tsx
  65. 3 3
      apps/app/src/client/components/RevisionComparer/RevisionComparer.tsx
  66. 4 0
      apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.module.scss
  67. 4 0
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss
  68. 2 2
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  69. 9 1
      apps/app/src/client/components/TemplateModal/TemplateModal.tsx
  70. 1 1
      apps/app/src/client/components/TreeItem/TreeItemLayout.tsx
  71. 6 0
      apps/app/src/client/services/page-operation.ts
  72. 7 15
      apps/app/src/client/services/side-effects/yjs.ts
  73. 6 4
      apps/app/src/features/questionnaire/server/service/questionnaire.ts
  74. 1 1
      apps/app/src/interfaces/websocket.ts
  75. 6 1
      apps/app/src/interfaces/yjs.ts
  76. 1 1
      apps/app/src/migrations/20180927102719-init-serverurl.js
  77. 1 1
      apps/app/src/migrations/20190618055300-abolish-crowi-classic-auth.js
  78. 1 1
      apps/app/src/migrations/20190618104011-add-config-app-installed.js
  79. 1 1
      apps/app/src/migrations/20200420160390-remove-crowi-layout.js
  80. 1 1
      apps/app/src/migrations/20200512005851-remove-behavior-type.js
  81. 1 1
      apps/app/src/migrations/20200514001356-update-theme-color-for-dark.js
  82. 1 1
      apps/app/src/migrations/20200620203632-normalize-locale-id.js
  83. 1 1
      apps/app/src/migrations/20200827045151-remove-layout-setting.js
  84. 1 1
      apps/app/src/migrations/20200828024025-copy-aws-setting.js
  85. 1 1
      apps/app/src/migrations/20200901034313-update-mail-transmission.js
  86. 1 1
      apps/app/src/migrations/20200903080025-remove-timeline-type.js.js
  87. 1 1
      apps/app/src/migrations/20200915035234-rename-s3-config.js
  88. 1 1
      apps/app/src/migrations/20210830074539-update-configs-for-slackbot.js
  89. 1 1
      apps/app/src/migrations/20211005131430-config-without-proxy-command-permission-for-renaming.js
  90. 1 2
      apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js
  91. 2 3
      apps/app/src/migrations/20220311011114-convert-page-delete-config.js
  92. 1 1
      apps/app/src/migrations/20221014130200-remove-customize-is-saved-states-of-tab-changes.js
  93. 1 1
      apps/app/src/migrations/20221219011829-remove-basic-auth-related-config.js
  94. 1 1
      apps/app/src/migrations/20230213090921-remove-presentation-configurations.js
  95. 1 3
      apps/app/src/migrations/20230731075753-add_installed_date_to_config.js
  96. 2 0
      apps/app/src/pages/_document.page.tsx
  97. 5 8
      apps/app/src/server/crowi/index.js
  98. 3 3
      apps/app/src/server/models/config.ts
  99. 1 1
      apps/app/src/server/models/index.ts
  100. 4 1
      apps/app/src/server/models/page.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",

+ 0 - 1
.eslintrc.js

@@ -49,7 +49,6 @@ module.exports = {
       },
       },
     ],
     ],
     '@typescript-eslint/consistent-type-imports': 'warn',
     '@typescript-eslint/consistent-type-imports': 'warn',
-    '@typescript-eslint/no-explicit-any': 'off',
     '@typescript-eslint/explicit-module-boundary-types': 'off',
     '@typescript-eslint/explicit-module-boundary-types': 'off',
     indent: [
     indent: [
       'error',
       'error',

+ 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: ['20', '21', '23', '30', '50']
+        spec-group: ['21', '23', '30', '50']
 
 
     services:
     services:
       mongodb:
       mongodb:

+ 88 - 1
CHANGELOG.md

@@ -1,9 +1,96 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.11...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
+
+### 💎 Features
+
+* feat: Sync latest revision body to Yjs draft (#8939) @miya
+
+### 🚀 Improvement
+
+* imprv: Better synchronizing between YDoc and the latest revision (#8959) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Revision model (#8967) @yuki-takei
+* fix: Healthcheck with checkServices=mongo (#8961) @yuki-takei
+* fix: Enable  # next to headline in view (#8826) @reiji-h
+
+### 🧰 Maintenance
+
+* ci(deps): bump nodemailer from 6.6.2 to 6.9.14 (#8928) @dependabot
+* support: Update favicon (#8957) @satof3
+
+## [v7.0.12](https://github.com/weseek/growi/compare/v7.0.11...v7.0.12) - 2024-07-10
+
+### 🚀 Improvement
+
+* imprv: lang attribute in Html element to correctly reflect locale (#8940) @maeshinshin
+* imprv: Archive importing and exporting (#8943) @yuki-takei
+* imprv: Restrict indexing for full text search when the body length exceeds the threshold (#8937) @yuki-takei
+* imprv: Dark theme support for emoji mart (#8936) @reiji-h
+* imprv: Add env var for set Elasticsearch reindex bulk size (#8933) @yuki-takei
+* imprv: Size for skeleton for tags (#8923) @yuki-takei
+* imprv: Button opacity of TableWithEditButton and DrawioViewerWithEditButton (#8924) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Initialize sanitize option (#8946) @yuki-takei
+* fix: PageTitleHeader rename input status (#8944) @yuki-takei
+* fix: Presentation section tag (#8941) @yuki-takei
+* fix: Page history colorscheme is broken (#8938) @reiji-h
+* imprv: Rename label for bookmark item (#8925) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Refactor Yjs service (#8949) @yuki-takei
+* support: Upgrade y-mongodb-provider (#8953) @yuki-takei
+* support: Typescriptize Revision model (#8954) @yuki-takei
+* support: Typescriptize SocketIoService (#8948) @yuki-takei
+* support: Update GROWI logo type in NoLogin (#8942) @satof3
+* support: Update logo design (#8934) @satof3
+* ci(deps): bump @azure/identity from 4.0.1 to 4.3.0 (#8927) @dependabot
+* support: Upgrade vitest (#8920) @yuki-takei
+* support: Upgrade playwright (#8921) @yuki-takei
+
 ## [v7.0.11](https://github.com/weseek/growi/compare/v7.0.10...v7.0.11) - 2024-06-25
 ## [v7.0.11](https://github.com/weseek/growi/compare/v7.0.10...v7.0.11) - 2024-06-25
 
 
 ### 💎 Features
 ### 💎 Features

+ 2 - 0
apps/app/config/logger/config.dev.js

@@ -17,6 +17,8 @@ module.exports = {
   'growi:middleware:safe-redirect': 'debug',
   'growi:middleware:safe-redirect': 'debug',
   'growi:service:PassportService': 'debug',
   'growi:service:PassportService': 'debug',
   'growi:service:s2s-messaging:*': 'debug',
   'growi:service:s2s-messaging:*': 'debug',
+  'growi:service:yjs': 'debug',
+  'growi:service:yjs:*': 'debug',
   // 'growi:service:socket-io': 'debug',
   // 'growi:service:socket-io': 'debug',
   // 'growi:service:ConfigManager': 'debug',
   // 'growi:service:ConfigManager': 'debug',
   // 'growi:service:mail': 'debug',
   // 'growi:service:mail': 'debug',

+ 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.11`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.11/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)

+ 0 - 1
apps/app/next-env.d.ts

@@ -1,6 +1,5 @@
 /// <reference types="next" />
 /// <reference types="next" />
 /// <reference types="next/image-types/global" />
 /// <reference types="next/image-types/global" />
-/// <reference types="next/navigation-types/compat/navigation" />
 
 
 // NOTE: This file should not be edited
 // NOTE: This file should not be edited
 // see https://nextjs.org/docs/basic-features/typescript for more information.
 // see https://nextjs.org/docs/basic-features/typescript for more information.

+ 4 - 1
apps/app/nodemon.json

@@ -5,8 +5,11 @@
     "public/static",
     "public/static",
     "package.json",
     "package.json",
     "playwright",
     "playwright",
+    "src/client",
+    "src/**/client",
     "test",
     "test",
     "test-with-vite",
     "test-with-vite",
-    "tmp"
+    "tmp",
+    "*.mongodb.js"
   ]
   ]
 }
 }

+ 6 - 6
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.0.12-RC.0",
+  "version": "7.0.16-RC.0",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
   "scripts": {
   "scripts": {
@@ -148,9 +148,9 @@
     "next-i18next": "^15.2.0",
     "next-i18next": "^15.2.0",
     "next-superjson": "^0.0.4",
     "next-superjson": "^0.0.4",
     "next-themes": "^0.2.1",
     "next-themes": "^0.2.1",
-    "nocache": "^3.0.1",
+    "nocache": "^4.0.0",
     "node-cron": "^3.0.2",
     "node-cron": "^3.0.2",
-    "nodemailer": "^6.6.2",
+    "nodemailer": "^6.9.14",
     "nodemailer-ses-transport": "~1.5.0",
     "nodemailer-ses-transport": "~1.5.0",
     "openid-client": "^5.4.0",
     "openid-client": "^5.4.0",
     "p-retry": "^4.0.0",
     "p-retry": "^4.0.0",
@@ -209,9 +209,9 @@
     "validator": "^13.7.0",
     "validator": "^13.7.0",
     "ws": "^8.17.1",
     "ws": "^8.17.1",
     "xss": "^1.0.14",
     "xss": "^1.0.14",
-    "y-mongodb-provider": "^0.1.10",
+    "y-mongodb-provider": "^0.2.0",
     "y-socket.io": "^1.1.3",
     "y-socket.io": "^1.1.3",
-    "yjs": "^13.6.15"
+    "yjs": "^13.6.18"
   },
   },
   "// comments for defDependencies": {
   "// comments for defDependencies": {
     "bootstrap": "v5.3.3 has a bug. refs: https://github.com/twbs/bootstrap/issues/39798",
     "bootstrap": "v5.3.3 has a bug. refs: https://github.com/twbs/bootstrap/issues/39798",
@@ -230,7 +230,7 @@
     "@swc/jest": "^0.2.36",
     "@swc/jest": "^0.2.36",
     "@testing-library/react": "^14.1.2",
     "@testing-library/react": "^14.1.2",
     "@testing-library/user-event": "^14.5.2",
     "@testing-library/user-event": "^14.5.2",
-    "@types/express": "^4.17.11",
+    "@types/express": "^4.17.21",
     "@types/jest": "^29.5.2",
     "@types/jest": "^29.5.2",
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-scroll": "^1.8.4",

+ 129 - 1
apps/app/playwright/20-basic-features/access-to-page.spec.ts

@@ -1,4 +1,9 @@
-import { test, expect } from '@playwright/test';
+import { test, expect, type Page } from '@playwright/test';
+
+const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+  await page.locator('.cm-content').fill(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+};
 
 
 test('has title', async({ page }) => {
 test('has title', async({ page }) => {
   await page.goto('/Sandbox');
   await page.goto('/Sandbox');
@@ -14,9 +19,132 @@ test('get h1', async({ page }) => {
   await expect(page.getByRole('heading').filter({ hasText: /\/Sandbox/ })).toBeVisible();
   await expect(page.getByRole('heading').filter({ hasText: /\/Sandbox/ })).toBeVisible();
 });
 });
 
 
+test('/Sandbox/Math is successfully loaded', async({ page }) => {
+  await page.goto('/Sandbox/Math');
+
+  // Expect the Math-specific elements to be present
+  await expect(page.locator('.math').first()).toBeVisible();
+});
+
+test('Sandbox with edit is successfully loaded', async({ page }) => {
+  await page.goto('/Sandbox#edit');
+
+  // Expect the Editor-specific elements to be present
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+  await expect(page.getByTestId('save-page-btn')).toBeVisible();
+  await expect(page.getByTestId('grw-grant-selector')).toBeVisible();
+});
+
+test.describe.serial('PageEditor', () => {
+  const body1 = 'hello';
+  const body2 = ' world!';
+  const targetPath = '/Sandbox/testForUseEditingMarkdown';
+
+  test('Edit and save with save-page-btn', async({ page }) => {
+    await page.goto(targetPath);
+
+    await page.getByTestId('editor-button').click();
+    await appendTextToEditorUntilContains(page, body1);
+    await page.getByTestId('save-page-btn').click();
+
+    await expect(page.locator('.wiki').first()).toContainText(body1);
+  });
+
+  test('Edit and save with shortcut key', async({ page }) => {
+    const savePageShortcutKey = 'Control+s';
+
+    await page.goto(targetPath);
+
+    await page.getByTestId('editor-button').click();
+
+    await expect(page.locator('.cm-content')).toContainText(body1);
+    await expect(page.getByTestId('page-editor-preview-body')).toContainText(body1);
+
+    await appendTextToEditorUntilContains(page, body1 + body2);
+    await page.keyboard.press(savePageShortcutKey);
+    await page.getByTestId('view-button').click();
+
+    await expect(page.locator('.wiki').first()).toContainText(body1 + body2);
+  });
+});
+
 test('Access to /me page', async({ page }) => {
 test('Access to /me page', async({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Expect the UserSettgins-specific elements to be present when accessing /me (UserSettgins)
   // Expect the UserSettgins-specific elements to be present when accessing /me (UserSettgins)
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
 });
 });
+
+test('All In-App Notification list is successfully loaded', async({ page }) => {
+  await page.goto('/me/all-in-app-notifications');
+
+  // Expect the In-App Notification-specific elements to be present when accessing /me/all-in-app-notifications
+  await expect(page.getByTestId('grw-in-app-notification-page')).toBeVisible();
+});
+
+test('/trash is successfully loaded', async({ page }) => {
+  await page.goto('/trash');
+
+  await expect(page.getByTestId('trash-page-list')).toContainText('There are no pages under this page.');
+});
+
+test('/tags is successfully loaded', async({ page }) => {
+  await page.goto('/tags');
+
+  await expect(page.getByTestId('grw-tags-list')).toContainText('You have no tag, You can set tags on pages');
+});
+
+test.describe.serial('Access to Template Editing Mode', () => {
+  const templateBody1 = 'Template for children';
+  const templateBody2 = 'Template for descendants';
+
+  test('Successfully created template for children', async({ page }) => {
+    await page.goto('/Sandbox');
+
+    await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
+    await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+    await page.getByTestId('open-page-template-modal-btn').click();
+    expect(page.getByTestId('page-template-modal')).toBeVisible();
+
+    await page.getByTestId('template-button-children').click();
+
+    await appendTextToEditorUntilContains(page, templateBody1);
+    await page.getByTestId('save-page-btn').click();
+
+    await expect(page.locator('.wiki').first()).toContainText(templateBody1);
+  });
+
+  test('Template is applied to pages created (template for children)', async({ page }) => {
+    await page.goto('/Sandbox');
+
+    await page.getByTestId('grw-page-create-button').click();
+
+    await expect(page.locator('.cm-content')).toContainText(templateBody1);
+    await expect(page.getByTestId('page-editor-preview-body')).toContainText(templateBody1);
+  });
+
+  test('Successfully created template for descendants', async({ page }) => {
+    await page.goto('/Sandbox');
+
+    await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
+    await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+    await page.getByTestId('open-page-template-modal-btn').click();
+    expect(page.getByTestId('page-template-modal')).toBeVisible();
+
+    await page.getByTestId('template-button-descendants').click();
+
+    await appendTextToEditorUntilContains(page, templateBody2);
+    await page.getByTestId('save-page-btn').click();
+
+    await expect(page.locator('.wiki').first()).toContainText(templateBody2);
+  });
+
+  test('Template is applied to pages created (template for descendants)', async({ page }) => {
+    await page.goto('/Sandbox/Bootstrap5');
+
+    await page.getByTestId('grw-page-create-button').click();
+
+    await expect(page.locator('.cm-content')).toContainText(templateBody2);
+    await expect(page.getByTestId('page-editor-preview-body')).toContainText(templateBody2);
+  });
+});

+ 50 - 0
apps/app/playwright/23-editor/saving.spec.ts

@@ -0,0 +1,50 @@
+import path from 'path';
+
+import { test, expect, type Page } from '@playwright/test';
+
+const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+  await page.locator('.cm-content').fill(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+};
+
+
+test('Successfully create page under specific path', async({ page }) => {
+  const newPagePath = '/child';
+  const openPageCreateModalShortcutKey = 'c';
+
+  await page.goto('/Sandbox');
+
+  await page.keyboard.press(openPageCreateModalShortcutKey);
+  await expect(page.getByTestId('page-create-modal')).toBeVisible();
+  page.getByTestId('page-create-modal').locator('.rbt-input-main').fill(newPagePath);
+  page.getByTestId('btn-create-page-under-below').click();
+  await page.getByTestId('view-button').click();
+
+  const createdPageId = path.basename(page.url());
+  expect(createdPageId.length).toBe(24);
+});
+
+
+test('Successfully updating a page using a shortcut on a previously created page', async({ page }) => {
+  const body1 = 'hello';
+  const body2 = ' world!';
+  const savePageShortcutKey = 'Control+s';
+
+  await page.goto('/Sandbox/child');
+
+  // 1st
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+  await appendTextToEditorUntilContains(page, body1);
+  await page.keyboard.press(savePageShortcutKey);
+  await page.getByTestId('view-button').click();
+  await expect(page.locator('.main')).toContainText(body1);
+
+  // 2nd
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+  await appendTextToEditorUntilContains(page, body1 + body2);
+  await page.keyboard.press(savePageShortcutKey);
+  await page.getByTestId('view-button').click();
+  await expect(page.locator('.main')).toContainText(body1 + body2);
+});

+ 27 - 0
apps/app/playwright/23-editor/template-modal.spec.ts

@@ -0,0 +1,27 @@
+import { test, expect } from '@playwright/test';
+
+test('Successfully select template and template locale', async({ page }) => {
+  const jaText = '今日の目標';
+  const enText = "TODAY'S GOALS";
+  await page.goto('/Sandbox/TemplateModal');
+
+  // move to edit mode
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  // open TemplateModal
+  const templateModal = page.getByTestId('template-modal');
+  await page.getByTestId('open-template-button').click();
+  await expect(templateModal).toBeVisible();
+
+  // select template and template locale
+  await templateModal.locator('.list-group-item').nth(0).click();
+  await expect(templateModal.locator('.card-body').locator('.has-data-line').nth(1)).toHaveText(enText);
+  await templateModal.getByTestId('select-locale-dropdown-toggle').click();
+  await templateModal.getByTestId('select-locale-dropdown-item').nth(1).click();
+  await expect(templateModal.locator('.card-body').locator('.has-data-line').nth(1)).toHaveText(jaText);
+
+  // insert
+  await templateModal.locator('.btn-primary').click();
+  await expect(page.locator('.has-data-line').nth(1)).toHaveText(jaText);
+});

BIN
apps/app/public/favicon.ico


+ 24 - 0
apps/app/public/favicon.svg

@@ -0,0 +1,24 @@
+
+<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <style>
+    .logo {
+      fill: #777570;
+    }
+    .bg {
+      fill: white;
+    }
+    @media (prefers-color-scheme: dark) {
+      .logo {
+        fill: #E6E5E3;
+      }
+      .bg {
+        fill: black;
+      }
+    }
+  </style>
+<path class="bg" d="M47.134 22.5C47.6699 23.4282 47.6699 24.5718 47.134 25.5L36.866 43.2846C36.3301 44.2128 35.3397 44.7846 34.2679 44.7846L13.7321 44.7846C12.6603 44.7846 11.6699 44.2128 11.134 43.2846L0.866028 25.5C0.33013 24.5718 0.33013 23.4282 0.866028 22.5L11.134 4.71539C11.6699 3.78719 12.6603 3.21539 13.7321 3.21539L34.268 3.21539C35.3397 3.21539 36.3301 3.78719 36.866 4.71539L47.134 22.5Z"/>
+<path class="logo" d="M16.0962 27.332L12.5626 33.4861C12.4547 33.6759 12.4547 33.9097 12.5626 34.0961L15.2836 38.8337C15.3847 39.0065 15.5904 39.1251 15.7995 39.1251H16.0962L19.4848 33.2286L16.0962 27.332Z"/>
+<path class="logo" d="M33.9938 24.8076L29.3307 32.9272C29.243 33.0763 29.0845 33.2322 28.8148 33.2322H19.4819L16.0933 39.1254H32.2135C32.4226 39.1254 32.5979 39.0203 32.7058 38.8339L40.7642 24.8042H33.9938V24.8076Z"/>
+<path class="logo" d="M40.9127 24.5569C41.024 24.3671 41.0307 24.1536 40.9228 23.9639L38.1985 19.2229C38.0906 19.0331 37.9051 18.9111 37.686 18.9111H21.2892C21.0701 18.9111 20.8712 19.0297 20.7599 19.2127L18.1873 23.686L21.5758 29.5893L24.0676 25.2516C24.226 24.9805 24.516 24.8111 24.8262 24.8111H40.7677L40.9127 24.5603V24.5569Z"/>
+<path class="logo" d="M19.1953 15.2715H35.9292L32.7193 9.68338C32.6114 9.49361 32.426 9.375 32.2068 9.375H15.8101C15.5909 9.375 15.392 9.48344 15.2807 9.67322L7.08068 23.9435C6.97278 24.1333 6.97278 24.3638 7.08068 24.5535L10.2973 30.1519L18.6659 15.5698C18.7738 15.38 18.9761 15.2682 19.1953 15.2682V15.2715Z"/>
+</svg>

BIN
apps/app/public/images/icons/favicon/android-icon-144x144.png


BIN
apps/app/public/images/icons/favicon/android-icon-192x192.png


BIN
apps/app/public/images/icons/favicon/android-icon-36x36.png


BIN
apps/app/public/images/icons/favicon/android-icon-48x48.png


BIN
apps/app/public/images/icons/favicon/android-icon-72x72.png


BIN
apps/app/public/images/icons/favicon/android-icon-96x96.png


BIN
apps/app/public/images/icons/favicon/apple-icon-114x114.png


BIN
apps/app/public/images/icons/favicon/apple-icon-120x120.png


BIN
apps/app/public/images/icons/favicon/apple-icon-144x144.png


BIN
apps/app/public/images/icons/favicon/apple-icon-152x152.png


BIN
apps/app/public/images/icons/favicon/apple-icon-180x180.png


BIN
apps/app/public/images/icons/favicon/apple-icon-57x57.png


BIN
apps/app/public/images/icons/favicon/apple-icon-60x60.png


BIN
apps/app/public/images/icons/favicon/apple-icon-72x72.png


BIN
apps/app/public/images/icons/favicon/apple-icon-76x76.png


BIN
apps/app/public/images/icons/favicon/apple-icon-precomposed.png


BIN
apps/app/public/images/icons/favicon/apple-icon.png


+ 0 - 2
apps/app/public/images/icons/favicon/browserconfig.xml

@@ -1,2 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>

BIN
apps/app/public/images/icons/favicon/favicon-16x16.png


BIN
apps/app/public/images/icons/favicon/favicon-32x32.png


BIN
apps/app/public/images/icons/favicon/favicon-96x96.png


BIN
apps/app/public/images/icons/favicon/ms-icon-144x144.png


BIN
apps/app/public/images/icons/favicon/ms-icon-150x150.png


BIN
apps/app/public/images/icons/favicon/ms-icon-310x310.png


BIN
apps/app/public/images/icons/favicon/ms-icon-70x70.png


+ 8 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -849,5 +849,13 @@
   },
   },
   "create_page": {
   "create_page": {
     "untitled": "Untitled"
     "untitled": "Untitled"
+  },
+  "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?",
+    "alert": "The latest text may not have been synchronized. Please reload and check again.",
+    "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"
   }
   }
 }
 }

+ 8 - 0
apps/app/public/static/locales/fr_FR/translation.json

@@ -840,5 +840,13 @@
     "show_wip_page": "Voir brouillon",
     "show_wip_page": "Voir brouillon",
     "size_s": "Taille: P",
     "size_s": "Taille: P",
     "size_l": "Taille: G"
     "size_l": "Taille: G"
+  },
+  "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?",
+    "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é",
+    "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é"
   }
   }
 }
 }

+ 8 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -882,5 +882,13 @@
   },
   },
   "create_page": {
   "create_page": {
     "untitled": "無題のページ"
     "untitled": "無題のページ"
+  },
+  "sync-latest-revision-body": {
+    "menuitem": "最新のリビジョンの本文とエディタのテキストを同期",
+    "confirm": "エディターに入力中のドラフトデータを削除して最新の本文を同期します。実行しますか?",
+    "alert": "最新の本文が同期されていない可能性があります。リロードして再度ご確認ください。",
+    "success-toaster": "最新の本文を同期しました",
+    "skipped-toaster": "エディターがアクティブではないため、同期をスキップしました。エディターを開いて再度お試しください。",
+    "error-toaster": "最新の本文の同期に失敗しました"
   }
   }
 }
 }

+ 8 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -852,5 +852,13 @@
   },
   },
   "create_page": {
   "create_page": {
     "untitled": "Untitled"
     "untitled": "Untitled"
+  },
+  "sync-latest-revision-body": {
+    "menuitem": "同步编辑器文本与最新修订正文",
+    "confirm": "删除输入编辑器的草稿数据,同步最新文本。 您真的想运行它吗?",
+    "alert": "最新文本可能尚未同步。 请重新加载并再次检查。",
+    "success-toaster": "同步最新文本",
+    "skipped-toaster": "由于编辑器未激活,因此跳过同步。 请打开编辑器并重试。",
+    "error-toaster": "同步最新文本失败"
   }
   }
 }
 }

+ 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>

+ 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;
     }
     }
   }
   }

+ 0 - 1
apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx

@@ -108,7 +108,6 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     await onClickRevertMenuItem(pageId);
     await onClickRevertMenuItem(pageId);
   }, [onClickRevertMenuItem, pageId]);
   }, [onClickRevertMenuItem, pageId]);
 
 
-
   // eslint-disable-next-line react-hooks/rules-of-hooks
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const deleteItemClickedHandler = useCallback(async() => {
   const deleteItemClickedHandler = useCallback(async() => {
     if (pageInfo == null || onClickDeleteMenuItem == null) {
     if (pageInfo == null || onClickDeleteMenuItem == null) {

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

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

+ 4 - 2
apps/app/src/client/components/InAppNotification/PageNotification/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
@@ -21,8 +23,8 @@ export const ModelNotification: FC<Props> = (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">
         <b>{actionUsers}</b>
         <b>{actionUsers}</b>
         {actionMsg}
         {actionMsg}
         <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
         <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />

+ 41 - 1
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';
@@ -14,7 +16,8 @@ import { useRouter } from 'next/router';
 import Sticky from 'react-stickynode';
 import Sticky from 'react-stickynode';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
-import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
+import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
+import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
@@ -76,8 +79,45 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
 
 
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+
+  const syncLatestRevisionBodyHandler = useCallback(async() => {
+    // eslint-disable-next-line no-alert
+    const answer = window.confirm(t('sync-latest-revision-body.confirm'));
+    if (answer) {
+      try {
+        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 {
+        toastError(t('sync-latest-revision-body.error-toaster'));
+      }
+    }
+  }, [codeMirrorEditor, pageId, t]);
+
   return (
   return (
     <>
     <>
+      <DropdownItem
+        onClick={() => syncLatestRevisionBodyHandler()}
+        className="grw-page-control-dropdown-item"
+      >
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">sync</span>
+        {t('sync-latest-revision-body.menuitem')}
+      </DropdownItem>
+
       {/* Presentation */}
       {/* Presentation */}
       <DropdownItem
       <DropdownItem
         onClick={() => openPresentationModal()}
         onClick={() => openPresentationModal()}

+ 2 - 2
apps/app/src/client/components/Navbar/PageEditorModeManager.tsx

@@ -93,11 +93,11 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
   const _isBtnDisabled = isCreating || isBtnDisabled;
   const _isBtnDisabled = isCreating || isBtnDisabled;
 
 
   const circleColor = useMemo(() => {
   const circleColor = useMemo(() => {
-    if (currentPageYjsData?.awarenessStateSize != null && currentPageYjsData.awarenessStateSize > 0) {
+    if ((currentPageYjsData?.awarenessStateSize ?? 0) > 0) {
       return 'bg-primary';
       return 'bg-primary';
     }
     }
 
 
-    if (currentPageYjsData?.hasRevisionBodyDiff != null && currentPageYjsData.hasRevisionBodyDiff) {
+    if (currentPageYjsData?.hasYdocsNewerThanLatestRevision ?? false) {
       return 'bg-secondary';
       return 'bg-secondary';
     }
     }
   }, [currentPageYjsData]);
   }, [currentPageYjsData]);

+ 2 - 0
apps/app/src/client/components/Page/DisplaySwitcher.tsx

@@ -3,6 +3,7 @@ import dynamic from 'next/dynamic';
 import { useHashChangedEffect } from '~/client/services/side-effects/hash-changed';
 import { useHashChangedEffect } from '~/client/services/side-effects/hash-changed';
 import { useIsEditable } from '~/stores-universal/context';
 import { useIsEditable } from '~/stores-universal/context';
 import { EditorMode, useEditorMode } from '~/stores-universal/ui';
 import { EditorMode, useEditorMode } from '~/stores-universal/ui';
+import { useReservedNextCaretLine } from '~/stores/editor';
 import { useIsLatestRevision } from '~/stores/page';
 import { useIsLatestRevision } from '~/stores/page';
 
 
 import { LazyRenderer } from '../Common/LazyRenderer';
 import { LazyRenderer } from '../Common/LazyRenderer';
@@ -18,6 +19,7 @@ export const DisplaySwitcher = (): JSX.Element => {
   const { data: isLatestRevision } = useIsLatestRevision();
   const { data: isLatestRevision } = useIsLatestRevision();
 
 
   useHashChangedEffect();
   useHashChangedEffect();
+  useReservedNextCaretLine();
 
 
   return (
   return (
     <LazyRenderer shouldRender={isEditable === true && editorMode === EditorMode.Editor}>
     <LazyRenderer shouldRender={isEditable === true && editorMode === EditorMode.Editor}>

+ 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';

+ 18 - 10
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -32,6 +32,7 @@ import {
 import { EditorMode, useEditorMode } from '~/stores-universal/ui';
 import { EditorMode, useEditorMode } from '~/stores-universal/ui';
 import { useNextThemes } from '~/stores-universal/use-next-themes';
 import { useNextThemes } from '~/stores-universal/use-next-themes';
 import {
 import {
+  useReservedNextCaretLine,
   useEditorSettings,
   useEditorSettings,
   useCurrentIndentSize,
   useCurrentIndentSize,
   useEditingMarkdown,
   useEditingMarkdown,
@@ -109,6 +110,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: user } = useCurrentUser();
   const { data: user } = useCurrentUser();
   const { onEditorsUpdated } = useEditingUsers();
   const { onEditorsUpdated } = useEditingUsers();
   const onConflict = useConflictResolver();
   const onConflict = useConflictResolver();
+  const { data: reservedNextCaretLine, mutate: mutateReservedNextCaretLine } = useReservedNextCaretLine();
 
 
   const { data: rendererOptions } = usePreviewOptions();
   const { data: rendererOptions } = usePreviewOptions();
 
 
@@ -298,19 +300,25 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     }
     }
   }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
   }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
 
 
-  // set handler to set caret line
+
+  // set caret line if the edit button next to Header is clicked.
   useEffect(() => {
   useEffect(() => {
-    const handler = (lineNumber?: number) => {
-      codeMirrorEditor?.setCaretLine(lineNumber);
+    if (codeMirrorEditor?.setCaretLine == null) {
+      return;
+    }
+    if (editorMode === EditorMode.Editor) {
+      codeMirrorEditor.setCaretLine(reservedNextCaretLine ?? 0, true);
+    }
 
 
-      // TODO: scroll to the caret line
-    };
-    globalEmitter.on('setCaretLine', handler);
+  }, [codeMirrorEditor, editorMode, reservedNextCaretLine]);
+
+  // reset caret line if returning to the View.
+  useEffect(() => {
+    if (editorMode === EditorMode.View) {
+      mutateReservedNextCaretLine(0);
+    }
+  }, [editorMode, mutateReservedNextCaretLine]);
 
 
-    return function cleanup() {
-      globalEmitter.removeListener('setCaretLine', handler);
-    };
-  }, [codeMirrorEditor]);
 
 
   // TODO: Check the reproduction conditions that made this code necessary and confirm reproduction
   // TODO: Check the reproduction conditions that made this code necessary and confirm reproduction
   // // when transitioning to a different page, if the initialValue is the same,
   // // when transitioning to a different page, if the initialValue is the same,

+ 6 - 6
apps/app/src/client/components/PageEditor/ScrollSyncHelper.tsx

@@ -121,12 +121,12 @@ const scrollEditor = (editorRootElement: HTMLElement, previewRootElement: HTMLEl
   newScrollTop += calcScrollElementToTop(previewElements[topPreviewElementIndex]);
   newScrollTop += calcScrollElementToTop(previewElements[topPreviewElementIndex]);
   newScrollTop += calcScorllElementByRatio(
   newScrollTop += calcScorllElementByRatio(
     {
     {
-      start: editorElements[startEditorElementIndex].getBoundingClientRect(),
-      top: editorElements[topEditorElementIndex].getBoundingClientRect(),
+      start: editorElements[startEditorElementIndex]?.getBoundingClientRect(),
+      top: editorElements[topEditorElementIndex]?.getBoundingClientRect(),
       next: editorElements[nextEditorElementIndex]?.getBoundingClientRect(),
       next: editorElements[nextEditorElementIndex]?.getBoundingClientRect(),
     },
     },
     {
     {
-      start: previewElements[topPreviewElementIndex].getBoundingClientRect(),
+      start: previewElements[topPreviewElementIndex]?.getBoundingClientRect(),
       next: previewElements[topPreviewElementIndex + 1]?.getBoundingClientRect(),
       next: previewElements[topPreviewElementIndex + 1]?.getBoundingClientRect(),
     },
     },
   );
   );
@@ -156,12 +156,12 @@ const scrollPreview = (editorRootElement: HTMLElement, previewRootElement: HTMLE
   newScrollTop += calcScrollElementToTop(editorElements[startEditorElementIndex]);
   newScrollTop += calcScrollElementToTop(editorElements[startEditorElementIndex]);
   newScrollTop += calcScorllElementByRatio(
   newScrollTop += calcScorllElementByRatio(
     {
     {
-      start: previewElements[topPreviewElementIndex].getBoundingClientRect(),
-      top: previewElements[topPreviewElementIndex].getBoundingClientRect(),
+      start: previewElements[topPreviewElementIndex]?.getBoundingClientRect(),
+      top: previewElements[topPreviewElementIndex]?.getBoundingClientRect(),
       next: previewElements[topPreviewElementIndex + 1]?.getBoundingClientRect(),
       next: previewElements[topPreviewElementIndex + 1]?.getBoundingClientRect(),
     },
     },
     {
     {
-      start: editorElements[startEditorElementIndex].getBoundingClientRect(),
+      start: editorElements[startEditorElementIndex]?.getBoundingClientRect(),
       next: editorElements[nextEditorElementIndex]?.getBoundingClientRect(),
       next: editorElements[nextEditorElementIndex]?.getBoundingClientRect(),
     },
     },
   );
   );

+ 4 - 4
apps/app/src/client/components/PageHistory/PageRevisionTable.tsx

@@ -2,7 +2,7 @@ import React, {
   useEffect, useRef, useState,
   useEffect, useRef, useState,
 } from 'react';
 } from 'react';
 
 
-import type { IRevisionHasPageId } from '@growi/core';
+import type { IRevisionHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { useSWRxInfinitePageRevisions } from '~/stores/page';
 import { useSWRxInfinitePageRevisions } from '~/stores/page';
@@ -48,8 +48,8 @@ export const PageRevisionTable = (props: PageRevisionTableProps): JSX.Element =>
     || (isValidating && data != null && typeof data[size - 1] === 'undefined');
     || (isValidating && data != null && typeof data[size - 1] === 'undefined');
   const isReachingEnd = (revisionPerPage === 0) || !!(data != null && data[data.length - 1]?.revisions.length < REVISIONS_PER_PAGE);
   const isReachingEnd = (revisionPerPage === 0) || !!(data != null && data[data.length - 1]?.revisions.length < REVISIONS_PER_PAGE);
 
 
-  const [sourceRevision, setSourceRevision] = useState<IRevisionHasPageId>();
-  const [targetRevision, setTargetRevision] = useState<IRevisionHasPageId>();
+  const [sourceRevision, setSourceRevision] = useState<IRevisionHasId>();
+  const [targetRevision, setTargetRevision] = useState<IRevisionHasId>();
 
 
   const tbodyRef = useRef<HTMLTableSectionElement>(null);
   const tbodyRef = useRef<HTMLTableSectionElement>(null);
 
 
@@ -96,7 +96,7 @@ export const PageRevisionTable = (props: PageRevisionTableProps): JSX.Element =>
   }, [isLoadingMore, isReachingEnd, setSize, size]);
   }, [isLoadingMore, isReachingEnd, setSize, size]);
 
 
 
 
-  const renderRow = (revision: IRevisionHasPageId, previousRevision: IRevisionHasPageId, latestRevision: IRevisionHasPageId,
+  const renderRow = (revision: IRevisionHasId, previousRevision: IRevisionHasId, latestRevision: IRevisionHasId,
       isOldestRevision: boolean, hasDiff: boolean) => {
       isOldestRevision: boolean, hasDiff: boolean) => {
 
 
     const revisionId = revision._id;
     const revisionId = revision._id;

+ 3 - 3
apps/app/src/client/components/PageHistory/RevisionDiff.tsx

@@ -1,6 +1,6 @@
 import { useMemo } from 'react';
 import { useMemo } from 'react';
 
 
-import type { IRevisionHasPageId } from '@growi/core';
+import type { IRevisionHasId } from '@growi/core';
 import { GrowiThemeSchemeType } from '@growi/core';
 import { GrowiThemeSchemeType } from '@growi/core';
 import { returnPathForURL } from '@growi/core/dist/utils/path-utils';
 import { returnPathForURL } from '@growi/core/dist/utils/path-utils';
 import { PresetThemesMetadatas } from '@growi/preset-themes';
 import { PresetThemesMetadatas } from '@growi/preset-themes';
@@ -26,8 +26,8 @@ import 'diff2html/bundles/css/diff2html.min.css';
 const moduleClass = styles['revision-diff-container'];
 const moduleClass = styles['revision-diff-container'];
 
 
 type RevisioinDiffProps = {
 type RevisioinDiffProps = {
-  currentRevision: IRevisionHasPageId,
-  previousRevision: IRevisionHasPageId,
+  currentRevision: IRevisionHasId,
+  previousRevision: IRevisionHasId,
   revisionDiffOpened: boolean,
   revisionDiffOpened: boolean,
   currentPageId: string,
   currentPageId: string,
   currentPagePath: string,
   currentPagePath: string,

+ 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;
 }
 }

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

@@ -13,6 +13,7 @@ import {
   useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
   useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
 } from '~/stores-universal/context';
 } from '~/stores-universal/context';
 import { useIsRevisionOutdated } from '~/stores/page';
 import { useIsRevisionOutdated } from '~/stores/page';
+import { useCurrentPageYjsData } from '~/stores/yjs';
 
 
 import '@growi/remark-drawio/dist/style.css';
 import '@growi/remark-drawio/dist/style.css';
 import styles from './DrawioViewerWithEditButton.module.scss';
 import styles from './DrawioViewerWithEditButton.module.scss';
@@ -34,6 +35,7 @@ export const DrawioViewerWithEditButton = React.memo((props: DrawioViewerProps):
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
   const { data: shareLinkId } = useShareLinkId();
   const { data: isRevisionOutdated } = useIsRevisionOutdated();
   const { data: isRevisionOutdated } = useIsRevisionOutdated();
+  const { data: currentPageYjsData } = useCurrentPageYjsData();
 
 
   const [isRendered, setRendered] = useState(false);
   const [isRendered, setRendered] = useState(false);
   const [mxfile, setMxfile] = useState('');
   const [mxfile, setMxfile] = useState('');
@@ -57,7 +59,14 @@ export const DrawioViewerWithEditButton = React.memo((props: DrawioViewerProps):
     }
     }
   }, []);
   }, []);
 
 
-  const showEditButton = !isRevisionOutdated && isRendered && !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null;
+  const isNoEditingUsers = currentPageYjsData?.awarenessStateSize == null || currentPageYjsData?.awarenessStateSize === 0;
+  const showEditButton = isNoEditingUsers
+     && !isRevisionOutdated
+     && isRendered
+     && !isGuestUser
+     && !isReadOnlyUser
+     && !isSharedUser
+     && shareLinkId == null;
 
 
   return (
   return (
     <div className={`drawio-viewer-with-edit-button ${styles['drawio-viewer-with-edit-button']}`}>
     <div className={`drawio-viewer-with-edit-button ${styles['drawio-viewer-with-edit-button']}`}>

+ 9 - 2
apps/app/src/client/components/ReactMarkdownComponents/Header.tsx

@@ -9,6 +9,7 @@ import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import {
 import {
   useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
   useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
 } from '~/stores-universal/context';
 } from '~/stores-universal/context';
+import { useCurrentPageYjsData } from '~/stores/yjs';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 
 
@@ -26,7 +27,7 @@ declare global {
 
 
 function setCaretLine(line?: number): void {
 function setCaretLine(line?: number): void {
   if (line != null) {
   if (line != null) {
-    globalEmitter.emit('setCaretLine', line);
+    globalEmitter.emit('reservedNextCaretLine', line);
   }
   }
 }
 }
 
 
@@ -66,6 +67,7 @@ export const Header = (props: HeaderProps): JSX.Element => {
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
   const { data: shareLinkId } = useShareLinkId();
+  const { data: currentPageYjsData } = useCurrentPageYjsData();
 
 
   const router = useRouter();
   const router = useRouter();
 
 
@@ -111,7 +113,12 @@ export const Header = (props: HeaderProps): JSX.Element => {
     };
     };
   }, [activateByHash, router.events]);
   }, [activateByHash, router.events]);
 
 
-  const showEditButton = !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null;
+  // TODO: currentPageYjsData?.hasYdocsNewerThanLatestRevision === false make to hide the edit button when a Yjs draft exists
+  // This is because the current conditional logic cannot handle cases where the draft is an empty string.
+  // It will be possible to address this TODO ySyncAnnotation become available for import.
+  // Ref: https://github.com/yjs/y-codemirror.next/pull/30
+  const showEditButton = !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null
+                            && currentPageYjsData?.hasYdocsNewerThanLatestRevision === false;
 
 
   return (
   return (
     <>
     <>

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

@@ -8,6 +8,7 @@ import {
   useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
   useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
 } from '~/stores-universal/context';
 } from '~/stores-universal/context';
 import { useIsRevisionOutdated } from '~/stores/page';
 import { useIsRevisionOutdated } from '~/stores/page';
+import { useCurrentPageYjsData } from '~/stores/yjs';
 
 
 import styles from './TableWithEditButton.module.scss';
 import styles from './TableWithEditButton.module.scss';
 
 
@@ -31,6 +32,7 @@ export const TableWithEditButton = React.memo((props: TableWithEditButtonProps):
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
   const { data: shareLinkId } = useShareLinkId();
   const { data: isRevisionOutdated } = useIsRevisionOutdated();
   const { data: isRevisionOutdated } = useIsRevisionOutdated();
+  const { data: currentPageYjsData } = useCurrentPageYjsData();
 
 
   const bol = node.position?.start.line;
   const bol = node.position?.start.line;
   const eol = node.position?.end.line;
   const eol = node.position?.end.line;
@@ -39,7 +41,13 @@ export const TableWithEditButton = React.memo((props: TableWithEditButtonProps):
     globalEmitter.emit('launchHandsonTableModal', bol, eol);
     globalEmitter.emit('launchHandsonTableModal', bol, eol);
   }, [bol, eol]);
   }, [bol, eol]);
 
 
-  const showEditButton = !isRevisionOutdated && !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null;
+  const isNoEditingUsers = currentPageYjsData?.awarenessStateSize == null || currentPageYjsData?.awarenessStateSize === 0;
+  const showEditButton = isNoEditingUsers
+    && !isRevisionOutdated
+    && !isGuestUser
+    && !isReadOnlyUser
+    && !isSharedUser
+    && shareLinkId == null;
 
 
   return (
   return (
     <div className={`editable-with-handsontable ${styles['editable-with-handsontable']}`}>
     <div className={`editable-with-handsontable ${styles['editable-with-handsontable']}`}>

+ 3 - 3
apps/app/src/client/components/RevisionComparer/RevisionComparer.tsx

@@ -1,6 +1,6 @@
 import React, { useState } from 'react';
 import React, { useState } from 'react';
 
 
-import type { IRevisionHasPageId } from '@growi/core';
+import type { IRevisionHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
@@ -22,8 +22,8 @@ const DropdownItemContents = ({ title, contents }) => (
 );
 );
 
 
 type RevisionComparerProps = {
 type RevisionComparerProps = {
-  sourceRevision: IRevisionHasPageId
-  targetRevision: IRevisionHasPageId
+  sourceRevision: IRevisionHasId
+  targetRevision: IRevisionHasId
   currentPageId?: string
   currentPageId?: string
   currentPagePath: string
   currentPagePath: string
   onClose: () => void
   onClose: () => void

+ 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
 
 

+ 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';

+ 9 - 1
apps/app/src/client/components/TemplateModal/TemplateModal.tsx

@@ -247,13 +247,21 @@ const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element
               </div>
               </div>
               <div className="col-6 d-flex justify-content-end">
               <div className="col-6 d-flex justify-content-end">
                 <UncontrolledDropdown>
                 <UncontrolledDropdown>
-                  <DropdownToggle caret type="button" outline className="float-end" disabled={selectedTemplateSummary == null}>
+                  <DropdownToggle
+                    caret
+                    type="button"
+                    outline
+                    className="float-end"
+                    disabled={selectedTemplateSummary == null}
+                    data-testid="select-locale-dropdown-toggle"
+                  >
                     <span className="float-start">{selectedTemplateLocale != null ? selectedTemplateLocale : t('Language')}</span>
                     <span className="float-start">{selectedTemplateLocale != null ? selectedTemplateLocale : t('Language')}</span>
                   </DropdownToggle>
                   </DropdownToggle>
                   <DropdownMenu className="dropdown-menu" role="menu">
                   <DropdownMenu className="dropdown-menu" role="menu">
                     { selectedTemplateLocales != null && Array.from(selectedTemplateLocales).map((locale) => {
                     { selectedTemplateLocales != null && Array.from(selectedTemplateLocales).map((locale) => {
                       return (
                       return (
                         <DropdownItem
                         <DropdownItem
+                          data-testid="select-locale-dropdown-item"
                           key={locale}
                           key={locale}
                           onClick={() => setSelectedTemplateLocale(locale)}
                           onClick={() => setSelectedTemplateLocale(locale)}
                         >
                         >

+ 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>
           )}
           )}

+ 6 - 0
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,
@@ -174,3 +175,8 @@ export const unpublish = async(pageId: string): Promise<IPageHasId> => {
   const res = await apiv3Put(`/page/${pageId}/unpublish`);
   const res = await apiv3Put(`/page/${pageId}/unpublish`);
   return res.data;
   return res.data;
 };
 };
+
+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;
+};

+ 7 - 15
apps/app/src/client/services/side-effects/yjs.ts

@@ -1,4 +1,4 @@
-import { useCallback, useEffect } from 'react';
+import { useEffect } from 'react';
 
 
 import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 
 
@@ -7,27 +7,19 @@ import { useCurrentPageYjsData } from '~/stores/yjs';
 
 
 export const useCurrentPageYjsDataEffect = (): void => {
 export const useCurrentPageYjsDataEffect = (): void => {
   const { data: socket } = useGlobalSocket();
   const { data: socket } = useGlobalSocket();
-  const { updateHasRevisionBodyDiff, updateAwarenessStateSize } = useCurrentPageYjsData();
-
-  const hasRevisionBodyDiffUpdateHandler = useCallback((hasRevisionBodyDiff: boolean) => {
-    updateHasRevisionBodyDiff(hasRevisionBodyDiff);
-  }, [updateHasRevisionBodyDiff]);
-
-  const awarenessStateSizeUpdateHandler = useCallback(((awarenessStateSize: number) => {
-    updateAwarenessStateSize(awarenessStateSize);
-  }), [updateAwarenessStateSize]);
+  const { updateHasYdocsNewerThanLatestRevision, updateAwarenessStateSize } = useCurrentPageYjsData();
 
 
   useEffect(() => {
   useEffect(() => {
 
 
     if (socket == null) { return }
     if (socket == null) { return }
 
 
-    socket.on(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiffUpdateHandler);
-    socket.on(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSizeUpdateHandler);
+    socket.on(SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated, updateHasYdocsNewerThanLatestRevision);
+    socket.on(SocketEventName.YjsAwarenessStateSizeUpdated, updateAwarenessStateSize);
 
 
     return () => {
     return () => {
-      socket.off(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiffUpdateHandler);
-      socket.off(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSizeUpdateHandler);
+      socket.off(SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated, updateHasYdocsNewerThanLatestRevision);
+      socket.off(SocketEventName.YjsAwarenessStateSizeUpdated, updateAwarenessStateSize);
     };
     };
 
 
-  }, [socket, awarenessStateSizeUpdateHandler, hasRevisionBodyDiffUpdateHandler]);
+  }, [socket, updateAwarenessStateSize, updateHasYdocsNewerThanLatestRevision]);
 };
 };

+ 6 - 4
apps/app/src/features/questionnaire/server/service/questionnaire.ts

@@ -3,19 +3,21 @@ import * as os from 'node:os';
 
 
 import type { IUserHasId } from '@growi/core';
 import type { IUserHasId } from '@growi/core';
 
 
-import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { aclService } from '~/server/service/acl';
 import { aclService } from '~/server/service/acl';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import type { IGrowiInfo } from '../../interfaces/growi-info';
 import {
 import {
-  GrowiWikiType, GrowiExternalAuthProviderType, IGrowiInfo, GrowiServiceType, GrowiAttachmentType, GrowiDeploymentType,
+  GrowiWikiType, GrowiExternalAuthProviderType, GrowiServiceType, GrowiAttachmentType, GrowiDeploymentType,
 } from '../../interfaces/growi-info';
 } from '../../interfaces/growi-info';
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
 import { type IUserInfo, UserType } from '../../interfaces/user-info';
 import { type IUserInfo, UserType } from '../../interfaces/user-info';
 import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
 import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
-import QuestionnaireOrder, { QuestionnaireOrderDocument } from '../models/questionnaire-order';
+import type { QuestionnaireOrderDocument } from '../models/questionnaire-order';
+import QuestionnaireOrder from '../models/questionnaire-order';
 import { isShowableCondition } from '../util/condition';
 import { isShowableCondition } from '../util/condition';
 
 
 
 

+ 1 - 1
apps/app/src/interfaces/websocket.ts

@@ -51,7 +51,7 @@ export const SocketEventName = {
 
 
   // Yjs
   // Yjs
   YjsAwarenessStateSizeUpdated: 'yjs:awareness-state-size-update',
   YjsAwarenessStateSizeUpdated: 'yjs:awareness-state-size-update',
-  YjsHasRevisionBodyDiffUpdated: 'yjs:has-revision-body-diff-update',
+  YjsHasYdocsNewerThanLatestRevisionUpdated: 'yjs:has-ydocs-newer-than-latest-revision-update',
 } as const;
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 
 

+ 6 - 1
apps/app/src/interfaces/yjs.ts

@@ -1,4 +1,9 @@
 export type CurrentPageYjsData = {
 export type CurrentPageYjsData = {
-  hasRevisionBodyDiff?: boolean,
+  hasYdocsNewerThanLatestRevision?: boolean,
   awarenessStateSize?: number,
   awarenessStateSize?: number,
 }
 }
+
+export type SyncLatestRevisionBody = {
+  synced: boolean,
+  isYjsDataBroken?: boolean,
+}

+ 1 - 1
apps/app/src/migrations/20180927102719-init-serverurl.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
apps/app/src/migrations/20190618055300-abolish-crowi-classic-auth.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
apps/app/src/migrations/20190618104011-add-config-app-installed.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
apps/app/src/migrations/20200420160390-remove-crowi-layout.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
apps/app/src/migrations/20200512005851-remove-behavior-type.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
apps/app/src/migrations/20200514001356-update-theme-color-for-dark.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
apps/app/src/migrations/20200620203632-normalize-locale-id.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
apps/app/src/migrations/20200827045151-remove-layout-setting.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
apps/app/src/migrations/20200828024025-copy-aws-setting.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
apps/app/src/migrations/20200901034313-update-mail-transmission.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
apps/app/src/migrations/20200903080025-remove-timeline-type.js.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
apps/app/src/migrations/20200915035234-rename-s3-config.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
apps/app/src/migrations/20210830074539-update-configs-for-slackbot.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
apps/app/src/migrations/20211005131430-config-without-proxy-command-permission-for-renaming.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 2
apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js

@@ -4,6 +4,7 @@ import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 import streamToPromise from 'stream-to-promise';
 
 
 import getPageModel from '~/server/models/page';
 import getPageModel from '~/server/models/page';
+import { Revision } from '~/server/models/revision';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -18,7 +19,6 @@ module.exports = {
   async up(db, client) {
   async up(db, client) {
     mongoose.connect(getMongoUri(), mongoOptions);
     mongoose.connect(getMongoUri(), mongoOptions);
     const Page = getModelSafely('Page') || getPageModel();
     const Page = getModelSafely('Page') || getPageModel();
-    const Revision = getModelSafely('Revision') || require('~/server/models/revision')();
 
 
     const pagesStream = await Page.find({ revision: { $ne: null } }, { _id: 1, path: 1 }).cursor({ batch_size: LIMIT });
     const pagesStream = await Page.find({ revision: { $ne: null } }, { _id: 1, path: 1 }).cursor({ batch_size: LIMIT });
     const batchStrem = createBatchStream(LIMIT);
     const batchStrem = createBatchStream(LIMIT);
@@ -69,7 +69,6 @@ module.exports = {
   async down(db, client) {
   async down(db, client) {
     mongoose.connect(getMongoUri(), mongoOptions);
     mongoose.connect(getMongoUri(), mongoOptions);
     const Page = getModelSafely('Page') || getPageModel();
     const Page = getModelSafely('Page') || getPageModel();
-    const Revision = getModelSafely('Revision') || require('~/server/models/revision')();
 
 
     const pagesStream = await Page.find({ revision: { $ne: null } }, { _id: 1, path: 1 }).cursor({ batch_size: LIMIT });
     const pagesStream = await Page.find({ revision: { $ne: null } }, { _id: 1, path: 1 }).cursor({ batch_size: LIMIT });
     const batchStrem = createBatchStream(LIMIT);
     const batchStrem = createBatchStream(LIMIT);

+ 2 - 3
apps/app/src/migrations/20220311011114-convert-page-delete-config.js

@@ -3,8 +3,8 @@ import mongoose from 'mongoose';
 import {
 import {
   PageRecursiveDeleteConfigValue, PageRecursiveDeleteCompConfigValue,
   PageRecursiveDeleteConfigValue, PageRecursiveDeleteCompConfigValue,
 } from '~/interfaces/page-delete-config';
 } from '~/interfaces/page-delete-config';
-import ConfigModel from '~/server/models/config';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import { Config } from '~/server/models/config';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:migrate:convert-page-delete-config');
 const logger = loggerFactory('growi:migrate:convert-page-delete-config');
@@ -13,7 +13,6 @@ const logger = loggerFactory('growi:migrate:convert-page-delete-config');
 module.exports = {
 module.exports = {
   async up(db, client) {
   async up(db, client) {
     mongoose.connect(getMongoUri(), mongoOptions);
     mongoose.connect(getMongoUri(), mongoOptions);
-    const Config = getModelSafely('Config') || ConfigModel;
 
 
     const isNewConfigExists = await Config.count({
     const isNewConfigExists = await Config.count({
       ns: 'crowi',
       ns: 'crowi',

+ 1 - 1
apps/app/src/migrations/20221014130200-remove-customize-is-saved-states-of-tab-changes.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
apps/app/src/migrations/20221219011829-remove-basic-auth-related-config.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
apps/app/src/migrations/20230213090921-remove-presentation-configurations.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 3
apps/app/src/migrations/20230731075753-add_installed_date_to_config.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
 // eslint-disable-next-line import/no-named-as-default
-import ConfigModel from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -12,7 +12,6 @@ module.exports = {
   async up() {
   async up() {
     logger.info('Apply migration');
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
     mongoose.connect(getMongoUri(), mongoOptions);
-    const Config = getModelSafely('Config') || ConfigModel;
     const User = getModelSafely('User') || require('~/server/models/user')();
     const User = getModelSafely('User') || require('~/server/models/user')();
 
 
     const appInstalled = await Config.findOne({ key: 'app:installed' });
     const appInstalled = await Config.findOne({ key: 'app:installed' });
@@ -39,7 +38,6 @@ module.exports = {
   async down() {
   async down() {
     logger.info('Rollback migration');
     logger.info('Rollback migration');
     mongoose.connect(getMongoUri(), mongoOptions);
     mongoose.connect(getMongoUri(), mongoOptions);
-    const Config = getModelSafely('Config') || ConfigModel;
 
 
     const appInstalled = await Config.findOne({ key: 'app:installed' });
     const appInstalled = await Config.findOne({ key: 'app:installed' });
     if (appInstalled != null) {
     if (appInstalled != null) {

+ 2 - 0
apps/app/src/pages/_document.page.tsx

@@ -121,6 +121,8 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
         <Head>
         <Head>
           {this.renderCustomScript(customScript)}
           {this.renderCustomScript(customScript)}
           <link rel="stylesheet" key="link-theme" href={themeHref} />
           <link rel="stylesheet" key="link-theme" href={themeHref} />
+          <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+          <link rel="alternate icon" href="/favicon.ico" />
           <HeadersForGrowiPlugin pluginResourceEntries={pluginResourceEntries} />
           <HeadersForGrowiPlugin pluginResourceEntries={pluginResourceEntries} />
           {this.renderCustomCss(customCss)}
           {this.renderCustomCss(customCss)}
         </Head>
         </Head>

+ 5 - 8
apps/app/src/server/crowi/index.js

@@ -34,9 +34,10 @@ 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 { instantiateYjsConnectionManager } from '../service/yjs-connection-manager';
+import { initializeYjsService } from '../service/yjs';
 import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
 
 
 
 
@@ -301,10 +302,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() {
@@ -475,9 +473,8 @@ Crowi.prototype.start = async function() {
   // attach to socket.io
   // attach to socket.io
   this.socketIoService.attachServer(httpServer);
   this.socketIoService.attachServer(httpServer);
 
 
-  // Initialization YjsConnectionManager
-  instantiateYjsConnectionManager(this.socketIoService.io);
-  this.socketIoService.setupYjsConnection();
+  // Initialization YjsService
+  initializeYjsService(this.socketIoService.io);
 
 
   await this.autoInstall();
   await this.autoInstall();
 
 

+ 3 - 3
apps/app/src/server/models/config.ts

@@ -7,7 +7,7 @@ import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
 import { getOrCreateModel } from '../util/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
 
 
-export interface Config {
+export interface IConfig {
   _id: Types.ObjectId;
   _id: Types.ObjectId;
   ns: string;
   ns: string;
   key: string;
   key: string;
@@ -21,7 +21,7 @@ export interface Config {
 interface ModelMethods { any }
 interface ModelMethods { any }
 
 
 
 
-const schema = new Schema<Config>({
+const schema = new Schema<IConfig>({
   ns: { type: String, required: true },
   ns: { type: String, required: true },
   key: { type: String, required: true },
   key: { type: String, required: true },
   value: { type: String, required: true },
   value: { type: String, required: true },
@@ -176,4 +176,4 @@ export const defaultNotificationConfigs: { [key: string]: any } = {
   'slack:token': undefined,
   'slack:token': undefined,
 };
 };
 
 
-export default getOrCreateModel<Config, ModelMethods>('Config', schema);
+export const Config = getOrCreateModel<IConfig, ModelMethods>('Config', schema);

+ 1 - 1
apps/app/src/server/models/index.ts

@@ -5,7 +5,6 @@ export const modelsDependsOnCrowi = {
   Page,
   Page,
   PageTagRelation: require('./page-tag-relation'),
   PageTagRelation: require('./page-tag-relation'),
   User: require('./user'),
   User: require('./user'),
-  Revision: require('./revision'),
   Bookmark: require('./bookmark'),
   Bookmark: require('./bookmark'),
   GlobalNotificationSetting: GlobalNotificationSettingFactory,
   GlobalNotificationSetting: GlobalNotificationSettingFactory,
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
@@ -17,6 +16,7 @@ export const modelsDependsOnCrowi = {
 export * from './attachment';
 export * from './attachment';
 export * as Activity from './activity';
 export * as Activity from './activity';
 export * as PageRedirect from './page-redirect';
 export * as PageRedirect from './page-redirect';
+export * from './revision';
 export * as ShareLink from './share-link';
 export * as ShareLink from './share-link';
 export * as Tag from './tag';
 export * as Tag from './tag';
 export * as UserGroup from './user-group';
 export * as UserGroup from './user-group';

+ 4 - 1
apps/app/src/server/models/page.ts

@@ -67,7 +67,10 @@ export type CreateMethod = (path: string, body: string, user, options: IOptionsF
 
 
 export interface PageModel extends Model<PageDocument> {
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete static methods
   [x: string]: any; // for obsolete static methods
-  findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean): Promise<PageDocument[]>
+  findByIdAndViewer(pageId: ObjectIdLike, user, userGroups?, includeEmpty?: boolean): Promise<PageDocument & HasObjectId>
+  findByIdsAndViewer(
+    pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean,
+  ): Promise<(PageDocument & HasObjectId)[]>
   findByPath(path: string, includeEmpty?: boolean): Promise<PageDocument | null>
   findByPath(path: string, includeEmpty?: boolean): Promise<PageDocument | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<PageDocument & HasObjectId | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<PageDocument & HasObjectId | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<(PageDocument & HasObjectId)[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<(PageDocument & HasObjectId)[]>

Неке датотеке нису приказане због велике количине промена