소스 검색

Merge branch 'master' into 144136-145716-scroll-to-header-when-start-to-edit

reiji-h 1 년 전
부모
커밋
c28ea7f919
100개의 변경된 파일1069개의 추가작업 그리고 759개의 파일을 삭제
  1. 0 1
      .eslintrc.js
  2. 1 1
      .github/workflows/reusable-app-prod.yml
  3. 33 1
      CHANGELOG.md
  4. 2 0
      apps/app/config/logger/config.dev.js
  5. 1 1
      apps/app/docker/README.md
  6. 0 1
      apps/app/next-env.d.ts
  7. 7 1
      apps/app/nodemon.json
  8. 3 3
      apps/app/package.json
  9. 129 1
      apps/app/playwright/20-basic-features/access-to-page.spec.ts
  10. 28 0
      apps/app/playwright/20-basic-features/presentation.spec.ts
  11. 86 0
      apps/app/playwright/20-basic-features/use-tools.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. 7 0
      apps/app/public/images/growi-brand-logo-login.svg
  16. BIN
      apps/app/public/images/icons/favicon/android-icon-144x144.png
  17. BIN
      apps/app/public/images/icons/favicon/android-icon-192x192.png
  18. BIN
      apps/app/public/images/icons/favicon/android-icon-36x36.png
  19. BIN
      apps/app/public/images/icons/favicon/android-icon-48x48.png
  20. BIN
      apps/app/public/images/icons/favicon/android-icon-72x72.png
  21. BIN
      apps/app/public/images/icons/favicon/android-icon-96x96.png
  22. BIN
      apps/app/public/images/icons/favicon/apple-icon-114x114.png
  23. BIN
      apps/app/public/images/icons/favicon/apple-icon-120x120.png
  24. BIN
      apps/app/public/images/icons/favicon/apple-icon-144x144.png
  25. BIN
      apps/app/public/images/icons/favicon/apple-icon-152x152.png
  26. BIN
      apps/app/public/images/icons/favicon/apple-icon-180x180.png
  27. BIN
      apps/app/public/images/icons/favicon/apple-icon-57x57.png
  28. BIN
      apps/app/public/images/icons/favicon/apple-icon-60x60.png
  29. BIN
      apps/app/public/images/icons/favicon/apple-icon-72x72.png
  30. BIN
      apps/app/public/images/icons/favicon/apple-icon-76x76.png
  31. BIN
      apps/app/public/images/icons/favicon/apple-icon-precomposed.png
  32. BIN
      apps/app/public/images/icons/favicon/apple-icon.png
  33. 0 2
      apps/app/public/images/icons/favicon/browserconfig.xml
  34. BIN
      apps/app/public/images/icons/favicon/favicon-16x16.png
  35. BIN
      apps/app/public/images/icons/favicon/favicon-32x32.png
  36. BIN
      apps/app/public/images/icons/favicon/favicon-96x96.png
  37. BIN
      apps/app/public/images/icons/favicon/ms-icon-144x144.png
  38. BIN
      apps/app/public/images/icons/favicon/ms-icon-150x150.png
  39. BIN
      apps/app/public/images/icons/favicon/ms-icon-310x310.png
  40. BIN
      apps/app/public/images/icons/favicon/ms-icon-70x70.png
  41. 3 31
      apps/app/public/static/locales/en_US/translation.json
  42. 3 31
      apps/app/public/static/locales/fr_FR/translation.json
  43. 3 31
      apps/app/public/static/locales/ja_JP/translation.json
  44. 3 31
      apps/app/public/static/locales/zh_CN/translation.json
  45. 29 31
      apps/app/src/client/components/Admin/ExportArchiveData/ArchiveFilesTable.tsx
  46. 3 3
      apps/app/src/client/components/Admin/ExportArchiveData/ArchiveFilesTableMenu.tsx
  47. 12 4
      apps/app/src/client/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx
  48. 1 1
      apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx
  49. 1 1
      apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.jsx
  50. 2 2
      apps/app/src/client/components/Navbar/PageEditorModeManager.tsx
  51. 3 1
      apps/app/src/client/components/PageComment/DeleteCommentModal.tsx
  52. 11 5
      apps/app/src/client/components/PageCreateModal.tsx
  53. 2 2
      apps/app/src/client/components/PageHeader/PageTitleHeader.tsx
  54. 4 4
      apps/app/src/client/components/PageHistory/PageRevisionTable.tsx
  55. 30 7
      apps/app/src/client/components/PageHistory/RevisionDiff.tsx
  56. 3 1
      apps/app/src/client/components/PagePresentationModal.tsx
  57. 1 1
      apps/app/src/client/components/PageTags/TagEditModal.tsx
  58. 4 4
      apps/app/src/client/components/RevisionComparer/RevisionComparer.tsx
  59. 9 1
      apps/app/src/client/components/TemplateModal/TemplateModal.tsx
  60. 8 24
      apps/app/src/client/services/renderer/renderer.tsx
  61. 7 15
      apps/app/src/client/services/side-effects/yjs.ts
  62. 1 1
      apps/app/src/components/Layout/NoLoginLayout.module.scss
  63. 3 1
      apps/app/src/components/Layout/NoLoginLayout.tsx
  64. 31 0
      apps/app/src/components/PageView/PageAlerts/FullTextSearchNotCoverAlert.tsx
  65. 2 2
      apps/app/src/components/PageView/PageAlerts/PageAlerts.tsx
  66. 0 19
      apps/app/src/features/comment/server/models/comment.ts
  67. 11 1
      apps/app/src/features/rate-limiter/config/index.ts
  68. 2 1
      apps/app/src/features/rate-limiter/utils/config-generator.ts
  69. 1 1
      apps/app/src/interfaces/websocket.ts
  70. 1 1
      apps/app/src/interfaces/yjs.ts
  71. 1 2
      apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js
  72. 4 0
      apps/app/src/pages/[[...path]].page.tsx
  73. 23 2
      apps/app/src/pages/_document.page.tsx
  74. 11 6
      apps/app/src/pages/utils/commons.ts
  75. 3 4
      apps/app/src/server/crowi/index.js
  76. 1 19
      apps/app/src/server/models/activity.ts
  77. 1 1
      apps/app/src/server/models/index.ts
  78. 0 9
      apps/app/src/server/models/obsolete-page.js
  79. 6 9
      apps/app/src/server/models/page-tag-relation.ts
  80. 4 1
      apps/app/src/server/models/page.ts
  81. 0 71
      apps/app/src/server/models/revision.js
  82. 82 0
      apps/app/src/server/models/revision.ts
  83. 1 0
      apps/app/src/server/models/tag.ts
  84. 6 4
      apps/app/src/server/routes/apiv3/page/index.ts
  85. 3 3
      apps/app/src/server/routes/apiv3/page/update-page.ts
  86. 1 1
      apps/app/src/server/routes/apiv3/revisions.js
  87. 6 5
      apps/app/src/server/routes/apiv3/staffs.js
  88. 1 1
      apps/app/src/server/routes/tag.js
  89. 6 0
      apps/app/src/server/service/config-loader.ts
  90. 1 1
      apps/app/src/server/service/import.js
  91. 12 38
      apps/app/src/server/service/page/index.ts
  92. 139 0
      apps/app/src/server/service/search-delegator/aggregate-to-index.ts
  93. 52 0
      apps/app/src/server/service/search-delegator/bulk-write.d.ts
  94. 33 135
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  95. 2 1
      apps/app/src/server/service/slack-command-handler/keep.js
  96. 3 2
      apps/app/src/server/service/slack-command-handler/togetter.js
  97. 27 54
      apps/app/src/server/service/socket-io.ts
  98. 0 120
      apps/app/src/server/service/yjs-connection-manager.ts
  99. 43 0
      apps/app/src/server/service/yjs/create-indexes.ts
  100. 55 0
      apps/app/src/server/service/yjs/create-mongodb-persistence.ts

+ 0 - 1
.eslintrc.js

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

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

@@ -213,7 +213,7 @@ jobs:
       fail-fast: false
       matrix:
         # 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:
       mongodb:

+ 33 - 1
CHANGELOG.md

@@ -1,9 +1,41 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.11...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.0.12...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [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
 
 ### 💎 Features

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

@@ -17,6 +17,8 @@ module.exports = {
   'growi:middleware:safe-redirect': 'debug',
   'growi:service:PassportService': 'debug',
   'growi:service:s2s-messaging:*': 'debug',
+  'growi:service:yjs': 'debug',
+  'growi:service:yjs:*': 'debug',
   // 'growi:service:socket-io': 'debug',
   // 'growi:service:ConfigManager': '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
 ------------------------------------------------
 
-* [`7.0.11`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.11/apps/app/docker/Dockerfile)
+* [`7.0.12`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.12/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.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/image-types/global" />
-/// <reference types="next/navigation-types/compat/navigation" />
 
 // NOTE: This file should not be edited
 // see https://nextjs.org/docs/basic-features/typescript for more information.

+ 7 - 1
apps/app/nodemon.json

@@ -4,6 +4,12 @@
     ".next",
     "public/static",
     "package.json",
-    "playwright"
+    "playwright",
+    "src/client",
+    "src/**/client",
+    "test",
+    "test-with-vite",
+    "tmp",
+    "*.mongodb.js"
   ]
 }

+ 3 - 3
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.12-RC.0",
+  "version": "7.0.13-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -209,9 +209,9 @@
     "validator": "^13.7.0",
     "ws": "^8.17.1",
     "xss": "^1.0.14",
-    "y-mongodb-provider": "^0.1.10",
+    "y-mongodb-provider": "^0.2.0",
     "y-socket.io": "^1.1.3",
-    "yjs": "^13.6.15"
+    "yjs": "^13.6.18"
   },
   "// comments for defDependencies": {
     "bootstrap": "v5.3.3 has a bug. refs: https://github.com/twbs/bootstrap/issues/39798",

+ 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 }) => {
   await page.goto('/Sandbox');
@@ -14,9 +19,132 @@ test('get h1', async({ page }) => {
   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 }) => {
   await page.goto('/me');
 
   // Expect the UserSettgins-specific elements to be present when accessing /me (UserSettgins)
   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);
+  });
+});

+ 28 - 0
apps/app/playwright/20-basic-features/presentation.spec.ts

@@ -0,0 +1,28 @@
+import { test, expect } from '@playwright/test';
+
+test('Presentation', async({ page }) => {
+  await page.goto('/');
+
+  // show presentation modal
+  await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+  await page.getByTestId('open-presentation-modal-btn').click();
+
+  // check the content of the h1
+  await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
+    .toHaveText(/Welcome to GROWI/);
+
+  // forward the slide with keyboard
+  await page.keyboard.press('ArrowRight');
+
+  // check the content of the h1
+  await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
+    .toHaveText(/What can you do with GROWI?/);
+
+  // forward the slide with button
+  await page.getByRole('application').getByLabel('next slide').click();
+
+  // check the content of the h2
+  await expect(page.getByRole('application').getByRole('heading', { level: 2 }))
+    .toHaveText(/1. Knowledge Management: Create pages to store information and knowledge/);
+
+});

+ 86 - 0
apps/app/playwright/20-basic-features/use-tools.spec.ts

@@ -0,0 +1,86 @@
+import { test, expect, type Page } from '@playwright/test';
+
+const openPageItemControl = async(page: Page): Promise<void> => {
+  await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
+  await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+};
+
+test('Page Deletion and PutBack is executed successfully', async({ page }) => {
+  await page.goto('/Sandbox/Bootstrap5');
+
+  // Delete
+  await openPageItemControl(page);
+  await page.getByTestId('open-page-delete-modal-btn').click();
+  await expect(page.getByTestId('page-delete-modal')).toBeVisible();
+  await page.getByTestId('delete-page-button').click();
+
+  // PutBack
+  await expect(page.getByTestId('trash-page-alert')).toBeVisible();
+  await page.getByTestId('put-back-button').click();
+  await expect(page.getByTestId('put-back-page-modal')).toBeVisible();
+  await page.getByTestId('put-back-execution-button').click();
+  await expect(page.getByTestId('trash-page-alert')).not.toBeVisible();
+});
+
+test('PageDuplicateModal is shown successfully', async({ page }) => {
+  await page.goto('/Sandbox');
+
+  await openPageItemControl(page);
+  await page.getByTestId('open-page-duplicate-modal-btn').click();
+
+  await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
+});
+
+test('PageMoveRenameModal is shown successfully', async({ page }) => {
+  await page.goto('/Sandbox');
+
+  await openPageItemControl(page);
+  await page.getByTestId('rename-page-btn').click();
+
+  await expect(page.getByTestId('page-rename-modal')).toBeVisible();
+});
+
+// TODO: Uncomment after https://redmine.weseek.co.jp/issues/149786
+// test('PresentationModal for "/" is shown successfully', async({ page }) => {
+//   await page.goto('/');
+
+//   await openPageItemControl(page);
+//   await page.getByTestId('open-presentation-modal-btn').click();
+
+//   expect(page.getByTestId('page-presentation-modal')).toBeVisible();
+// });
+
+test.describe('Page Accessories Modal', () => {
+  test.beforeEach(async({ page }) => {
+    await page.goto('/');
+    await openPageItemControl(page);
+  });
+
+  test('Page History is shown successfully', async({ page }) => {
+    await page.getByTestId('open-page-accessories-modal-btn-with-history-tab').click();
+    await expect(page.getByTestId(('page-history'))).toBeVisible();
+  });
+
+  test('Page Attachment Data is shown successfully', async({ page }) => {
+    await page.getByTestId('open-page-accessories-modal-btn-with-attachment-data-tab').click();
+    await expect(page.getByTestId('page-attachment')).toBeVisible();
+  });
+
+  test('Share Link Management is shown successfully', async({ page }) => {
+    await page.getByTestId('open-page-accessories-modal-btn-with-share-link-management-data-tab').click();
+    await expect(page.getByTestId('share-link-management')).toBeVisible();
+  });
+});
+
+test('Successfully add new tag', async({ page }) => {
+  const tag = 'we';
+  await page.goto('/Sandbox/Bootstrap5');
+
+  await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
+  await expect(page.locator('#edit-tag-modal')).toBeVisible();
+  await page.locator('.rbt-input-main').fill(tag);
+  await expect(page.locator('#tag-typeahead-asynctypeahead-item-0')).toBeVisible();
+  await page.locator('#tag-typeahead-asynctypeahead-item-0').click();
+  await page.getByTestId('tag-edit-done-btn').click();
+  await expect(page.getByTestId('grw-tag-labels')).toContainText(tag);
+});

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

+ 7 - 0
apps/app/public/images/growi-brand-logo-login.svg

@@ -0,0 +1,7 @@
+<svg viewBox="0 0 353 78" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M352.62 2.32007H338V75.4701H352.62V2.32007Z" fill="white"/>
+<path d="M293.71 75.4701L279.67 28.4501L265.56 75.4701H251.85L230.46 2.32007H245.76L258.94 50.6901L272.85 5.33007H286.81L300.39 50.4801L313.26 2.32007H328.81L308.26 75.4701H293.71Z" fill="white"/>
+<path d="M186.671 77.28C181.341 77.28 176.351 76.33 171.831 74.45C167.301 72.57 163.331 69.89 160.041 66.5C156.741 63.11 154.131 59.01 152.271 54.31C150.411 49.62 149.471 44.37 149.471 38.7C149.471 33.03 150.411 27.58 152.271 22.89C154.131 18.19 156.751 14.14 160.051 10.84C163.351 7.55001 167.321 4.97001 171.851 3.19001C176.371 1.41001 181.361 0.51001 186.681 0.51001C192.001 0.51001 197.101 1.41001 201.651 3.19001C206.221 4.97001 210.221 7.55001 213.541 10.84C216.881 14.14 219.511 18.19 221.371 22.89C223.231 27.58 224.171 32.9 224.171 38.7C224.171 44.5 223.231 49.62 221.371 54.31C219.511 59 216.881 63.1 213.551 66.5C210.221 69.89 206.221 72.57 201.661 74.45C197.101 76.33 192.061 77.28 186.681 77.28H186.671ZM186.761 13.62C183.401 13.62 180.321 14.27 177.621 15.54C174.911 16.82 172.541 18.61 170.601 20.84C168.651 23.09 167.121 25.75 166.051 28.77C164.981 31.8 164.431 35.13 164.431 38.69C164.431 42.25 164.971 45.8 166.051 48.86C167.121 51.91 168.661 54.59 170.651 56.83C172.631 59.07 175.001 60.83 177.711 62.08C180.421 63.33 183.461 63.96 186.761 63.96C190.061 63.96 193.131 63.33 195.871 62.08C198.611 60.83 201.001 59.07 202.981 56.83C204.961 54.59 206.511 51.91 207.571 48.86C208.641 45.79 209.191 42.37 209.191 38.69C209.191 35.01 208.641 31.81 207.571 28.77C206.501 25.75 204.971 23.09 203.021 20.84C201.081 18.61 198.701 16.82 195.961 15.54C193.221 14.26 190.131 13.61 186.771 13.61L186.761 13.62Z" fill="white"/>
+<path d="M123 75.4701L107.33 48.3101H98.8104V75.4701H84.1904V2.32007H109.9C113.31 2.32007 116.66 2.68007 119.87 3.40007C123.12 4.13007 126.05 5.34007 128.59 7.01007C131.16 8.70007 133.24 10.9401 134.79 13.6801C136.34 16.4301 137.13 21.5601 137.13 25.4701C137.13 32.6801 132.83 41.7601 122.21 46.3201L139.04 75.4801H123.01L123 75.4701ZM108.2 36.0101C109.96 36.0101 111.74 35.8801 113.49 35.6201C115.18 35.3701 116.75 34.9401 118.07 34.2101C120.83 32.6801 122.52 28.4301 122.52 25.4801C122.52 22.5301 121.06 19.0801 118.76 17.2001C117.09 15.8301 114.48 14.9401 109.31 14.9401H98.8204V36.0101H108.2Z" fill="white"/>
+<path d="M40.0404 77.28C34.1804 77.28 28.7504 76.33 23.9004 74.45C19.0404 72.57 14.8004 69.89 11.3104 66.5C7.81039 63.11 5.05039 59 3.09039 54.31C1.14039 49.62 0.150391 44.37 0.150391 38.7C0.150391 33.03 1.17039 27.57 3.20039 22.88C5.22039 18.18 8.04039 14.13 11.5704 10.84C15.1004 7.55002 19.3204 4.98002 24.1104 3.20002C28.8904 1.42002 34.1504 0.52002 39.7304 0.52002C45.3104 0.52002 50.8804 1.37002 55.6904 3.04002C60.5204 4.72002 64.4804 6.97002 67.4504 9.73002L68.1204 10.35L60.4204 23.7C58.7404 21 53.1704 17.01 50.0604 15.66C46.9504 14.31 43.5104 13.63 39.8404 13.63C36.1704 13.63 32.9404 14.28 30.0304 15.56C27.1204 16.84 24.6004 18.62 22.5604 20.86C20.5104 23.1 18.9004 25.77 17.7804 28.79C16.6504 31.82 16.0804 35.15 16.0804 38.71C16.0804 42.27 16.6504 45.82 17.7804 48.88C18.9004 51.93 20.5304 54.61 22.6104 56.85C24.6804 59.08 27.2404 60.85 30.2204 62.09C33.2004 63.34 36.6404 63.98 40.4504 63.98C45.4604 63.98 49.7604 63.29 53.5104 61.83V48.33H46.1004L38.9304 36.02H68.1204V71.4C64.4504 73.17 59.5604 74.83 55.1804 75.81C50.8004 76.79 45.7004 77.29 40.0304 77.29L40.0404 77.28Z" fill="white"/>
+</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


+ 3 - 31
apps/app/public/static/locales/en_US/translation.json

@@ -321,7 +321,9 @@
       "stale": "More than {{count}} year has passed since last update.",
       "stale_plural": "More than {{count}} years has passed since last update.",
       "expiration": "This share link will expire at <strong>{{expiredAt}}</strong>.",
-      "no_deadline": "This page has no expiration date"
+      "no_deadline": "This page has no expiration date",
+      "not_indexed1": "This page may not be indexed by Full-Text search engines.",
+      "not_indexed2": "Page body exceeds the threshold specified by {{threshold}}."
     }
   },
   "page_edit": {
@@ -707,36 +709,6 @@
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match",
     "please_enable_mailer_alert": "The password reset feature is disabled because email setup has not been completed. Please ask administrator to complete the email setup."
   },
-  "emoji": {
-    "title": "Pick an Emoji",
-    "search": "Search",
-    "clear": "Clear",
-    "notfound": "No Emoji Found",
-    "skintext": "Choose your default skin tone",
-    "categories": {
-      "search": "Search Results",
-      "recent": "Frequently Used",
-      "smileys": "Smileys & Emotion",
-      "people": "People & Body",
-      "nature": "Animals & Nature",
-      "foods": "Food & Drink",
-      "activity": "Activity",
-      "places": "Travel & Places",
-      "objects": "Objects",
-      "symbols": "Symbols",
-      "flags": "Flags",
-      "custom": "Custom"
-    },
-    "categorieslabel": "Emoji categories",
-    "skintones": {
-      "1": "Default Skin Tone",
-      "2": "Light Skin Tone",
-      "3": "Medium-Light Skin Tone",
-      "4": "Medium Skin Tone",
-      "5": "Medium-Dark Skin Tone",
-      "6": "Dark Skin Tone"
-    }
-  },
   "maintenance_mode": {
     "maintenance_mode": "Maintenance Mode",
     "growi_is_under_maintenance": "GROWI is under maintenance. Please wait until it ends.",

+ 3 - 31
apps/app/public/static/locales/fr_FR/translation.json

@@ -321,7 +321,9 @@
       "stale": "Plus de {{count}} an est passé depuis la dernière mise à jour.",
       "stale_plural": "Plus de {{count}} années sont passées depuis la dernière mise à jour.",
       "expiration": "Ce lien expirera <strong>{{expiredAt}}</strong>.",
-      "no_deadline": "Cette page n'a pas de date d'expiration"
+      "no_deadline": "Cette page n'a pas de date d'expiration",
+      "not_indexed1": "Cette page n'est peut-être pas indexée par les moteurs de recherche Full-Text.",
+      "not_indexed2": "Le corps de la page dépasse le seuil spécifié par {{threshold}}."
     }
   },
   "page_edit": {
@@ -701,36 +703,6 @@
     "password_and_confirm_password_does_not_match": "Le mot de passe ne correspond pas",
     "please_enable_mailer_alert": "La réinitialisation de mot de passe est désactivée, car la configuration d'envois de courriels est incomplète."
   },
-  "emoji": {
-    "title": "Choisir un émoji",
-    "search": "Rechercher",
-    "clear": "Vider",
-    "notfound": "Aucun émoji trouvé",
-    "skintext": "Choisir le teint par défaut",
-    "categories": {
-      "search": "Résultats de recherche",
-      "recent": "Récents",
-      "smileys": "Émotions",
-      "people": "Individus & corps",
-      "nature": "Animaux & nature",
-      "foods": "Nourriture & boisson",
-      "activity": "Activités",
-      "places": "Voyage",
-      "objects": "Objets",
-      "symbols": "Symboles",
-      "flags": "Drapeaux",
-      "custom": "Personnalisé"
-    },
-    "categorieslabel": "Catégories d'émojis",
-    "skintones": {
-      "1": "Teint par défaut",
-      "2": "Teint clair",
-      "3": "Teint moyen-clair",
-      "4": "Teint moyen",
-      "5": "Teint moyen-foncé",
-      "6": "Teint foncé"
-    }
-  },
   "maintenance_mode": {
     "maintenance_mode": "Mode maintenance",
     "growi_is_under_maintenance": "GROWI est actuellement en maintenance.",

+ 3 - 31
apps/app/public/static/locales/ja_JP/translation.json

@@ -354,7 +354,9 @@
       "restricted": "このページの閲覧は制限されています",
       "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
       "expiration": "この共有パーマリンクの有効期限は <strong>{{expiredAt}}</strong> です。",
-      "no_deadline": "このページに有効期限は設定されていません。"
+      "no_deadline": "このページに有効期限は設定されていません。",
+      "not_indexed1": "このページは全文検索エンジンにインデックスされない可能性があります.",
+      "not_indexed2": "ページ本文が閾値を超えています: {{threshold}}."
     }
   },
   "page_edit": {
@@ -740,36 +742,6 @@
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません",
     "please_enable_mailer_alert": "メール設定が完了していないため、パスワード再設定機能が無効になっています。メール設定を完了させるよう管理者に依頼してください。"
   },
-  "emoji": {
-    "title": "絵文字を選択",
-    "search": "探す",
-    "clear": "リセット",
-    "notfound": "絵文字が見つかりません",
-    "skintext": "デフォルトの肌の色を選択",
-    "categories": {
-      "search": "検索結果",
-      "recent": "最新履歴",
-      "smileys": "スマイリーと感情",
-      "people": "人と体",
-      "nature": "動物と自然",
-      "foods": "食べ物や飲み物",
-      "activity": "アクティビティ",
-      "places": "旅行と場所",
-      "objects": "オブジェクト",
-      "symbols": "シンボル",
-      "flags": "国旗",
-      "custom": "カスタマイズ"
-    },
-    "categorieslabel": "絵文字カテゴリ",
-    "skintones": {
-      "1": "デフォルトの肌の色",
-      "2": "明るい肌のトーン",
-      "3": "ミディアム-明るい肌のトーン",
-      "4": "ミディアムスキントーン",
-      "5": "ミディアムダークスキントーン",
-      "6": "肌の色が濃い"
-    }
-  },
   "maintenance_mode": {
     "maintenance_mode": "メンテナンスモード",
     "growi_is_under_maintenance": "GROWI はメンテナンス中です。終了するまでお待ちください",

+ 3 - 31
apps/app/public/static/locales/zh_CN/translation.json

@@ -311,7 +311,9 @@
       "restricted": "访问此页受到限制",
       "stale": "自上次更新以来,已超过{{count}年。",
       "stale_plural": "自上次更新以来已过去{{count}年以上。",
-      "no_deadline": "This page has no expiration date"
+      "no_deadline": "此页面没有到期日期",
+      "not_indexed1": "此页面可能不会被全文搜索引擎索引。",
+      "not_indexed2": "页面正文超过了{{threshold}}指定的阈值。"
 		}
 	},
 	"page_edit": {
@@ -710,36 +712,6 @@
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配",
     "please_enable_mailer_alert": "密码重置功能被禁用,因为电子邮件设置尚未完成。请要求管理员完成电子邮件的设置。"
   },
-  "emoji": {
-    "title": "选择一个表情符号",
-    "search": "搜索",
-    "clear": "重置",
-    "notfound": "找不到表情符号",
-    "skintext": "选择您的默认肤色",
-    "categories": {
-      "search": "搜索结果",
-      "recent": "经常使用",
-      "smileys": "笑脸和情感",
-      "people": "人和身体",
-      "nature": "动物与自然",
-      "foods": "食物和饮料",
-      "activity": "活动",
-      "places": "旅行和地方",
-      "objects": "对象",
-      "symbols": "符号",
-      "flags": "旗帜",
-      "custom": "定制"
-    },
-    "categorieslabel": "表情符号类别",
-    "skintones": {
-      "1": "默认肤色",
-      "2": "浅肤色",
-      "3": "中浅肤色",
-      "4": "中等肤色",
-      "5": "中深肤色",
-      "6": "深色肤色"
-    }
-  },
   "maintenance_mode": {
     "maintenance_mode": "维护模式",
     "growi_is_under_maintenance": "GROWI正在进行维护。请等待,直到它结束。",

+ 29 - 31
apps/app/src/client/components/Admin/ExportArchiveData/ArchiveFilesTable.tsx

@@ -14,37 +14,35 @@ const ArchiveFilesTable = (props: ArchiveFilesTableProps): JSX.Element => {
   const { t } = useTranslation();
 
   return (
-    <div className="table-responsive">
-      <table className="table table-bordered">
-        <thead>
-          <tr>
-            <th>{t('admin:export_management.file')}</th>
-            <th>{t('admin:export_management.growi_version')}</th>
-            <th>{t('admin:export_management.collections')}</th>
-            <th>{t('admin:export_management.exported_at')}</th>
-            <th></th>
-          </tr>
-        </thead>
-        <tbody>
-          {props.zipFileStats.map(({ meta, fileName, innerFileStats }) => {
-            return (
-              <tr key={fileName}>
-                <th>{fileName}</th>
-                <td>{meta.version}</td>
-                <td className="text-capitalize">{innerFileStats.map(fileStat => fileStat.collectionName).join(', ')}</td>
-                <td>{meta.exportedAt ? format(new Date(meta.exportedAt), 'yyyy/MM/dd HH:mm:ss') : ''}</td>
-                <td>
-                  <ArchiveFilesTableMenu
-                    fileName={fileName}
-                    onZipFileStatRemove={props.onZipFileStatRemove}
-                  />
-                </td>
-              </tr>
-            );
-          })}
-        </tbody>
-      </table>
-    </div>
+    <table className="table table-bordered">
+      <thead>
+        <tr>
+          <th>{t('admin:export_management.file')}</th>
+          <th>{t('admin:export_management.growi_version')}</th>
+          <th>{t('admin:export_management.collections')}</th>
+          <th>{t('admin:export_management.exported_at')}</th>
+          <th></th>
+        </tr>
+      </thead>
+      <tbody>
+        {props.zipFileStats.map(({ meta, fileName, innerFileStats }) => {
+          return (
+            <tr key={fileName}>
+              <th>{fileName}</th>
+              <td>{meta.version}</td>
+              <td className="text-capitalize">{innerFileStats.map(fileStat => fileStat.collectionName).join(', ')}</td>
+              <td>{meta.exportedAt ? format(new Date(meta.exportedAt), 'yyyy/MM/dd HH:mm:ss') : ''}</td>
+              <td>
+                <ArchiveFilesTableMenu
+                  fileName={fileName}
+                  onZipFileStatRemove={props.onZipFileStatRemove}
+                />
+              </td>
+            </tr>
+          );
+        })}
+      </tbody>
+    </table>
   );
 };
 

+ 3 - 3
apps/app/src/client/components/Admin/ExportArchiveData/ArchiveFilesTableMenu.tsx

@@ -13,11 +13,11 @@ const ArchiveFilesTableMenu = (props: ArchiveFilesTableMenuProps):JSX.Element =>
   const { t } = useTranslation();
 
   return (
-    <div className="btn-group admin-user-menu dropdown">
-      <button type="button" className="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown">
+    <div className="dropdown">
+      <button type="button" className="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
         <span className="material-symbols-outlined">settings</span> <span className="caret"></span>
       </button>
-      <ul className="dropdown-menu" role="menu">
+      <ul className="dropdown-menu dropdown-menu-end">
         <li className="dropdown-header">{t('admin:export_management.export_menu')}</li>
         <button type="button" className="dropdown-item" onClick={() => { window.location.href = `/admin/export/${props.fileName}` }}>
           <span className="material-symbols-outlined">cloud_download</span> {t('admin:export_management.download')}

+ 12 - 4
apps/app/src/client/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx

@@ -14,11 +14,13 @@ const GROUPS_PAGE = [
 ];
 const GROUPS_USER = [
   'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
-  'useruisettings', 'editorsettings', 'bookmarks', 'subscriptions',
+  'externalusergroups', 'externalusergrouprelations',
+  'useruisettings', 'editorsettings', 'bookmarks', 'bookmarkfolders', 'subscriptions',
   'inappnotificationsettings',
 ];
 const GROUPS_CONFIG = [
-  'configs', 'updateposts', 'globalnotificationsettings', 'slackappintegrations',
+  'configs', 'migrations', 'updateposts', 'globalnotificationsettings', 'slackappintegrations',
+  'growiplugins',
 ];
 const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
 
@@ -102,8 +104,14 @@ const SelectCollectionsModal = (props: Props): JSX.Element => {
 
     const html = t('admin:export_management.desc_password_seed');
 
-    // eslint-disable-next-line react/no-danger
-    return <div className="card custom-card bg-body-tertiary" dangerouslySetInnerHTML={{ __html: html }}></div>;
+    return (
+      <div className="card">
+        <div className="card-body">
+          {/* eslint-disable-next-line react/no-danger */}
+          <p className="card-text" dangerouslySetInnerHTML={{ __html: html }} />
+        </div>
+      </div>
+    );
   }, [selectedCollections, t]);
 
   const renderCheckboxes = useCallback((collectionNames, color?) => {

+ 1 - 1
apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx

@@ -14,7 +14,7 @@ import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
 
 
 const IGNORED_COLLECTION_NAMES = [
-  'sessions', 'rlflx', 'activities',
+  'sessions', 'rlflx', 'activities', 'yjs-writings', 'transferkeys',
 ];
 
 const ExportArchiveDataPage = (): JSX.Element => {

+ 1 - 1
apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.jsx

@@ -21,7 +21,7 @@ const DropdownItemContents = ({
 }) => (
   <>
     <div className="h6 mt-1 mb-2"><strong>{title}</strong></div>
-    <div className={`card custom-card mb-1 p-2 ${className}`} style={style}>{contents}</div>
+    <div className={`card mb-1 p-2 ${className}`} style={style}>{contents}</div>
   </>
 );
 /* eslint-enable react/prop-types */

+ 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 circleColor = useMemo(() => {
-    if (currentPageYjsData?.awarenessStateSize != null && currentPageYjsData.awarenessStateSize > 0) {
+    if ((currentPageYjsData?.awarenessStateSize ?? 0) > 0) {
       return 'bg-primary';
     }
 
-    if (currentPageYjsData?.hasRevisionBodyDiff != null && currentPageYjsData.hasRevisionBodyDiff) {
+    if (currentPageYjsData?.hasYdocsNewerThanLatestRevision ?? false) {
       return 'bg-secondary';
     }
   }, [currentPageYjsData]);

+ 3 - 1
apps/app/src/client/components/PageComment/DeleteCommentModal.tsx

@@ -62,7 +62,9 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
     return (
       <>
         <UserPicture user={creator} size="xs" /> <strong className="me-2"><Username user={creator}></Username></strong>{commentDate}:
-        <p className="card custom-card comment-body mt-2 p-2">{commentBodyElement}</p>
+        <div className="card mt-2">
+          <div className="card-body comment-body px-3 py-2">{commentBodyElement}</div>
+        </div>
       </>
     );
   };

+ 11 - 5
apps/app/src/client/components/PageCreateModal.tsx

@@ -2,8 +2,12 @@ import React, {
   useEffect, useState, useMemo, useCallback,
 } from 'react';
 
+import path from 'path';
+
+
 import { Origin } from '@growi/core';
 import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
+import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import { format } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import {
@@ -33,14 +37,13 @@ const PageCreateModal: React.FC = () => {
 
   const { data: pageCreateModalData, close: closeCreateModal } = usePageCreateModal();
 
-  const path = pageCreateModalData?.path;
   const isOpened = pageCreateModalData?.isOpened ?? false;
 
   const { create } = useCreatePage();
   const { createTemplate } = useCreateTemplatePage();
 
   const { data: isReachable } = useIsSearchServiceReachable();
-  const pathname = path || '';
+  const pathname = pageCreateModalData?.path ?? '';
   const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
   const isCreatable = isCreatablePage(pathname) || isUsersHomepage(pathname);
   const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
@@ -106,16 +109,19 @@ const PageCreateModal: React.FC = () => {
    * access input page
    */
   const createInputPage = useCallback(async() => {
+    const targetPath = normalizePath(pageNameInput);
+    const parentPath = path.dirname(targetPath);
+
     return create(
       {
-        path: pageNameInput,
-        parentPath: pathname,
+        path: targetPath,
+        parentPath,
         wip: true,
         origin: Origin.View,
       },
       { onTerminated: closeCreateModal },
     );
-  }, [closeCreateModal, create, pageNameInput, pathname]);
+  }, [closeCreateModal, create, pageNameInput]);
 
   /**
    * access template page

+ 2 - 2
apps/app/src/client/components/PageHeader/PageTitleHeader.tsx

@@ -94,8 +94,8 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
 
   useEffect(() => {
     setEditedPagePath(currentPagePath);
-    if (isUntitledPage && editorMode === EditorMode.Editor) {
-      setRenameInputShown(true);
+    if (isUntitledPage != null) {
+      setRenameInputShown(isUntitledPage && editorMode === EditorMode.Editor);
     }
   }, [currentPage._id, currentPagePath, editorMode, isUntitledPage]);
 

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

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

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

@@ -1,7 +1,9 @@
 import { useMemo } from 'react';
 
-import type { IRevisionHasPageId } from '@growi/core';
+import type { IRevisionHasId } from '@growi/core';
+import { GrowiThemeSchemeType } from '@growi/core';
 import { returnPathForURL } from '@growi/core/dist/utils/path-utils';
+import { PresetThemesMetadatas } from '@growi/preset-themes';
 import { createPatch } from 'diff';
 import type { Diff2HtmlConfig } from 'diff2html';
 import { html } from 'diff2html';
@@ -10,17 +12,22 @@ import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import urljoin from 'url-join';
 
+
 import { Themes, useNextThemes } from '~/stores-universal/use-next-themes';
 
 import UserDate from '../../../components/User/UserDate';
+import { useSWRxGrowiThemeSetting } from '../../../stores/admin/customize';
+
 
 import styles from './RevisionDiff.module.scss';
 
 import 'diff2html/bundles/css/diff2html.min.css';
 
+const moduleClass = styles['revision-diff-container'];
+
 type RevisioinDiffProps = {
-  currentRevision: IRevisionHasPageId,
-  previousRevision: IRevisionHasPageId,
+  currentRevision: IRevisionHasId,
+  previousRevision: IRevisionHasId,
   revisionDiffOpened: boolean,
   currentPageId: string,
   currentPagePath: string,
@@ -34,10 +41,26 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
     currentRevision, previousRevision, revisionDiffOpened, currentPageId, currentPagePath, onClose,
   } = props;
 
-  const { theme } = useNextThemes();
+  const { theme: userTheme } = useNextThemes();
+  const { data: growiTheme } = useSWRxGrowiThemeSetting();
 
   const colorScheme: ColorSchemeType = useMemo(() => {
-    switch (theme) {
+    if (growiTheme == null) {
+      return ColorSchemeType.AUTO;
+    }
+
+    const growiThemeSchemeType = growiTheme.pluginThemesMetadatas[0]?.schemeType
+        ?? PresetThemesMetadatas.find(theme => theme.name === growiTheme.currentTheme)?.schemeType;
+
+    switch (growiThemeSchemeType) {
+      case GrowiThemeSchemeType.DARK:
+        return ColorSchemeType.DARK;
+      case GrowiThemeSchemeType.LIGHT:
+        return ColorSchemeType.LIGHT;
+      default:
+        // growiThemeSchemeType === GrowiThemeSchemeType.BOTH
+    }
+    switch (userTheme) {
       case Themes.DARK:
         return ColorSchemeType.DARK;
       case Themes.LIGHT:
@@ -45,7 +68,7 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
       default:
         return ColorSchemeType.AUTO;
     }
-  }, [theme]);
+  }, [growiTheme, userTheme]);
 
   const previousText = (currentRevision._id === previousRevision._id) ? '' : previousRevision.body;
 
@@ -66,7 +89,7 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
   const diffView = { __html: diffViewHTML };
 
   return (
-    <div className={`${styles['revision-diff-container']}`}>
+    <div className={moduleClass}>
       <div className="container">
         <div className="row mt-2">
           <div className="col px-0 py-2">

+ 3 - 1
apps/app/src/client/components/PagePresentationModal.tsx

@@ -19,6 +19,8 @@ import { usePresentationViewOptions } from '~/stores/renderer';
 
 import styles from './PagePresentationModal.module.scss';
 
+const moduleClass = styles['grw-presentation-modal'] ?? '';
+
 
 const Presentation = dynamic<PresentationProps>(() => import('./Presentation/Presentation').then(mod => mod.Presentation), {
   ssr: false,
@@ -71,7 +73,7 @@ const PagePresentationModal = (): JSX.Element => {
       isOpen={isOpen}
       toggle={closeHandler}
       data-testid="page-presentation-modal"
-      className={`grw-presentation-modal ${styles['grw-presentation-modal']}`}
+      className={moduleClass}
     >
       <div className="grw-presentation-controls d-flex">
         <button

+ 1 - 1
apps/app/src/client/components/PageTags/TagEditModal.tsx

@@ -59,7 +59,7 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
         <TagsInput tags={initTags} onTagsUpdated={tags => setTags(tags)} autoFocus />
       </ModalBody>
       <ModalFooter>
-        <button type="button" className="btn btn-primary" onClick={handleSubmit}>
+        <button type="button" data-testid="tag-edit-done-btn" className="btn btn-primary" onClick={handleSubmit}>
           {t('tag_edit_modal.done')}
         </button>
       </ModalFooter>

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

@@ -1,6 +1,6 @@
 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 { useTranslation } from 'next-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
@@ -17,13 +17,13 @@ const { encodeSpaces } = pagePathUtils;
 const DropdownItemContents = ({ title, contents }) => (
   <>
     <div className="h6 mt-1 mb-2"><strong>{title}</strong></div>
-    <div className="card custom-card mb-1 p-2">{contents}</div>
+    <div className="card mb-1 p-2">{contents}</div>
   </>
 );
 
 type RevisionComparerProps = {
-  sourceRevision: IRevisionHasPageId
-  targetRevision: IRevisionHasPageId
+  sourceRevision: IRevisionHasId
+  targetRevision: IRevisionHasId
   currentPageId?: string
   currentPagePath: string
   onClose: () => void

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

@@ -247,13 +247,21 @@ const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element
               </div>
               <div className="col-6 d-flex justify-content-end">
                 <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>
                   </DropdownToggle>
                   <DropdownMenu className="dropdown-menu" role="menu">
                     { selectedTemplateLocales != null && Array.from(selectedTemplateLocales).map((locale) => {
                       return (
                         <DropdownItem
+                          data-testid="select-locale-dropdown-item"
                           key={locale}
                           onClick={() => setSelectedTemplateLocale(locale)}
                         >

+ 8 - 24
apps/app/src/client/services/renderer/renderer.tsx

@@ -1,6 +1,7 @@
 import assert from 'assert';
 
 import { isClient } from '@growi/core/dist/utils/browser-utils';
+import * as presentation from '@growi/presentation/dist/client/services/sanitize-option';
 import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client';
 import * as drawio from '@growi/remark-drawio';
 import * as lsxGrowiDirective from '@growi/remark-lsx/dist/client';
@@ -20,7 +21,6 @@ import { RichAttachment } from '~/client/components/ReactMarkdownComponents/Rich
 import { TableWithEditButton } from '~/client/components/ReactMarkdownComponents/TableWithEditButton';
 import * as mermaid from '~/features/mermaid';
 import type { RendererOptions } from '~/interfaces/renderer-options';
-import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
@@ -29,7 +29,7 @@ import * as attachment from '~/services/renderer/remark-plugins/attachment';
 import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import {
-  commonSanitizeOption, generateCommonOptions, injectCustomSanitizeOption, verifySanitizePlugin,
+  getCommonSanitizeOption, generateCommonOptions, verifySanitizePlugin,
 } from '~/services/renderer/renderer';
 import loggerFactory from '~/utils/logger';
 
@@ -71,13 +71,10 @@ export const generateViewOptions = (
     remarkPlugins.push(breaks);
   }
 
-  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
-    injectCustomSanitizeOption(config);
-  }
-
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
-      commonSanitizeOption,
+      getCommonSanitizeOption(config),
+      presentation.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       attachment.sanitizeOption,
@@ -132,14 +129,9 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   // add remark plugins
   // remarkPlugins.push();
 
-  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
-    injectCustomSanitizeOption(config);
-  }
-
-
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
-      commonSanitizeOption,
+      getCommonSanitizeOption(config),
     )]
     : () => {};
 
@@ -184,14 +176,10 @@ export const generateSimpleViewOptions = (
     remarkPlugins.push(breaks);
   }
 
-  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
-    injectCustomSanitizeOption(config);
-  }
-
-
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
-      commonSanitizeOption,
+      getCommonSanitizeOption(config),
+      presentation.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       attachment.sanitizeOption,
@@ -277,13 +265,9 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     remarkPlugins.push(breaks);
   }
 
-  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
-    injectCustomSanitizeOption(config);
-  }
-
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
-      commonSanitizeOption,
+      getCommonSanitizeOption(config),
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       attachment.sanitizeOption,

+ 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';
 
@@ -7,27 +7,19 @@ import { useCurrentPageYjsData } from '~/stores/yjs';
 
 export const useCurrentPageYjsDataEffect = (): void => {
   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(() => {
 
     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 () => {
-      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]);
 };

+ 1 - 1
apps/app/src/components/Layout/NoLoginLayout.module.scss

@@ -25,7 +25,7 @@
     }
 
     .growi-logo-type {
-      letter-spacing: .5rem;
+      margin-left: 7px;
     }
 
   }

+ 3 - 1
apps/app/src/components/Layout/NoLoginLayout.tsx

@@ -1,6 +1,8 @@
 import type { ReactNode } from 'react';
 import React from 'react';
 
+import Image from 'next/image';
+
 import { useAppTitle } from '~/stores-universal/context';
 
 import GrowiLogo from '../Common/GrowiLogo';
@@ -37,7 +39,7 @@ export const NoLoginLayout = ({
               <div className="nologin-header mx-auto rounded-4 rounded-bottom-0">
                 <div className="d-flex justify-content-center align-items-center">
                   <GrowiLogo />
-                  <h1 className="growi-logo-type text-white fs-3 my-3 ms-3">GROWI</h1>
+                  <Image width={128.48} height={28} src="/images/growi-brand-logo-login.svg" alt="GROWI" className="growi-logo-type my-3" />
                 </div>
                 {appTitle !== 'GROWI' ? (
                   <h2 className="fs-4 text-center text-white">{ appTitle }</h2>

+ 31 - 0
apps/app/src/components/PageView/PageAlerts/FullTextSearchNotCoverAlert.tsx

@@ -0,0 +1,31 @@
+import { useTranslation } from 'react-i18next';
+
+import { useElasticsearchMaxBodyLengthToIndex } from '~/stores-universal/context';
+import { useSWRxCurrentPage } from '~/stores/page';
+
+export const FullTextSearchNotCoverAlert = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: elasticsearchMaxBodyLengthToIndex } = useElasticsearchMaxBodyLengthToIndex();
+  const { data } = useSWRxCurrentPage();
+
+  const markdownLength = data?.revision?.body?.length;
+
+  if (markdownLength == null || elasticsearchMaxBodyLengthToIndex == null || markdownLength <= elasticsearchMaxBodyLengthToIndex) {
+    return <></>;
+  }
+
+  return (
+    <div className="alert alert-warning">
+      <strong>{t('Warning')}: {t('page_page.notice.not_indexed1')}</strong><br />
+      <small
+        // eslint-disable-next-line react/no-danger
+        dangerouslySetInnerHTML={{
+          __html: t('page_page.notice.not_indexed2', {
+            threshold: `<code>ELASTICSEARCH_MAX_BODY_LENGTH_TO_INDEX=${elasticsearchMaxBodyLengthToIndex}</code>`,
+          }),
+        }}
+      />
+    </div>
+  );
+};

+ 2 - 2
apps/app/src/components/PageView/PageAlerts/PageAlerts.tsx

@@ -1,5 +1,3 @@
-import React from 'react';
-
 import dynamic from 'next/dynamic';
 
 import { useIsNotFound } from '~/stores/page';
@@ -9,6 +7,7 @@ import { PageGrantAlert } from './PageGrantAlert';
 import { PageStaleAlert } from './PageStaleAlert';
 import { WipPageAlert } from './WipPageAlert';
 
+const FullTextSearchNotCoverAlert = dynamic(() => import('./FullTextSearchNotCoverAlert').then(mod => mod.FullTextSearchNotCoverAlert), { ssr: false });
 const PageRedirectedAlert = dynamic(() => import('./PageRedirectedAlert').then(mod => mod.PageRedirectedAlert), { ssr: false });
 const FixPageGrantAlert = dynamic(() => import('./FixPageGrantAlert').then(mod => mod.FixPageGrantAlert), { ssr: false });
 const TrashPageAlert = dynamic(() => import('./TrashPageAlert').then(mod => mod.TrashPageAlert), { ssr: false });
@@ -22,6 +21,7 @@ export const PageAlerts = (): JSX.Element => {
       <div className="col-sm-12">
         {/* alerts */}
         { !isNotFound && <FixPageGrantAlert /> }
+        <FullTextSearchNotCoverAlert />
         <WipPageAlert />
         <PageGrantAlert />
         <TrashPageAlert />

+ 0 - 19
apps/app/src/features/comment/server/models/comment.ts

@@ -27,7 +27,6 @@ type Add = (
 type FindCommentsByPageId = (pageId: Types.ObjectId) => Query<CommentDocument[], CommentDocument>;
 type FindCommentsByRevisionId = (revisionId: Types.ObjectId) => Query<CommentDocument[], CommentDocument>;
 type FindCreatorsByPage = (pageId: Types.ObjectId) => Promise<IUser[]>
-type GetPageIdToCommentMap = (pageIds: Types.ObjectId[]) => Promise<Record<string, CommentDocument[]>>
 type CountCommentByPageId = (pageId: Types.ObjectId) => Promise<number>
 
 export interface CommentModel extends Model<CommentDocument> {
@@ -35,7 +34,6 @@ export interface CommentModel extends Model<CommentDocument> {
   findCommentsByPageId: FindCommentsByPageId
   findCommentsByRevisionId: FindCommentsByRevisionId
   findCreatorsByPage: FindCreatorsByPage
-  getPageIdToCommentMap: GetPageIdToCommentMap
   countCommentByPageId: CountCommentByPageId
 }
 
@@ -91,23 +89,6 @@ commentSchema.statics.findCreatorsByPage = async function(page) {
   return this.distinct('creator', { page }).exec();
 };
 
-/**
- * @return {object} key: page._id, value: comments
- */
-commentSchema.statics.getPageIdToCommentMap = async function(pageIds) {
-  const results = await this.aggregate()
-    .match({ page: { $in: pageIds } })
-    .group({ _id: '$page', comments: { $push: '$comment' } });
-
-  // convert to map
-  const idToCommentMap = {};
-  results.forEach((result, i) => {
-    idToCommentMap[result._id] = result.comments;
-  });
-
-  return idToCommentMap;
-};
-
 commentSchema.statics.countCommentByPageId = async function(page) {
   return this.count({ page });
 };

+ 11 - 1
apps/app/src/features/rate-limiter/config/index.ts

@@ -58,8 +58,18 @@ export const defaultConfig: IApiRateLimitEndpointMap = {
   },
 };
 
+const isDev = process.env.NODE_ENV === 'development';
+const defaultConfigWithRegExpForDev: IApiRateLimitEndpointMap = isDev ? {
+  '/__nextjs_original-stack-frame': {
+    method: 'GET',
+    maxRequests: Infinity,
+  },
+} : {};
+
 // default config with reg exp
-export const defaultConfigWithRegExp = {
+export const defaultConfigWithRegExp: IApiRateLimitEndpointMap = {
+  ...defaultConfigWithRegExpForDev,
+
   '/forgot-password/.*': {
     method: 'ALL',
     maxRequests: MAX_REQUESTS_TIER_1,

+ 2 - 1
apps/app/src/features/rate-limiter/utils/config-generator.ts

@@ -1,5 +1,6 @@
+import type { IApiRateLimitEndpointMap } from '../config';
 import {
-  defaultConfig, defaultConfigWithRegExp, IApiRateLimitEndpointMap,
+  defaultConfig, defaultConfigWithRegExp,
 } from '../config';
 
 const envVar = process.env;

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

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

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

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

+ 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 getPageModel from '~/server/models/page';
+import { Revision } from '~/server/models/revision';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
@@ -18,7 +19,6 @@ module.exports = {
   async up(db, client) {
     mongoose.connect(getMongoUri(), mongoOptions);
     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 batchStrem = createBatchStream(LIMIT);
@@ -69,7 +69,6 @@ module.exports = {
   async down(db, client) {
     mongoose.connect(getMongoUri(), mongoOptions);
     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 batchStrem = createBatchStream(LIMIT);

+ 4 - 0
apps/app/src/pages/[[...path]].page.tsx

@@ -42,6 +42,7 @@ import {
   useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
   useIsAllReplyShown, useIsContainerFluid, useIsNotCreatable,
   useIsUploadAllFileAllowed, useIsUploadEnabled,
+  useElasticsearchMaxBodyLengthToIndex,
 } from '~/stores-universal/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import {
@@ -157,6 +158,7 @@ type Props = CommonProps & {
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
+  elasticsearchMaxBodyLengthToIndex: number,
   isEnabledMarp: boolean,
 
   sidebarConfig: ISidebarConfig,
@@ -215,6 +217,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useIsEnabledAttachTitleHeader(props.isEnabledAttachTitleHeader);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
+  useElasticsearchMaxBodyLengthToIndex(props.elasticsearchMaxBodyLengthToIndex);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
 
   useIsSlackConfigured(props.isSlackConfigured);
@@ -537,6 +540,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+  props.elasticsearchMaxBodyLengthToIndex = configManager.getConfig('crowi', 'app:elasticsearchMaxBodyLengthToIndex');
 
   props.isSlackConfigured = crowi.slackIntegrationService.isSlackConfigured;
   // props.isMailerSetup = mailService.isMailerSetup;

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

@@ -1,6 +1,7 @@
 /* eslint-disable @next/next/google-font-display */
 import React from 'react';
 
+import { Lang } from '@growi/core';
 import type { DocumentContext, DocumentInitialProps } from 'next/document';
 import Document, {
   Html, Head, Main, NextScript,
@@ -8,8 +9,12 @@ import Document, {
 
 import type { GrowiPluginResourceEntries } from '~/features/growi-plugin/server/services';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { configManager } from '~/server/service/config-manager';
+import { detectLocaleFromBrowserAcceptLanguage } from '~/server/util/locale-utils';
 import loggerFactory from '~/utils/logger';
 
+import { getLocateAtServerSide } from './utils/commons';
+
 const logger = loggerFactory('growi:page:_document');
 
 type HeadersForGrowiPluginProps = {
@@ -41,14 +46,24 @@ interface GrowiDocumentProps {
   customCss: string | null,
   customNoscript: string | null,
   pluginResourceEntries: GrowiPluginResourceEntries;
+  locale: string;
 }
 declare type GrowiDocumentInitialProps = DocumentInitialProps & GrowiDocumentProps;
 
 class GrowiDocument extends Document<GrowiDocumentInitialProps> {
 
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
+
+    const langMap = {
+      [Lang.ja_JP]: 'ja-jp',
+      [Lang.en_US]: 'en-us',
+      [Lang.zh_CN]: 'zh-cn',
+      [Lang.fr_FR]: 'fr-fr',
+    } as const;
+
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
-    const { crowi } = ctx.req as CrowiRequest;
+    const req = ctx.req as CrowiRequest;
+    const { crowi } = req;
     const { customizeService } = crowi;
 
     const { themeHref } = customizeService;
@@ -60,6 +75,8 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const growiPluginService = await import('~/features/growi-plugin/server/services').then(mod => mod.growiPluginService);
     const pluginResourceEntries = await growiPluginService.retrieveAllPluginResourceEntries();
 
+    const locale = langMap[getLocateAtServerSide(req)];
+
     return {
       ...initialProps,
       themeHref,
@@ -67,6 +84,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
       customCss,
       customNoscript,
       pluginResourceEntries,
+      locale,
     };
   }
 
@@ -95,13 +113,16 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const {
       customCss, customScript, customNoscript,
       themeHref, pluginResourceEntries,
+      locale,
     } = this.props;
 
     return (
-      <Html>
+      <Html lang={locale}>
         <Head>
           {this.renderCustomScript(customScript)}
           <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} />
           {this.renderCustomCss(customCss)}
         </Head>

+ 11 - 6
apps/app/src/pages/utils/commons.ts

@@ -106,6 +106,15 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
   return { props };
 };
 
+
+export const getLocateAtServerSide = (req: CrowiRequest): Lang => {
+  const { user, headers } = req;
+  const { configManager } = req.crowi;
+
+  return user == null ? detectLocaleFromBrowserAcceptLanguage(headers)
+    : (user.lang ?? configManager.getConfig('crowi', 'app:globalLang') as Lang ?? Lang.en_US);
+};
+
 export const getNextI18NextConfig = async(
     // 'serverSideTranslations' method should be given from Next.js Page
     //  because importing it in this file causes https://github.com/isaachinman/next-i18next/issues/1545
@@ -115,13 +124,9 @@ export const getNextI18NextConfig = async(
     context: GetServerSidePropsContext, namespacesRequired?: string[] | undefined, preloadAllLang = false,
 ): Promise<SSRConfig> => {
 
-  const req: CrowiRequest = context.req as CrowiRequest;
-  const { crowi, user, headers } = req;
-  const { configManager } = crowi;
-
   // determine language
-  const locale = user == null ? detectLocaleFromBrowserAcceptLanguage(headers)
-    : (user.lang ?? configManager.getConfig('crowi', 'app:globalLang') as Lang ?? Lang.en_US);
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const locale = getLocateAtServerSide(req);
 
   const namespaces = ['commons'];
   if (namespacesRequired != null) {

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

@@ -36,7 +36,7 @@ import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import UserGroupService from '../service/user-group';
 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';
 
 
@@ -475,9 +475,8 @@ Crowi.prototype.start = async function() {
   // attach to socket.io
   this.socketIoService.attachServer(httpServer);
 
-  // Initialization YjsConnectionManager
-  instantiateYjsConnectionManager(this.socketIoService.io);
-  this.socketIoService.setupYjsConnection();
+  // Initialization YjsService
+  initializeYjsService(this.socketIoService.io);
 
   await this.autoInstall();
 

+ 1 - 19
apps/app/src/server/models/activity.ts

@@ -1,4 +1,3 @@
-import type { Ref, IPage } from '@growi/core';
 import type {
   Types, Document, Model, SortOrder,
 } from 'mongoose';
@@ -15,10 +14,7 @@ import {
 } from '~/interfaces/activity';
 
 import loggerFactory from '../../utils/logger';
-import { getOrCreateModel, getModelSafely } from '../util/mongoose-utils';
-
-
-import Subscription from './subscription';
+import { getOrCreateModel } from '../util/mongoose-utils';
 
 
 const logger = loggerFactory('growi:models:activity');
@@ -34,8 +30,6 @@ export interface ActivityDocument extends Document {
   event: Types.ObjectId
   action: SupportedActionType
   snapshot: ISnapshot
-
-  getNotificationTargetUsers(): Promise<any[]>
 }
 
 export interface ActivityModel extends Model<ActivityDocument> {
@@ -98,18 +92,6 @@ activitySchema.post('save', function() {
   logger.debug('activity has been created', this);
 });
 
-activitySchema.methods.getNotificationTargetUsers = async function() {
-  const User = getModelSafely('User') || require('~/server/models/user')();
-  const { user: actionUser, target } = this;
-  const subscribedUsers = await Subscription.getSubscription(target as unknown as Ref<IPage>);
-  const notificationUsers = subscribedUsers.filter(item => (item.toString() !== actionUser._id.toString()));
-  const activeNotificationUsers = await User.find({
-    _id: { $in: notificationUsers },
-    status: User.STATUS_ACTIVE,
-  }).distinct('_id');
-  return activeNotificationUsers;
-};
-
 activitySchema.statics.createByParameters = async function(parameters): Promise<IActivity> {
   const activity = await this.create(parameters) as unknown as IActivity;
 

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

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

+ 0 - 9
apps/app/src/server/models/obsolete-page.js

@@ -668,15 +668,6 @@ export const getPageSchema = (crowi) => {
     await this.updateMany({ _id: { $in: pages.map(p => p._id) } }, { grantedGroups: [transferToUserGroup] });
   };
 
-  pageSchema.methods.getNotificationTargetUsers = async function() {
-    const Revision = mongoose.model('Revision');
-
-    const [commentCreators, revisionAuthors] = await Promise.all([Comment.findCreatorsByPage(this), Revision.findAuthorsByPage(this)]);
-
-    const targetUsers = new Set([this.creator].concat(commentCreators, revisionAuthors));
-    return Array.from(targetUsers);
-  };
-
   pageSchema.statics.getHistories = function() {
     // TODO
 

+ 6 - 9
apps/app/src/server/models/page-tag-relation.ts

@@ -1,6 +1,6 @@
 import type { ITag } from '@growi/core';
-import type { Document, Model } from 'mongoose';
-import mongoose, { ObjectId } from 'mongoose';
+import type { Document, Model, ObjectId } from 'mongoose';
+import mongoose from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
@@ -9,13 +9,10 @@ import type { IPageTagRelation } from '~/interfaces/page-tag-relation';
 import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-import type { IdToNameMap } from './tag';
+import type { IdToNameMap, IdToNamesMap } from './tag';
 import Tag from './tag';
 
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 
@@ -36,7 +33,7 @@ type CreateTagListWithCountResult = {
 }
 type CreateTagListWithCount = (this: PageTagRelationModel, opts?: CreateTagListWithCountOpts) => Promise<CreateTagListWithCountResult>;
 
-type GetIdToTagNamesMap = (this: PageTagRelationModel, pageIds: string[]) => Promise<IdToNameMap>;
+type GetIdToTagNamesMap = (this: PageTagRelationModel, pageIds: string[]) => Promise<IdToNamesMap>;
 
 type UpdatePageTags = (this: PageTagRelationModel, pageId: string, tags: string[]) => Promise<void>
 
@@ -54,13 +51,13 @@ export interface PageTagRelationModel extends Model<PageTagRelationDocument> {
  */
 const schema = new mongoose.Schema<PageTagRelationDocument, PageTagRelationModel>({
   relatedPage: {
-    type: ObjectId,
+    type: mongoose.Schema.Types.ObjectId,
     ref: 'Page',
     required: true,
     index: true,
   },
   relatedTag: {
-    type: ObjectId,
+    type: mongoose.Schema.Types.ObjectId,
     ref: 'Tag',
     required: true,
     index: true,

+ 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> {
   [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>
   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)[]>

+ 0 - 71
apps/app/src/server/models/revision.js

@@ -1,71 +0,0 @@
-import { allOrigin } from '@growi/core';
-
-import loggerFactory from '~/utils/logger';
-
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-module.exports = function(crowi) {
-  // eslint-disable-next-line no-unused-vars
-  const logger = loggerFactory('growi:models:revision');
-
-  const mongoose = require('mongoose');
-  const mongoosePaginate = require('mongoose-paginate-v2');
-
-  // allow empty strings
-  mongoose.Schema.Types.String.checkRequired(v => v != null);
-
-  const ObjectId = mongoose.Schema.Types.ObjectId;
-  const revisionSchema = new mongoose.Schema({
-    // OBSOLETE path: { type: String, required: true, index: true }
-    pageId: { type: ObjectId, required: true, index: true },
-    body: {
-      type: String,
-      required: true,
-      get: (data) => {
-      // replace CR/CRLF to LF above v3.1.5
-      // see https://github.com/weseek/growi/issues/463
-        return data ? data.replace(/\r\n?/g, '\n') : '';
-      },
-    },
-    format: { type: String, default: 'markdown' },
-    author: { type: ObjectId, ref: 'User' },
-    hasDiffToPrev: { type: Boolean },
-    origin: { type: String, enum: allOrigin },
-  }, {
-    timestamps: { createdAt: true, updatedAt: false },
-  });
-  revisionSchema.plugin(mongoosePaginate);
-
-  revisionSchema.statics.updateRevisionListByPageId = async function(pageId, updateData) {
-    return this.updateMany({ pageId }, { $set: updateData });
-  };
-
-  revisionSchema.statics.prepareRevision = function(pageData, body, previousBody, user, origin, options) {
-    const Revision = this;
-
-    if (!options) {
-      // eslint-disable-next-line no-param-reassign
-      options = {};
-    }
-    const format = options.format || 'markdown';
-
-    if (!user._id) {
-      throw new Error('Error: user should have _id');
-    }
-
-    const newRevision = new Revision();
-    newRevision.pageId = pageData._id;
-    newRevision.body = body;
-    newRevision.format = format;
-    newRevision.author = user._id;
-    newRevision.origin = origin;
-    if (pageData.revision != null) {
-      newRevision.hasDiffToPrev = body !== previousBody;
-    }
-
-    return newRevision;
-  };
-
-  return mongoose.model('Revision', revisionSchema);
-};

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

@@ -0,0 +1,82 @@
+import type {
+  HasObjectId,
+  IRevision,
+  Origin,
+} from '@growi/core';
+import { allOrigin } from '@growi/core';
+import {
+  Schema, Types, type Document, type Model,
+} from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+
+import loggerFactory from '~/utils/logger';
+
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+import type { PageDocument } from './page';
+
+const logger = loggerFactory('growi:models:revision');
+
+export interface IRevisionDocument extends IRevision, Document {
+}
+
+type UpdateRevisionListByPageId = (pageId: string, updateData: Partial<IRevision>) => Promise<void>;
+type PrepareRevision = (
+  pageData: PageDocument, body: string, previousBody: string | null, user: HasObjectId, origin?: Origin, options?: { format: string }
+) => IRevisionDocument;
+
+export interface IRevisionModel extends Model<IRevisionDocument> {
+  updateRevisionListByPageId: UpdateRevisionListByPageId,
+  prepareRevision: PrepareRevision,
+}
+
+// Use this to allow empty strings to pass the `required` validator
+Schema.Types.String.checkRequired(v => typeof v === 'string');
+
+const revisionSchema = new Schema<IRevisionDocument, IRevisionModel>({
+  pageId: {
+    type: Types.ObjectId, ref: 'Page', required: true, index: true,
+  },
+  body: {
+    type: String,
+    required: true,
+    get: (data) => {
+    // replace CR/CRLF to LF above v3.1.5
+    // see https://github.com/weseek/growi/issues/463
+      return data ? data.replace(/\r\n?/g, '\n') : '';
+    },
+  },
+  format: { type: String, default: 'markdown' },
+  author: { type: Types.ObjectId, ref: 'User' },
+  hasDiffToPrev: { type: Boolean },
+  origin: { type: String, enum: allOrigin },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
+});
+revisionSchema.plugin(mongoosePaginate);
+
+const updateRevisionListByPageId: UpdateRevisionListByPageId = async function(this: IRevisionModel, pageId, updateData) {
+  await this.updateMany({ pageId }, { $set: updateData });
+};
+revisionSchema.statics.updateRevisionListByPageId = updateRevisionListByPageId;
+
+const prepareRevision: PrepareRevision = function(this: IRevisionModel, pageData, body, previousBody, user, origin, options = { format: 'markdown' }) {
+  if (!user._id) {
+    throw new Error('Error: user should have _id');
+  }
+
+  const newRevision = new this();
+  newRevision.pageId = pageData._id;
+  newRevision.body = body;
+  newRevision.format = options.format;
+  newRevision.author = user._id;
+  newRevision.origin = origin;
+  if (pageData.revision != null) {
+    newRevision.hasDiffToPrev = body !== previousBody;
+  }
+
+  return newRevision;
+};
+revisionSchema.statics.prepareRevision = prepareRevision;
+
+export const Revision = getOrCreateModel<IRevisionDocument, IRevisionModel>('Revision', revisionSchema);

+ 1 - 0
apps/app/src/server/models/tag.ts

@@ -14,6 +14,7 @@ export interface TagDocument {
 }
 
 export type IdToNameMap = {[key: string] : string }
+export type IdToNamesMap = {[key: string] : string[] }
 
 export interface TagModel extends Model<TagDocument>{
   getIdToNameMap(tagIds: ObjectIdLike[]): IdToNameMap

+ 6 - 4
apps/app/src/server/routes/apiv3/page/index.ts

@@ -3,6 +3,7 @@ import path from 'path';
 import type { IPage } from '@growi/core';
 import {
   AllSubscriptionStatusType, PageGrant, SubscriptionStatusType,
+  getIdForRef,
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils';
@@ -15,8 +16,9 @@ import type { IPageGrantData } from '~/interfaces/page';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
-import { GlobalNotificationSettingEvent } from '~/server/models';
+import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageModel } from '~/server/models/page';
+import { Revision } from '~/server/models/revision';
 import Subscription from '~/server/models/subscription';
 import { configManager } from '~/server/service/config-manager';
 import type { IPageGrantService } from '~/server/service/page-grant';
@@ -591,7 +593,8 @@ module.exports = (crowi) => {
     } = page;
     let isGrantNormalized = false;
     try {
-      isGrantNormalized = await pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsers, grantedGroups, false, false);
+      const grantedUsersId = grantedUsers.map(ref => getIdForRef(ref));
+      isGrantNormalized = await pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsersId, grantedGroups, false, false);
     }
     catch (err) {
       logger.error('Error occurred while processing isGrantNormalized.', err);
@@ -614,7 +617,7 @@ module.exports = (crowi) => {
       return res.apiv3({ isGrantNormalized, grantData });
     }
 
-    const parentPage = await Page.findByIdAndViewer(page.parent, req.user, null, false);
+    const parentPage = await Page.findByIdAndViewer(getIdForRef(page.parent), req.user, null, false);
 
     // user isn't allowed to see parent's grant
     if (parentPage == null) {
@@ -757,7 +760,6 @@ module.exports = (crowi) => {
 
       const revisionIdForFind = revisionId || page.revision;
 
-      const Revision = crowi.model('Revision');
       revision = await Revision.findById(revisionIdForFind);
       pagePath = page.path;
 

+ 3 - 3
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -18,7 +18,7 @@ import {
 } from '~/server/models';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import { preNotifyService } from '~/server/service/pre-notify';
-import { getYjsConnectionManager } from '~/server/service/yjs-connection-manager';
+import { getYjsService } from '~/server/service/yjs';
 import { generalXssFilter } from '~/services/general-xss-filter';
 import loggerFactory from '~/utils/logger';
 
@@ -67,8 +67,8 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     // Reflect the updates in ydoc
     const origin = req.body.origin;
     if (origin === Origin.View || origin === undefined) {
-      const yjsConnectionManager = getYjsConnectionManager();
-      await yjsConnectionManager.handleYDocUpdate(req.body.pageId, req.body.body);
+      const yjsService = getYjsService();
+      await yjsService.syncWithTheLatestRevisionForce(req.body.pageId);
     }
 
     // persist activity

+ 1 - 1
apps/app/src/server/routes/apiv3/revisions.js

@@ -1,5 +1,6 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 
+import { Revision } from '~/server/models/revision';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -64,7 +65,6 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
 
   const {
-    Revision,
     Page,
     User,
   } = crowi.models;

+ 6 - 5
apps/app/src/server/routes/apiv3/staffs.js

@@ -1,13 +1,14 @@
+import axios from 'axios';
+import { addHours } from 'date-fns/addHours';
+import { isAfter } from 'date-fns/isAfter';
+import { Router } from 'express';
+
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:staffs'); // eslint-disable-line no-unused-vars
 
-const express = require('express');
-
-const axios = require('axios');
 
-const router = express.Router();
-const { isAfter, addHours } = require('date-fns');
+const router = Router();
 
 const contributors = require('^/resource/Contributor');
 

+ 1 - 1
apps/app/src/server/routes/tag.js

@@ -2,6 +2,7 @@ import { SupportedAction } from '~/interfaces/activity';
 import Tag from '~/server/models/tag';
 
 import PageTagRelation from '../models/page-tag-relation';
+import { Revision } from '../models/revision';
 import ApiResponse from '../util/apiResponse';
 
 /**
@@ -139,7 +140,6 @@ module.exports = function(crowi, app) {
   api.update = async function(req, res) {
     const Page = crowi.model('Page');
     const User = crowi.model('User');
-    const Revision = crowi.model('Revision');
     const tagEvent = crowi.event('tag');
     const pageId = req.body.pageId;
     const tags = req.body.tags;

+ 6 - 0
apps/app/src/server/service/config-loader.ts

@@ -282,6 +282,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     default: false,
   },
+  ELASTICSEARCH_MAX_BODY_LENGTH_TO_INDEX: {
+    ns:      'crowi',
+    key:     'app:elasticsearchMaxBodyLengthToIndex',
+    type:    ValueType.NUMBER,
+    default: 100000,
+  },
   ELASTICSEARCH_REINDEX_BULK_SIZE: {
     ns:      'crowi',
     key:     'app:elasticsearchReindexBulkSize',

+ 1 - 1
apps/app/src/server/service/import.js

@@ -3,6 +3,7 @@
  * @typedef {import("@types/unzip-stream").Entry} Entry
  */
 
+import { parseISO } from 'date-fns/parseISO';
 import gc from 'expose-gc/function';
 
 import loggerFactory from '~/utils/logger';
@@ -12,7 +13,6 @@ const path = require('path');
 const { Writable, Transform } = require('stream');
 
 const JSONStream = require('JSONStream');
-const parseISO = require('date-fns/parseISO');
 const isIsoDate = require('is-iso-date');
 const mongoose = require('mongoose');
 const streamToPromise = require('stream-to-promise');

+ 12 - 38
apps/app/src/server/service/page/index.ts

@@ -7,7 +7,7 @@ import type {
   IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup, IRevisionHasId,
 } from '@growi/core';
 import {
-  PageGrant, PageStatus, getIdForRef,
+  PageGrant, PageStatus, YDocStatus, getIdForRef,
 } from '@growi/core';
 import {
   pagePathUtils, pathUtils,
@@ -41,7 +41,6 @@ import {
 import type { PageTagRelationDocument } from '~/server/models/page-tag-relation';
 import PageTagRelation from '~/server/models/page-tag-relation';
 import type { UserGroupDocument } from '~/server/models/user-group';
-import { getYjsConnectionManager } from '~/server/service/yjs-connection-manager';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { collectAncestorPaths } from '~/server/util/collect-ancestor-paths';
 import { generalXssFilter } from '~/services/general-xss-filter';
@@ -49,11 +48,12 @@ import loggerFactory from '~/utils/logger';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 import type { ObjectIdLike } from '../../interfaces/mongoose-utils';
-import { Attachment } from '../../models';
+import { Attachment } from '../../models/attachment';
 import { PathAlreadyExistsError } from '../../models/errors';
 import type { PageOperationDocument } from '../../models/page-operation';
 import PageOperation from '../../models/page-operation';
 import PageRedirect from '../../models/page-redirect';
+import { Revision } from '../../models/revision';
 import { serializePageSecurely } from '../../models/serializers/page-serializer';
 import ShareLink from '../../models/share-link';
 import Subscription from '../../models/subscription';
@@ -63,6 +63,7 @@ import { divideByType } from '../../util/granted-group';
 import { configManager } from '../config-manager';
 import type { IPageGrantService } from '../page-grant';
 import { preNotifyService } from '../pre-notify';
+import { getYjsService } from '../yjs';
 
 import { BULK_REINDEX_SIZE, LIMIT_FOR_MULTIPLE_PAGE_OP } from './consts';
 import type { IPageService } from './page-service';
@@ -832,7 +833,6 @@ class PageService implements IPageService {
 
   private async renamePageV4(page, newPagePath, user, options) {
     const Page = this.crowi.model('Page');
-    const Revision = this.crowi.model('Revision');
     const {
       isRecursively = false,
       createRedirectPage = false,
@@ -1348,7 +1348,6 @@ class PageService implements IPageService {
     }
 
     const Page = this.crowi.model('Page');
-    const Revision = this.crowi.model('Revision');
 
     const pageIds = pages.map(page => page._id);
     const revisions = await Revision.find({ pageId: { $in: pageIds } });
@@ -1356,7 +1355,7 @@ class PageService implements IPageService {
     // Mapping to set to the body of the new revision
     const pageIdRevisionMapping = {};
     revisions.forEach((revision) => {
-      pageIdRevisionMapping[revision.pageId] = revision;
+      pageIdRevisionMapping[getIdForRef(revision.pageId)] = revision;
     });
 
     // key: oldPageId, value: newPageId
@@ -1404,7 +1403,6 @@ class PageService implements IPageService {
 
   private async duplicateDescendantsV4(pages, user, oldPagePathPrefix, newPagePathPrefix) {
     const Page = this.crowi.model('Page');
-    const Revision = this.crowi.model('Revision');
 
     const pageIds = pages.map(page => page._id);
     const revisions = await Revision.find({ pageId: { $in: pageIds } });
@@ -1412,7 +1410,7 @@ class PageService implements IPageService {
     // Mapping to set to the body of the new revision
     const pageIdRevisionMapping = {};
     revisions.forEach((revision) => {
-      pageIdRevisionMapping[revision.pageId] = revision;
+      pageIdRevisionMapping[getIdForRef(revision.pageId)] = revision;
     });
 
     // key: oldPageId, value: newPageId
@@ -1709,7 +1707,6 @@ class PageService implements IPageService {
 
   private async deletePageV4(page, user, options = {}, isRecursively = false) {
     const Page = mongoose.model('Page') as PageModel;
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
 
     const newPath = Page.getDeletedPageName(page.path);
     const isTrashed = isTrashPage(page.path);
@@ -1872,7 +1869,6 @@ class PageService implements IPageService {
   async deleteCompletelyOperation(pageIds, pagePaths): Promise<void> {
     // Delete Attachments, Revisions, Pages and emit delete
     const Page = this.crowi.model('Page');
-    const Revision = this.crowi.model('Revision');
 
     const { attachmentService } = this.crowi;
     const attachments = await Attachment.find({ page: { $in: pageIds } });
@@ -3839,7 +3835,6 @@ class PageService implements IPageService {
     let savedPage = await page.save();
 
     // Create revision
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, options.origin);
     savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
@@ -3901,7 +3896,6 @@ class PageService implements IPageService {
    */
   private async createV4(path, body, user, options: any = {}) {
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const Revision = mongoose.model('Revision') as any; // TODO: TypeScriptize model
 
     const format = options.format || 'markdown';
     const grantUserGroupIds = options.grantUserGroupIds || null;
@@ -4037,8 +4031,7 @@ class PageService implements IPageService {
     let savedPage = await page.save();
 
     // Create revision
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const dummyUser = { _id: new mongoose.Types.ObjectId() };
+    const dummyUser: HasObjectId = { _id: new mongoose.Types.ObjectId().toString() };
     const newRevision = Revision.prepareRevision(savedPage, body, null, dummyUser);
     savedPage = await pushRevision(savedPage, newRevision, dummyUser);
 
@@ -4146,7 +4139,6 @@ class PageService implements IPageService {
       options: IOptionsForUpdate = {},
   ): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
 
     const wasOnTree = pageData.parent != null || isTopPage(pageData.path);
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
@@ -4290,7 +4282,6 @@ class PageService implements IPageService {
 
   async updatePageV4(pageData: PageDocument, body, previousBody, user, options: IOptionsForUpdate = {}): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const Revision = mongoose.model('Revision') as any; // TODO: TypeScriptize model
 
     // use the previous data if absent
     const grant = options.grant || pageData.grant;
@@ -4444,35 +4435,18 @@ class PageService implements IPageService {
   }
 
   async getYjsData(pageId: string): Promise<CurrentPageYjsData> {
-    const yjsConnectionManager = getYjsConnectionManager();
+    const yjsService = getYjsService();
 
-    const currentYdoc = yjsConnectionManager.getCurrentYdoc(pageId);
-    const persistedYdoc = await yjsConnectionManager.getPersistedYdoc(pageId);
-
-    const yjsDraft = (currentYdoc ?? persistedYdoc)?.getText('codemirror').toString();
-    const hasRevisionBodyDiff = await this.hasRevisionBodyDiff(pageId, yjsDraft);
+    const currentYdoc = yjsService.getCurrentYdoc(pageId);
+    const ydocStatus = await yjsService.getYDocStatus(pageId);
+    const hasYdocsNewerThanLatestRevision = ydocStatus === YDocStatus.DRAFT;
 
     return {
-      hasRevisionBodyDiff,
+      hasYdocsNewerThanLatestRevision,
       awarenessStateSize: currentYdoc?.awareness.states.size,
     };
   }
 
-  async hasRevisionBodyDiff(pageId: string, comparisonTarget?: string): Promise<boolean> {
-    if (comparisonTarget == null) {
-      return false;
-    }
-
-    const Revision = mongoose.model<IRevisionHasId>('Revision');
-    const revision = await Revision.findOne({ pageId }).sort({ createdAt: -1 });
-
-    if (revision == null) {
-      return false;
-    }
-
-    return revision.body !== comparisonTarget;
-  }
-
   async createTtlIndex(): Promise<void> {
     const wipPageExpirationSeconds = configManager.getConfig('crowi', 'app:wipPageExpirationSeconds') ?? 172800;
     const collection = mongoose.connection.collection('pages');

+ 139 - 0
apps/app/src/server/service/search-delegator/aggregate-to-index.ts

@@ -0,0 +1,139 @@
+import type { IPage } from '@growi/core';
+import type { PipelineStage, Query } from 'mongoose';
+
+import type { PageModel } from '~/server/models/page';
+
+export const aggregatePipelineToIndex = (maxBodyLengthToIndex: number, query?: Query<PageModel, IPage>): PipelineStage[] => {
+
+  const basePipeline = query == null
+    ? []
+    : [{ $match: query.getQuery() }];
+
+  return [
+    ...basePipeline,
+
+    // join Revision
+    {
+      $lookup: {
+        from: 'revisions',
+        localField: 'revision',
+        foreignField: '_id',
+        as: 'revision',
+      },
+    },
+    // unwind and filter pages that does not have revision
+    {
+      $unwind: {
+        path: '$revision',
+      },
+    },
+    {
+      $addFields: {
+        bodyLength: { $strLenCP: '$revision.body' },
+      },
+    },
+
+    // join User
+    {
+      $lookup: {
+        from: 'users',
+        localField: 'creator',
+        foreignField: '_id',
+        as: 'creator',
+      },
+    },
+    {
+      $unwind: {
+        path: '$creator',
+        preserveNullAndEmptyArrays: true,
+      },
+    },
+
+    // join Comment
+    {
+      $lookup: {
+        from: 'comments',
+        localField: '_id',
+        foreignField: 'page',
+        pipeline: [
+          {
+            $addFields: {
+              commentLength: { $strLenCP: '$comment' },
+            },
+          },
+        ],
+        as: 'comments',
+      },
+    },
+    {
+      $addFields: {
+        commentsCount: { $size: '$comments' },
+      },
+    },
+
+    // join Bookmark
+    {
+      $lookup: {
+        from: 'bookmarks',
+        localField: '_id',
+        foreignField: 'page',
+        as: 'bookmarks',
+      },
+    },
+    {
+      $addFields: {
+        bookmarksCount: { $size: '$bookmarks' },
+      },
+    },
+
+    // add counts for embedded arrays
+    {
+      $addFields: {
+        likeCount: { $size: '$liker' },
+      },
+    },
+    {
+      $addFields: {
+        seenUsersCount: { $size: '$seenUsers' },
+      },
+    },
+
+    // project
+    {
+      $project: {
+        path: 1,
+        createdAt: 1,
+        updatedAt: 1,
+        grant: 1,
+        grantedUsers: 1,
+        grantedGroups: 1,
+        'revision.body': {
+          $cond: {
+            if: { $lte: ['$bodyLength', maxBodyLengthToIndex] },
+            then: '$revision.body',
+            else: '',
+          },
+        },
+        comments: {
+          $map: {
+            input: '$comments',
+            as: 'comment',
+            in: {
+              $cond: {
+                if: { $lte: ['$$comment.commentLength', maxBodyLengthToIndex] },
+                then: '$$comment.comment',
+                else: '',
+              },
+            },
+          },
+        },
+        commentsCount: 1,
+        bookmarksCount: 1,
+        likeCount: 1,
+        seenUsersCount: 1,
+        'creator.username': 1,
+        'creator.email': 1,
+      },
+    },
+  ];
+};

+ 52 - 0
apps/app/src/server/service/search-delegator/bulk-write.d.ts

@@ -0,0 +1,52 @@
+import type { IPageHasId, PageGrant } from '@growi/core';
+
+export type AggregatedPage = Pick<IPageHasId,
+  '_id'
+  | 'path'
+  | 'createdAt'
+  | 'updatedAt'
+  | 'grant'
+  | 'grantedUsers'
+  | 'grantedGroups'
+> & {
+  revision: { body: string },
+  comments: string[],
+  commentsCount: number,
+  bookmarksCount: number,
+  likeCount: number,
+  seenUsersCount: number,
+  creator: {
+    username: string,
+    email: string,
+  },
+} & {
+  tagNames: string[],
+};
+
+export type BulkWriteCommand = {
+  index: {
+    _index: string,
+    _type: '_doc' | undefined,
+    _id: string,
+  },
+}
+
+export type BulkWriteBodyRestriction = {
+  grant: PageGrant,
+  granted_users?: string[],
+  granted_groups: string[],
+}
+
+export type BulkWriteBody = {
+  path: string;
+  created_at: Date;
+  updated_at: Date;
+  body: string;
+  username?: string;
+  comments?: string[];
+  comment_count: number;
+  bookmark_count: number;
+  seenUsers_count: number;
+  like_count: number;
+  tag_names?: string[];
+} & BulkWriteBodyRestriction;

+ 33 - 135
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -1,11 +1,11 @@
 import { Writable, Transform } from 'stream';
 import { URL } from 'url';
 
+import { getIdForRef, type IPage } from '@growi/core';
 import gc from 'expose-gc/function';
 import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
-import { Comment } from '~/features/comment/server';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import type { ISearchResult, ISearchResultData } from '~/interfaces/search';
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
@@ -22,6 +22,8 @@ import { configManager } from '../config-manager';
 import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
 
 
+import { aggregatePipelineToIndex } from './aggregate-to-index';
+import type { AggregatedPage, BulkWriteBody, BulkWriteCommand } from './bulk-write';
 import ElasticsearchClient from './elasticsearch-client';
 
 const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
@@ -109,10 +111,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     return `${this.indexName}-alias`;
   }
 
-  shouldIndexed(page) {
-    return page.revision != null;
-  }
-
   initClient() {
     const { host, auth, indexName } = this.getConnectionInfo();
 
@@ -128,7 +126,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     this.indexName = indexName;
   }
 
-  getType() {
+  getType(): '_doc' | undefined {
     return this.isElasticsearchV7 ? '_doc' : undefined;
   }
 
@@ -358,20 +356,9 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   /**
    * generate object that is related to page.grant*
    */
-  generateDocContentsRelatedToRestriction(page) {
-    let grantedUserIds = null;
-    if (page.grantedUsers != null && page.grantedUsers.length > 0) {
-      grantedUserIds = page.grantedUsers.map((user) => {
-        const userId = (user._id == null) ? user : user._id;
-        return userId.toString();
-      });
-    }
-
-    let grantedGroupIds = [];
-    grantedGroupIds = page.grantedGroups.map((group) => {
-      const groupId = (group.item._id == null) ? group.item : group.item._id;
-      return groupId.toString();
-    });
+  generateDocContentsRelatedToRestriction(page: AggregatedPage) {
+    const grantedUserIds = page.grantedUsers.map(user => getIdForRef(user));
+    const grantedGroupIds = page.grantedGroups.map(group => getIdForRef(group.item));
 
     return {
       grant: page.grant,
@@ -380,10 +367,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     };
   }
 
-  prepareBodyForCreate(body, page) {
-    if (!Array.isArray(body)) {
-      throw new Error('Body must be an array.');
-    }
+  prepareBodyForCreate(page: AggregatedPage): [BulkWriteCommand, BulkWriteBody] {
 
     const command = {
       index: {
@@ -393,27 +377,22 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       },
     };
 
-    const bookmarkCount = page.bookmarkCount || 0;
-    const seenUsersCount = page.seenUsers?.length || 0;
-    let document = {
+    const document: BulkWriteBody = {
       path: page.path,
       body: page.revision.body,
-      // username: page.creator?.username, // available Node.js v14 and above
-      username: page.creator != null ? page.creator.username : null,
-      comments: page.comments,
-      comment_count: page.commentCount,
-      bookmark_count: bookmarkCount,
-      seenUsers_count: seenUsersCount,
-      like_count: page.liker?.length || 0,
+      username: page.creator?.username,
+      comments: page.commentsCount > 0 ? page.comments : undefined,
+      comment_count: page.commentsCount,
+      bookmark_count: page.bookmarksCount,
+      like_count: page.likeCount,
+      seenUsers_count: page.seenUsersCount,
       created_at: page.createdAt,
       updated_at: page.updatedAt,
       tag_names: page.tagNames,
+      ...this.generateDocContentsRelatedToRestriction(page),
     };
 
-    document = Object.assign(document, this.generateDocContentsRelatedToRestriction(page));
-
-    body.push(command);
-    body.push(document);
+    return [command, document];
   }
 
   prepareBodyForDelete(body, page) {
@@ -456,91 +435,29 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   async updateOrInsertPages(queryFactory, option: UpdateOrInsertPagesOpts = {}) {
     const { shouldEmitProgress = false, invokeGarbageCollection = false } = option;
 
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
     const { PageQueryBuilder } = Page;
-    const Bookmark = mongoose.model('Bookmark') as any; // TODO: typescriptize model
 
     const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;
 
     // prepare functions invoked from custom streams
     const prepareBodyForCreate = this.prepareBodyForCreate.bind(this);
-    const shouldIndexed = this.shouldIndexed.bind(this);
     const bulkWrite = this.client.bulk.bind(this.client);
 
-    const findQuery = new PageQueryBuilder(queryFactory()).query;
-    const countQuery = new PageQueryBuilder(queryFactory()).query;
+    const matchQuery = new PageQueryBuilder(queryFactory()).query;
 
+    const countQuery = new PageQueryBuilder(queryFactory()).query;
     const totalCount = await countQuery.count();
 
-    const readStream = findQuery
-      // populate data which will be referenced by prepareBodyForCreate()
-      .populate([
-        { path: 'creator', model: 'User', select: 'username' },
-        { path: 'revision', model: 'Revision', select: 'body' },
-      ])
-      .lean()
-      .cursor();
-
-    let skipped = 0;
-    const thinOutStream = new Transform({
-      objectMode: true,
-      async transform(doc, encoding, callback) {
-        if (shouldIndexed(doc)) {
-          this.push(doc);
-        }
-        else {
-          skipped++;
-        }
-        callback();
-      },
-    });
+    const maxBodyLengthToIndex = configManager.getConfig('crowi', 'app:elasticsearchMaxBodyLengthToIndex');
+
+    const readStream = Page.aggregate<AggregatedPage>(
+      aggregatePipelineToIndex(maxBodyLengthToIndex, matchQuery),
+    ).cursor();
 
     const bulkSize: number = configManager.getConfig('crowi', 'app:elasticsearchReindexBulkSize');
     const batchStream = createBatchStream(bulkSize);
 
-    const appendBookmarkCountStream = new Transform({
-      objectMode: true,
-      async transform(chunk, encoding, callback) {
-        const pageIds = chunk.map(doc => doc._id);
-
-        const idToCountMap = await Bookmark.getPageIdToCountMap(pageIds);
-        const idsHavingCount = Object.keys(idToCountMap);
-
-        // append count
-        chunk
-          .filter(doc => idsHavingCount.includes(doc._id.toString()))
-          .forEach((doc) => {
-            // append count from idToCountMap
-            doc.bookmarkCount = idToCountMap[doc._id.toString()];
-          });
-
-        this.push(chunk);
-        callback();
-      },
-    });
-
-
-    const appendCommentStream = new Transform({
-      objectMode: true,
-      async transform(chunk, encoding, callback) {
-        const pageIds = chunk.map(doc => doc._id);
-
-        const idToCommentMap = await Comment.getPageIdToCommentMap(pageIds);
-        const idsHavingComment = Object.keys(idToCommentMap);
-
-        // append comments
-        chunk
-          .filter(doc => idsHavingComment.includes(doc._id.toString()))
-          .forEach((doc) => {
-            // append comments from idToCommentMap
-            doc.comments = idToCommentMap[doc._id.toString()];
-          });
-
-        this.push(chunk);
-        callback();
-      },
-    });
-
     const appendTagNamesStream = new Transform({
       objectMode: true,
       async transform(chunk, encoding, callback) {
@@ -552,7 +469,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
         // append tagNames
         chunk
           .filter(doc => idsHavingTagNames.includes(doc._id.toString()))
-          .forEach((doc) => {
+          .forEach((doc: AggregatedPage) => {
             // append tagName from idToTagNamesMap
             doc.tagNames = idToTagNamesMap[doc._id.toString()];
           });
@@ -566,8 +483,10 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const writeStream = new Writable({
       objectMode: true,
       async write(batch, encoding, callback) {
-        const body = [];
-        batch.forEach(doc => prepareBodyForCreate(body, doc));
+        const body: (BulkWriteCommand|BulkWriteBody)[] = [];
+        batch.forEach((doc: AggregatedPage) => {
+          body.push(...prepareBodyForCreate(doc));
+        });
 
         try {
           const bulkResponse = await bulkWrite({
@@ -580,7 +499,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
           logger.info(`Adding pages progressing: (count=${count}, errors=${bulkResponse.errors}, took=${bulkResponse.took}ms)`);
 
           if (shouldEmitProgress) {
-            socket?.emit(SocketEventName.AddPageProgress, { totalCount, count, skipped });
+            socket?.emit(SocketEventName.AddPageProgress, { totalCount, count });
           }
         }
         catch (err) {
@@ -601,20 +520,17 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
         callback();
       },
       final(callback) {
-        logger.info(`Adding pages has completed: (totalCount=${totalCount}, skipped=${skipped})`);
+        logger.info(`Adding pages has completed: (totalCount=${totalCount})`);
 
         if (shouldEmitProgress) {
-          socket?.emit(SocketEventName.FinishAddPage, { totalCount, count, skipped });
+          socket?.emit(SocketEventName.FinishAddPage, { totalCount, count });
         }
         callback();
       },
     });
 
     readStream
-      .pipe(thinOutStream)
       .pipe(batchStream)
-      .pipe(appendBookmarkCountStream)
-      .pipe(appendCommentStream)
       .pipe(appendTagNamesStream)
       .pipe(writeStream);
 
@@ -977,30 +893,12 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
   async syncPageUpdated(page, user) {
     logger.debug('SearchClient.syncPageUpdated', page.path);
-
-    // delete if page should not indexed
-    if (!this.shouldIndexed(page)) {
-      try {
-        await this.deletePages([page]);
-      }
-      catch (err) {
-        logger.error('deletePages:ES Error', err);
-      }
-      return;
-    }
-
     return this.updateOrInsertPageById(page._id);
   }
 
   // remove pages whitch should nod Indexed
   async syncPagesUpdated(pages, user) {
     const shoudDeletePages: any[] = [];
-    pages.forEach((page) => {
-      logger.debug('SearchClient.syncPageUpdated', page.path);
-      if (!this.shouldIndexed(page)) {
-        shoudDeletePages.push(page);
-      }
-    });
 
     // delete if page should not indexed
     try {

+ 2 - 1
apps/app/src/server/service/slack-command-handler/keep.js

@@ -1,11 +1,12 @@
 import {
   inputBlock, actionsBlock, buttonElement, markdownSectionBlock,
 } from '@growi/slack/dist/utils/block-kit-builder';
+import { format } from 'date-fns/format';
+import { parse } from 'date-fns/parse';
 
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:SlackBotService:keep');
-const { parse, format } = require('date-fns');
 
 const { SlackCommandHandlerError } = require('../../models/vo/slack-command-handler-error');
 

+ 3 - 2
apps/app/src/server/service/slack-command-handler/togetter.js

@@ -2,13 +2,14 @@ import {
   inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider,
 } from '@growi/slack/dist/utils/block-kit-builder';
 import { respond, deleteOriginal } from '@growi/slack/dist/utils/response-url';
+import { format, formatDate } from 'date-fns/format';
+import { parse } from 'date-fns/parse';
 
 import loggerFactory from '~/utils/logger';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:SlackBotService:togetter');
 
-const { parse, format } = require('date-fns');
 
 const { SlackCommandHandlerError } = require('../../models/vo/slack-command-handler-error');
 
@@ -163,7 +164,7 @@ module.exports = (crowi) => {
         // include header
         else {
           const ts = (parseInt(message.ts) - grwTzoffset) * 1000;
-          const time = format(new Date(ts), 'h:mm a');
+          const time = formatDate(new Date(ts), 'h:mm a');
           cleanedContents.push(`${message.user}  ${time}\n${message.text}\n`);
           lastMessage = message;
         }

+ 27 - 54
apps/app/src/server/service/socket-io.js → apps/app/src/server/service/socket-io.ts

@@ -1,29 +1,41 @@
-import { GlobalSocketEventName } from '@growi/core/dist/interfaces';
+import type { IncomingMessage } from 'http';
+
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+import expressSession from 'express-session';
+import passport from 'passport';
+import type { Namespace } from 'socket.io';
 import { Server } from 'socket.io';
 
 import { SocketEventName } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 
+import type Crowi from '../crowi';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
-import { getYjsConnectionManager, extractPageIdFromYdocId } from './yjs-connection-manager';
-
+import { configManager } from './config-manager';
 
-const expressSession = require('express-session');
-const passport = require('passport');
 
 const logger = loggerFactory('growi:service:socket-io');
 
 
+type RequestWithUser = IncomingMessage & { user: IUserHasId };
+
 /**
  * Serve socket.io for server-to-client messaging
  */
 class SocketIoService {
 
+  crowi: Crowi;
+
+  guestClients: Set<string>;
+
+  io: Server;
+
+  adminNamespace: Namespace;
+
+
   constructor(crowi) {
     this.crowi = crowi;
-    this.configManager = crowi.configManager;
-
     this.guestClients = new Set();
   }
 
@@ -33,11 +45,9 @@ class SocketIoService {
 
   // Since the Order is important, attachServer() should be async
   async attachServer(server) {
-    this.io = new Server({
-      transports: ['websocket'],
+    this.io = new Server(server, {
       serveClient: false,
     });
-    this.io.attach(server);
 
     // create namespace for admin
     this.adminNamespace = this.io.of('/admin');
@@ -128,7 +138,7 @@ class SocketIoService {
 
   setupStoreGuestIdEventHandler() {
     this.io.on('connection', (socket) => {
-      if (socket.request.user == null) {
+      if ((socket.request as RequestWithUser).user == null) {
         this.guestClients.add(socket.id);
 
         socket.on('disconnect', () => {
@@ -140,7 +150,7 @@ class SocketIoService {
 
   setupLoginedUserRoomsJoinOnConnection() {
     this.io.on('connection', (socket) => {
-      const user = socket.request.user;
+      const user = (socket.request as RequestWithUser).user;
       if (user == null) {
         logger.debug('Socket io: An anonymous user has connected');
         return;
@@ -166,53 +176,16 @@ class SocketIoService {
     });
   }
 
-  setupYjsConnection() {
-    const yjsConnectionManager = getYjsConnectionManager();
-
-    this.io.on('connection', (socket) => {
-
-      yjsConnectionManager.ysocketioInstance.on('awareness-update', async(update) => {
-        const pageId = extractPageIdFromYdocId(update.name);
-        const awarenessStateSize = update.awareness.states.size;
-
-        // Triggered when awareness changes
-        this.io
-          .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
-          .emit(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSize);
-
-        // Triggered when the last user leaves the editor
-        if (awarenessStateSize === 0) {
-          const currentYdoc = yjsConnectionManager.getCurrentYdoc(pageId);
-          const yjsDraft = currentYdoc?.getText('codemirror').toString();
-          const hasRevisionBodyDiff = await this.crowi.pageService.hasRevisionBodyDiff(pageId, yjsDraft);
-          this.io
-            .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
-            .emit(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiff);
-        }
-      });
-
-      socket.on(GlobalSocketEventName.YDocSync, async({ pageId, initialValue }) => {
-        try {
-          await yjsConnectionManager.handleYDocSync(pageId, initialValue);
-        }
-        catch (error) {
-          logger.warn(error.message);
-          socket.emit(GlobalSocketEventName.YDocSyncError, 'An error occurred during YDoc synchronization.');
-        }
-      });
-    });
-  }
-
   async checkConnectionLimitsForAdmin(socket, next) {
     const namespaceName = socket.nsp.name;
 
     if (namespaceName === '/admin') {
-      const clients = await this.getAdminSocket().allSockets();
+      const clients = await this.getAdminSocket().fetchSockets();
       const clientsCount = clients.length;
 
       logger.debug('Current count of clients for \'/admin\':', clientsCount);
 
-      const limit = this.configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimitForAdmin');
+      const limit = configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimitForAdmin');
       if (limit <= clientsCount) {
         const msg = `The connection was refused because the current count of clients for '/admin' is ${clientsCount} and exceeds the limit`;
         logger.warn(msg);
@@ -231,7 +204,7 @@ class SocketIoService {
 
       logger.debug('Current count of clients for guests:', clientsCount);
 
-      const limit = this.configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimitForGuest');
+      const limit = configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimitForGuest');
       if (limit <= clientsCount) {
         const msg = `The connection was refused because the current count of clients for guests is ${clientsCount} and exceeds the limit`;
         logger.warn(msg);
@@ -253,12 +226,12 @@ class SocketIoService {
       next();
     }
 
-    const clients = await this.getDefaultSocket().allSockets();
+    const clients = await this.getDefaultSocket().fetchSockets();
     const clientsCount = clients.length;
 
     logger.debug('Current count of clients for \'/\':', clientsCount);
 
-    const limit = this.configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimit');
+    const limit = configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimit');
     if (limit <= clientsCount) {
       const msg = `The connection was refused because the current count of clients for '/' is ${clientsCount} and exceeds the limit`;
       logger.warn(msg);

+ 0 - 120
apps/app/src/server/service/yjs-connection-manager.ts

@@ -1,120 +0,0 @@
-import type { Server } from 'socket.io';
-import { MongodbPersistence } from 'y-mongodb-provider';
-import { YSocketIO, type Document as Ydoc } from 'y-socket.io/dist/server';
-import * as Y from 'yjs';
-
-import { getMongoUri } from '../util/mongoose-utils';
-
-const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
-const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
-
-export const extractPageIdFromYdocId = (ydocId: string): string | undefined => {
-  const result = ydocId.match(/yjs\/(.*)/);
-  return result?.[1];
-};
-
-class YjsConnectionManager {
-
-  private static instance: YjsConnectionManager;
-
-  private ysocketio: YSocketIO;
-
-  private mdb: MongodbPersistence;
-
-  get ysocketioInstance(): YSocketIO {
-    return this.ysocketio;
-  }
-
-  private constructor(io: Server) {
-    this.ysocketio = new YSocketIO(io);
-    this.ysocketio.initialize();
-
-    this.mdb = new MongodbPersistence(getMongoUri(), {
-      collectionName: MONGODB_PERSISTENCE_COLLECTION_NAME,
-      flushSize: MONGODB_PERSISTENCE_FLUSH_SIZE,
-    });
-  }
-
-  public static getInstance(io?: Server) {
-    if (this.instance != null) {
-      return this.instance;
-    }
-
-    if (io == null) {
-      throw new Error("'io' is required if initialize YjsConnectionManager");
-    }
-
-    this.instance = new YjsConnectionManager(io);
-    return this.instance;
-  }
-
-  public async handleYDocSync(pageId: string, initialValue: string): Promise<void> {
-    const currentYdoc = this.getCurrentYdoc(pageId);
-    if (currentYdoc == null) {
-      return;
-    }
-
-    const persistedYdoc = await this.getPersistedYdoc(pageId);
-    const persistedStateVector = Y.encodeStateVector(persistedYdoc);
-
-    await this.mdb.flushDocument(pageId);
-
-    // If no write operation has been performed, insert initial value
-    const clientsSize = persistedYdoc.store.clients.size;
-    if (clientsSize === 0) {
-      currentYdoc.getText('codemirror').insert(0, initialValue);
-    }
-
-    const diff = Y.encodeStateAsUpdate(currentYdoc, persistedStateVector);
-
-    if (diff.reduce((prev, curr) => prev + curr, 0) > 0) {
-      this.mdb.storeUpdate(pageId, diff);
-    }
-
-    Y.applyUpdate(currentYdoc, Y.encodeStateAsUpdate(persistedYdoc));
-
-    currentYdoc.on('update', async(update) => {
-      await this.mdb.storeUpdate(pageId, update);
-    });
-
-    currentYdoc.on('destroy', async() => {
-      await this.mdb.flushDocument(pageId);
-    });
-
-    persistedYdoc.destroy();
-  }
-
-  public async handleYDocUpdate(pageId: string, newValue: string): Promise<void> {
-    // TODO: https://redmine.weseek.co.jp/issues/132775
-    // It's necessary to confirm that the user is not editing the target page in the Editor
-    const currentYdoc = this.getCurrentYdoc(pageId);
-    if (currentYdoc == null) {
-      return;
-    }
-
-    const currentMarkdownLength = currentYdoc.getText('codemirror').length;
-    currentYdoc.getText('codemirror').delete(0, currentMarkdownLength);
-    currentYdoc.getText('codemirror').insert(0, newValue);
-    Y.encodeStateAsUpdate(currentYdoc);
-  }
-
-  public getCurrentYdoc(pageId: string): Ydoc | undefined {
-    const currentYdoc = this.ysocketio.documents.get(`yjs/${pageId}`);
-    return currentYdoc;
-  }
-
-  public async getPersistedYdoc(pageId: string): Promise<Y.Doc> {
-    const persistedYdoc = await this.mdb.getYDoc(pageId);
-    return persistedYdoc;
-  }
-
-}
-
-export const instantiateYjsConnectionManager = (io: Server): YjsConnectionManager => {
-  return YjsConnectionManager.getInstance(io);
-};
-
-// export the singleton instance
-export const getYjsConnectionManager = (): YjsConnectionManager => {
-  return YjsConnectionManager.getInstance();
-};

+ 43 - 0
apps/app/src/server/service/yjs/create-indexes.ts

@@ -0,0 +1,43 @@
+import mongoose from 'mongoose';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:yjs:create-indexes');
+
+export const createIndexes = async(collectionName: string): Promise<void> => {
+
+  const collection = mongoose.connection.collection(collectionName);
+
+  try {
+    await collection.createIndexes([
+      {
+        key: {
+          version: 1,
+          docName: 1,
+          action: 1,
+          clock: 1,
+          part: 1,
+        },
+      },
+      // for metaKey
+      {
+        key: {
+          version: 1,
+          docName: 1,
+          metaKey: 1,
+        },
+      },
+      // for flushDocument / clearDocument
+      {
+        key: {
+          docName: 1,
+          clock: 1,
+        },
+      },
+    ]);
+  }
+  catch (err) {
+    logger.error('Failed to create Index', err);
+    throw err;
+  }
+};

+ 55 - 0
apps/app/src/server/service/yjs/create-mongodb-persistence.ts

@@ -0,0 +1,55 @@
+import type { Persistence } from 'y-socket.io/dist/server';
+import * as Y from 'yjs';
+
+import loggerFactory from '~/utils/logger';
+
+import type { MongodbPersistence } from './extended/mongodb-persistence';
+
+const logger = loggerFactory('growi:service:yjs:create-mongodb-persistence');
+
+/**
+ * Based on the example by https://github.com/MaxNoetzold/y-mongodb-provider?tab=readme-ov-file#an-other-example
+ * @param mdb
+ * @returns
+ */
+export const createMongoDBPersistence = (mdb: MongodbPersistence): Persistence => {
+  const persistece: Persistence = {
+    provider: mdb,
+    bindState: async(docName, ydoc) => {
+      logger.debug('bindState', { docName });
+
+      const persistedYdoc = await mdb.getYDoc(docName);
+
+      // get the state vector so we can just store the diffs between client and server
+      const persistedStateVector = Y.encodeStateVector(persistedYdoc);
+      const diff = Y.encodeStateAsUpdate(ydoc, persistedStateVector);
+
+      // store the new data in db (if there is any: empty update is an array of 0s)
+      if (diff.reduce((previousValue, currentValue) => previousValue + currentValue, 0) > 0) {
+        mdb.storeUpdate(docName, diff);
+        mdb.setTypedMeta(docName, 'updatedAt', Date.now());
+      }
+
+      // send the persisted data to clients
+      Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc));
+
+      // store updates of the document in db
+      ydoc.on('update', async(update) => {
+        mdb.storeUpdate(docName, update);
+        mdb.setTypedMeta(docName, 'updatedAt', Date.now());
+      });
+
+      // cleanup some memory
+      persistedYdoc.destroy();
+    },
+    writeState: async(docName) => {
+      logger.debug('writeState', { docName });
+      // This is called when all connections to the document are closed.
+
+      // flush document on close to have the smallest possible database
+      await mdb.flushDocument(docName);
+    },
+  };
+
+  return persistece;
+};

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.