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

Merge branch 'master' into imprv/148445-upgrade-remark-growi-directive

reiji-h 1 год назад
Родитель
Сommit
8539b98902
84 измененных файлов с 1175 добавлено и 1008 удалено
  1. 0 5
      .changeset/metal-donkeys-collect.md
  2. 1 1
      .github/workflows/reusable-app-prod.yml
  3. 28 1
      CHANGELOG.md
  4. 1 1
      apps/app/docker/README.md
  5. 4 2
      apps/app/package.json
  6. 227 0
      apps/app/playwright/30-search/search.spect.ts
  7. 1 0
      apps/app/public/static/locales/en_US/translation.json
  8. 1 0
      apps/app/public/static/locales/fr_FR/translation.json
  9. 1 0
      apps/app/public/static/locales/ja_JP/translation.json
  10. 1 0
      apps/app/public/static/locales/zh_CN/translation.json
  11. 1 1
      apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx
  12. 2 2
      apps/app/src/client/components/Admin/G2GDataTransferExportForm.tsx
  13. 11 9
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  14. 1 1
      apps/app/src/client/components/Common/CountBadge.tsx
  15. 21 5
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  16. 1 2
      apps/app/src/components/Layout/AdminLayout.tsx
  17. 4 2
      apps/app/src/migrations/20180926134048-make-email-unique.js
  18. 8 6
      apps/app/src/migrations/20181019114028-abolish-page-group-relation.js
  19. 3 3
      apps/app/src/migrations/20190618104011-add-config-app-installed.js
  20. 4 2
      apps/app/src/migrations/20200402160380-remove-deleteduser-from-relationgroup.js
  21. 3 3
      apps/app/src/migrations/20200620203632-normalize-locale-id.js
  22. 4 3
      apps/app/src/migrations/20210906194521-slack-app-integration-set-default-value.js
  23. 4 3
      apps/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js
  24. 4 4
      apps/app/src/migrations/20211005120030-slack-app-integration-rename-keys.js
  25. 3 2
      apps/app/src/migrations/20220411114257-set-sparse-option-to-slack-member-id.js
  26. 1 1
      apps/app/src/migrations/20220613064207-add-attachment-type-to-existing-attachments.js
  27. 3 4
      apps/app/src/migrations/20230731075753-add_installed_date_to_config.js
  28. 2 1
      apps/app/src/models/admin/growi-archive-import-option.js
  29. 0 19
      apps/app/src/models/admin/import-option-for-pages.js
  30. 31 0
      apps/app/src/models/admin/import-option-for-pages.ts
  31. 3 5
      apps/app/src/models/admin/import-option-for-revisions.js
  32. 13 5
      apps/app/src/pages/[[...path]].page.tsx
  33. 2 1
      apps/app/src/pages/_app.page.tsx
  34. 2 1
      apps/app/src/pages/_document.page.tsx
  35. 7 0
      apps/app/src/pages/share/[[...path]].page.tsx
  36. 20 13
      apps/app/src/pages/utils/commons.ts
  37. 17 30
      apps/app/src/server/crowi/index.js
  38. 75 0
      apps/app/src/server/crowi/setup-models.ts
  39. 3 1
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js
  40. 3 1
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js
  41. 3 1
      apps/app/src/server/models/bookmark.js
  42. 0 27
      apps/app/src/server/models/index.ts
  43. 10 1
      apps/app/src/server/models/slack-app-integration.js
  44. 11 1
      apps/app/src/server/models/user.js
  45. 5 1
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  46. 36 44
      apps/app/src/server/routes/apiv3/import.js
  47. 0 32
      apps/app/src/server/routes/apiv3/overwrite-params/attachmentFiles.chunks.js
  48. 0 62
      apps/app/src/server/routes/apiv3/overwrite-params/pages.js
  49. 0 31
      apps/app/src/server/routes/apiv3/overwrite-params/revisions.js
  50. 6 5
      apps/app/src/server/routes/comment.js
  51. 10 5
      apps/app/src/server/service/activity.ts
  52. 7 6
      apps/app/src/server/service/comment.ts
  53. 9 1
      apps/app/src/server/service/export.js
  54. 10 9
      apps/app/src/server/service/g2g-transfer.ts
  55. 6 29
      apps/app/src/server/service/growi-bridge/index.ts
  56. 35 0
      apps/app/src/server/service/import/construct-convert-map.integ.ts
  57. 38 0
      apps/app/src/server/service/import/construct-convert-map.ts
  58. 20 0
      apps/app/src/server/service/import/get-model-from-collection-name.ts
  59. 6 0
      apps/app/src/server/service/import/import-mode.ts
  60. 10 0
      apps/app/src/server/service/import/import-settings.ts
  61. 61 0
      apps/app/src/server/service/import/import.spec.ts
  62. 106 156
      apps/app/src/server/service/import/import.ts
  63. 23 0
      apps/app/src/server/service/import/index.ts
  64. 49 0
      apps/app/src/server/service/import/overwrite-function.ts
  65. 19 0
      apps/app/src/server/service/import/overwrite-params/attachmentFiles.chunks.ts
  66. 33 0
      apps/app/src/server/service/import/overwrite-params/index.ts
  67. 48 0
      apps/app/src/server/service/import/overwrite-params/pages.ts
  68. 18 0
      apps/app/src/server/service/import/overwrite-params/revisions.ts
  69. 6 6
      apps/app/src/server/service/page/index.ts
  70. 1 0
      apps/app/src/server/service/page/page-service.ts
  71. 5 6
      apps/app/src/server/service/pre-notify.ts
  72. 1 0
      apps/app/src/server/service/search-delegator/private-legacy-pages.ts
  73. 1 4
      apps/app/src/server/util/mongoose-utils.ts
  74. 4 0
      apps/app/src/stores-universal/context.tsx
  75. 0 412
      apps/app/test/cypress/e2e/30-search/30-search--search.cy.ts
  76. 0 6
      apps/app/test/integration/crowi/crowi.test.js
  77. 3 2
      apps/app/test/integration/setup-crowi.ts
  78. 1 1
      apps/slackbot-proxy/package.json
  79. 1 1
      package.json
  80. 12 0
      packages/core/CHANGELOG.md
  81. 1 1
      packages/core/package.json
  82. 1 8
      packages/core/src/interfaces/locale.ts
  83. 9 6
      packages/core/src/interfaces/page.ts
  84. 29 4
      yarn.lock

+ 0 - 5
.changeset/metal-donkeys-collect.md

@@ -1,5 +0,0 @@
----
-'@growi/core': minor
----
-
-Transplant and re-implement serializers for User and Attachment

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

+ 28 - 1
CHANGELOG.md

@@ -1,9 +1,36 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.16...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.0.17...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.0.17](https://github.com/weseek/growi/compare/v7.0.16...v7.0.17) - 2024-08-26
+
+### 🚀 Improvement
+
+* imprv: Serializers for User model and Attachment model (#9019) @yuki-takei
+* imprv: translation modification (#9035) @maeshinshin
+* imprv: Add UI and logic for disabled user registration (#9034) @maeshinshin
+* imprv: lang attribute in Html element (#8960) @maeshinshin
+
+### 🐛 Bug Fixes
+
+* fix: Serializer for accessing to an empty page (#9042) @yuki-takei
+* fix: Import data (#8994) @yuki-takei
+* fix: Comment operation by API (#9026) @yuki-takei
+* fix: Tests fail due to docker image and Playwright  version mismatch on CI (#9022) @miya
+* fix: Use the scrollbar to prevent the toolbar from being hidden (#8976) @maeshinshin
+* fix: Revision pageId schema type (#9008) @yuki-takei
+* fix: Revision pageId schema type (add a changeset) (#9010) @yuki-takei
+* fix: Hide WideViewMenuItem in search result (#9009) @yuki-takei
+* fix: Wrongly autofocus to PageHeader even after updating (#9011) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Dark mode support for CountBadge (#9036) @satof3
+* support: Update import lines (#9018) @yuki-takei
+* support: Typescriptize REPL launcher (#9013) @yuki-takei
+
 ## [v7.0.16](https://github.com/weseek/growi/compare/v7.0.15...v7.0.16) - 2024-07-31
 
 ### 💎 Features

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

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.0.16`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.16/apps/app/docker/Dockerfile)
+* [`7.0.17`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.17/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)

+ 4 - 2
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.17-RC.0",
+  "version": "7.0.18-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -217,7 +217,8 @@
   "// comments for defDependencies": {
     "bootstrap": "v5.3.3 has a bug. refs: https://github.com/twbs/bootstrap/issues/39798",
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
-    "handsontable": "v7.0.0 or above is no loger MIT lisence."
+    "handsontable": "v7.0.0 or above is no loger MIT lisence.",
+    "mongodb": "mongoose which is used requires mongo@4.16.0."
   },
   "devDependencies": {
     "@growi/core-styles": "link:../../packages/core-styles",
@@ -261,6 +262,7 @@
     "jest-localstorage-mock": "^2.4.14",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
+    "mongodb": "4.16.0",
     "mongodb-memory-server-core": "^9.1.1",
     "morgan": "^1.10.0",
     "null-loader": "^4.0.1",

+ 227 - 0
apps/app/playwright/30-search/search.spect.ts

@@ -0,0 +1,227 @@
+import { test, expect } from '@playwright/test';
+
+test('Search page with "q" param is successfully loaded', async({ page }) => {
+  // Navigate to the search page with query parameters
+  await page.goto('/_search?q=alerts');
+
+  // Confirm search result elements are visible
+  await expect(page.getByTestId('search-result-base')).toBeVisible();
+  await expect(page.getByTestId('search-result-list')).toBeVisible();
+  await expect(page.getByTestId('search-result-content')).toBeVisible();
+  await expect(page.locator('.wiki')).toBeVisible();
+});
+
+test('checkboxes behaviors', async({ page }) => {
+  // Navigate to the search page with query parameters
+  await page.goto('/_search?q=alerts');
+
+  // Confirm search result elements are visible
+  await expect(page.getByTestId('search-result-base')).toBeVisible();
+  await expect(page.getByTestId('search-result-list')).toBeVisible();
+  await expect(page.getByTestId('search-result-content')).toBeVisible();
+  await expect(page.locator('.wiki')).toBeVisible();
+
+  // Click the first checkbox
+  await page.getByTestId('cb-select').first().click({ force: true });
+
+  // Unclick the first checkbox
+  await page.getByTestId('cb-select').first().click({ force: true });
+
+  // Click the select all checkbox
+  await page.getByTestId('delete-control-button').first().click({ force: true });
+  await page.getByTestId('cb-select-all').click({ force: true });
+
+  // Unclick the first checkbox after selecting all
+  await page.getByTestId('cb-select').first().click({ force: true });
+
+  // Click the first checkbox again
+  await page.getByTestId('cb-select').first().click({ force: true });
+
+  // Unclick the select all checkbox
+  await page.getByTestId('cb-select').first().click({ force: true });
+});
+
+
+test('successfully loads /_private-legacy-pages', async({ page }) => {
+  await page.goto('/_private-legacy-pages');
+
+  // Confirm search result elements are visible
+  await expect(page.locator('[data-testid="search-result-base"]')).toBeVisible();
+  await expect(page.locator('[data-testid="search-result-private-legacy-pages"]')).toBeVisible();
+});
+
+test('Search all pages by word', async({ page }) => {
+  await page.goto('/');
+  await page.getByTestId('open-search-modal-button').click();
+  await expect(page.getByTestId('search-modal')).toBeVisible();
+  await page.locator('.form-control').fill('sand');
+  await expect(page.locator('.search-menu-item').first()).toBeVisible();
+});
+
+test.describe.serial('Search all pages', () => {
+  const tag = 'help';
+  const searchText = `tag:${tag}`;
+
+  test('Successfully created tags', async({ page }) => {
+    await page.goto('/');
+
+    // open Edit Tags Modal to add tag
+    await page.locator('.grw-side-contents-sticky-container').isVisible();
+    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 page.locator('#tag-typeahead-asynctypeahead-item-0').click();
+    await page.getByTestId('tag-edit-done-btn').click();
+
+  });
+
+  test('Search all pages by tag is successfully loaded', async({ page }) => {
+    await page.goto('/');
+
+    // Search
+    await page.getByTestId('open-search-modal-button').click();
+    await expect(page.getByTestId('search-modal')).toBeVisible();
+    await page.locator('.form-control').fill(searchText);
+    await page.getByTestId('search-all-menu-item').click();
+
+    // Confirm search result elements are visible
+    const searchResultList = page.getByTestId('search-result-list');
+    await expect(searchResultList).toBeVisible();
+    await expect(searchResultList.locator('li')).toHaveCount(1);
+  });
+
+  test('Successfully order page search results by tag', async({ page }) => {
+    await page.goto('/');
+
+    await page.locator('.grw-tag-simple-bar').locator('a').click();
+
+    expect(page.getByTestId('search-result-base')).toBeVisible();
+    expect(page.getByTestId('search-result-list')).toBeVisible();
+    expect(page.getByTestId('search-result-content')).toBeVisible();
+  });
+});
+
+test.describe('Sort with dropdown', () => {
+  test.beforeEach(async({ page }) => {
+    await page.goto('/_search?q=sand');
+
+    await expect(page.getByTestId('search-result-base')).toBeVisible();
+    await expect(page.getByTestId('search-result-list')).toBeVisible();
+    await expect(page.getByTestId('search-result-content')).toBeVisible();
+
+    // open sort dropdown
+    await page.locator('.search-control').locator('button').first().click();
+  });
+
+  test('Open sort dropdown', async({ page }) => {
+    await expect(page.locator('.search-control .dropdown-menu.show')).toBeVisible();
+  });
+
+  test('Sort by relevance', async({ page }) => {
+    const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
+
+    await expect(dropdownMenu).toBeVisible();
+    await dropdownMenu.locator('.dropdown-item').nth(0).click();
+
+
+    await expect(page.getByTestId('search-result-base')).toBeVisible();
+    await expect(page.getByTestId('search-result-list')).toBeVisible();
+    await expect(page.getByTestId('search-result-content')).toBeVisible();
+  });
+
+  test('Sort by creation date', async({ page }) => {
+    const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
+
+    await expect(dropdownMenu).toBeVisible();
+    await dropdownMenu.locator('.dropdown-item').nth(1).click();
+
+
+    await expect(page.getByTestId('search-result-base')).toBeVisible();
+    await expect(page.getByTestId('search-result-list')).toBeVisible();
+    await expect(page.getByTestId('search-result-content')).toBeVisible();
+  });
+
+  test('Sort by last update date', async({ page }) => {
+    const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
+
+    await expect(dropdownMenu).toBeVisible();
+    await dropdownMenu.locator('.dropdown-item').nth(2).click();
+
+
+    await expect(page.getByTestId('search-result-base')).toBeVisible();
+    await expect(page.getByTestId('search-result-list')).toBeVisible();
+    await expect(page.getByTestId('search-result-content')).toBeVisible();
+  });
+});
+
+test.describe('Search and use', () => {
+  test.beforeEach(async({ page }) => {
+    await page.goto('/_search?q=alerts');
+
+    await expect(page.getByTestId('search-result-base')).toBeVisible();
+    await expect(page.getByTestId('search-result-list')).toBeVisible();
+    await expect(page.getByTestId('search-result-content')).toBeVisible();
+
+    await page.getByTestId('page-list-item-L').first().getByTestId('open-page-item-control-btn').click();
+    await expect(page.locator('.dropdown-menu.show')).toBeVisible();
+  });
+
+  test('Successfully the dropdown is opened', async({ page }) => {
+    await expect(page.locator('.dropdown-menu.show')).toBeVisible();
+  });
+
+  test('Successfully add bookmark', async({ page }) => {
+    const dropdonwMenu = page.locator('.dropdown-menu.show');
+
+    await expect(dropdonwMenu).toBeVisible();
+
+    // Add bookmark
+    await dropdonwMenu.getByTestId('add-bookmark-btn').click();
+
+    await expect(page.getByTestId('search-result-content').locator('.btn-bookmark.active').first()).toBeVisible();
+  });
+
+  test('Successfully open duplicate modal', async({ page }) => {
+    const dropdonwMenu = page.locator('.dropdown-menu.show');
+
+    await expect(dropdonwMenu).toBeVisible();
+
+    await dropdonwMenu.getByTestId('open-page-duplicate-modal-btn').click();
+
+    await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
+  });
+
+  test('Successfully open move/rename modal', async({ page }) => {
+    const dropdonwMenu = page.locator('.dropdown-menu.show');
+
+    await expect(dropdonwMenu).toBeVisible();
+
+    await dropdonwMenu.getByTestId('rename-page-btn').click();
+
+    await expect(page.getByTestId('page-rename-modal')).toBeVisible();
+  });
+
+  test('Successfully open delete modal', async({ page }) => {
+    const dropdonwMenu = page.locator('.dropdown-menu.show');
+
+    await expect(dropdonwMenu).toBeVisible();
+
+    await dropdonwMenu.getByTestId('open-page-delete-modal-btn').click();
+
+    await expect(page.getByTestId('page-delete-modal')).toBeVisible();
+  });
+});
+
+test('Search current tree by word is successfully loaded', async({ page }) => {
+  await page.goto('/');
+  const searchText = 'GROWI';
+
+  await page.getByTestId('open-search-modal-button').click();
+  await expect(page.getByTestId('search-modal')).toBeVisible();
+  await page.locator('.form-control').fill(searchText);
+  await page.getByTestId('search-prefix-menu-item').click();
+
+  await expect(page.getByTestId('search-result-base')).toBeVisible();
+  await expect(page.getByTestId('search-result-list')).toBeVisible();
+  await expect(page.getByTestId('search-result-content')).toBeVisible();
+});

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

@@ -765,6 +765,7 @@
     "receive_notifications": "Receive Notifications",
     "stop_notification": "Stop Notification",
     "footprints": "Footprints",
+    "login_required": "Login required",
     "operation": {
       "attention": {
         "rename": "Renaming paths of descendant pages was not successful, please open the menu from the 3-point reader and select 'Path recovery'"

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

@@ -759,6 +759,7 @@
     "receive_notifications": "Recevoir les notifications",
     "stop_notification": "Stopper les notifications",
     "footprints": "Visiteurs",
+    "login_required": "Connexion requise",
     "operation": {
       "attention": {
         "rename": "Échec du renommage du chemin des pages descendantes, ouvrir le menu du lecteur 3-points et sélectionner 'Récupération du chemin'"

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

@@ -798,6 +798,7 @@
     "receive_notifications": "通知を受け取る",
     "stop_notification": "通知を止める",
     "footprints": "足跡",
+    "login_required": "ログインが必要です",
     "operation": {
       "attention": {
         "rename": "配下のページパスの更新が正常に行われませんでした。3点リーダーからメニューを開き、「パスを修復」を選択してしてください。"

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

@@ -768,6 +768,7 @@
     "receive_notifications": "接收通知",
     "stop_notification": "停止通知",
     "footprints": "脚印",
+    "login_required": "需要登录",
     "operation": {
       "attention": {
         "rename": "重命名子孙页的路径没有成功,请从三点式阅读器上打开菜单,选择 '路径恢复'。"

+ 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', 'yjs-writings', 'transferkeys',
+  'sessions', 'rlflx', 'yjs-writings', 'transferkeys',
 ];
 
 const ExportArchiveDataPage = (): JSX.Element => {

+ 2 - 2
apps/app/src/client/components/Admin/G2GDataTransferExportForm.tsx

@@ -5,8 +5,8 @@ import React, {
 import { useTranslation } from 'next-i18next';
 
 import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
-import ImportOptionForPages from '~/models/admin/import-option-for-pages';
-import ImportOptionForRevisions from '~/models/admin/import-option-for-revisions';
+import { ImportOptionForPages } from '~/models/admin/import-option-for-pages';
+import { ImportOptionForRevisions } from '~/models/admin/import-option-for-revisions';
 
 import ImportCollectionConfigurationModal from './ImportData/GrowiArchive/ImportCollectionConfigurationModal';
 import ImportCollectionItem, { DEFAULT_MODE, MODE_RESTRICTED_COLLECTION } from './ImportData/GrowiArchive/ImportCollectionItem';

+ 11 - 9
apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -6,8 +6,8 @@ import PropTypes from 'prop-types';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
-import ImportOptionForPages from '~/models/admin/import-option-for-pages';
-import ImportOptionForRevisions from '~/models/admin/import-option-for-revisions';
+import { ImportOptionForPages } from '~/models/admin/import-option-for-pages';
+import { ImportOptionForRevisions } from '~/models/admin/import-option-for-revisions';
 import { useAdminSocket } from '~/stores/socket-io';
 
 
@@ -41,7 +41,7 @@ class ImportForm extends React.Component {
       isImporting: false,
       isImported: false,
       progressMap: [],
-      errorsMap: [],
+      errorsMap: {},
 
       selectedCollections: new Set(),
 
@@ -73,7 +73,7 @@ class ImportForm extends React.Component {
         : DEFAULT_MODE;
       // create GrowiArchiveImportOption instance
       const ImportOption = IMPORT_OPTION_CLASS_MAPPING[collectionName] || GrowiArchiveImportOption;
-      this.initialState.optionsMap[collectionName] = new ImportOption(initialMode);
+      this.initialState.optionsMap[collectionName] = new ImportOption(collectionName, initialMode);
     });
 
     this.state = this.initialState;
@@ -109,8 +109,10 @@ class ImportForm extends React.Component {
       const { progressMap, errorsMap } = this.state;
       progressMap[collectionName] = collectionProgress;
 
-      const errors = errorsMap[collectionName] || [];
-      errorsMap[collectionName] = errors.concat(appendedErrors);
+      if (appendedErrors != null) {
+        const errors = errorsMap[collectionName] || [];
+        errorsMap[collectionName] = errors.concat(appendedErrors);
+      }
 
       this.setState({
         isImporting: true,
@@ -303,7 +305,7 @@ class ImportForm extends React.Component {
       await apiv3Post('/import', {
         fileName,
         collections: Array.from(selectedCollections),
-        optionsMap,
+        options: Object.values(optionsMap),
       });
 
       if (onPostImport != null) {
@@ -378,7 +380,7 @@ class ImportForm extends React.Component {
       <div className="row">
         {collectionNames.map((collectionName) => {
           const collectionProgress = progressMap[collectionName];
-          const errors = errorsMap[collectionName];
+          const errorsCount = errorsMap[collectionName]?.length ?? 0;
           const isConfigButtonAvailable = Object.keys(IMPORT_OPTION_CLASS_MAPPING).includes(collectionName);
 
           return (
@@ -388,7 +390,7 @@ class ImportForm extends React.Component {
                 isImported={collectionProgress ? isImported : false}
                 insertedCount={collectionProgress ? collectionProgress.insertedCount : 0}
                 modifiedCount={collectionProgress ? collectionProgress.modifiedCount : 0}
-                errorsCount={errors ? errors.length : 0}
+                errorsCount={errorsCount}
                 collectionName={collectionName}
                 isSelected={selectedCollections.has(collectionName)}
                 option={optionsMap[collectionName]}

+ 1 - 1
apps/app/src/client/components/Common/CountBadge.tsx

@@ -11,7 +11,7 @@ const CountBadge: FC<CountProps> = (props:CountProps) => {
 
 
   return (
-    <span className="grw-count-badge px-2 badge rounded-pill bg-light text-dark">
+    <span className="grw-count-badge px-2 badge bg-body-tertiary text-body-tertiary">
       { count == null && <span className="text-muted">―</span> }
       { count != null && count + offset }
     </span>

+ 21 - 5
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -9,12 +9,13 @@ import type {
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
+import { auto } from '@popperjs/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
 import { useRouter } from 'next/router';
 import Sticky from 'react-stickynode';
-import { DropdownItem } from 'reactstrap';
+import { DropdownItem, UncontrolledTooltip } from 'reactstrap';
 
 import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
@@ -23,7 +24,7 @@ import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
 import {
   useCurrentPathname,
-  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
+  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsLocalAccountRegistrationEnabled, useIsSharedUser, useShareLinkId,
 } from '~/stores-universal/context';
 import { useEditorMode } from '~/stores-universal/ui';
 import {
@@ -233,6 +234,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: currentUser } = useCurrentUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
+  const { data: isLocalAccountRegistrationEnabled } = useIsLocalAccountRegistrationEnabled();
   const { data: isSharedUser } = useIsSharedUser();
 
   const shouldExpandContent = useShouldExpandContent(currentPage);
@@ -387,9 +389,23 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
             { isGuestUser && (
               <div className="mt-2">
-                <Link href="/login#register" className="btn me-2" prefetch={false}>
-                  <span className="material-symbols-outlined me-1">person_add</span>{t('Sign up')}
-                </Link>
+                <span>
+                  <span className="d-inline-block" id="sign-up-link">
+                    <Link
+                      href={!isLocalAccountRegistrationEnabled ? '#' : '/login#register'}
+                      className={`btn me-2 ${!isLocalAccountRegistrationEnabled ? 'opacity-25' : ''}`}
+                      style={{ pointerEvents: !isLocalAccountRegistrationEnabled ? 'none' : undefined }}
+                      prefetch={false}
+                    >
+                      <span className="material-symbols-outlined me-1">person_add</span>{t('Sign up')}
+                    </Link>
+                  </span>
+                  {!isLocalAccountRegistrationEnabled && (
+                    <UncontrolledTooltip target="sign-up-link" fade={false}>
+                      {t('tooltip.login_required')}
+                    </UncontrolledTooltip>
+                  )}
+                </span>
                 <Link href="/login#login" className="btn btn-primary" prefetch={false}>
                   <span className="material-symbols-outlined me-1">login</span>{t('Sign in')}
                 </Link>

+ 1 - 2
apps/app/src/components/Layout/AdminLayout.tsx

@@ -6,14 +6,13 @@ import Link from 'next/link';
 
 import GrowiLogo from '~/components/Common/GrowiLogo';
 
-import { AdminNavigation } from '../Admin/Common/AdminNavigation';
-
 import { RawLayout } from './RawLayout';
 
 
 import styles from './Admin.module.scss';
 
 
+const AdminNavigation = dynamic(() => import('../Admin/Common/AdminNavigation').then(mod => mod.AdminNavigation), { ssr: false });
 const PageCreateModal = dynamic(() => import('~/client/components/PageCreateModal'), { ssr: false });
 const SystemVersion = dynamic(() => import('~/client/components/SystemVersion'), { ssr: false });
 const HotkeysManager = dynamic(() => import('~/client/components/Hotkeys/HotkeysManager'), { ssr: false });

+ 4 - 2
apps/app/src/migrations/20180926134048-make-email-unique.js

@@ -1,8 +1,10 @@
 import mongoose from 'mongoose';
 
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import userModelFactory from '~/server/models/user';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
+
 const logger = loggerFactory('growi:migrate:make-email-unique');
 
 module.exports = {
@@ -11,7 +13,7 @@ module.exports = {
     logger.info('Start migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const User = getModelSafely('User') || require('~/server/models/user')();
+    const User = userModelFactory();
 
     // get all users who has 'deleted@deleted' email
     const users = await User.find({ email: 'deleted@deleted' });

+ 8 - 6
apps/app/src/migrations/20181019114028-abolish-page-group-relation.js

@@ -1,9 +1,11 @@
 import mongoose from 'mongoose';
 
-import getPageModel from '~/server/models/page';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import pageModelFactory from '~/server/models/page';
+import userGroupModelFactory from '~/server/models/user-group';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
+
 const logger = loggerFactory('growi:migrate:abolish-page-group-relation');
 
 async function isCollectionExists(db, collectionName) {
@@ -38,8 +40,8 @@ module.exports = {
       return;
     }
 
-    const Page = getModelSafely('Page') || getPageModel();
-    const UserGroup = getModelSafely('UserGroup') || require('~/server/models/user-group')();
+    const Page = pageModelFactory();
+    const UserGroup = userGroupModelFactory();
 
     // retrieve all documents from 'pagegrouprelations'
     const relations = await db.collection('pagegrouprelations').find().toArray();
@@ -75,8 +77,8 @@ module.exports = {
     logger.info('Rollback migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const Page = getModelSafely('Page') || getPageModel();
-    const UserGroup = getModelSafely('UserGroup') || require('~/server/models/user-group')();
+    const Page = pageModelFactory();
+    const UserGroup = userGroupModelFactory();
 
     // retrieve all Page documents which granted by UserGroup
     const relatedPages = await Page.find({ grant: Page.GRANT_USER_GROUP });

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

@@ -1,8 +1,8 @@
 import mongoose from 'mongoose';
 
-// eslint-disable-next-line import/no-named-as-default
 import { Config } from '~/server/models/config';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import userModelFactory from '~/server/models/user';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:add-config-app-installed');
@@ -21,7 +21,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const User = getModelSafely('User') || require('~/server/models/user')();
+    const User = userModelFactory();
 
     // find 'app:siteUrl'
     const appInstalled = await Config.findOne({

+ 4 - 2
apps/app/src/migrations/20200402160380-remove-deleteduser-from-relationgroup.js

@@ -1,9 +1,11 @@
 import mongoose from 'mongoose';
 
+import userModelFactory from '~/server/models/user';
 import UserGroupRelation from '~/server/models/user-group-relation';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
+
 const logger = loggerFactory('growi:migrate:remove-deleteduser-from-relationgroup');
 
 module.exports = {
@@ -11,7 +13,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const User = getModelSafely('User') || require('~/server/models/user')();
+    const User = userModelFactory();
 
     const deletedUsers = await User.find({ status: 4 }); // deleted user
     const requests = await UserGroupRelation.remove({ relatedUser: deletedUsers });

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

@@ -1,8 +1,8 @@
 import mongoose from 'mongoose';
 
-// eslint-disable-next-line import/no-named-as-default
 import { Config } from '~/server/models/config';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import userModelFactory from '~/server/models/user';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:normalize-locale-id');
@@ -12,7 +12,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const User = getModelSafely('User') || require('~/server/models/user')();
+    const User = userModelFactory();
 
     await Promise.all([
       // update en-US -> en_US

+ 4 - 3
apps/app/src/migrations/20210906194521-slack-app-integration-set-default-value.js

@@ -1,8 +1,10 @@
 import mongoose from 'mongoose';
 
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import slackAppIntegrationFactory from '~/server/models/slack-app-integration';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
+
 const logger = loggerFactory('growi:migrate:slack-app-integration-set-default-value');
 
 module.exports = {
@@ -10,8 +12,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    // Add columns + set all default commands if supportedCommandsForBroadcastUse column does not exist
-    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+    const SlackAppIntegration = slackAppIntegrationFactory();
 
     // Add togetter command if supportedCommandsForBroadcastUse already exists
     const slackAppIntegrations = await SlackAppIntegration.find();

+ 4 - 3
apps/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js

@@ -1,7 +1,8 @@
 import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
 import mongoose from 'mongoose';
 
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import slackAppIntegrationFactory from '~/server/models/slack-app-integration';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 
@@ -26,7 +27,7 @@ module.exports = {
       await mongoose.connect(getMongoUri(), mongoOptions);
     }
 
-    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+    const SlackAppIntegration = slackAppIntegrationFactory();
 
     const slackAppIntegrations = await SlackAppIntegration.find();
 
@@ -95,7 +96,7 @@ module.exports = {
     logger.info('Rollback migration');
     await mongoose.connect(getMongoUri(), mongoOptions);
 
-    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+    const SlackAppIntegration = slackAppIntegrationFactory();
 
     const slackAppIntegrations = await SlackAppIntegration.find();
 

+ 4 - 4
apps/app/src/migrations/20211005120030-slack-app-integration-rename-keys.js

@@ -1,16 +1,16 @@
 import mongoose from 'mongoose';
 
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import slackAppIntegrationFactory from '~/server/models/slack-app-integration';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
-
 const logger = loggerFactory('growi:migrate:slack-app-integration-rename-keys');
 
 module.exports = {
   async up(db) {
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+    const SlackAppIntegration = slackAppIntegrationFactory();
 
     const slackAppIntegrations = await SlackAppIntegration.find();
 
@@ -47,7 +47,7 @@ module.exports = {
   async down(db, next) {
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+    const SlackAppIntegration = slackAppIntegrationFactory();
 
     const slackAppIntegrations = await SlackAppIntegration.find();
 

+ 3 - 2
apps/app/src/migrations/20220411114257-set-sparse-option-to-slack-member-id.js

@@ -1,6 +1,7 @@
 import mongoose from 'mongoose';
 
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import userModelFactory from '~/server/models/user';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:set-sparse-option-to-slack-member-id');
@@ -13,7 +14,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const User = getModelSafely('User') || require('~/server/models/user')();
+    const User = userModelFactory();
     await User.syncIndexes();
 
     logger.info('Migration has successfully applied');

+ 1 - 1
apps/app/src/migrations/20220613064207-add-attachment-type-to-existing-attachments.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 import { AttachmentType } from '~/server/interfaces/attachment';
-import { Attachment } from '~/server/models';
+import { Attachment } from '~/server/models/attachment';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

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

@@ -1,9 +1,8 @@
-// eslint-disable-next-line import/no-named-as-default
 import { Config } from '~/server/models/config';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import userModelFactory from '~/server/models/user';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
-
 const logger = loggerFactory('growi:migrate:add-installed-date-to-config');
 
 const mongoose = require('mongoose');
@@ -12,7 +11,7 @@ module.exports = {
   async up() {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
-    const User = getModelSafely('User') || require('~/server/models/user')();
+    const User = userModelFactory();
 
     const appInstalled = await Config.findOne({ key: 'app:installed' });
     if (appInstalled != null && appInstalled.createdAt == null) {

+ 2 - 1
apps/app/src/models/admin/growi-archive-import-option.js

@@ -1,6 +1,7 @@
 class GrowiArchiveImportOption {
 
-  constructor(mode, initProps = {}) {
+  constructor(collectionName, mode, initProps = {}) {
+    this.collectionName = collectionName;
     this.mode = mode;
 
     Object.entries(initProps).forEach(([key, value]) => {

+ 0 - 19
apps/app/src/models/admin/import-option-for-pages.js

@@ -1,19 +0,0 @@
-const GrowiArchiveImportOption = require('./growi-archive-import-option');
-
-const DEFAULT_PROPS = {
-  isOverwriteAuthorWithCurrentUser: false,
-  makePublicForGrant2: false,
-  makePublicForGrant4: false,
-  makePublicForGrant5: false,
-  initPageMetadatas: false,
-};
-
-class ImportOptionForPages extends GrowiArchiveImportOption {
-
-  constructor(mode, initProps) {
-    super(mode, initProps || DEFAULT_PROPS);
-  }
-
-}
-
-module.exports = ImportOptionForPages;

+ 31 - 0
apps/app/src/models/admin/import-option-for-pages.ts

@@ -0,0 +1,31 @@
+import GrowiArchiveImportOption from './growi-archive-import-option';
+
+const DEFAULT_PROPS = {
+  isOverwriteAuthorWithCurrentUser: false,
+  makePublicForGrant2: false,
+  makePublicForGrant4: false,
+  makePublicForGrant5: false,
+  initPageMetadatas: false,
+};
+
+export class ImportOptionForPages extends GrowiArchiveImportOption {
+
+  isOverwriteAuthorWithCurrentUser;
+
+  makePublicForGrant2;
+
+  makePublicForGrant4;
+
+  makePublicForGrant5;
+
+  initPageMetadatas;
+
+  constructor(collectionName: string, mode: string, initProps) {
+    super(collectionName, mode, initProps || DEFAULT_PROPS);
+  }
+
+}
+
+export const isImportOptionForPages = (opt: GrowiArchiveImportOption): opt is ImportOptionForPages => {
+  return 'isOverwriteAuthorWithCurrentUser' in opt;
+};

+ 3 - 5
apps/app/src/models/admin/import-option-for-revisions.js

@@ -4,12 +4,10 @@ const DEFAULT_PROPS = {
   isOverwriteAuthorWithCurrentUser: false,
 };
 
-class ImportOptionForRevisions extends GrowiArchiveImportOption {
+export class ImportOptionForRevisions extends GrowiArchiveImportOption {
 
-  constructor(mode, initProps) {
-    super(mode, initProps || DEFAULT_PROPS);
+  constructor(collectionName, mode, initProps) {
+    super(collectionName, mode, initProps || DEFAULT_PROPS);
   }
 
 }
-
-module.exports = ImportOptionForRevisions;

+ 13 - 5
apps/app/src/pages/[[...path]].page.tsx

@@ -3,9 +3,9 @@ import React, { useEffect } from 'react';
 
 import EventEmitter from 'events';
 
-import { isIPageInfoForEntity } from '@growi/core';
+import { isIPageInfo } from '@growi/core';
 import type {
-  IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision,
+  IDataWithMeta, IPageInfo, IPagePopulatedToShowRevision,
 } from '@growi/core';
 import {
   isClient, pagePathUtils, pathUtils,
@@ -25,6 +25,7 @@ import { PageView } from '~/components/PageView/PageView';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { SupportedAction, type SupportedActionType } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { RegistrationMode } from '~/interfaces/registration-mode';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { CurrentPageYjsData } from '~/interfaces/yjs';
@@ -43,6 +44,7 @@ import {
   useIsAllReplyShown, useIsContainerFluid, useIsNotCreatable,
   useIsUploadAllFileAllowed, useIsUploadEnabled,
   useElasticsearchMaxBodyLengthToIndex,
+  useIsLocalAccountRegistrationEnabled,
 } from '~/stores-universal/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import {
@@ -98,7 +100,7 @@ const {
 } = pagePathUtils;
 const { removeHeadingSlash } = pathUtils;
 
-type IPageToShowRevisionWithMeta = IDataWithMeta<IPagePopulatedToShowRevision & PageDocument, IPageInfoForEntity>;
+type IPageToShowRevisionWithMeta = IDataWithMeta<IPagePopulatedToShowRevision & PageDocument, IPageInfo>;
 type IPageToShowRevisionWithMetaSerialized = IDataWithMeta<string, string>;
 
 superjson.registerCustom<IPageToShowRevisionWithMeta, IPageToShowRevisionWithMetaSerialized>(
@@ -106,8 +108,7 @@ superjson.registerCustom<IPageToShowRevisionWithMeta, IPageToShowRevisionWithMet
     isApplicable: (v): v is IPageToShowRevisionWithMeta => {
       return v?.data != null
         && v?.data.toObject != null
-        && v?.meta != null
-        && isIPageInfoForEntity(v.meta);
+        && isIPageInfo(v.meta);
     },
     serialize: (v) => {
       return {
@@ -155,6 +156,8 @@ type Props = CommonProps & {
   templateTagData?: string[],
   templateBodyData?: string,
 
+  isLocalAccountRegistrationEnabled: boolean,
+
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
@@ -237,6 +240,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useIsUploadAllFileAllowed(props.isUploadAllFileAllowed);
   useIsUploadEnabled(props.isUploadEnabled);
 
+  useIsLocalAccountRegistrationEnabled(props.isLocalAccountRegistrationEnabled);
+
   const { pageWithMeta } = props;
 
   const pageId = pageWithMeta?.data._id;
@@ -556,6 +561,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isUploadAllFileAllowed = crowi.fileUploadService.getFileUploadEnabled();
   props.isUploadEnabled = crowi.fileUploadService.getIsUploadable();
 
+  props.isLocalAccountRegistrationEnabled = crowi.passportService.isLocalStrategySetup
+  && configManager.getConfig('crowi', 'security:registrationMode') !== RegistrationMode.CLOSED;
+
   props.adminPreferredIndentSize = configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize');
   props.isIndentSizeForced = configManager.getConfig('markdown', 'markdown:isIndentSizeForced');
 

+ 2 - 1
apps/app/src/pages/_app.page.tsx

@@ -1,6 +1,7 @@
 import type { ReactElement, ReactNode } from 'react';
 import React, { useEffect } from 'react';
 
+import type { Locale } from '@growi/core';
 import type { NextPage } from 'next';
 import { appWithTranslation } from 'next-i18next';
 import type { AppContext, AppProps } from 'next/app';
@@ -29,7 +30,7 @@ export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
 
 type GrowiAppProps = AppProps & {
   Component: NextPageWithLayout,
-  userLocale: string,
+  userLocale: Locale,
 };
 
 // register custom serializer

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

@@ -1,6 +1,7 @@
 /* eslint-disable @next/next/google-font-display */
 import React from 'react';
 
+import type { Locale } from '@growi/core';
 import type { DocumentContext, DocumentInitialProps } from 'next/document';
 import Document, {
   Html, Head, Main, NextScript,
@@ -43,7 +44,7 @@ interface GrowiDocumentProps {
   customCss: string | null,
   customNoscript: string | null,
   pluginResourceEntries: GrowiPluginResourceEntries;
-  locale: string;
+  locale: Locale;
 }
 declare type GrowiDocumentInitialProps = DocumentInitialProps & GrowiDocumentProps;
 

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

@@ -15,6 +15,7 @@ import { ShareLinkPageView } from '~/components/ShareLinkPageView';
 import type { SupportedActionType } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { RegistrationMode } from '~/interfaces/registration-mode';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
 import type { PageDocument } from '~/server/models/page';
@@ -22,6 +23,7 @@ import ShareLink from '~/server/models/share-link';
 import {
   useCurrentUser, useRendererConfig, useIsSearchPage, useCurrentPathname,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useIsContainerFluid, useIsEnabledMarp,
+  useIsLocalAccountRegistrationEnabled,
 } from '~/stores-universal/context';
 import { useCurrentPageId, useIsNotFound, useSWRMUTxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
@@ -48,6 +50,7 @@ type Props = CommonProps & {
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
   isEnabledMarp: boolean,
+  isLocalAccountRegistrationEnabled: boolean,
   drawioUri: string | null,
   rendererConfig: RendererConfig,
   skipSSR: boolean,
@@ -98,6 +101,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsEnabledMarp(props.rendererConfig.isEnabledMarp);
+  useIsLocalAccountRegistrationEnabled(props.isLocalAccountRegistrationEnabled);
   useIsContainerFluid(props.isContainerFluid);
 
   const { trigger: mutateCurrentPage, data: currentPage } = useSWRMUTxCurrentPage();
@@ -163,6 +167,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
   props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
 
+  props.isLocalAccountRegistrationEnabled = crowi.passportService.isLocalStrategySetup
+    && configManager.getConfig('crowi', 'security:registrationMode') !== RegistrationMode.CLOSED;
+
   props.rendererConfig = {
     isSharedPage: true,
     isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),

+ 20 - 13
apps/app/src/pages/utils/commons.ts

@@ -1,5 +1,5 @@
-import type { ColorScheme, IUserHasId } from '@growi/core';
-import { Lang, AllLang, Locale } from '@growi/core';
+import type { ColorScheme, IUserHasId, Locale } from '@growi/core';
+import { Lang, AllLang } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { isServer } from '@growi/core/dist/utils';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
@@ -110,18 +110,24 @@ export type LangMap = {
 };
 
 export const langMap: LangMap = {
-  [Lang.ja_JP]: Locale['ja-JP'],
-  [Lang.en_US]: Locale['en-US'],
-  [Lang.zh_CN]: Locale['zh-CN'],
-  [Lang.fr_FR]: Locale['fr-FR'],
-};
-
-export const getLocaleAtServerSide = (req: CrowiRequest): Locale => {
+  [Lang.ja_JP]: 'ja-JP',
+  [Lang.en_US]: 'en-US',
+  [Lang.zh_CN]: 'zh-CN',
+  [Lang.fr_FR]: 'fr-FR',
+} as const;
+
+// use this function to translate content
+export const getLangAtServerSide = (req: CrowiRequest): Lang => {
   const { user, headers } = req;
   const { configManager } = req.crowi;
 
-  return langMap[user == null ? detectLocaleFromBrowserAcceptLanguage(headers)
-    : (user.lang ?? configManager.getConfig('crowi', 'app:globalLang') as Lang ?? Lang.en_US) ?? Lang.en_US];
+  return user == null ? detectLocaleFromBrowserAcceptLanguage(headers)
+    : (user.lang ?? configManager.getConfig('crowi', 'app:globalLang') as Lang ?? Lang.en_US) ?? Lang.en_US;
+};
+
+// use this function to get locale for html lang attribute
+export const getLocaleAtServerSide = (req: CrowiRequest): Locale => {
+  return langMap[getLangAtServerSide(req)];
 };
 
 export const getNextI18NextConfig = async(
@@ -135,7 +141,7 @@ export const getNextI18NextConfig = async(
 
   // determine language
   const req: CrowiRequest = context.req as CrowiRequest;
-  const locale = getLocaleAtServerSide(req);
+  const lang = getLangAtServerSide(req);
 
   const namespaces = ['commons'];
   if (namespacesRequired != null) {
@@ -146,7 +152,8 @@ export const getNextI18NextConfig = async(
     namespaces.push('translation');
   }
 
-  return serverSideTranslations(locale, namespaces, nextI18NextConfig, preloadAllLang ? AllLang : false);
+  // The first argument must be a language code with an underscore, such as en_US
+  return serverSideTranslations(lang, namespaces, nextI18NextConfig, preloadAllLang ? AllLang : false);
 };
 
 /**

+ 17 - 30
apps/app/src/server/crowi/index.js

@@ -18,7 +18,6 @@ import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 import UserEvent from '../events/user';
-import { modelsDependsOnCrowi } from '../models';
 import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
@@ -26,6 +25,7 @@ import { configManager as configManagerSingletonInstance } from '../service/conf
 import { instanciate as instanciateExternalAccountService } from '../service/external-account';
 import { FileUploader, getUploader } from '../service/file-uploader'; // eslint-disable-line no-unused-vars
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
+import { initializeImportService } from '../service/import';
 import { InstallerService } from '../service/installer';
 import { normalizeData } from '../service/normalize-data';
 import PageService from '../service/page';
@@ -38,7 +38,9 @@ import { SocketIoService } from '../service/socket-io';
 import UserGroupService from '../service/user-group';
 import { UserNotificationService } from '../service/user-notification';
 import { initializeYjsService } from '../service/yjs';
-import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
+import { getModelSafely, getMongoUri, mongoOptions } from '../util/mongoose-utils';
+
+import { setupModelsDependentOnCrowi } from './setup-models';
 
 
 const logger = loggerFactory('growi:crowi');
@@ -60,6 +62,9 @@ class Crowi {
   /** @type {FileUploader} */
   fileUploadService;
 
+  /** @type {SocketIoService} */
+  socketIoService;
+
   constructor() {
     this.version = pkg.version;
     this.runtimeVersions = undefined; // initialized by scanRuntimeVersions()
@@ -87,7 +92,6 @@ class Crowi {
     this.restQiitaAPIService = null;
     this.growiBridgeService = null;
     this.exportService = null;
-    this.importService = null;
     this.pluginService = null;
     this.searchService = null;
     this.socketIoService = null;
@@ -101,6 +105,7 @@ class Crowi {
 
     this.tokens = null;
 
+    /** @type {import('./setup-models').ModelsMapDependentOnCrowi} */
     this.models = {};
 
     this.env = process.env;
@@ -122,7 +127,7 @@ class Crowi {
 
 Crowi.prototype.init = async function() {
   await this.setupDatabase();
-  await this.setupModels();
+  this.models = await setupModelsDependentOnCrowi(this);
   await this.setupConfigManager();
   await this.setupSessionConfig();
   this.setupCron();
@@ -210,14 +215,13 @@ Crowi.prototype.getEnv = function() {
   return this.env;
 };
 
-// getter/setter of model instance
-//
-Crowi.prototype.model = function(name, model) {
-  if (model != null) {
-    this.models[name] = model;
-  }
-
-  return this.models[name];
+/**
+ * Wrapper function of mongoose.model()
+ * @param {string} modelName
+ * @returns {mongoose.Model}
+ */
+Crowi.prototype.model = function(modelName) {
+  return getModelSafely(modelName);
 };
 
 // getter/setter of event instance
@@ -305,20 +309,6 @@ Crowi.prototype.setupSocketIoService = async function() {
   this.socketIoService = new SocketIoService(this);
 };
 
-Crowi.prototype.setupModels = async function() {
-  Object.keys(modelsDependsOnCrowi).forEach((key) => {
-    const factory = modelsDependsOnCrowi[key];
-
-    if (!(factory instanceof Function)) {
-      logger.warn(`modelsDependsOnCrowi['${key}'] is not a function. skipped.`);
-      return;
-    }
-
-    return this.model(key, modelsDependsOnCrowi[key](this));
-  });
-
-};
-
 Crowi.prototype.setupCron = function() {
   this.questionnaireCronService = new QuestionnaireCronService(this);
   this.questionnaireCronService.startCron();
@@ -692,10 +682,7 @@ Crowi.prototype.setupExport = async function() {
 };
 
 Crowi.prototype.setupImport = async function() {
-  const ImportService = require('../service/import');
-  if (this.importService == null) {
-    this.importService = new ImportService(this);
-  }
+  initializeImportService(this);
 };
 
 Crowi.prototype.setupGrowiPluginService = async function() {

+ 75 - 0
apps/app/src/server/crowi/setup-models.ts

@@ -0,0 +1,75 @@
+import type { Model } from 'mongoose';
+
+import loggerFactory from '~/utils/logger';
+
+import type Crowi from '.';
+
+const logger = loggerFactory('growi:crowi:setup-models');
+
+export type ModelsMapDependentOnCrowi = {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  [modelName: string]: Model<any>,
+}
+
+export const setupModelsDependentOnCrowi = async(crowi: Crowi): Promise<ModelsMapDependentOnCrowi> => {
+  const modelsMap: ModelsMapDependentOnCrowi = {};
+
+  const modelsDependsOnCrowi = {
+    Page: (await import('../models/page')).default,
+    User: (await import('../models/user')).default,
+    Bookmark: (await import('../models/bookmark')).default,
+    GlobalNotificationSetting: (await import('../models/GlobalNotificationSetting')).default,
+    GlobalNotificationMailSetting: (await import('../models/GlobalNotificationSetting/GlobalNotificationMailSetting')).default,
+    GlobalNotificationSlackSetting: (await import('../models/GlobalNotificationSetting/GlobalNotificationSlackSetting')).default,
+    SlackAppIntegration: (await import('../models/slack-app-integration')).default,
+  };
+
+  Object.keys(modelsDependsOnCrowi).forEach((modelName) => {
+    const factory = modelsDependsOnCrowi[modelName];
+
+    if (!(factory instanceof Function)) {
+      logger.warn(`modelsDependsOnCrowi['${modelName}'] is not a function. skipped.`);
+      return;
+    }
+
+    modelsMap[modelName] = factory(crowi);
+  });
+
+  return modelsMap;
+};
+
+export const setupIndependentModels = async(): Promise<void> => {
+  await Promise.all([
+    import('~/features/comment/server/models'),
+    import('~/features/external-user-group/server/models/external-user-group-relation'),
+    import('~/features/external-user-group/server/models/external-user-group'),
+    import('~/features/growi-plugin/server/models'),
+    import('~/features/questionnaire/server/models/proactive-questionnaire-answer'),
+    import('~/features/questionnaire/server/models/questionnaire-answer-status'),
+    import('~/features/questionnaire/server/models/questionnaire-answer'),
+    import('~/features/questionnaire/server/models/questionnaire-order'),
+    import('../models/activity'),
+    import('../models/attachment'),
+    import('../models/bookmark-folder'),
+    import('../models/config'),
+    import('../models/editor-settings'),
+    import('../models/external-account'),
+    import('../models/in-app-notification-settings'),
+    import('../models/in-app-notification'),
+    import('../models/named-query'),
+    import('../models/page-operation'),
+    import('../models/page-redirect'),
+    import('../models/page-tag-relation'),
+    import('../models/password-reset-order'),
+    import('../models/revision'),
+    import('../models/share-link'),
+    import('../models/subscription'),
+    import('../models/tag'),
+    import('../models/transfer-key'),
+    import('../models/update-post'),
+    import('../models/user-group-relation'),
+    import('../models/user-group'),
+    import('../models/user-registration-order'),
+    import('../models/user-ui-settings'),
+  ]);
+};

+ 3 - 1
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js

@@ -7,7 +7,7 @@ const GlobalNotificationSetting = require('./index');
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
 const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
 
-module.exports = function(crowi) {
+const factory = (crowi) => {
   GlobalNotificationSettingClass.crowi = crowi;
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
 
@@ -23,3 +23,5 @@ module.exports = function(crowi) {
 
   return GlobalNotificationMailSettingModel;
 };
+
+export default factory;

+ 3 - 1
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js

@@ -7,7 +7,7 @@ const GlobalNotificationSetting = require('./index');
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
 const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
 
-module.exports = function(crowi) {
+const factory = (crowi) => {
   GlobalNotificationSettingClass.crowi = crowi;
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
 
@@ -23,3 +23,5 @@ module.exports = function(crowi) {
 
   return GlobalNotificationSlackSettingModel;
 };
+
+export default factory;

+ 3 - 1
apps/app/src/server/models/bookmark.js

@@ -7,7 +7,7 @@ const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
-module.exports = function(crowi) {
+const factory = (crowi) => {
   const bookmarkEvent = crowi.event('bookmark');
 
   let bookmarkSchema = null;
@@ -114,3 +114,5 @@ module.exports = function(crowi) {
 
   return mongoose.model('Bookmark', bookmarkSchema);
 };
+
+export default factory;

+ 0 - 27
apps/app/src/server/models/index.ts

@@ -1,27 +0,0 @@
-import GlobalNotificationSettingFactory from './GlobalNotificationSetting';
-import Page from './page';
-
-export const modelsDependsOnCrowi = {
-  Page,
-  PageTagRelation: require('./page-tag-relation'),
-  User: require('./user'),
-  Bookmark: require('./bookmark'),
-  GlobalNotificationSetting: GlobalNotificationSettingFactory,
-  GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
-  GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
-  SlackAppIntegration: require('./slack-app-integration'),
-};
-
-// setup models that independent from crowi
-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';
-export * as PageTagRelation from './page-tag-relation';
-
-export * from './serializers';
-
-export * from './GlobalNotificationSetting';

+ 10 - 1
apps/app/src/server/models/slack-app-integration.js

@@ -3,6 +3,8 @@ import crypto from 'crypto';
 import { defaultSupportedSlackEventActions } from '@growi/slack';
 import mongoose from 'mongoose';
 
+import { getModelSafely } from '../util/mongoose-utils';
+
 
 const schema = new mongoose.Schema({
   tokenGtoP: { type: String, required: true, unique: true },
@@ -53,8 +55,15 @@ class SlackAppIntegration {
 
 }
 
-module.exports = function(crowi) {
+const factory = (crowi) => {
+  const modelExists = getModelSafely('SlackAppIntegration');
+  if (modelExists != null) {
+    return modelExists;
+  }
+
   SlackAppIntegration.crowi = crowi;
   schema.loadClass(SlackAppIntegration);
   return mongoose.model('SlackAppIntegration', schema);
 };
+
+export default factory;

+ 11 - 1
apps/app/src/server/models/user.js

@@ -7,6 +7,8 @@ import { i18n } from '^/config/next-i18next.config';
 import { generateGravatarSrc } from '~/utils/gravatar';
 import loggerFactory from '~/utils/logger';
 
+import { getModelSafely } from '../util/mongoose-utils';
+
 import { Attachment } from './attachment';
 
 
@@ -18,7 +20,13 @@ const uniqueValidator = require('mongoose-unique-validator');
 
 const logger = loggerFactory('growi:models:user');
 
-module.exports = function(crowi) {
+const factory = (crowi) => {
+
+  const userModelExists = getModelSafely('User');
+  if (userModelExists != null) {
+    return userModelExists;
+  }
+
   const STATUS_REGISTERED = 1;
   const STATUS_ACTIVE = 2;
   const STATUS_SUSPENDED = 3;
@@ -783,3 +791,5 @@ module.exports = function(crowi) {
 
   return mongoose.model('User', userSchema);
 };
+
+export default factory;

+ 5 - 1
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -11,6 +11,7 @@ import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
 import { configManager } from '~/server/service/config-manager';
 import type { IDataGROWIInfo } from '~/server/service/g2g-transfer';
 import { X_GROWI_TRANSFER_KEY_HEADER_NAME } from '~/server/service/g2g-transfer';
+import { getImportService } from '~/server/service/import';
 import loggerFactory from '~/utils/logger';
 import { TransferKey } from '~/utils/vo/transfer-key';
 
@@ -39,9 +40,12 @@ const validator = {
  */
 module.exports = (crowi: Crowi): Router => {
   const {
-    g2gTransferPusherService, g2gTransferReceiverService, exportService, importService,
+    g2gTransferPusherService, g2gTransferReceiverService, exportService,
     growiBridgeService,
   } = crowi;
+
+  const importService = getImportService();
+
   if (g2gTransferPusherService == null || g2gTransferReceiverService == null || exportService == null || importService == null
     || growiBridgeService == null || configManager == null) {
     throw Error('GROWI is not ready for g2g transfer');

+ 36 - 44
apps/app/src/server/routes/apiv3/import.js

@@ -1,15 +1,12 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { getImportService } from '~/server/service/import';
+import { generateOverwriteParams } from '~/server/service/import/overwrite-params';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 
-import overwriteParamsAttachmentFilesChunks from './overwrite-params/attachmentFiles.chunks';
-import overwriteParamsPages from './overwrite-params/pages';
-import overwriteParamsRevisions from './overwrite-params/revisions';
-
-
 const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
 
 const path = require('path');
@@ -18,9 +15,6 @@ const express = require('express');
 const multer = require('multer');
 
 
-const GrowiArchiveImportOption = require('~/models/admin/growi-archive-import-option');
-
-
 const router = express.Router();
 
 /**
@@ -29,6 +23,21 @@ const router = express.Router();
  *    name: Import
  */
 
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      GrowiArchiveImportOption:
+ *        description: GrowiArchiveImportOption
+ *        type: object
+ *        properties:
+ *          mode:
+ *            description: Import mode
+ *            type: string
+ *            enum: [insert, upsert, flushAndInsert]
+ */
+
 /**
  * @swagger
  *
@@ -51,27 +60,10 @@ const router = express.Router();
  *            description: whether the current importing job exists or not
  */
 
-/**
- * generate overwrite params with overwrite-params/* modules
- * @param {string} collectionName
- * @param {string} operatorUserId Operator user id
- * @param {GrowiArchiveImportOption} options GrowiArchiveImportOption instance
- */
-export const generateOverwriteParams = (collectionName, operatorUserId, options) => {
-  switch (collectionName) {
-    case 'pages':
-      return overwriteParamsPages(operatorUserId, options);
-    case 'revisions':
-      return overwriteParamsRevisions(operatorUserId, options);
-    case 'attachmentFiles.chunks':
-      return overwriteParamsAttachmentFilesChunks(operatorUserId, options);
-    default:
-      return {};
-  }
-};
-
 export default function route(crowi) {
-  const { growiBridgeService, importService, socketIoService } = crowi;
+  const { growiBridgeService, socketIoService } = crowi;
+  const importService = getImportService(crowi);
+
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
@@ -199,29 +191,27 @@ export default function route(crowi) {
    *                  type: array
    *                  items:
    *                    type: string
-   *                optionsMap:
+   *                options:
    *                  description: |
-   *                    the map object of importing option that have collection name as the key
+   *                    the array of importing option that have collection name as the key
    *                  additionalProperties:
-   *                    type: object
-   *                    properties:
-   *                      mode:
-   *                        description: Import mode
-   *                        type: string
-   *                        enum: [insert, upsert, flushAndInsert]
+   *                    type: array
+   *                    items:
+   *                      $ref: '#/components/schemas/GrowiArchiveImportOption'
    *      responses:
    *        200:
    *          description: Import process has requested
    */
   router.post('/', accessTokenParser, loginRequired, adminRequired, addActivity, async(req, res) => {
     // TODO: add express validator
-    const { fileName, collections, optionsMap } = req.body;
+    const { fileName, collections, options } = req.body;
 
     // pages collection can only be imported by upsert if isV5Compatible is true
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const isImportPagesCollection = collections.includes('pages');
     if (isV5Compatible && isImportPagesCollection) {
-      const option = new GrowiArchiveImportOption(null, optionsMap.pages);
+      /** @type {ImportOptionForPages} */
+      const option = options.find(opt => opt.collectionName === 'pages');
       if (option.mode !== 'upsert') {
         return res.apiv3Err(new ErrorV3('Upsert is only available for importing pages collection.', 'only_upsert_available'));
       }
@@ -278,14 +268,16 @@ export default function route(crowi) {
     const importSettingsMap = {};
     fileStatsToImport.forEach(({ fileName, collectionName }) => {
       // instanciate GrowiArchiveImportOption
-      const options = new GrowiArchiveImportOption(null, optionsMap[collectionName]);
+      /** @type {GrowiArchiveImportOption} */
+      const option = options.find(opt => opt.collectionName === collectionName);
 
       // generate options
-      const importSettings = importService.generateImportSettings(options.mode);
-      importSettings.jsonFileName = fileName;
-
-      // generate overwrite params
-      importSettings.overwriteParams = generateOverwriteParams(collectionName, req.user._id, options);
+      /** @type {import('~/server/service/import').ImportSettings} */
+      const importSettings = {
+        mode: option.mode,
+        jsonFileName: fileName,
+        overwriteParams: generateOverwriteParams(collectionName, req.user._id, option),
+      };
 
       importSettingsMap[collectionName] = importSettings;
     });

+ 0 - 32
apps/app/src/server/routes/apiv3/overwrite-params/attachmentFiles.chunks.js

@@ -1,32 +0,0 @@
-const { Binary } = require('mongodb');
-const { ObjectId } = require('mongoose').Types;
-
-class AttachmentFilesChunksOverwriteParamsFactory {
-
-  /**
-   * generate overwrite params object
-   * @param {string} operatorUserId
-   * @param {ImportOptionForPages} option
-   * @return object
-   *  key: property name
-   *  value: any value or a function `(value, { document, schema, propertyName }) => { return newValue }`
-   */
-  static generate(operatorUserId, option) {
-    const params = {};
-
-    // Date
-    params.files_id = (value, { document, schema, propertyName }) => {
-      return ObjectId(value);
-    };
-
-    // Binary
-    params.data = (value, { document, schema, propertyName }) => {
-      return Binary(value);
-    };
-
-    return params;
-  }
-
-}
-
-module.exports = (operatorUserId, option) => AttachmentFilesChunksOverwriteParamsFactory.generate(operatorUserId, option);

+ 0 - 62
apps/app/src/server/routes/apiv3/overwrite-params/pages.js

@@ -1,62 +0,0 @@
-import { PageGrant } from '@growi/core';
-
-const mongoose = require('mongoose');
-
-// eslint-disable-next-line no-unused-vars
-const ImportOptionForPages = require('~/models/admin/import-option-for-pages');
-
-const { ObjectId } = mongoose.Types;
-
-class PageOverwriteParamsFactory {
-
-  /**
-   * generate overwrite params object
-   * @param {string} operatorUserId
-   * @param {ImportOptionForPages} option
-   * @return object
-   *  key: property name
-   *  value: any value or a function `(value, { document, schema, propertyName }) => { return newValue }`
-   */
-  static generate(operatorUserId, option) {
-    const params = {};
-
-    if (option.isOverwriteAuthorWithCurrentUser) {
-      const userId = ObjectId(operatorUserId);
-      params.creator = userId;
-      params.lastUpdateUser = userId;
-    }
-
-    params.grant = (value, { document, schema, propertyName }) => {
-      if (option.makePublicForGrant2 && value === 2) {
-        return PageGrant.GRANT_PUBLIC;
-      }
-      if (option.makePublicForGrant4 && value === 4) {
-        return PageGrant.GRANT_PUBLIC;
-      }
-      if (option.makePublicForGrant5 && value === 5) {
-        return PageGrant.GRANT_PUBLIC;
-      }
-      return value;
-    };
-
-    params.parent = (value, { document, schema, propertyName }) => {
-      return null;
-    };
-
-    params.descendantCount = (value, { document, schema, propertyName }) => {
-      return 0;
-    };
-
-    if (option.initPageMetadatas) {
-      params.liker = [];
-      params.seenUsers = [];
-      params.commentCount = 0;
-      params.extended = {};
-    }
-
-    return params;
-  }
-
-}
-
-module.exports = (operatorUserId, option) => PageOverwriteParamsFactory.generate(operatorUserId, option);

+ 0 - 31
apps/app/src/server/routes/apiv3/overwrite-params/revisions.js

@@ -1,31 +0,0 @@
-const mongoose = require('mongoose');
-
-// eslint-disable-next-line no-unused-vars
-const ImportOptionForPages = require('~/models/admin/import-option-for-pages');
-
-const { ObjectId } = mongoose.Types;
-
-class RevisionOverwriteParamsFactory {
-
-  /**
-   * generate overwrite params object
-   * @param {string} operatorUserId
-   * @param {ImportOptionForPages} option
-   * @return object
-   *  key: property name
-   *  value: any value or a function `(value, { document, schema, propertyName }) => { return newValue }`
-   */
-  static generate(operatorUserId, option) {
-    const params = {};
-
-    if (option.isOverwriteAuthorWithCurrentUser) {
-      const userId = ObjectId(operatorUserId);
-      params.author = userId;
-    }
-
-    return params;
-  }
-
-}
-
-module.exports = (operatorUserId, option) => RevisionOverwriteParamsFactory.generate(operatorUserId, option);

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

@@ -275,6 +275,7 @@ module.exports = function(crowi, app) {
       action: SupportedAction.ACTION_COMMENT_CREATE,
     };
 
+    /** @type {import('../service/pre-notify').GetAdditionalTargetUsers} */
     const getAdditionalTargetUsers = async(activity) => {
       const mentionedUsers = await crowi.commentService.getMentionedUsers(activity.event);
 
@@ -367,9 +368,9 @@ module.exports = function(crowi, app) {
   api.update = async function(req, res) {
     const { commentForm } = req.body;
 
-    const commentStr = commentForm.comment;
-    const commentId = commentForm.comment_id;
-    const revision = commentForm.revision_id;
+    const commentStr = commentForm?.comment;
+    const commentId = commentForm?.comment_id;
+    const revision = commentForm?.revision_id;
 
     if (commentStr === '') {
       return res.json(ApiResponse.error('Comment text is required'));
@@ -393,7 +394,7 @@ module.exports = function(crowi, app) {
       if (!isAccessible) {
         throw new Error('Current user is not accessible to this page.');
       }
-      if (req.user.id !== comment.creator.toString()) {
+      if (req.user._id.toString() !== comment.creator.toString()) {
         throw new Error('Current user is not operatable to this comment.');
       }
 
@@ -476,7 +477,7 @@ module.exports = function(crowi, app) {
       if (!isAccessible) {
         throw new Error('Current user is not accessible to this page.');
       }
-      if (req.user.id !== comment.creator.toString()) {
+      if (req.user._id !== comment.creator.toString()) {
         throw new Error('Current user is not operatable to this comment.');
       }
 

+ 10 - 5
apps/app/src/server/service/activity.ts

@@ -1,14 +1,16 @@
 import type { IPage } from '@growi/core';
 import mongoose from 'mongoose';
 
+import type { IActivity, SupportedActionType } from '~/interfaces/activity';
 import {
-  IActivity, SupportedActionType, AllSupportedActions, ActionGroupSize,
+  AllSupportedActions, ActionGroupSize,
   AllEssentialActions, AllSmallGroupActions, AllMediumGroupActions, AllLargeGroupActions,
 } from '~/interfaces/activity';
-import Activity, { ActivityDocument } from '~/server/models/activity';
+import type { ActivityDocument } from '~/server/models/activity';
+import Activity from '~/server/models/activity';
 
 import loggerFactory from '../../utils/logger';
-import Crowi from '../crowi';
+import type Crowi from '../crowi';
 
 
 import type { GeneratePreNotify, GetAdditionalTargetUsers } from './pre-notify';
@@ -134,12 +136,15 @@ class ActivityService {
   createTtlIndex = async function() {
     const configManager = this.crowi.configManager;
     const activityExpirationSeconds = configManager != null ? configManager.getConfig('crowi', 'app:activityExpirationSeconds') : 2592000;
-    const collection = mongoose.connection.collection('activities');
 
     try {
-      const targetField = 'createdAt_1';
+      // create the collection with indexes at first
+      await Activity.createIndexes();
 
+      const collection = mongoose.connection.collection('activities');
       const indexes = await collection.indexes();
+
+      const targetField = 'createdAt_1';
       const foundCreatedAt = indexes.find(i => i.name === targetField);
 
       const isNotSpec = foundCreatedAt?.expireAfterSeconds == null || foundCreatedAt?.expireAfterSeconds !== activityExpirationSeconds;

+ 7 - 6
apps/app/src/server/service/comment.ts

@@ -1,10 +1,11 @@
-import { Types } from 'mongoose';
+import type { Types } from 'mongoose';
 
 import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
+import pageModelFactory from '~/server/models/page';
 
 import loggerFactory from '../../utils/logger';
-import Crowi from '../crowi';
-import { getModelSafely } from '../util/mongoose-utils';
+import type Crowi from '../crowi';
+import userModelFactory from '../models/user';
 
 // https://regex101.com/r/Ztxj2j/1
 const USERNAME_PATTERN = new RegExp(/\B@[\w@.-]+/g);
@@ -33,7 +34,7 @@ class CommentService {
     commentEvent.on(CommentEvent.CREATE, async(savedComment) => {
 
       try {
-        const Page = getModelSafely('Page') || require('../models/page')(this.crowi);
+        const Page = pageModelFactory(this.crowi);
         await Page.updateCommentCount(savedComment.page);
       }
       catch (err) {
@@ -49,7 +50,7 @@ class CommentService {
     // remove
     commentEvent.on(CommentEvent.DELETE, async(removedComment) => {
       try {
-        const Page = getModelSafely('Page') || require('../models/page')(this.crowi);
+        const Page = pageModelFactory(this.crowi);
         await Page.updateCommentCount(removedComment.page);
       }
       catch (err) {
@@ -59,7 +60,7 @@ class CommentService {
   }
 
   getMentionedUsers = async(commentId: Types.ObjectId): Promise<Types.ObjectId[]> => {
-    const User = getModelSafely('User') || require('../models/user')(this.crowi);
+    const User = userModelFactory(this.crowi);
 
     // Get comment by comment ID
     const commentData = await Comment.findOne({ _id: commentId });

+ 9 - 1
apps/app/src/server/service/export.js

@@ -36,7 +36,6 @@ class ExportService {
     this.crowi = crowi;
     this.appService = crowi.appService;
     this.growiBridgeService = crowi.growiBridgeService;
-    this.getFile = this.growiBridgeService.getFile.bind(this);
     this.baseDir = path.join(crowi.tmpDir, 'downloads');
     this.per = 100;
     this.zlibLevel = 9; // 0(min) - 9(max)
@@ -46,6 +45,15 @@ class ExportService {
     this.currentProgressingStatus = null;
   }
 
+  /**
+   *
+   * @param {string} fileName
+   * @returns {string} path to the file
+   */
+  getFile(fileName) {
+    return this.growiBridgeService.getFile(fileName, this.baseDir);
+  }
+
   /**
    * parse all zip files in downloads dir
    *

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

@@ -12,8 +12,7 @@ import mongoose, { Types as MongooseTypes } from 'mongoose';
 import { G2G_PROGRESS_STATUS } from '~/interfaces/g2g-transfer';
 import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
 import TransferKeyModel from '~/server/models/transfer-key';
-import { generateOverwriteParams } from '~/server/routes/apiv3/import';
-import { type ImportSettings } from '~/server/service/import';
+import { getImportService, ImportMode, type ImportSettings } from '~/server/service/import';
 import { createBatchStream } from '~/server/util/batch-stream';
 import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
@@ -24,6 +23,7 @@ import { Attachment } from '../models/attachment';
 import { G2GTransferError, G2GTransferErrorCode } from '../models/vo/g2g-transfer-error';
 
 import { configManager } from './config-manager';
+import { generateOverwriteParams } from './import/overwrite-params';
 
 const logger = loggerFactory('growi:service:g2g-transfer');
 
@@ -607,13 +607,11 @@ export class G2GTransferReceiverService implements Receiver {
       optionsMap: { [key: string]: GrowiArchiveImportOption; },
       operatorUserId: string,
   ): { [key: string]: ImportSettings; } {
-    const { importService } = this.crowi;
-
     const importSettingsMap = {};
     innerFileStats.forEach(({ fileName, collectionName }) => {
       const options = new GrowiArchiveImportOption(null, optionsMap[collectionName]);
 
-      if (collectionName === 'configs' && options.mode !== 'flushAndInsert') {
+      if (collectionName === 'configs' && options.mode !== ImportMode.flushAndInsert) {
         throw new Error('`flushAndInsert` is only available as an import setting for configs collection');
       }
       if (collectionName === 'pages' && options.mode === 'insert') {
@@ -626,9 +624,11 @@ export class G2GTransferReceiverService implements Receiver {
         throw new Error('`attachmentFiles.files` must not be transferred. Please omit it from request body `collections`.');
       }
 
-      const importSettings = importService.generateImportSettings(options.mode);
-      importSettings.jsonFileName = fileName;
-      importSettings.overwriteParams = generateOverwriteParams(collectionName, operatorUserId, options);
+      const importSettings: ImportSettings = {
+        mode: options.mode,
+        jsonFileName: fileName,
+        overwriteParams: generateOverwriteParams(collectionName, operatorUserId, options),
+      };
       importSettingsMap[collectionName] = importSettings;
     });
 
@@ -640,7 +640,8 @@ export class G2GTransferReceiverService implements Receiver {
       importSettingsMap: { [key: string]: ImportSettings; },
       sourceGROWIUploadConfigs: FileUploadConfigs,
   ): Promise<void> {
-    const { importService, appService } = this.crowi;
+    const { appService } = this.crowi;
+    const importService = getImportService();
     /** whether to keep current file upload configs */
     const shouldKeepUploadConfigs = configManager.getConfig('crowi', 'app:fileUploadType') !== 'none';
 

+ 6 - 29
apps/app/src/server/service/growi-bridge/index.ts

@@ -1,14 +1,13 @@
-import { Model } from 'mongoose';
+import fs from 'fs';
+import path from 'path';
+
+import streamToPromise from 'stream-to-promise';
 import unzipStream, { type Entry } from 'unzip-stream';
 
 import loggerFactory from '~/utils/logger';
 
 import { tapStreamDataByPromise } from './unzip-stream-utils';
 
-const fs = require('fs');
-const path = require('path');
-
-const streamToPromise = require('stream-to-promise');
 
 const logger = loggerFactory('growi:services:GrowiBridgeService'); // eslint-disable-line no-unused-vars
 
@@ -52,35 +51,13 @@ class GrowiBridgeService {
     return this.metaFileName;
   }
 
-  /**
-   * get a model from collection name
-   *
-   * @memberOf GrowiBridgeService
-   * @param {string} collectionName collection name
-   * @return {object} instance of mongoose model
-   */
-  getModelFromCollectionName(collectionName) {
-    const Model = Object.values(this.crowi.models).find((m: Model<unknown>) => {
-      return m.collection != null && m.collection.name === collectionName;
-    });
-
-    return Model;
-  }
-
   /**
    * get the absolute path to a file
-   * this method must must be bound to the caller (this.baseDir is undefined in this service)
    *
    * @memberOf GrowiBridgeService
-   * @param {string} fileName base name of file
-   * @return {string} absolute path to the file
    */
-  getFile(fileName) {
-    if (this.baseDir == null) {
-      throw new Error('baseDir is not defined');
-    }
-
-    const jsonFile = path.join(this.baseDir, fileName);
+  getFile(fileName: string, baseDir: string): string {
+    const jsonFile = path.join(baseDir, fileName);
 
     // throws err if the file does not exist
     fs.accessSync(jsonFile);

+ 35 - 0
apps/app/src/server/service/import/construct-convert-map.integ.ts

@@ -0,0 +1,35 @@
+import type { EventEmitter } from 'events';
+
+import { mock } from 'vitest-mock-extended';
+
+import type Crowi from '~/server/crowi';
+import { setupIndependentModels, setupModelsDependentOnCrowi } from '~/server/crowi/setup-models';
+
+import { constructConvertMap } from './construct-convert-map';
+
+describe('constructConvertMap', () => {
+
+  beforeAll(async() => {
+    const events = {
+      page: mock<EventEmitter>(),
+      user: mock<EventEmitter>(),
+    };
+    const crowiMock = mock<Crowi>({
+      event: (name: string) => events[name],
+    });
+
+    await setupModelsDependentOnCrowi(crowiMock);
+    await setupIndependentModels();
+  });
+
+  test('should return convert map', () => {
+    // arrange
+
+    // act
+    const result = constructConvertMap();
+
+    // assert
+    expect(result).not.toBeNull();
+    expect(Object.keys(result).length).toEqual(36);
+  });
+});

+ 38 - 0
apps/app/src/server/service/import/construct-convert-map.ts

@@ -0,0 +1,38 @@
+import mongoose from 'mongoose';
+
+import type { OverwriteFunction } from './overwrite-function';
+import { keepOriginal } from './overwrite-function';
+
+
+export type ConvertMap = {
+  [collectionName: string]: {
+    [propertyName: string]: OverwriteFunction,
+  }
+}
+
+/**
+ * Initialize convert map. set keepOriginal as default
+ *
+ * @param {Crowi} crowi Crowi instance
+ */
+export const constructConvertMap = (): ConvertMap => {
+  const convertMap: ConvertMap = {};
+
+  mongoose.modelNames().forEach((modelName) => {
+    const model = mongoose.model(modelName);
+
+    if (model.collection == null) {
+      return;
+    }
+
+    const collectionName = model.collection.name;
+
+    convertMap[collectionName] = {};
+
+    for (const key of Object.keys(model.schema.paths)) {
+      convertMap[collectionName][key] = keepOriginal;
+    }
+  });
+
+  return convertMap;
+};

+ 20 - 0
apps/app/src/server/service/import/get-model-from-collection-name.ts

@@ -0,0 +1,20 @@
+import type { Model } from 'mongoose';
+import mongoose from 'mongoose';
+
+/**
+   * get a model from collection name
+   *
+   * @memberOf GrowiBridgeService
+   * @param collectionName collection name
+   * @return instance of mongoose model
+   */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const getModelFromCollectionName = (collectionName: string): Model<any, unknown, unknown, unknown, any> | undefined => {
+  const models = mongoose.modelNames().map(modelName => mongoose.model(modelName));
+
+  const Model = Object.values(models).find((m) => {
+    return m.collection != null && m.collection.name === collectionName;
+  });
+
+  return Model;
+};

+ 6 - 0
apps/app/src/server/service/import/import-mode.ts

@@ -0,0 +1,6 @@
+export const ImportMode = {
+  insert: 'insert',
+  upsert: 'upsert',
+  flushAndInsert: 'flushAndInsert',
+} as const;
+export type ImportMode = typeof ImportMode[keyof typeof ImportMode];

+ 10 - 0
apps/app/src/server/service/import/import-settings.ts

@@ -0,0 +1,10 @@
+import type { ImportMode } from './import-mode';
+import type { OverwriteFunction } from './overwrite-function';
+
+export type OverwriteParams = { [propertyName: string]: OverwriteFunction | unknown }
+
+export type ImportSettings = {
+  mode: ImportMode,
+  jsonFileName: string,
+  overwriteParams: OverwriteParams,
+}

+ 61 - 0
apps/app/src/server/service/import/import.spec.ts

@@ -0,0 +1,61 @@
+import { mock } from 'vitest-mock-extended';
+
+import type Crowi from '~/server/crowi';
+
+import { ImportService } from './import';
+
+
+const mocks = vi.hoisted(() => {
+  return {
+    constructConvertMapMock: vi.fn(),
+    setupIndependentModelsMock: vi.fn(),
+  };
+});
+
+vi.mock('~/server/crowi/setup-models', () => ({
+  setupIndependentModels: mocks.setupIndependentModelsMock,
+}));
+vi.mock('./construct-convert-map', () => ({
+  constructConvertMap: mocks.constructConvertMapMock,
+}));
+
+
+/**
+ * Get private property from ImportService
+ */
+const getPrivateProperty = <T>(importService: ImportService, propertyName: string): T => {
+  return importService[propertyName];
+};
+
+
+describe('ImportService', () => {
+
+  let importService: ImportService;
+
+  beforeAll(async() => {
+    const crowiMock = mock<Crowi>({
+      growiBridgeService: {
+        getFile: vi.fn(),
+      },
+      tmpDir: '/tmp',
+    });
+
+    importService = new ImportService(crowiMock);
+  });
+
+  describe('preImport', () => {
+    test('should call setupIndependentModels', async() => {
+      // arrange
+      const convertMapMock = mock();
+      mocks.constructConvertMapMock.mockImplementation(() => convertMapMock);
+
+      // act
+      await importService.preImport();
+
+      // assert
+      expect(mocks.setupIndependentModelsMock).toHaveBeenCalledOnce();
+      expect(mocks.constructConvertMapMock).toHaveBeenCalledOnce();
+      expect(getPrivateProperty(importService, 'convertMap')).toStrictEqual(convertMapMock);
+    });
+  });
+});

+ 106 - 156
apps/app/src/server/service/import.js → apps/app/src/server/service/import/import.ts

@@ -1,27 +1,34 @@
-/**
- * @typedef {import("@types/unzip-stream").Parse} Parse
- * @typedef {import("@types/unzip-stream").Entry} Entry
- */
+import fs from 'fs';
+import path from 'path';
+import type { EventEmitter } from 'stream';
+import { Writable, Transform } from 'stream';
 
-import { parseISO } from 'date-fns/parseISO';
+import JSONStream from 'JSONStream';
 import gc from 'expose-gc/function';
-
+import type {
+  BulkWriteResult, MongoBulkWriteError, UnorderedBulkOperation, WriteError,
+} from 'mongodb';
+import type { Document } from 'mongoose';
+import mongoose from 'mongoose';
+import streamToPromise from 'stream-to-promise';
+import unzipStream from 'unzip-stream';
+
+import type Crowi from '~/server/crowi';
+import { setupIndependentModels } from '~/server/crowi/setup-models';
+import type CollectionProgress from '~/server/models/vo/collection-progress';
 import loggerFactory from '~/utils/logger';
 
-const fs = require('fs');
-const path = require('path');
-const { Writable, Transform } = require('stream');
-
-const JSONStream = require('JSONStream');
-const isIsoDate = require('is-iso-date');
-const mongoose = require('mongoose');
-const streamToPromise = require('stream-to-promise');
-const unzipStream = require('unzip-stream');
+import CollectionProgressingStatus from '../../models/vo/collection-progressing-status';
+import { createBatchStream } from '../../util/batch-stream';
+import { configManager } from '../config-manager';
 
-const CollectionProgressingStatus = require('../models/vo/collection-progressing-status');
-const { createBatchStream } = require('../util/batch-stream');
+import type { ConvertMap } from './construct-convert-map';
+import { constructConvertMap } from './construct-convert-map';
+import { getModelFromCollectionName } from './get-model-from-collection-name';
+import { ImportMode } from './import-mode';
+import type { ImportSettings, OverwriteParams } from './import-settings';
+import { keepOriginal } from './overwrite-function';
 
-const { ObjectId } = mongoose.Types;
 
 const logger = loggerFactory('growi:services:ImportService'); // eslint-disable-line no-unused-vars
 
@@ -29,18 +36,10 @@ const logger = loggerFactory('growi:services:ImportService'); // eslint-disable-
 const BULK_IMPORT_SIZE = 100;
 
 
-export class ImportSettings {
-
-  constructor(mode) {
-    this.mode = mode || 'insert';
-    this.jsonFileName = null;
-    this.overwriteParams = null;
-  }
-
-}
-
 class ImportingCollectionError extends Error {
 
+  collectionProgress: CollectionProgress;
+
   constructor(collectionProgress, error) {
     super(error);
     this.collectionProgress = collectionProgress;
@@ -49,83 +48,33 @@ class ImportingCollectionError extends Error {
 }
 
 
-class ImportService {
+export class ImportService {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-    this.growiBridgeService = crowi.growiBridgeService;
-    this.getFile = this.growiBridgeService.getFile.bind(this);
-    this.baseDir = path.join(crowi.tmpDir, 'imports');
-    this.keepOriginal = this.keepOriginal.bind(this);
+  private crowi: Crowi;
 
-    this.adminEvent = crowi.event('admin');
+  private growiBridgeService: any;
 
-    // { pages: { _id: ..., path: ..., ...}, users: { _id: ..., username: ..., }, ... }
-    this.convertMap = {};
-    this.initConvertMap(crowi.models);
+  private adminEvent: EventEmitter;
 
-    this.currentProgressingStatus = null;
-  }
+  private currentProgressingStatus: CollectionProgressingStatus | null;
 
-  /**
-   * generate ImportSettings instance
-   * @param {string} mode bulk operation mode (insert | upsert | flushAndInsert)
-   */
-  generateImportSettings(mode) {
-    return new ImportSettings(mode);
-  }
+  private convertMap: ConvertMap;
 
-  /**
-   * initialize convert map. set keepOriginal as default
-   *
-   * @memberOf ImportService
-   * @param {object} models from models/index.js
-   */
-  initConvertMap(models) {
-    // by default, original value is used for imported documents
-    for (const model of Object.values(models)) {
-      if (model.collection == null) {
-        continue;
-      }
+  constructor(crowi: Crowi) {
+    this.crowi = crowi;
+    this.growiBridgeService = crowi.growiBridgeService;
 
-      const collectionName = model.collection.name;
-      this.convertMap[collectionName] = {};
+    this.adminEvent = crowi.event('admin');
 
-      for (const key of Object.keys(model.schema.paths)) {
-        this.convertMap[collectionName][key] = this.keepOriginal;
-      }
-    }
+    this.currentProgressingStatus = null;
   }
 
-  /**
-   * keep original value
-   * automatically convert ObjectId
-   *
-   * @memberOf ImportService
-   * @param {any} value value from imported document
-   * @param {{ document: object, schema: object, propertyName: string }}
-   * @return {any} new value for the document
-   *
-   * @see https://mongoosejs.com/docs/api/schematype.html#schematype_SchemaType-cast
-   */
-  keepOriginal(value, { document, schema, propertyName }) {
-    // Model
-    if (schema != null && schema.path(propertyName) != null) {
-      const schemaType = schema.path(propertyName);
-      return schemaType.cast(value);
-    }
-
-    // _id
-    if (propertyName === '_id' && ObjectId.isValid(value)) {
-      return ObjectId(value);
-    }
-
-    // Date
-    if (isIsoDate(value)) {
-      return parseISO(value);
-    }
+  get baseDir(): string {
+    return path.join(this.crowi.tmpDir, 'imports');
+  }
 
-    return value;
+  getFile(fileName: string): string {
+    return this.growiBridgeService.getFile(fileName, this.baseDir);
   }
 
   /**
@@ -138,8 +87,8 @@ class ImportService {
     const zipFiles = fs.readdirSync(this.baseDir).filter(file => path.extname(file) === '.zip');
 
     // process serially so as not to waste memory
-    const zipFileStats = [];
-    const parseZipFilePromises = zipFiles.map((file) => {
+    const zipFileStats: any[] = [];
+    const parseZipFilePromises: Promise<any>[] = zipFiles.map((file) => {
       const zipFile = this.getFile(file);
       return this.growiBridgeService.parseZipFile(zipFile);
     });
@@ -153,8 +102,6 @@ class ImportService {
     // sort with ctime("Change Time" - Time when file status was last changed (inode data modification).)
     filtered.sort((a, b) => { return a.fileStat.ctime - b.fileStat.ctime });
 
-    const isImporting = this.currentProgressingStatus != null;
-
     const zipFileStat = filtered.pop();
     let isTheSameVersion = false;
 
@@ -173,18 +120,27 @@ class ImportService {
     return {
       isTheSameVersion,
       zipFileStat,
-      isImporting,
-      progressList: isImporting ? this.currentProgressingStatus.progressList : null,
+      isImporting: this.currentProgressingStatus != null,
+      progressList: this.currentProgressingStatus?.progressList ?? null,
     };
   }
 
+
+  async preImport() {
+    await setupIndependentModels();
+
+    // initialize convertMap
+    this.convertMap = constructConvertMap();
+  }
+
   /**
    * import collections from json
-   *
-   * @param {string} collections MongoDB collection name
-   * @param {array} importSettingsMap key: collection name, value: ImportSettings instance
+   * @param collections MongoDB collection name
+   * @param importSettingsMap
    */
-  async import(collections, importSettingsMap) {
+  async import(collections: string[], importSettingsMap: { [collectionName: string]: ImportSettings }): Promise<void> {
+    await this.preImport();
+
     // init status object
     this.currentProgressingStatus = new CollectionProgressingStatus(collections);
 
@@ -208,9 +164,9 @@ class ImportService {
     this.currentProgressingStatus = null;
     this.emitTerminateEvent();
 
-    await this.crowi.configManager.loadConfigs();
+    await configManager.loadConfigs();
 
-    const currentIsV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    const currentIsV5Compatible = configManager.getConfig('crowi', 'app:isV5Compatible');
     const isImportPagesCollection = collections.includes('pages');
     const shouldNormalizePages = currentIsV5Compatible && isImportPagesCollection;
 
@@ -221,11 +177,12 @@ class ImportService {
    * import a collection from json
    *
    * @memberOf ImportService
-   * @param {string} collectionName MongoDB collection name
-   * @param {ImportSettings} importSettings
-   * @return {insertedIds: Array.<string>, failedIds: Array.<string>}
    */
-  async importCollection(collectionName, importSettings) {
+  protected async importCollection(collectionName: string, importSettings: ImportSettings): Promise<void> {
+    if (this.currentProgressingStatus == null) {
+      throw new Error('Something went wrong: currentProgressingStatus is not initialized');
+    }
+
     // prepare functions invoked from custom streams
     const convertDocuments = this.convertDocuments.bind(this);
     const bulkOperate = this.bulkOperate.bind(this);
@@ -244,7 +201,7 @@ class ImportService {
       this.validateImportSettings(collectionName, importSettings);
 
       // flush
-      if (mode === 'flushAndInsert') {
+      if (mode === ImportMode.flushAndInsert) {
         await collection.deleteMany({});
       }
 
@@ -279,10 +236,13 @@ class ImportService {
           });
 
           // exec
-          const { insertedCount, modifiedCount, errors } = await execUnorderedBulkOpSafely(unorderedBulkOp);
-          logger.debug(`Importing ${collectionName}. Inserted: ${insertedCount}. Modified: ${modifiedCount}. Failed: ${errors.length}.`);
+          const { result, errors } = await execUnorderedBulkOpSafely(unorderedBulkOp);
+          const { insertedCount, modifiedCount } = result;
+          const errorCount = errors?.length ?? 0;
+
+          logger.debug(`Importing ${collectionName}. Inserted: ${insertedCount}. Modified: ${modifiedCount}. Failed: ${errorCount}.`);
 
-          const increment = insertedCount + modifiedCount + errors.length;
+          const increment = insertedCount + modifiedCount + errorCount;
           collectionProgress.currentCount += increment;
           collectionProgress.totalCount += increment;
           collectionProgress.insertedCount += insertedCount;
@@ -334,7 +294,7 @@ class ImportService {
 
     switch (collectionName) {
       case 'configs':
-        if (mode !== 'flushAndInsert') {
+        if (mode !== ImportMode.flushAndInsert) {
           throw new Error(`The specified mode '${mode}' is not allowed when importing to 'configs' collection.`);
         }
         break;
@@ -392,7 +352,7 @@ class ImportService {
   async unzip(zipFile) {
     const readStream = fs.createReadStream(zipFile);
     const unzipStreamPipe = readStream.pipe(unzipStream.Parse());
-    const files = [];
+    const files: string[] = [];
 
     unzipStreamPipe.on('entry', (/** @type {Entry} */ entry) => {
       const fileName = entry.path;
@@ -426,61 +386,53 @@ class ImportService {
    * execute unorderedBulkOp and ignore errors
    *
    * @memberOf ImportService
-   * @param {object} unorderedBulkOp result of Model.collection.initializeUnorderedBulkOp()
-   * @return {object} e.g. { insertedCount: 10, errors: [...] }
    */
-  async execUnorderedBulkOpSafely(unorderedBulkOp) {
-    let errors = [];
-    let result = null;
-
+  async execUnorderedBulkOpSafely(unorderedBulkOp: UnorderedBulkOperation): Promise<{ result: BulkWriteResult, errors?: WriteError[] }> {
     try {
-      const log = await unorderedBulkOp.execute();
-      result = log.result;
+      return {
+        result: await unorderedBulkOp.execute(),
+      };
     }
     catch (err) {
-      result = err.result;
-      errors = err.writeErrors || [err];
-      errors.map((err) => {
-        const moreDetailErr = err.err;
-        return { _id: moreDetailErr.op._id, message: err.errmsg };
-      });
-    }
+      const errTypeGuard = (err): err is MongoBulkWriteError => {
+        return 'result' in err && 'writeErrors' in err;
+      };
+
+      if (errTypeGuard(err)) {
+        return {
+          result: err.result,
+          errors: Array.isArray(err.writeErrors) ? err.writeErrors : [err.writeErrors],
+        };
+      }
 
-    const insertedCount = result.nInserted + result.nUpserted;
-    const modifiedCount = result.nModified;
+      logger.error('Failed to execute unorderedBulkOp and the error could not handled.', err);
+      throw new Error('Failed to execute unorderedBulkOp and the error could not handled.', err);
+    }
 
-    return {
-      insertedCount,
-      modifiedCount,
-      errors,
-    };
   }
 
   /**
    * execute unorderedBulkOp and ignore errors
    *
    * @memberOf ImportService
-   * @param {string} collectionName
-   * @param {object} document document being imported
-   * @param {object} overwriteParams overwrite each document with unrelated value. e.g. { creator: req.user }
-   * @return {object} document to be persisted
+   * @param collectionName
+   * @param document document being imported
+   * @returns document to be persisted
    */
-  convertDocuments(collectionName, document, overwriteParams) {
-    const Model = this.growiBridgeService.getModelFromCollectionName(collectionName);
-    const schema = (Model != null) ? Model.schema : null;
+  convertDocuments<D extends Document>(collectionName: string, document: D, overwriteParams: OverwriteParams): D {
+    const Model = getModelFromCollectionName(collectionName);
+    const schema = (Model != null) ? Model.schema : undefined;
     const convertMap = this.convertMap[collectionName];
 
-    const _document = {};
+    const _document: D = structuredClone(document);
+
+    // apply keepOriginal to all of properties
+    Object.entries(document).forEach(([propertyName, value]) => {
+      _document[propertyName] = keepOriginal(value, { document, propertyName });
+    });
 
-    // not Mongoose Model
-    if (convertMap == null) {
-      // apply keepOriginal to all of properties
-      Object.entries(document).forEach(([propertyName, value]) => {
-        _document[propertyName] = this.keepOriginal(value, { document, propertyName });
-      });
-    }
     // Mongoose Model
-    else {
+    if (convertMap != null) {
       // assign value from documents being imported
       Object.entries(convertMap).forEach(([propertyName, convertedValue]) => {
         const value = document[propertyName];
@@ -537,5 +489,3 @@ class ImportService {
   }
 
 }
-
-module.exports = ImportService;

+ 23 - 0
apps/app/src/server/service/import/index.ts

@@ -0,0 +1,23 @@
+import type Crowi from '~/server/crowi';
+
+import { ImportService } from './import';
+
+
+let instance: ImportService;
+
+export const initializeImportService = (crowi: Crowi): void => {
+  if (instance == null) {
+    instance = new ImportService(crowi);
+  }
+};
+
+export const getImportService = (): ImportService => {
+  if (instance == null) {
+    throw new Error('ImportService has not been initialized');
+  }
+  return instance;
+};
+
+
+export * from './import-mode';
+export * from './import-settings';

+ 49 - 0
apps/app/src/server/service/import/overwrite-function.ts

@@ -0,0 +1,49 @@
+import { parseISO } from 'date-fns/parseISO';
+import isIsoDate from 'is-iso-date';
+import type { Schema } from 'mongoose';
+import {
+  Types, type Document,
+} from 'mongoose';
+
+
+const { ObjectId } = Types;
+
+export type OverwriteFunction = (value: unknown, ctx: { document: Document, propertyName: string, schema?: Schema }) => unknown;
+
+/**
+ * keep original value
+ * automatically convert ObjectId
+ *
+ * @param value value from imported document
+ * @param ctx context object
+ * @return new value for the document
+ *
+ * @see https://mongoosejs.com/docs/api/schematype.html#schematype_SchemaType-cast
+ */
+export const keepOriginal: OverwriteFunction = (value, { document, schema, propertyName }) => {
+  // Model
+  if (schema != null && schema.path(propertyName) != null) {
+    const schemaType = schema.path(propertyName);
+
+    // force to set schema to the document
+    //  cz: SchemaArray.cast requires the document to have 'schema' property
+    // ref: https://github.com/Automattic/mongoose/blob/6.11.4/lib/schema/array.js#L334
+    document.schema = schema;
+
+    return schemaType.cast(value, document, true);
+  }
+
+  // _id
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  if (propertyName === '_id' && ObjectId.isValid(value as any)) {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    return new ObjectId(value as any);
+  }
+
+  // Date
+  if (isIsoDate(value)) {
+    return parseISO(value as string);
+  }
+
+  return value;
+};

+ 19 - 0
apps/app/src/server/service/import/overwrite-params/attachmentFiles.chunks.ts

@@ -0,0 +1,19 @@
+import { Binary } from 'mongodb';
+import { Types } from 'mongoose';
+
+import type { OverwriteParams } from '../import-settings';
+
+
+const { ObjectId } = Types;
+
+export const overwriteParams: OverwriteParams = {
+  // ObjectId
+  files_id: (value, { document, schema, propertyName }) => {
+    return new ObjectId(value);
+  },
+
+  // Binary
+  data: (value, { document, schema, propertyName }) => {
+    return new Binary(Buffer.from(value, 'base64'));
+  },
+};

+ 33 - 0
apps/app/src/server/service/import/overwrite-params/index.ts

@@ -0,0 +1,33 @@
+import type GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
+import { isImportOptionForPages } from '~/models/admin/import-option-for-pages';
+
+import type { OverwriteParams } from '../import-settings';
+
+import { overwriteParams as overwriteParamsForAttachmentFilesChunks } from './attachmentFiles.chunks';
+import { generateOverwriteParams as generateForPages } from './pages';
+import { generateOverwriteParams as generateForRevisions } from './revisions';
+
+/**
+ * generate overwrite params with overwrite-params/* modules
+ */
+export const generateOverwriteParams = <OPT extends GrowiArchiveImportOption>(
+  collectionName: string, operatorUserId: string, option: OPT,
+): OverwriteParams => {
+
+  switch (collectionName) {
+    case 'pages':
+      if (!isImportOptionForPages(option)) {
+        throw new Error('Invalid option for pages');
+      }
+      return generateForPages(operatorUserId, option);
+    case 'revisions':
+      if (!isImportOptionForPages(option)) {
+        throw new Error('Invalid option for revisions');
+      }
+      return generateForRevisions(operatorUserId, option);
+    case 'attachmentFiles.chunks':
+      return overwriteParamsForAttachmentFilesChunks;
+    default:
+      return {};
+  }
+};

+ 48 - 0
apps/app/src/server/service/import/overwrite-params/pages.ts

@@ -0,0 +1,48 @@
+import { PageGrant } from '@growi/core';
+import { Types } from 'mongoose';
+
+import type { ImportOptionForPages } from '~/models/admin/import-option-for-pages';
+
+import type { OverwriteParams } from '../import-settings';
+
+const { ObjectId } = Types;
+
+export const generateOverwriteParams = (operatorUserId: string, option: ImportOptionForPages): OverwriteParams => {
+  const params: OverwriteParams = {};
+
+  if (option.isOverwriteAuthorWithCurrentUser) {
+    const userId = new ObjectId(operatorUserId);
+    params.creator = userId;
+    params.lastUpdateUser = userId;
+  }
+
+  params.grant = (value, { document, schema, propertyName }) => {
+    if (option.makePublicForGrant2 && value === 2) {
+      return PageGrant.GRANT_PUBLIC;
+    }
+    if (option.makePublicForGrant4 && value === 4) {
+      return PageGrant.GRANT_PUBLIC;
+    }
+    if (option.makePublicForGrant5 && value === 5) {
+      return PageGrant.GRANT_PUBLIC;
+    }
+    return value;
+  };
+
+  params.parent = (value, { document, schema, propertyName }) => {
+    return null;
+  };
+
+  params.descendantCount = (value, { document, schema, propertyName }) => {
+    return 0;
+  };
+
+  if (option.initPageMetadatas) {
+    params.liker = [];
+    params.seenUsers = [];
+    params.commentCount = 0;
+    params.extended = {};
+  }
+
+  return params;
+};

+ 18 - 0
apps/app/src/server/service/import/overwrite-params/revisions.ts

@@ -0,0 +1,18 @@
+import { Types } from 'mongoose';
+
+import type { ImportOptionForPages } from '~/models/admin/import-option-for-pages';
+
+import type { OverwriteParams } from '../import-settings';
+
+const { ObjectId } = Types;
+
+export const generateOverwriteParams = (operatorUserId: string, option: ImportOptionForPages): OverwriteParams => {
+  const params: OverwriteParams = {};
+
+  if (option.isOverwriteAuthorWithCurrentUser) {
+    const userId = new ObjectId(operatorUserId);
+    params.author = userId;
+  }
+
+  return params;
+};

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

@@ -711,7 +711,7 @@ class PageService implements IPageService {
       await this.renameDescendantsWithStream(page, newPagePath, user, options, false, descendantsSubscribedSets);
       const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets) as Ref<IUser>[];
 
-      const preNotify = preNotifyService.generatePreNotify(activity, () => { return descendantsSubscribedUsers });
+      const preNotify = preNotifyService.generatePreNotify(activity, async() => { return descendantsSubscribedUsers });
 
       this.activityEvent.emit('updated', activity, page, preNotify);
     }
@@ -1702,7 +1702,7 @@ class PageService implements IPageService {
 
     const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets) as Ref<IUser>[];
 
-    const preNotify = preNotifyService.generatePreNotify(activity, () => { return descendantsSubscribedUsers });
+    const preNotify = preNotifyService.generatePreNotify(activity, async() => { return descendantsSubscribedUsers });
 
     this.activityEvent.emit('updated', activity, page, preNotify);
 
@@ -2021,7 +2021,7 @@ class PageService implements IPageService {
     await this.deleteCompletelyDescendantsWithStream(page, user, options, false, descendantsSubscribedSets);
     const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets) as Ref<IUser>[];
 
-    const preNotify = preNotifyService.generatePreNotify(activity, () => { return descendantsSubscribedUsers });
+    const preNotify = preNotifyService.generatePreNotify(activity, async() => { return descendantsSubscribedUsers });
 
     this.activityEvent.emit('updated', activity, page, preNotify);
 
@@ -2068,7 +2068,7 @@ class PageService implements IPageService {
     const pages = await this.deleteCompletelyDescendantsWithStream(page, user, options, true, descendantsSubscribedSets);
     const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets) as Ref<IUser>[];
 
-    const preNotify = preNotifyService.generatePreNotify(activity, () => { return descendantsSubscribedUsers });
+    const preNotify = preNotifyService.generatePreNotify(activity, async() => { return descendantsSubscribedUsers });
 
     this.activityEvent.emit('updated', activity, page, preNotify);
 
@@ -2304,7 +2304,7 @@ class PageService implements IPageService {
     await this.revertDeletedDescendantsWithStream(page, user, options, false, descendantsSubscribedSets);
     const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets) as Ref<IUser>[];
 
-    const preNotify = preNotifyService.generatePreNotify(activity, () => { return descendantsSubscribedUsers });
+    const preNotify = preNotifyService.generatePreNotify(activity, async() => { return descendantsSubscribedUsers });
 
     this.activityEvent.emit('updated', activity, page, preNotify);
 
@@ -3057,7 +3057,7 @@ class PageService implements IPageService {
     return isUnique;
   }
 
-  async normalizeAllPublicPages() {
+  async normalizeAllPublicPages(): Promise<void> {
     let isUnique;
     try {
       isUnique = await this._isPagePathIndexUnique();

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

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

+ 5 - 6
apps/app/src/server/service/pre-notify.ts

@@ -4,16 +4,15 @@ import type {
 
 import type { ActivityDocument } from '../models/activity';
 import Subscription from '../models/subscription';
-import { getModelSafely } from '../util/mongoose-utils';
+import userModelFactory from '../models/user';
 
 export type PreNotifyProps = {
   notificationTargetUsers?: Ref<IUser>[],
 }
 
 export type PreNotify = (props: PreNotifyProps) => Promise<void>;
-export type GeneratePreNotify = (activity: ActivityDocument, getAdditionalTargetUsers?: (activity?: ActivityDocument) => Ref<IUser>[]) => PreNotify;
-
-export type GetAdditionalTargetUsers = (activity: ActivityDocument) => Ref<IUser>[];
+export type GetAdditionalTargetUsers = (activity: ActivityDocument) => Promise<Ref<IUser>[]>;
+export type GeneratePreNotify = (activity: ActivityDocument, getAdditionalTargetUsers?: GetAdditionalTargetUsers) => PreNotify;
 
 interface IPreNotifyService {
   generateInitialPreNotifyProps: (PreNotifyProps) => { notificationTargetUsers?: Ref<IUser>[] },
@@ -34,7 +33,7 @@ class PreNotifyService implements IPreNotifyService {
     const preNotify = async(props: PreNotifyProps) => {
       const { notificationTargetUsers } = props;
 
-      const User = getModelSafely('User') || require('~/server/models/user')();
+      const User = userModelFactory();
       const actionUser = activity.user;
       const target = activity.target;
       const subscribedUsers = await Subscription.getSubscription(target as unknown as Ref<IPage>);
@@ -48,7 +47,7 @@ class PreNotifyService implements IPreNotifyService {
         notificationTargetUsers?.push(...activeNotificationUsers);
       }
       else {
-        const AdditionalTargetUsers = getAdditionalTargetUsers(activity);
+        const AdditionalTargetUsers = await getAdditionalTargetUsers(activity);
 
         notificationTargetUsers?.push(
           ...activeNotificationUsers,

+ 1 - 0
apps/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -1,4 +1,5 @@
 import type { IPage } from '@growi/core';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import mongoose from 'mongoose';
 
 import { SearchDelegatorName } from '~/interfaces/named-query';

+ 1 - 4
apps/app/src/server/util/mongoose-utils.ts

@@ -27,10 +27,7 @@ export const getModelSafely = <Interface, Method = Interface>(modelName: string)
 
 // TODO: Do not use any type
 export const getOrCreateModel = <Interface, Method>(modelName: string, schema: any): Method & Model<Interface & Document> => {
-  if (mongoose.modelNames().includes(modelName)) {
-    return mongoose.model<Interface & Document, Method & Model<Interface & Document>>(modelName);
-  }
-  return mongoose.model<Interface & Document, Method & Model<Interface & Document>>(modelName, schema);
+  return getModelSafely(modelName) ?? mongoose.model<Interface & Document, Method & Model<Interface & Document>>(modelName, schema);
 };
 
 // supress deprecation warnings

+ 4 - 0
apps/app/src/stores-universal/context.tsx

@@ -204,6 +204,10 @@ export const useIsContainerFluid = (initialData?: boolean): SWRResponse<boolean,
   return useContextSWR('isContainerFluid', initialData);
 };
 
+export const useIsLocalAccountRegistrationEnabled = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useContextSWR('isLocalAccountRegistrationEnabled', initialData);
+};
+
 /** **********************************************************
  *                     Computed contexts
  *********************************************************** */

+ 0 - 412
apps/app/test/cypress/e2e/30-search/30-search--search.cy.ts

@@ -1,412 +0,0 @@
-context('Access to search result page', () => {
-  const ssPrefix = 'access-to-result-page-directly-';
-
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it('/_search with "q" param is successfully loaded', () => {
-    cy.visit('/_search', { qs: { q: 'labels alerts cards blocks' } });
-
-    cy.getByTestid('search-result-base').should('be.visible');
-    cy.getByTestid('search-result-list').should('be.visible');
-    cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('.wiki').should('be.visible');
-    // for avoid mismatch by auto scrolling
-    cy.get('.search-result-content-body-container').scrollTo('top');
-
-    cy.collapseSidebar(true, true);
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}with-q`);
-  });
-
-  it('checkboxes behaviors', () => {
-    cy.visit('/_search', { qs: { q: 'labels alerts cards blocks' } });
-
-    cy.collapseSidebar(true);
-
-    cy.getByTestid('search-result-base').should('be.visible');
-    cy.getByTestid('search-result-list').should('be.visible');
-    cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('.wiki').should('be.visible');
-    // for avoid mismatch by auto scrolling
-    cy.get('.search-result-content-body-container').scrollTo('top');
-
-    // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
-    cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
-
-    cy.getByTestid('cb-select').first().click({force: true});
-
-    cy.screenshot(`${ssPrefix}the-first-checkbox-on`);
-
-    cy.getByTestid('cb-select').first().click({force: true});
-    cy.screenshot(`${ssPrefix}the-first-checkbox-off`);
-
-    // click select all checkbox
-    cy.getByTestid('cb-select-all').click({force: true});
-    cy.screenshot(`${ssPrefix}the-select-all-checkbox-1`);
-    cy.getByTestid('cb-select').first().click({force: true});
-    cy.screenshot(`${ssPrefix}the-select-all-checkbox-2`);
-    cy.getByTestid('cb-select').first().click({force: true});
-    cy.screenshot(`${ssPrefix}the-select-all-checkbox-3`);
-    cy.getByTestid('cb-select-all').click({force: true});
-    cy.screenshot(`${ssPrefix}the-select-all-checkbox-4`);
-  });
-
-});
-
-context('Access to legacy private pages', () => {
-  const ssPrefix = 'access-to-legacy-private-pages-directly-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it('/_private-legacy-pages is successfully loaded', () => {
-    cy.visit('/_private-legacy-pages');
-
-    cy.getByTestid('search-result-base').should('be.visible');
-    cy.getByTestid('search-result-private-legacy-pages').should('be.visible');
-
-    cy.collapseSidebar(true);
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}shown`);
-  });
-
-});
-
-context('Search all pages', () => {
-  const ssPrefix = 'search-all-pages-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it(`Search all pages by word is successfully loaded`, () => {
-    const searchText = 'help';
-
-    cy.visit('/');
-
-    cy.collapseSidebar(true, true);
-    cy.waitUntilSkeletonDisappear();
-
-    // open SearchModal
-    cy.getByTestid('grw-contextual-sub-nav').within(() => {
-      cy.getByTestid('open-search-modal-button').click();
-    })
-    cy.getByTestid('search-modal').should('be.visible').within(() => {
-      cy.screenshot(`${ssPrefix}1-search-input-focused`);
-    });
-
-    // inseart text
-    cy.getByTestid('search-form').should('be.visible').type(searchText);
-    cy.screenshot(`${ssPrefix}2-insert-search-text`, { capture: 'viewport'});
-  });
-
-  it(`Search all pages by tag is successfully loaded `, () => {
-    const tag = 'help';
-    const searchText = `tag:${tag}`;
-
-    cy.visit('/');
-
-    // open Edit Tags Modal to add tag
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('grw-tag-labels').as('tagLabels').should('be.visible');
-      cy.get('@tagLabels').find('button').first().as('btn').click({force: true});
-      // wait until
-      return cy.get('body').within(() => {
-        return Cypress.$('.modal.show').is(':visible');
-      });
-    });
-
-    cy.get('#edit-tag-modal').should('be.visible').within(() => {
-      cy.get('.rbt-input-main').type(tag);
-      cy.get('#tag-typeahead-asynctypeahead').should('be.visible');
-      cy.get('#tag-typeahead-asynctypeahead-item-0').should('be.visible');
-      // select
-      cy.get('a#tag-typeahead-asynctypeahead-item-0').click();
-      // save
-      cy.get('div.modal-footer > button').click();
-    });
-
-    cy.visit('/');
-    cy.waitUntilSkeletonDisappear();
-
-    // open SearchModal
-    cy.getByTestid('grw-contextual-sub-nav').within(() => {
-      cy.getByTestid('open-search-modal-button').click();
-    })
-    cy.getByTestid('search-modal').should('be.visible');
-
-    // inseart text
-    cy.getByTestid('search-form').should('be.visible').type(searchText);
-    cy.screenshot(`${ssPrefix}1-insert-search-text-with-tag`, { capture: 'viewport'});
-
-    // click search method button
-    cy.getByTestid('search-all-menu-item').click();
-
-    cy.getByTestid('search-result-base').should('be.visible');
-    cy.getByTestid('search-result-list').should('be.visible');
-    cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('.wiki').should('be.visible');
-
-    // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
-    cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
-
-    cy.collapseSidebar(true);
-    cy.waitUntilSpinnerDisappear();
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}2-search-with-tag-result`, {capture: 'viewport'});
-
-  });
-
-  it('Successfully order page search results by tag', () => {
-    const tag = 'help';
-
-    cy.visit('/');
-
-    // open Edit Tags Modal to add tag
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('grw-tag-labels').as('tagLabels').should('be.visible');
-      cy.get('@tagLabels').find('a').contains(tag).as('tag').click();
-      // wait until
-      return cy.getByTestid('search-result-base').then($elem => $elem.is(':visible'));
-    });
-
-    cy.getByTestid('search-result-base').should('be.visible');
-    cy.getByTestid('search-result-list').should('be.visible');
-    cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('.wiki').should('be.visible');
-
-    // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
-    cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
-
-    cy.collapseSidebar(true);
-    cy.waitUntilSkeletonDisappear();
-    cy.waitUntilSpinnerDisappear();
-    cy.screenshot(`${ssPrefix}1-tag-order-click-tag-name`, {capture: 'viewport'});
-
-  });
-
-});
-
-context('Sort with dropdown', () => {
-  const ssPrefix = 'sort-with-dropdown-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-
-    cy.visit('/_search', { qs: { q: 'sand' } });
-
-    cy.getByTestid('search-result-base').should('be.visible');
-    cy.getByTestid('search-result-list').should('be.visible');
-    cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('.wiki').should('be.visible');
-
-    cy.waitUntilSpinnerDisappear();
-    // for avoid mismatch by auto scrolling
-    cy.get('.search-result-content-body-container').scrollTo('top');
-
-    // open sort dropdown
-    cy.waitUntil(() => {
-      // do
-      cy.get('.search-control').within(() => {
-        cy.get('button').first().click({force: true});
-      });
-      // wait until
-      return cy.get('.search-control').within(() => {
-        return Cypress.$('.dropdown-menu.show').is(':visible');
-      });
-    });
-  });
-
-  it('Open sort dropdown', () => {
-    cy.get('.search-control .dropdown-menu.show').should('be.visible');
-      cy.screenshot(`${ssPrefix}2-open-sort-dropdown`);
-  });
-
-  it('Sort by relevance', () => {
-    cy.get('.search-control .dropdown-menu.show').should('be.visible').within(() => {
-      cy.get('button:nth-child(1)').click({force: true});
-    });
-    cy.getByTestid('search-result-base').should('be.visible');
-    cy.getByTestid('search-result-list').should('be.visible');
-    cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('.wiki').should('be.visible');
-
-    cy.waitUntilSpinnerDisappear();
-    // for avoid mismatch by auto scrolling
-    cy.get('.search-result-content-body-container').scrollTo('top');
-    cy.screenshot(`${ssPrefix}3-tag-order-by-relevance`);
-  });
-
-  it('Sort by creation date', () => {
-    cy.get('.search-control .dropdown-menu.show').should('be.visible').within(() => {
-      cy.get('button:nth-child(2)').click({force: true});
-    });
-    cy.getByTestid('search-result-base').should('be.visible');
-    cy.getByTestid('search-result-list').should('be.visible');
-    cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('.wiki').should('be.visible');
-
-    cy.waitUntilSpinnerDisappear();
-    // for avoid mismatch by auto scrolling
-    cy.get('.search-result-content-body-container').scrollTo('top');
-    cy.screenshot(`${ssPrefix}3-tag-order-by-creation-date`);
-  });
-
-  it('Sort by last update date', () => {
-    cy.get('.search-control .dropdown-menu.show').should('be.visible').within(() => {
-      cy.get('button:nth-child(3)').click({force: true});
-    });
-    cy.getByTestid('search-result-base').should('be.visible');
-    cy.getByTestid('search-result-list').should('be.visible');
-    cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('.wiki').should('be.visible');
-
-    cy.waitUntilSpinnerDisappear();
-    // for avoid mismatch by auto scrolling
-    cy.get('.search-result-content-body-container').scrollTo('top');
-    cy.screenshot(`${ssPrefix}4-tag-order-by-last-update-date`);
-  });
-
-});
-
-
-context('Search and use', () => {
-  const ssPrefix = 'search-and-use-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-
-    cy.visit('/_search', { qs: { q: 'labels alerts cards blocks' } });
-    cy.getByTestid('search-result-base').should('be.visible');
-    cy.getByTestid('search-result-list').should('be.visible');
-    cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('.wiki').should('be.visible');
-
-    cy.getByTestid('page-list-item-L').first().as('firstItem');
-
-    // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
-    cy.get('@firstItem').invoke('addClass', 'active');
-    // for avoid mismatch by auto scrolling
-    cy.get('.search-result-content-body-container').scrollTo('top');
-
-    cy.waitUntil(() => {
-      // do
-      cy.get('@firstItem').within(() => {
-        cy.getByTestid('open-page-item-control-btn').click();
-      });
-      // wait until
-      return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
-    });
-  });
-
-  it('Successfully the dropdown is opened', () => {
-    cy.get('.dropdown-menu.show').should('be.visible');
-    cy.screenshot(`${ssPrefix}1-click-three-dots-menu`, {capture: 'viewport'});
-  });
-
-  it('Successfully add bookmark', () => {
-    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-      // Add bookmark
-      cy.getByTestid('add-bookmark-btn').click({force: true});
-    });
-    cy.getByTestid('search-result-content').within(() => {
-      cy.get('.btn-bookmark.active').should('be.visible');
-    });
-    cy.screenshot(`${ssPrefix}2-add-bookmark`, {capture: 'viewport'});
-  });
-
-  it('Successfully open duplicate modal', () => {
-    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-      cy.getByTestid('open-page-duplicate-modal-btn').click({force: true});
-    });
-    cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
-      cy.screenshot(`${ssPrefix}3-duplicate-page`);
-    });
-    // Close Modal
-    cy.get('body').type('{esc}');
-  });
-
-  it('Successfully open move/rename modal', () => {
-    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-      cy.getByTestid('rename-page-btn').click({force: true});
-    });
-    cy.getByTestid('page-rename-modal').should('be.visible').within(() => {
-      cy.screenshot(`${ssPrefix}4-move-rename-page`);
-    });
-    // Close Modal
-    cy.get('body').type('{esc}');
-  });
-
-  it('Successfully open delete modal', () => {
-    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-      cy.getByTestid('open-page-delete-modal-btn').click({ force: true});
-    });
-    cy.getByTestid('page-delete-modal').should('be.visible').within(() => {
-      cy.screenshot(`${ssPrefix}5-delete-page`);
-    });
-  });
-})
-
-context('Search current tree with "prefix":', () => {
-  const ssPrefix = 'search-current-tree-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it(`Search current tree by word is successfully loaded`, () => {
-    const searchText = 'help';
-
-    cy.visit('/');
-    cy.waitUntilSkeletonDisappear();
-
-    // open SearchModal
-    cy.getByTestid('grw-contextual-sub-nav').within(() => {
-      cy.getByTestid('open-search-modal-button').click();
-    })
-    cy.getByTestid('search-modal').should('be.visible');
-
-    // inseart text
-    cy.getByTestid('search-form').should('be.visible').type(searchText);
-
-    // click search method button
-    cy.getByTestid('search-prefix-menu-item').click();
-
-    cy.getByTestid('search-result-base').should('be.visible');
-    cy.getByTestid('search-result-list').should('be.visible');
-    cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('.wiki').should('be.visible');
-    cy.waitUntilSpinnerDisappear();
-
-    // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
-    cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
-    // for avoid mismatch by auto scrolling
-    cy.get('.search-result-content-body-container').scrollTo('top');
-    cy.screenshot(`${ssPrefix}3-search-page-results`, { capture: 'viewport'});
-  });
-
-});

+ 0 - 6
apps/app/test/integration/crowi/crowi.test.js

@@ -18,12 +18,6 @@ describe('Test for Crowi application context', () => {
       expect(crowi.getConfig()).toEqual({ test: 1 });
     });
 
-    test('model getter, setter', async() => {
-      const crowi = await getInstance();
-      // set
-      crowi.model('hoge', { fuga: 1 });
-      expect(crowi.model('hoge')).toEqual({ fuga: 1 });
-    });
   });
 
 });

+ 3 - 2
apps/app/test/integration/setup-crowi.ts

@@ -1,11 +1,12 @@
 import { Server } from 'http';
 
 import Crowi from '../../src/server/crowi';
+import { setupModelsDependentOnCrowi } from '../../src/server/crowi/setup-models';
 
 let _instance: Crowi;
 
-const initCrowi = async(crowi) => {
-  await crowi.setupModels();
+const initCrowi = async(crowi: Crowi) => {
+  crowi.models = await setupModelsDependentOnCrowi(crowi);
   await crowi.setupConfigManager();
 
   await crowi.setupSocketIoService();

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

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

+ 1 - 1
package.json

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

+ 12 - 0
packages/core/CHANGELOG.md

@@ -1,5 +1,17 @@
 # @growi/core
 
+## 1.3.0
+
+### Minor Changes
+
+- [#9042](https://github.com/weseek/growi/pull/9042) [`8f9189d`](https://github.com/weseek/growi/commit/8f9189d4fcf031c1344072f88b7d9febeb02ce1d) Thanks [@yuki-takei](https://github.com/yuki-takei)! - Add isIPageInfo type guard
+
+## 1.2.0
+
+### Minor Changes
+
+- [#9019](https://github.com/weseek/growi/pull/9019) [`60097ac`](https://github.com/weseek/growi/commit/60097ac686928cca76715a83a10b506576889108) Thanks [@yuki-takei](https://github.com/yuki-takei)! - Transplant and re-implement serializers for User and Attachment
+
 ## 1.1.0
 
 ### Minor Changes

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "1.1.0",
+  "version": "1.3.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 8
packages/core/src/interfaces/locale.ts

@@ -1,8 +1 @@
-export const Locale = {
-  'en-US': 'en-US',
-  'ja-JP': 'ja-JP',
-  'zh-CN': 'zh-CN',
-  'fr-FR': 'fr-FR',
-} as const;
-export const AllLocale = Object.values(Locale);
-export type Locale = typeof Locale[keyof typeof Locale];
+export type Locale = 'en-US' | 'ja-JP' | 'zh-CN' | 'fr-FR';

+ 9 - 6
packages/core/src/interfaces/page.ts

@@ -101,23 +101,26 @@ export type IPageInfoForListing = IPageInfoForEntity & HasRevisionShortbody;
 export type IPageInfoAll = IPageInfo | IPageInfoForEntity | IPageInfoForOperation | IPageInfoForListing;
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-export const isIPageInfoForEntity = (pageInfo: any | undefined): pageInfo is IPageInfoForEntity => {
+export const isIPageInfo = (pageInfo: any | undefined): pageInfo is IPageInfo => {
   return pageInfo != null && pageInfo instanceof Object
-    && ('isEmpty' in pageInfo)
+    && ('isEmpty' in pageInfo);
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const isIPageInfoForEntity = (pageInfo: any | undefined): pageInfo is IPageInfoForEntity => {
+  return isIPageInfo(pageInfo)
     && pageInfo.isEmpty === false;
 };
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export const isIPageInfoForOperation = (pageInfo: any | undefined): pageInfo is IPageInfoForOperation => {
-  return pageInfo != null
-    && isIPageInfoForEntity(pageInfo)
+  return isIPageInfoForEntity(pageInfo)
     && ('isBookmarked' in pageInfo || 'isLiked' in pageInfo || 'subscriptionStatus' in pageInfo);
 };
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export const isIPageInfoForListing = (pageInfo: any | undefined): pageInfo is IPageInfoForListing => {
-  return pageInfo != null
-    && isIPageInfoForEntity(pageInfo)
+  return isIPageInfoForEntity(pageInfo)
     && 'revisionShortBody' in pageInfo;
 };
 

+ 29 - 4
yarn.lock

@@ -2137,7 +2137,7 @@
   version "1.0.0"
 
 "@growi/core@link:packages/core":
-  version "1.1.0"
+  version "1.3.0"
   dependencies:
     bson-objectid "^2.0.4"
     escape-string-regexp "^4.0.0"
@@ -17347,7 +17347,7 @@ string-template@>=1.0.0:
   resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96"
   integrity sha1-np8iM9wA8hhxjsN5oopWc+zKi5Y=
 
-"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0":
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -17365,6 +17365,15 @@ string-width@=4.2.2:
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.0"
 
+"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.1"
+
 string-width@^5.0.1, string-width@^5.1.2:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
@@ -17448,7 +17457,7 @@ stringify-entities@^4.0.0:
     character-entities-html4 "^2.0.0"
     character-entities-legacy "^3.0.0"
 
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -17462,6 +17471,13 @@ strip-ansi@^3.0.0:
   dependencies:
     ansi-regex "^2.0.0"
 
+strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
 strip-ansi@^7.0.1, strip-ansi@^7.1.0:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -19258,7 +19274,7 @@ word-wrap@^1.2.3:
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -19276,6 +19292,15 @@ wrap-ansi@^6.2.0:
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
+wrap-ansi@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+  dependencies:
+    ansi-styles "^4.0.0"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
+
 wrap-ansi@^8.1.0:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"