Browse Source

Merge branch 'master' into support/136168-update-axios

Futa Arai 7 months ago
parent
commit
d2eda487cd
100 changed files with 2352 additions and 845 deletions
  1. 3 3
      .devcontainer/compose.yml
  2. 71 0
      .serena/memories/coding_conventions.md
  3. 45 0
      .serena/memories/development_environment.md
  4. 26 0
      .serena/memories/project_overview.md
  5. 90 0
      .serena/memories/project_structure.md
  6. 94 0
      .serena/memories/suggested_commands.md
  7. 95 0
      .serena/memories/task_completion_checklist.md
  8. 42 0
      .serena/memories/tech_stack.md
  9. 68 0
      .serena/project.yml
  10. 20 0
      .vscode/mcp.json
  11. 14 1
      CHANGELOG.md
  12. 3 1
      apps/app/.env.development
  13. 1 1
      apps/app/.env.production
  14. 14 0
      apps/app/.eslintrc.js
  15. 2 1
      apps/app/bin/openapi/definition-apiv1.js
  16. 3 11
      apps/app/bin/openapi/definition-apiv3.js
  17. 14 10
      apps/app/bin/openapi/generate-operation-ids/cli.spec.ts
  18. 5 4
      apps/app/bin/openapi/generate-operation-ids/cli.ts
  19. 29 31
      apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.spec.ts
  20. 42 16
      apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.ts
  21. 8 2
      apps/app/config/cdn.js
  22. 2 2
      apps/app/config/migrate-mongo-config.js
  23. 4 8
      apps/app/config/migrate-mongo-config.spec.ts
  24. 6 7
      apps/app/config/next-i18next.config.js
  25. 11 4
      apps/app/jest.config.js
  26. 18 15
      apps/app/next.config.js
  27. 1 4
      apps/app/nodemon.json
  28. 11 7
      apps/app/package.json
  29. 14 14
      apps/app/playwright.config.ts
  30. 3 2
      apps/app/playwright/60-home/home.spec.ts
  31. 11 0
      apps/app/public/static/locales/en_US/translation.json
  32. 11 0
      apps/app/public/static/locales/fr_FR/translation.json
  33. 11 0
      apps/app/public/static/locales/ja_JP/translation.json
  34. 11 0
      apps/app/public/static/locales/zh_CN/translation.json
  35. 6 8
      apps/app/resource/Contributor.js
  36. 0 119
      apps/app/resource/search/mappings-es7.json
  37. 0 118
      apps/app/resource/search/mappings-es8-for-ci.json
  38. 0 119
      apps/app/resource/search/mappings-es8.json
  39. 24 19
      apps/app/src/client/components/Admin/AuditLog/ActivityTable.tsx
  40. 27 18
      apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.tsx
  41. 1 0
      apps/app/src/client/components/Me/AccessTokenScopeList.tsx
  42. 1 0
      apps/app/src/client/components/PageHeader/PageTitleHeader.tsx
  43. 12 2
      apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx
  44. 13 1
      apps/app/src/components/Layout/Admin.module.scss
  45. 33 8
      apps/app/src/components/PageView/PageView.tsx
  46. 1 1
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditInstruction.tsx
  47. 31 30
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx
  48. 1 1
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx
  49. 28 7
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHeader.tsx
  50. 23 11
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx
  51. 100 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementKeywordSearch.module.scss
  52. 228 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementKeywordSearch.tsx
  53. 49 23
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx
  54. 35 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageSelectionMethod.tsx
  55. 11 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.module.scss
  56. 171 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.tsx
  57. 28 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/PageSelectionMethodButtons.module.scss
  58. 55 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/PageSelectionMethodButtons.tsx
  59. 43 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePageList.module.scss
  60. 249 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePageList.tsx
  61. 0 43
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectedPageList.tsx
  62. 4 4
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx
  63. 1 1
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantList.tsx
  64. 72 0
      apps/app/src/features/openai/client/services/use-selected-pages.tsx
  65. 17 2
      apps/app/src/features/openai/client/stores/ai-assistant.tsx
  66. 10 0
      apps/app/src/features/openai/interfaces/selectable-page.ts
  67. 0 6
      apps/app/src/features/openai/interfaces/selected-page.ts
  68. 2 7
      apps/app/src/features/openai/server/routes/middlewares/upsert-ai-assistant-validator.ts
  69. 13 0
      apps/app/src/features/openai/utils/is-creatable-page-path-pattern.ts
  70. 60 6
      apps/app/src/features/opentelemetry/server/node-sdk.spec.ts
  71. 3 2
      apps/app/src/features/opentelemetry/server/node-sdk.ts
  72. 1 11
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts
  73. 3 3
      apps/app/src/linter-checker/test.js
  74. 3 2
      apps/app/src/migrations/19700101000000-foremost-1000-20241123211930-remove-index-for-ns-from-configs.js
  75. 3 2
      apps/app/src/migrations/19700101000000-foremost-1010-20250109000000-generate-service-instance-id.js
  76. 0 3
      apps/app/src/migrations/20180926134048-make-email-unique.js
  77. 13 9
      apps/app/src/migrations/20180927102719-init-serverurl.js
  78. 12 7
      apps/app/src/migrations/20181019114028-abolish-page-group-relation.js
  79. 3 2
      apps/app/src/migrations/20190618104011-add-config-app-installed.js
  80. 4 7
      apps/app/src/migrations/20190619055421-adjust-page-grant.js
  81. 0 2
      apps/app/src/migrations/20190624110950-fill-last-update-user.js
  82. 3 1
      apps/app/src/migrations/20191102223901-drop-pages-indices.js
  83. 0 2
      apps/app/src/migrations/20191126173016-adjust-pages-path.js
  84. 6 2
      apps/app/src/migrations/20191127023815-drop-wrong-index-of-page-tag-relation.js
  85. 6 4
      apps/app/src/migrations/20200402160380-remove-deleteduser-from-relationgroup.js
  86. 8 2
      apps/app/src/migrations/20200514001356-update-theme-color-for-dark.js
  87. 2 8
      apps/app/src/migrations/20200620203632-normalize-locale-id.js
  88. 1 1
      apps/app/src/migrations/20200827045151-remove-layout-setting.js
  89. 3 1
      apps/app/src/migrations/20200828024025-copy-aws-setting.js
  90. 10 6
      apps/app/src/migrations/20200901034313-update-mail-transmission.js
  91. 0 1
      apps/app/src/migrations/20200903080025-remove-timeline-type.js.js
  92. 2 3
      apps/app/src/migrations/20200915035234-rename-s3-config.js
  93. 5 2
      apps/app/src/migrations/20210420160380-convert-double-to-date.js
  94. 8 4
      apps/app/src/migrations/20210906194521-slack-app-integration-set-default-value.js
  95. 7 3
      apps/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js
  96. 22 19
      apps/app/src/migrations/20210921173042-add-is-trashed-field.js
  97. 12 5
      apps/app/src/migrations/20211005120030-slack-app-integration-rename-keys.js
  98. 22 7
      apps/app/src/migrations/20211005131430-config-without-proxy-command-permission-for-renaming.js
  99. 19 10
      apps/app/src/migrations/20211129125654-initialize-private-legacy-pages-named-query.js
  100. 19 16
      apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js

+ 3 - 3
.devcontainer/compose.yml

@@ -26,10 +26,10 @@ services:
   #   cloned from https://github.com/weseek/growi-docker-compose.git
   elasticsearch:
     build:
-      context: ../../growi-docker-compose/elasticsearch/v8
+      context: ../../growi-docker-compose/elasticsearch/v9
       dockerfile: ./Dockerfile
       args:
-        - version=8.7.0
+        - version=9.0.3
     restart: unless-stopped
     ports:
       - 9200
@@ -43,7 +43,7 @@ services:
         hard: -1
     volumes:
       - /usr/share/elasticsearch/data
-      - ../../growi-docker-compose/elasticsearch/v8/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
+      - ../../growi-docker-compose/elasticsearch/v9/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
 
 volumes:
   pnpm-store:

+ 71 - 0
.serena/memories/coding_conventions.md

@@ -0,0 +1,71 @@
+# コーディング規約とスタイルガイド
+
+## Linter・フォーマッター設定
+
+### Biome設定(統一予定)
+- **適用範囲**: 
+  - dist/, node_modules/, coverage/ などは除外
+  - .next/, bin/, config/ などのビルド成果物は除外
+  - package.json, .eslintrc.js などの設定ファイルは除外
+- **推奨**: 新規開発では Biome を使用
+
+### ESLint設定(廃止予定・過渡期)
+- **ベース設定**: weseek ESLint設定を使用
+- **TypeScript**: weseek/typescript 設定を適用
+- **React**: React関連のルールを適用
+- **主要なルール**:
+  - `import/prefer-default-export`: オフ(名前付きエクスポートを推奨)
+  - `import/order`: import文の順序を規定
+    - React を最初に
+    - 内部モジュール(`/**`)をparentグループの前に配置
+
+## TypeScript設定
+- **ターゲット**: ESNext
+- **モジュール**: ESNext  
+- **厳格モード**: 有効(strict: true)
+- **モジュール解決**: Bundler
+- **その他**:
+  - allowJs: true(JSファイルも許可)
+  - skipLibCheck: true(型チェックの最適化)
+  - isolatedModules: true(単独モジュールとしてコンパイル)
+
+## Stylelint設定
+- SCSS/CSSファイルに対して適用
+- recess-order設定を使用(プロパティの順序規定)
+- recommended-scss設定を適用
+
+## ファイル命名規則
+- TypeScript/JavaScriptファイル: キャメルケースまたはケバブケース
+- コンポーネントファイル: PascalCase(Reactコンポーネント)
+- 設定ファイル: ドット記法(.eslintrc.js など)
+
+## テストファイル命名規則(Vitest)
+vitest.workspace.mts の設定に基づく:
+
+### 単体テスト(Unit Test)
+- **ファイル名**: `*.spec.{ts,js}`
+- **環境**: Node.js
+- **例**: `utils.spec.ts`, `helper.spec.js`
+
+### 統合テスト(Integration Test)
+- **ファイル名**: `*.integ.ts`
+- **環境**: Node.js(MongoDB設定あり)
+- **例**: `api.integ.ts`, `service.integ.ts`
+
+### コンポーネントテスト(Component Test)
+- **ファイル名**: `*.spec.{tsx,jsx}`
+- **環境**: happy-dom
+- **例**: `Button.spec.tsx`, `Modal.spec.jsx`
+
+## ディレクトリ構造の規則
+- `src/`: ソースコード
+- `test/`: Jest用の古いテストファイル(廃止予定)
+- `test-with-vite/`: Vitest用の新しいテストファイル
+- `playwright/`: E2Eテストファイル
+- `config/`: 設定ファイル
+- `public/`: 静的ファイル
+- `dist/`: ビルド出力
+
+## 移行ガイドライン
+- 新規開発: Biome + Vitest を使用
+- 既存コード: 段階的に ESLint → Biome、Jest → Vitest に移行

+ 45 - 0
.serena/memories/development_environment.md

@@ -0,0 +1,45 @@
+# 開発環境とツール
+
+## 推奨システム要件
+- **Node.js**: ^20 || ^22
+- **パッケージマネージャー**: pnpm 10.4.1
+- **OS**: Linux(Ubuntuベース)、macOS、Windows
+
+## 利用可能なLinuxコマンド
+基本的なLinuxコマンドが利用可能:
+- `apt`, `dpkg`: パッケージ管理
+- `git`: バージョン管理
+- `curl`, `wget`: HTTP クライアント
+- `ssh`, `scp`, `rsync`: ネットワーク関連
+- `ps`, `lsof`, `netstat`, `top`: プロセス・ネットワーク監視
+- `tree`, `find`, `grep`: ファイル検索・操作
+- `zip`, `unzip`, `tar`, `gzip`, `bzip2`, `xz`: アーカイブ操作
+
+## 開発用ブラウザ
+```bash
+# ローカルサーバーをブラウザで開く
+"$BROWSER" http://localhost:3000
+```
+
+## 環境変数管理
+- **dotenv-flow**: 環境ごとの設定管理
+- 環境ファイル:
+  - `.env.development`: 開発環境
+  - `.env.production`: 本番環境
+  - `.env.test`: テスト環境
+  - `.env.*.local`: ローカル固有設定
+
+## デバッグ
+```bash
+# デバッグモードでサーバー起動
+cd apps/app && pnpm run dev  # --inspectフラグ付きでnodemon起動
+
+# REPL(Read-Eval-Print Loop)
+cd apps/app && pnpm run repl
+```
+
+## VS Code設定
+`.vscode/` ディレクトリに設定ファイルが含まれており、推奨拡張機能や設定が適用される。
+
+## Docker対応
+各アプリケーションにDockerファイルが含まれており、コンテナベースでの開発も可能。

+ 26 - 0
.serena/memories/project_overview.md

@@ -0,0 +1,26 @@
+# GROWIプロジェクト概要
+
+## 目的
+GROWIは、マークダウンを使用したチームコラボレーションソフトウェアです。Wikiとドキュメント作成ツールの機能を持ち、チーム間の情報共有とコラボレーションを促進します。
+
+## プロジェクトの詳細
+- **プロジェクト名**: GROWI
+- **バージョン**: 7.3.0-RC.0
+- **ライセンス**: MIT
+- **作者**: Yuki Takei <yuki@weseek.co.jp>
+- **リポジトリ**: https://github.com/weseek/growi.git
+- **公式サイト**: https://growi.org
+
+## 主な特徴
+- Markdownベースのドキュメント作成
+- チームコラボレーション機能
+- Wikiのような情報共有プラットフォーム
+- ドキュメント管理とバージョン管理
+
+## アーキテクチャ
+- **モノレポ構成**: pnpm workspace + Turbo.js を使用
+- **主要アプリケーション**: apps/app (メインアプリケーション)
+- **追加アプリケーション**: 
+  - apps/pdf-converter (PDF変換サービス)
+  - apps/slackbot-proxy (Slackボットプロキシ)
+- **パッケージ**: packages/ 配下に複数の共有ライブラリ

+ 90 - 0
.serena/memories/project_structure.md

@@ -0,0 +1,90 @@
+# プロジェクト構造
+
+## ルートディレクトリ構造
+```
+growi/
+├── apps/                    # アプリケーション群
+│   ├── app/                # メインのGROWIアプリケーション
+│   ├── pdf-converter/      # PDF変換サービス
+│   └── slackbot-proxy/     # Slackボットプロキシ
+├── packages/               # 共有パッケージ群
+│   ├── core/              # コアライブラリ
+│   ├── core-styles/       # 共通スタイル
+│   ├── editor/            # エディターコンポーネント
+│   ├── pluginkit/         # プラグインキット
+│   ├── ui/                # UIコンポーネント
+│   ├── presentation/      # プレゼンテーション層
+│   ├── preset-templates/  # テンプレート
+│   ├── preset-themes/     # テーマ
+│   └── remark-*/          # remarkプラグイン群
+├── bin/                   # ユーティリティスクリプト
+└── 設定ファイル群
+```
+
+## メインアプリケーション (apps/app/)
+```
+apps/app/
+├── src/                   # ソースコード
+├── test/                  # 古いJestテストファイル(廃止予定)
+├── test-with-vite/        # 新しいVitestテストファイル
+├── playwright/            # E2Eテスト(Playwright)
+├── config/                # 設定ファイル
+├── public/                # 静的ファイル
+├── docker/                # Docker関連
+├── bin/                   # スクリプト
+└── 設定ファイル群
+```
+
+## テストディレクトリの詳細
+
+### test/ (廃止予定)
+- Jest用の古いテストファイル
+- 段階的にtest-with-vite/に移行予定
+- 新規テストは作成しない
+
+### test-with-vite/
+- Vitest用の新しいテストファイル
+- 新規テストはここに作成
+- セットアップファイル: `setup/mongoms.ts` (MongoDB用)
+
+### playwright/
+- E2Eテスト用ディレクトリ
+- ブラウザ操作を含むテスト
+
+## テストファイルの配置ルール
+
+### Vitestテストファイル
+以下のパターンでソースコードと同じディレクトリまたはtest-with-vite/配下に配置:
+
+- **単体テスト**: `*.spec.{ts,js}`
+- **統合テスト**: `*.integ.ts` 
+- **コンポーネントテスト**: `*.spec.{tsx,jsx}`
+
+例:
+```
+src/
+├── utils/
+│   ├── helper.ts
+│   └── helper.spec.ts       # 単体テスト
+├── components/
+│   ├── Button.tsx
+│   └── Button.spec.tsx      # コンポーネントテスト
+└── services/
+    ├── api.ts
+    └── api.integ.ts         # 統合テスト
+```
+
+## パッケージ(packages/)
+各パッケージは独立したnpmパッケージとして管理され、以下の構造を持つ:
+- `src/`: ソースコード
+- `dist/`: ビルド出力
+- `package.json`: パッケージ設定
+- `tsconfig.json`: TypeScript設定
+
+## 重要な設定ファイル
+- **pnpm-workspace.yaml**: ワークスペース設定
+- **turbo.json**: Turbo.jsビルド設定
+- **tsconfig.base.json**: TypeScript基本設定
+- **biome.json**: Biome linter/formatter設定
+- **.eslintrc.js**: ESLint設定(廃止予定)
+- **vitest.workspace.mts**: Vitestワークスペース設定

+ 94 - 0
.serena/memories/suggested_commands.md

@@ -0,0 +1,94 @@
+# 推奨開発コマンド集
+
+## セットアップ
+```bash
+# 初期セットアップ
+pnpm run bootstrap
+# または
+pnpm install
+```
+
+## 開発サーバー
+```bash
+# メインアプリケーション開発モード
+cd apps/app && pnpm run dev
+
+# ルートから起動(本番用ビルド後)
+pnpm start
+```
+
+## ビルド
+```bash
+# メインアプリケーションのビルド
+pnpm run app:build
+
+# Slackbot Proxyのビルド
+pnpm run slackbot-proxy:build
+
+# 全体ビルド(Turboで並列実行)
+turbo run build
+```
+
+## Lint・フォーマット
+```bash
+# 【推奨】Biome実行(lint + format)
+pnpm run lint:biome
+
+# 【過渡期】ESLint実行(廃止予定)
+pnpm run lint:eslint
+
+# Stylelint実行
+pnpm run lint:styles
+
+# 全てのLint実行(過渡期対応)
+pnpm run lint
+
+# TypeScript型チェック
+pnpm run lint:typecheck
+```
+
+## テスト
+```bash
+# 【推奨】Vitestテスト実行
+pnpm run test:vitest
+
+# 【過渡期】Jest(統合テスト)(廃止予定)
+pnpm run test:jest
+
+# 全てのテスト実行(過渡期対応)
+pnpm run test
+
+# Vitestで特定のファイルに絞って実行
+pnpm run test:vitest {target-file-name}
+
+# E2Eテスト(Playwright)
+npx playwright test
+```
+
+## データベース関連
+```bash
+# マイグレーション実行
+cd apps/app && pnpm run migrate
+
+# 開発環境でのマイグレーション
+cd apps/app && pnpm run dev:migrate
+
+# マイグレーション状態確認
+cd apps/app && pnpm run dev:migrate:status
+```
+
+## その他の便利コマンド
+```bash
+# REPL起動
+cd apps/app && pnpm run repl
+
+# OpenAPI仕様生成
+cd apps/app && pnpm run openapi:generate-spec:apiv3
+
+# クリーンアップ
+cd apps/app && pnpm run clean
+```
+
+## 注意事項
+- ESLintとJestは廃止予定のため、新規開発ではBiomeとVitestを使用してください
+- 既存のコードは段階的に移行中です

+ 95 - 0
.serena/memories/task_completion_checklist.md

@@ -0,0 +1,95 @@
+# タスク完了時のチェックリスト
+
+## コードを書いた後に必ず実行すべきコマンド
+
+### 1. Lint・フォーマットの実行
+```bash
+# 【推奨】Biome実行(新規開発)
+pnpm run lint:biome
+
+# 【過渡期】全てのLint実行(既存コード)
+pnpm run lint
+
+# 個別実行(必要に応じて)
+pnpm run lint:eslint      # ESLint(廃止予定)
+pnpm run lint:styles      # Stylelint
+pnpm run lint:typecheck   # TypeScript型チェック
+```
+
+### 2. テストの実行
+```bash
+# 【推奨】Vitestテスト実行(新規開発)
+pnpm run test:vitest
+
+# 【過渡期】全てのテスト実行(既存コード)
+pnpm run test
+
+# 個別実行
+pnpm run test:jest        # Jest(廃止予定)
+pnpm run test:vitest {target-file-name}     # Vitest
+```
+
+### 3. E2Eテストの実行(重要な機能変更時)
+```bash
+cd apps/app
+npx playwright test
+```
+
+### 4. ビルドの確認
+```bash
+# メインアプリケーションのビルド
+pnpm run app:build
+
+# 関連パッケージのビルド
+turbo run build
+```
+
+### 5. 動作確認
+```bash
+# 開発サーバーでの動作確認
+cd apps/app && pnpm run dev
+
+# または本番ビルドでの確認
+pnpm start
+```
+
+## 特別な確認事項
+
+### OpenAPI仕様の確認(API変更時)
+```bash
+cd apps/app
+pnpm run openapi:generate-spec:apiv3
+pnpm run lint:openapi:apiv3
+```
+
+### データベーススキーマ変更時
+```bash
+cd apps/app
+pnpm run dev:migrate:status  # 現在の状態確認
+pnpm run dev:migrate         # マイグレーション実行
+```
+
+## テストファイル作成時の注意
+
+### 新規テストファイル
+- **単体テスト**: `*.spec.{ts,js}` (Node.js環境)
+- **統合テスト**: `*.integ.ts` (Node.js + MongoDB環境)  
+- **コンポーネントテスト**: `*.spec.{tsx,jsx}` (happy-dom環境)
+- test-with-vite/ または対象ファイルと同じディレクトリに配置
+
+### 既存テストの修正
+- test/ 配下のJestテストは段階的に移行
+- 可能であればtest-with-vite/にVitestテストとして書き直し
+
+## コミット前の最終チェック
+1. Biome(または過渡期はESLint)エラーが解消されているか
+2. Vitestテスト(または過渡期はJest)がパスしているか
+3. 重要な変更はPlaywright E2Eテストも実行
+4. ビルドが成功するか
+5. 変更による既存機能への影響がないか
+6. 適切なコミットメッセージを作成したか
+
+## 移行期間中の注意事項
+- 新規開発: Biome + Vitest を使用
+- 既存コード修正: 可能な限り Biome + Vitest に移行
+- レガシーツールは段階的に廃止予定

+ 42 - 0
.serena/memories/tech_stack.md

@@ -0,0 +1,42 @@
+# 技術スタック
+
+## プログラミング言語
+- **TypeScript**: メイン言語(~5.0.0)
+- **JavaScript**: 一部のコンポーネント
+
+## フロントエンド
+- **Next.js**: Reactベースのフレームワーク
+- **React**: UIライブラリ
+- **Vite**: ビルドツール、開発サーバー
+- **SCSS**: スタイルシート
+- **SWR**: グローバルステート管理、データフェッチ・キャッシュ管理(^2.3.2)
+
+## バックエンド
+- **Node.js**: ランタイム(^20 || ^22)
+- **Express.js**: Webフレームワーク(推測)
+- **MongoDB**: データベース
+- **Mongoose**: MongoDB用ORM(^6.13.6)
+  - mongoose-gridfs: GridFS対応(^1.2.42)
+  - mongoose-paginate-v2: ページネーション(^1.3.9)
+  - mongoose-unique-validator: バリデーション(^2.0.3)
+
+## 開発ツール
+- **pnpm**: パッケージマネージャー(10.4.1)
+- **Turbo**: モノレポビルドシステム(^2.1.3)
+- **ESLint**: Linter(weseek設定を使用)【廃止予定 - 現在は過渡期】
+- **Biome**: 統一予定のLinter/Formatter
+- **Stylelint**: CSS/SCSSのLinter
+- **Jest**: テスティングフレームワーク【廃止予定 - 現在は過渡期】
+- **Vitest**: 高速テスティングフレームワーク【統一予定】
+- **Playwright**: E2Eテスト【統一予定】
+
+## その他のツール
+- **SWC**: TypeScriptコンパイラー(高速)
+- **ts-node**: TypeScript直接実行
+- **nodemon**: 開発時のホットリロード
+- **dotenv-flow**: 環境変数管理
+- **Swagger/OpenAPI**: API仕様
+
+## 移行計画
+- **Linter**: ESLint → Biome に統一予定
+- **テスト**: Jest → Vitest + Playwright に統一予定

+ 68 - 0
.serena/project.yml

@@ -0,0 +1,68 @@
+# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
+#  * For C, use cpp
+#  * For JavaScript, use typescript
+# Special requirements:
+#  * csharp: Requires the presence of a .sln file in the project folder.
+language: typescript
+
+# whether to use the project's gitignore file to ignore files
+# Added on 2025-04-07
+ignore_all_files_in_gitignore: true
+# list of additional paths to ignore
+# same syntax as gitignore, so you can use * and **
+# Was previously called `ignored_dirs`, please update your config if you are using that.
+# Added (renamed) on 2025-04-07
+ignored_paths: []
+
+# whether the project is in read-only mode
+# If set to true, all editing tools will be disabled and attempts to use them will result in an error
+# Added on 2025-04-18
+read_only: false
+
+
+# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
+# Below is the complete list of tools for convenience.
+# To make sure you have the latest list of tools, and to view their descriptions, 
+# execute `uv run scripts/print_tool_overview.py`.
+#
+#  * `activate_project`: Activates a project by name.
+#  * `check_onboarding_performed`: Checks whether project onboarding was already performed.
+#  * `create_text_file`: Creates/overwrites a file in the project directory.
+#  * `delete_lines`: Deletes a range of lines within a file.
+#  * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
+#  * `execute_shell_command`: Executes a shell command.
+#  * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
+#  * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
+#  * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
+#  * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
+#  * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
+#  * `initial_instructions`: Gets the initial instructions for the current project.
+#     Should only be used in settings where the system prompt cannot be set,
+#     e.g. in clients you have no control over, like Claude Desktop.
+#  * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
+#  * `insert_at_line`: Inserts content at a given line in a file.
+#  * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
+#  * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
+#  * `list_memories`: Lists memories in Serena's project-specific memory store.
+#  * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
+#  * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
+#  * `read_file`: Reads a file within the project directory.
+#  * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
+#  * `remove_project`: Removes a project from the Serena configuration.
+#  * `replace_lines`: Replaces a range of lines within a file with new content.
+#  * `replace_symbol_body`: Replaces the full definition of a symbol.
+#  * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
+#  * `search_for_pattern`: Performs a search for a pattern in the project.
+#  * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
+#  * `switch_modes`: Activates modes by providing a list of their names
+#  * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
+#  * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
+#  * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
+#  * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
+excluded_tools: []
+
+# initial prompt for the project. It will always be given to the LLM upon activating the project
+# (contrary to the memories, which are loaded on demand).
+initial_prompt: ""
+
+project_name: "growi"

+ 20 - 0
.vscode/mcp.json

@@ -0,0 +1,20 @@
+{
+  "servers": {
+    "context7": {
+      "type": "http",
+      "url": "https://mcp.context7.com/mcp"
+    },
+    "serena": {
+      "type": "stdio",
+      "command": "uvx",
+      "args": [
+        "--from",
+        "git+https://github.com/oraios/serena",
+        "serena",
+        "start-mcp-server",
+        "--context",
+        "ide-assistant"
+      ]
+    }
+  }
+}

+ 14 - 1
CHANGELOG.md

@@ -1,9 +1,22 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.2.8...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.2.9...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.2.9](https://github.com/weseek/growi/compare/v7.2.8...v7.2.9) - 2025-07-01
+
+### 🚀 Improvement
+
+* imprv(ai): Assistant instructions (#10129) @yuki-takei
+* imprv: OpenTelemetry phase 2 (#10095) @yuki-takei
+* imprv: Adjust margin-top for .main at md and lg breakpoints (#10131) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Sharelink expiration date parsing when the date is cleared by the calendar UI (#10132) @yuki-takei
+* fix: Cannot change file upload destination to "MongoDB (GridFS)" or "local" for dev/7.2.x (#10119) @miya
+
 ## [v7.2.8](https://github.com/weseek/growi/compare/v7.2.7...v7.2.8) - 2025-06-26
 
 ### 💎 Features

+ 3 - 1
apps/app/.env.development

@@ -30,7 +30,9 @@ OGP_URI="http://ogp:8088"
 # AUDIT_LOG_ADDITIONAL_ACTIONS=
 # AUDIT_LOG_EXCLUDE_ACTIONS=
 
-# OpenTelemetry Official Configuration
+SERVICE_TYPE=dev
+
+# OpenTelemetry Official Configuration for dev
 # Environment variables starting with 'OTEL_' are automatically loaded by the OpenTelemetry SDK
 OPENTELEMETRY_ENABLED=false
 OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317

+ 1 - 1
apps/app/.env.production

@@ -7,6 +7,6 @@ MIGRATIONS_DIR=dist/migrations/
 
 # OpenTelemetry Official Configuration
 # Environment variables starting with 'OTEL_' are automatically loaded by the OpenTelemetry SDK
-OTEL_TRACES_SAMPLER_ARG=0.1
+OTEL_TRACES_SAMPLER_ARG=0.01
 OTEL_METRIC_EXPORT_INTERVAL=300000
 OTEL_EXPORTER_OTLP_ENDPOINT="https://telemetry.growi.org"

+ 14 - 0
apps/app/.eslintrc.js

@@ -16,6 +16,20 @@ module.exports = {
     'src/linter-checker/**',
     'tmp/**',
     'next-env.d.ts',
+    'next.config.js',
+    'playwright.config.ts',
+    'test/integration/global-setup.js',
+    'test/integration/global-teardown.js',
+    'test/integration/setup-crowi.ts',
+    'test/integration/crowi/**',
+    'test/integration/middlewares/**',
+    'test/integration/migrations/**',
+    'test/integration/models/**',
+    'test/integration/setup.js',
+    'bin/**',
+    'config/**',
+    'src/linter-checker/**',
+    'src/migrations/**',
   ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript

+ 2 - 1
apps/app/bin/openapi/definition-apiv1.js

@@ -12,7 +12,8 @@ module.exports = {
       variables: {
         server: {
           default: 'https://demo.growi.org',
-          description: 'The base URL for the GROWI API except for the version path (/_api). This can be set to your GROWI instance URL.',
+          description:
+            'The base URL for the GROWI API except for the version path (/_api). This can be set to your GROWI instance URL.',
         },
       },
     },

+ 3 - 11
apps/app/bin/openapi/definition-apiv3.js

@@ -12,7 +12,8 @@ module.exports = {
       variables: {
         server: {
           default: 'https://demo.growi.org',
-          description: 'The base URL for the GROWI API except for the version path (/_api/v3). This can be set to your GROWI instance URL.',
+          description:
+            'The base URL for the GROWI API except for the version path (/_api/v3). This can be set to your GROWI instance URL.',
         },
       },
     },
@@ -115,16 +116,7 @@ module.exports = {
     },
     {
       name: 'Public API',
-      tags: [
-        'Healthcheck',
-        'Statistics',
-        '',
-        '',
-        '',
-        '',
-        '',
-        '',
-      ],
+      tags: ['Healthcheck', 'Statistics', '', '', '', '', '', ''],
     },
   ],
 };

+ 14 - 10
apps/app/bin/openapi/generate-operation-ids/cli.spec.ts

@@ -1,8 +1,6 @@
 import { writeFileSync } from 'fs';
 
-import {
-  beforeEach, describe, expect, it, vi,
-} from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
 
 import { generateOperationIds } from './generate-operation-ids';
 
@@ -23,7 +21,7 @@ describe('cli', () => {
     vi.spyOn(console, 'error').mockImplementation(() => {});
   });
 
-  it('processes input file and writes output to specified file', async() => {
+  it('processes input file and writes output to specified file', async () => {
     // Mock generateOperationIds to return success
     vi.mocked(generateOperationIds).mockResolvedValue(mockJsonStrings);
 
@@ -35,13 +33,15 @@ describe('cli', () => {
     await cliModule.main();
 
     // Verify generateOperationIds was called with correct arguments
-    expect(generateOperationIds).toHaveBeenCalledWith('input.json', { overwriteExisting: undefined });
+    expect(generateOperationIds).toHaveBeenCalledWith('input.json', {
+      overwriteExisting: undefined,
+    });
 
     // Verify writeFileSync was called with correct arguments
     expect(writeFileSync).toHaveBeenCalledWith('output.json', mockJsonStrings);
   });
 
-  it('uses input file as output when no output file is specified', async() => {
+  it('uses input file as output when no output file is specified', async () => {
     // Mock generateOperationIds to return success
     vi.mocked(generateOperationIds).mockResolvedValue(mockJsonStrings);
 
@@ -53,13 +53,15 @@ describe('cli', () => {
     await cliModule.main();
 
     // Verify generateOperationIds was called with correct arguments
-    expect(generateOperationIds).toHaveBeenCalledWith('input.json', { overwriteExisting: undefined });
+    expect(generateOperationIds).toHaveBeenCalledWith('input.json', {
+      overwriteExisting: undefined,
+    });
 
     // Verify writeFileSync was called with input file as output
     expect(writeFileSync).toHaveBeenCalledWith('input.json', mockJsonStrings);
   });
 
-  it('handles overwrite-existing option correctly', async() => {
+  it('handles overwrite-existing option correctly', async () => {
     // Mock generateOperationIds to return success
     vi.mocked(generateOperationIds).mockResolvedValue(mockJsonStrings);
 
@@ -71,10 +73,12 @@ describe('cli', () => {
     await cliModule.main();
 
     // Verify generateOperationIds was called with overwriteExisting option
-    expect(generateOperationIds).toHaveBeenCalledWith('input.json', { overwriteExisting: true });
+    expect(generateOperationIds).toHaveBeenCalledWith('input.json', {
+      overwriteExisting: true,
+    });
   });
 
-  it('handles generateOperationIds error correctly', async() => {
+  it('handles generateOperationIds error correctly', async () => {
     // Mock generateOperationIds to throw error
     const error = new Error('Test error');
     vi.mocked(generateOperationIds).mockRejectedValue(error);

+ 5 - 4
apps/app/bin/openapi/generate-operation-ids/cli.ts

@@ -1,10 +1,9 @@
-import { writeFileSync } from 'fs';
-
 import { Command } from 'commander';
+import { writeFileSync } from 'fs';
 
 import { generateOperationIds } from './generate-operation-ids';
 
-export const main = async(): Promise<void> => {
+export const main = async (): Promise<void> => {
   // parse command line arguments
   const program = new Command();
   program
@@ -18,7 +17,9 @@ export const main = async(): Promise<void> => {
   const [inputFile] = program.args;
 
   // eslint-disable-next-line no-console
-  const jsonStrings = await generateOperationIds(inputFile, { overwriteExisting }).catch(console.error);
+  const jsonStrings = await generateOperationIds(inputFile, {
+    overwriteExisting,
+  }).catch(console.error);
   if (jsonStrings != null) {
     writeFileSync(outputFile ?? inputFile, jsonStrings);
   }

+ 29 - 31
apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.spec.ts

@@ -1,13 +1,11 @@
 import fs from 'fs/promises';
+import type { OpenAPI3 } from 'openapi-typescript';
 import { tmpdir } from 'os';
 import path from 'path';
-
-import type { OpenAPI3 } from 'openapi-typescript';
 import { describe, expect, it } from 'vitest';
 
 import { generateOperationIds } from './generate-operation-ids';
 
-
 async function createTempOpenAPIFile(spec: OpenAPI3): Promise<string> {
   const tempDir = await fs.mkdtemp(path.join(tmpdir(), 'openapi-test-'));
   const filePath = path.join(tempDir, 'openapi.json');
@@ -19,15 +17,14 @@ async function cleanup(filePath: string): Promise<void> {
   try {
     await fs.unlink(filePath);
     await fs.rmdir(path.dirname(filePath));
-  }
-  catch (err) {
+  } catch (err) {
     // eslint-disable-next-line no-console
     console.error('Cleanup failed:', err);
   }
 }
 
 describe('generateOperationIds', () => {
-  it('should generate correct operationId for simple paths', async() => {
+  it('should generate correct operationId for simple paths', async () => {
     const spec: OpenAPI3 = {
       openapi: '3.0.0',
       info: { title: 'Test API', version: '1.0.0' },
@@ -46,13 +43,12 @@ describe('generateOperationIds', () => {
 
       expect(parsed.paths['/foo'].get.operationId).toBe('getFoo');
       expect(parsed.paths['/foo'].post.operationId).toBe('postFoo');
-    }
-    finally {
+    } finally {
       await cleanup(filePath);
     }
   });
 
-  it('should generate correct operationId for paths with parameters', async() => {
+  it('should generate correct operationId for paths with parameters', async () => {
     const spec: OpenAPI3 = {
       openapi: '3.0.0',
       info: { title: 'Test API', version: '1.0.0' },
@@ -72,14 +68,15 @@ describe('generateOperationIds', () => {
       const parsed = JSON.parse(result);
 
       expect(parsed.paths['/foo/{id}'].get.operationId).toBe('getFooById');
-      expect(parsed.paths['/foo/{id}/bar/{page}'].get.operationId).toBe('getBarByPageByIdForFoo');
-    }
-    finally {
+      expect(parsed.paths['/foo/{id}/bar/{page}'].get.operationId).toBe(
+        'getBarByPageByIdForFoo',
+      );
+    } finally {
       await cleanup(filePath);
     }
   });
 
-  it('should generate correct operationId for nested resources', async() => {
+  it('should generate correct operationId for nested resources', async () => {
     const spec: OpenAPI3 = {
       openapi: '3.0.0',
       info: { title: 'Test API', version: '1.0.0' },
@@ -96,13 +93,12 @@ describe('generateOperationIds', () => {
       const parsed = JSON.parse(result);
 
       expect(parsed.paths['/foo/bar'].get.operationId).toBe('getBarForFoo');
-    }
-    finally {
+    } finally {
       await cleanup(filePath);
     }
   });
 
-  it('should preserve existing operationId when overwriteExisting is false', async() => {
+  it('should preserve existing operationId when overwriteExisting is false', async () => {
     const existingOperationId = 'existingOperation';
     const spec: OpenAPI3 = {
       openapi: '3.0.0',
@@ -118,17 +114,18 @@ describe('generateOperationIds', () => {
 
     const filePath = await createTempOpenAPIFile(spec);
     try {
-      const result = await generateOperationIds(filePath, { overwriteExisting: false });
+      const result = await generateOperationIds(filePath, {
+        overwriteExisting: false,
+      });
       const parsed = JSON.parse(result);
 
       expect(parsed.paths['/foo'].get.operationId).toBe(existingOperationId);
-    }
-    finally {
+    } finally {
       await cleanup(filePath);
     }
   });
 
-  it('should overwrite existing operationId when overwriteExisting is true', async() => {
+  it('should overwrite existing operationId when overwriteExisting is true', async () => {
     const spec: OpenAPI3 = {
       openapi: '3.0.0',
       info: { title: 'Test API', version: '1.0.0' },
@@ -143,17 +140,18 @@ describe('generateOperationIds', () => {
 
     const filePath = await createTempOpenAPIFile(spec);
     try {
-      const result = await generateOperationIds(filePath, { overwriteExisting: true });
+      const result = await generateOperationIds(filePath, {
+        overwriteExisting: true,
+      });
       const parsed = JSON.parse(result);
 
       expect(parsed.paths['/foo'].get.operationId).toBe('getFoo');
-    }
-    finally {
+    } finally {
       await cleanup(filePath);
     }
   });
 
-  it('should generate correct operationId for root path', async() => {
+  it('should generate correct operationId for root path', async () => {
     const spec: OpenAPI3 = {
       openapi: '3.0.0',
       info: { title: 'Test API', version: '1.0.0' },
@@ -170,13 +168,12 @@ describe('generateOperationIds', () => {
       const parsed = JSON.parse(result);
 
       expect(parsed.paths['/'].get.operationId).toBe('getRoot');
-    }
-    finally {
+    } finally {
       await cleanup(filePath);
     }
   });
 
-  it('should generate operationId for all HTTP methods', async() => {
+  it('should generate operationId for all HTTP methods', async () => {
     const spec: OpenAPI3 = {
       openapi: '3.0.0',
       info: { title: 'Test API', version: '1.0.0' },
@@ -207,13 +204,14 @@ describe('generateOperationIds', () => {
       expect(parsed.paths['/foo'].options.operationId).toBe('optionsFoo');
       expect(parsed.paths['/foo'].head.operationId).toBe('headFoo');
       expect(parsed.paths['/foo'].trace.operationId).toBe('traceFoo');
-    }
-    finally {
+    } finally {
       await cleanup(filePath);
     }
   });
 
-  it('should throw error for non-existent file', async() => {
-    await expect(generateOperationIds('non-existent-file.json')).rejects.toThrow();
+  it('should throw error for non-existent file', async () => {
+    await expect(
+      generateOperationIds('non-existent-file.json'),
+    ).rejects.toThrow();
   });
 });

+ 42 - 16
apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.ts

@@ -1,15 +1,25 @@
 import SwaggerParser from '@apidevtools/swagger-parser';
-import type { OpenAPI3, OperationObject, PathItemObject } from 'openapi-typescript';
+import type {
+  OpenAPI3,
+  OperationObject,
+  PathItemObject,
+} from 'openapi-typescript';
 
-const toPascal = (s: string): string => s.split('-').map(w => w[0]?.toUpperCase() + w.slice(1)).join('');
+const toPascal = (s: string): string =>
+  s
+    .split('-')
+    .map((w) => w[0]?.toUpperCase() + w.slice(1))
+    .join('');
 
 const createParamSuffix = (params: string[]): string => {
   return params.length > 0
-    ? params.reverse().map(param => `By${toPascal(param.slice(1, -1))}`).join('')
+    ? params
+        .reverse()
+        .map((param) => `By${toPascal(param.slice(1, -1))}`)
+        .join('')
     : '';
 };
 
-
 /**
  * Generates a PascalCase operation name based on the HTTP method and path.
  *
@@ -24,8 +34,8 @@ const createParamSuffix = (params: string[]): string => {
  */
 function createOperationId(method: string, path: string): string {
   const segments = path.split('/').filter(Boolean);
-  const params = segments.filter(s => s.startsWith('{'));
-  const paths = segments.filter(s => !s.startsWith('{'));
+  const params = segments.filter((s) => s.startsWith('{'));
+  const paths = segments.filter((s) => !s.startsWith('{'));
 
   const paramSuffix = createParamSuffix(params);
 
@@ -37,19 +47,35 @@ function createOperationId(method: string, path: string): string {
   return `${method.toLowerCase()}${toPascal(resource)}${paramSuffix}For${context.reverse().map(toPascal).join('')}`;
 }
 
-export async function generateOperationIds(inputFile: string, opts?: { overwriteExisting: boolean }): Promise<string> {
-  const api = await SwaggerParser.parse(inputFile) as OpenAPI3;
+export async function generateOperationIds(
+  inputFile: string,
+  opts?: { overwriteExisting: boolean },
+): Promise<string> {
+  const api = (await SwaggerParser.parse(inputFile)) as OpenAPI3;
 
   Object.entries(api.paths || {}).forEach(([path, pathItem]) => {
     const item = pathItem as PathItemObject;
-    (['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'trace'] as const)
-      .forEach((method) => {
-        const operation = item[method] as OperationObject | undefined;
-        if (operation == null || (operation.operationId != null && !opts?.overwriteExisting)) {
-          return;
-        }
-        operation.operationId = createOperationId(method, path);
-      });
+    (
+      [
+        'get',
+        'post',
+        'put',
+        'delete',
+        'patch',
+        'options',
+        'head',
+        'trace',
+      ] as const
+    ).forEach((method) => {
+      const operation = item[method] as OperationObject | undefined;
+      if (
+        operation == null ||
+        (operation.operationId != null && !opts?.overwriteExisting)
+      ) {
+        return;
+      }
+      operation.operationId = createOperationId(method, path);
+    });
   });
 
   const output = JSON.stringify(api, null, 2);

+ 8 - 2
apps/app/config/cdn.js

@@ -2,7 +2,13 @@ import path from 'path';
 
 import { projectRoot } from '~/utils/project-dir-utils';
 
-export const cdnLocalScriptRoot = path.join(projectRoot, 'public/static/js/cdn');
+export const cdnLocalScriptRoot = path.join(
+  projectRoot,
+  'public/static/js/cdn',
+);
 export const cdnLocalScriptWebRoot = '/static/js/cdn';
-export const cdnLocalStyleRoot = path.join(projectRoot, 'public/static/styles/cdn');
+export const cdnLocalStyleRoot = path.join(
+  projectRoot,
+  'public/static/styles/cdn',
+);
 export const cdnLocalStyleWebRoot = '/static/styles/cdn';

+ 2 - 2
apps/app/config/migrate-mongo-config.js

@@ -9,8 +9,8 @@ const isProduction = process.env.NODE_ENV === 'production';
 const { URL } = require('url');
 
 const { getMongoUri, mongoOptions } = isProduction
-  // eslint-disable-next-line import/extensions, import/no-unresolved
-  ? require('../dist/server/util/mongoose-utils')
+  ? // eslint-disable-next-line import/extensions, import/no-unresolved
+    require('../dist/server/util/mongoose-utils')
   : require('../src/server/util/mongoose-utils');
 
 // get migrationsDir from env var

+ 4 - 8
apps/app/config/migrate-mongo-config.spec.ts

@@ -2,11 +2,8 @@ import mockRequire from 'mock-require';
 
 const { reRequire } = mockRequire;
 
-
 describe('config/migrate-mongo-config.js', () => {
-
   test.concurrent('throws an error when MIGRATIONS_DIR is not set', () => {
-
     const getMongoUriMock = vi.fn();
     const mongoOptionsMock = vi.fn();
 
@@ -32,13 +29,11 @@ describe('config/migrate-mongo-config.js', () => {
     ${'mongodb://user:pass@example.com/growi'}        | ${'growi'}
     ${'mongodb://example.com/growi?replicaSet=mySet'} | ${'growi'}
   `('returns', ({ MONGO_URI, expectedDbName }) => {
-
-    beforeEach(async() => {
+    beforeEach(async () => {
       process.env.MIGRATIONS_DIR = 'testdir/migrations';
     });
 
     test(`when 'MONGO_URI' is '${MONGO_URI}`, () => {
-
       const getMongoUriMock = vi.fn(() => MONGO_URI);
       const mongoOptionsMock = vi.fn();
 
@@ -49,7 +44,9 @@ describe('config/migrate-mongo-config.js', () => {
       });
 
       // use reRequire to avoid using module cache
-      const { mongodb, migrationsDir, changelogCollectionName } = reRequire('./migrate-mongo-config');
+      const { mongodb, migrationsDir, changelogCollectionName } = reRequire(
+        './migrate-mongo-config',
+      );
 
       mockRequire.stop('../src/server/util/mongoose-utils');
 
@@ -61,5 +58,4 @@ describe('config/migrate-mongo-config.js', () => {
       expect(changelogCollectionName).toBe('migrations');
     });
   });
-
 });

+ 6 - 7
apps/app/config/next-i18next.config.js

@@ -26,17 +26,17 @@ module.exports = {
     ? isServer()
       ? [new HMRPlugin({ webpack: { server: true } })]
       : [
-        require('i18next-chained-backend').default,
-        new HMRPlugin({ webpack: { client: true } }),
-      ]
+          require('i18next-chained-backend').default,
+          new HMRPlugin({ webpack: { client: true } }),
+        ]
     : [],
   backend: {
     backends: isServer()
       ? []
       : [
-        require('i18next-localstorage-backend').default,
-        require('i18next-http-backend').default,
-      ],
+          require('i18next-localstorage-backend').default,
+          require('i18next-http-backend').default,
+        ],
     backendOptions: [
       // options for i18next-localstorage-backend
       { expirationTime: isDev ? 0 : 24 * 60 * 60 * 1000 }, // 1 day in production
@@ -44,5 +44,4 @@ module.exports = {
       { loadPath: '/static/locales/{{lng}}/{{ns}}.json' },
     ],
   },
-
 };

+ 11 - 4
apps/app/jest.config.js

@@ -22,9 +22,14 @@ module.exports = {
 
       rootDir: '.',
       roots: ['<rootDir>'],
-      testMatch: ['<rootDir>/test/integration/**/*.test.ts', '<rootDir>/test/integration/**/*.test.js'],
+      testMatch: [
+        '<rootDir>/test/integration/**/*.test.ts',
+        '<rootDir>/test/integration/**/*.test.js',
+      ],
       // https://regex101.com/r/jTaxYS/1
-      modulePathIgnorePatterns: ['<rootDir>/test/integration/*.*/v5(..*)*.[t|j]s'],
+      modulePathIgnorePatterns: [
+        '<rootDir>/test/integration/*.*/v5(..*)*.[t|j]s',
+      ],
       testEnvironment: 'node',
       globalSetup: '<rootDir>/test/integration/global-setup.js',
       globalTeardown: '<rootDir>/test/integration/global-teardown.js',
@@ -43,7 +48,10 @@ module.exports = {
 
       rootDir: '.',
       roots: ['<rootDir>'],
-      testMatch: ['<rootDir>/test/integration/**/v5.*.test.ts', '<rootDir>/test/integration/**/v5.*.test.js'],
+      testMatch: [
+        '<rootDir>/test/integration/**/v5.*.test.ts',
+        '<rootDir>/test/integration/**/v5.*.test.js',
+      ],
 
       testEnvironment: 'node',
       globalSetup: '<rootDir>/test/integration/global-setup.js',
@@ -75,5 +83,4 @@ module.exports = {
     '/resource/',
     '/node_modules/',
   ],
-
 };

+ 18 - 15
apps/app/next.config.js

@@ -8,8 +8,10 @@
 const path = require('path');
 
 const { withSuperjson } = require('next-superjson');
-const { PHASE_PRODUCTION_BUILD, PHASE_PRODUCTION_SERVER } = require('next/constants');
-
+const {
+  PHASE_PRODUCTION_BUILD,
+  PHASE_PRODUCTION_SERVER,
+} = require('next/constants');
 
 const getTranspilePackages = () => {
   const { listPrefixedPackages } = require('./src/utils/next.config.utils');
@@ -56,7 +58,14 @@ const getTranspilePackages = () => {
     'github-slugger',
     'html-url-attributes',
     'estree-util-is-identifier-name',
-    ...listPrefixedPackages(['remark-', 'rehype-', 'hast-', 'mdast-', 'micromark-', 'unist-']),
+    ...listPrefixedPackages([
+      'remark-',
+      'rehype-',
+      'hast-',
+      'mdast-',
+      'micromark-',
+      'unist-',
+    ]),
   ];
 
   // const eazyLogger = require('eazy-logger');
@@ -84,13 +93,11 @@ const optimizePackageImports = [
   '@growi/ui',
 ];
 
-module.exports = async(phase, { defaultConfig }) => {
-
+module.exports = async (phase, { defaultConfig }) => {
   const { i18n, localePath } = require('./config/next-i18next.config');
 
   /** @type {import('next').NextConfig} */
   const nextConfig = {
-
     reactStrictMode: true,
     poweredByHeader: false,
     pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],
@@ -103,9 +110,8 @@ module.exports = async(phase, { defaultConfig }) => {
     typescript: {
       tsconfigPath: 'tsconfig.build.client.json',
     },
-    transpilePackages: phase !== PHASE_PRODUCTION_SERVER
-      ? getTranspilePackages()
-      : undefined,
+    transpilePackages:
+      phase !== PHASE_PRODUCTION_SERVER ? getTranspilePackages() : undefined,
     experimental: {
       optimizePackageImports,
     },
@@ -150,7 +156,6 @@ module.exports = async(phase, { defaultConfig }) => {
 
       return config;
     },
-
   };
 
   // production server
@@ -159,11 +164,9 @@ module.exports = async(phase, { defaultConfig }) => {
   }
 
   const withBundleAnalyzer = require('@next/bundle-analyzer')({
-    enabled: phase === PHASE_PRODUCTION_BUILD
-      && (
-        process.env.ANALYZE === 'true'
-          || process.env.ANALYZE === '1'
-      ),
+    enabled:
+      phase === PHASE_PRODUCTION_BUILD &&
+      (process.env.ANALYZE === 'true' || process.env.ANALYZE === '1'),
   });
 
   return withBundleAnalyzer(withSuperjson()(nextConfig));

+ 1 - 4
apps/app/nodemon.json

@@ -1,9 +1,6 @@
 {
   "ext": "js,ts,json",
-  "watch": [
-    ".",
-    "../../packages/**/dist"
-  ],
+  "watch": [".", "../../packages/**/dist"],
   "ignore": [
     ".next",
     "public/static",

+ 11 - 7
apps/app/package.json

@@ -28,18 +28,19 @@
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint:eslint": "eslint --quiet \"**/*.{js,mjs,jsx,ts,mts,tsx}\"",
+    "lint:biome": "biome check",
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
     "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
     "lint": "run-p lint:**",
     "prelint:openapi:apiv3": "pnpm run openapi:generate-spec:apiv3",
     "prelint:openapi:apiv1": "pnpm run openapi:generate-spec:apiv1",
-    "test": "run-p test:*",
+    "test": "run-p test:jest test:vitest:coverage",
     "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest",
-    "test:vitest": "vitest run --coverage",
+    "test:vitest": "vitest run",
+    "test:vitest:coverage": "COLUMNS=200 vitest run --coverage",
     "jest:run": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
-    "previtest:run:integ": "vitest run -c test-with-vite/download-mongo-binary/vitest.config.ts test-with-vite/download-mongo-binary",
     "//// misc": "",
     "console": "npm run repl",
     "repl": "cross-env NODE_ENV=development npm run ts-node src/server/repl.ts",
@@ -69,8 +70,9 @@
     "@azure/storage-blob": "^12.16.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@cspell/dynamic-import": "^8.15.4",
-    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
-    "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
+    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.4",
+    "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.18.2",
+    "@elastic/elasticsearch9": "npm:@elastic/elasticsearch@^9.0.3",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@growi/core": "workspace:^",
@@ -156,7 +158,7 @@
     "mdast-util-from-markdown": "^2.0.1",
     "mdast-util-gfm-table": "^2.0.0",
     "mdast-util-wiki-link": "^0.1.2",
-    "mermaid": "^11.9.0",
+    "mermaid": "^11.10.0",
     "method-override": "^3.0.0",
     "micromark-extension-gfm-table": "^2.1.0",
     "micromark-extension-wiki-link": "^0.0.4",
@@ -336,6 +338,8 @@
     "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.7.5",
     "source-map-loader": "^4.0.1",
-    "swagger2openapi": "^7.0.8"
+    "swagger2openapi": "^7.0.8",
+    "unist-util-is": "^6.0.0",
+    "unist-util-visit-parents": "^6.0.0"
   }
 }

+ 14 - 14
apps/app/playwright.config.ts

@@ -10,18 +10,20 @@ const storageState = fs.existsSync(authFile) ? authFile : undefined;
 
 const supportedBrowsers = ['chromium', 'firefox', 'webkit'] as const;
 
-const projects: Array<Project> = supportedBrowsers.map(browser => ({
+const projects: Array<Project> = supportedBrowsers.map((browser) => ({
   name: browser,
   use: { ...devices[`Desktop ${browser}`], storageState },
   testIgnore: /(10-installer|21-basic-features-for-guest)\/.*\.spec\.ts/,
   dependencies: ['setup', 'auth'],
 }));
 
-const projectsForGuestMode: Array<Project> = supportedBrowsers.map(browser => ({
-  name: `${browser}/guest-mode`,
-  use: { ...devices[`Desktop ${browser}`] }, // Do not use storageState
-  testMatch: /21-basic-features-for-guest\/.*\.spec\.ts/,
-}));
+const projectsForGuestMode: Array<Project> = supportedBrowsers.map(
+  (browser) => ({
+    name: `${browser}/guest-mode`,
+    use: { ...devices[`Desktop ${browser}`] }, // Do not use storageState
+    testMatch: /21-basic-features-for-guest\/.*\.spec\.ts/,
+  }),
+);
 
 /**
  * Read environment variables from file.
@@ -48,12 +50,7 @@ export default defineConfig({
   /* Opt out of parallel tests on CI. */
   workers: process.env.CI ? 1 : undefined,
   /* Reporter to use. See https://playwright.dev/docs/test-reporters */
-  reporter: process.env.CI
-    ? [
-      ['github'],
-      ['blob'],
-    ]
-    : 'list',
+  reporter: process.env.CI ? [['github'], ['blob']] : 'list',
 
   webServer: {
     command: 'pnpm run server',
@@ -79,7 +76,11 @@ export default defineConfig({
   /* Configure projects for major browsers */
   projects: [
     // Setup project
-    { name: 'setup', testMatch: /.*\.setup\.ts/, testIgnore: /auth\.setup\.ts/ },
+    {
+      name: 'setup',
+      testMatch: /.*\.setup\.ts/,
+      testIgnore: /auth\.setup\.ts/,
+    },
     { name: 'auth', testMatch: /auth\.setup\.ts/ },
 
     {
@@ -113,5 +114,4 @@ export default defineConfig({
     //   use: { ...devices['Desktop Chrome'], channel: 'chrome' },
     // },
   ],
-
 });

+ 3 - 2
apps/app/playwright/60-home/home.spec.ts

@@ -94,10 +94,11 @@ test('Access Access Token setting', async({ page }) => {
 
   // Expect a success toaster to be displayed when new Access Token is generated
   await page.getByTestId('btn-accesstoken-toggleform').click();
-  await page.getByTestId('grw-accesstoken-textare-description').fill('dummy');
+  await page.getByTestId('grw-accesstoken-textarea-description').fill('dummy');
+  await page.getByTestId('grw-accesstoken-checkbox-read:*').check();
   await page.getByTestId('grw-accesstoken-create-button').click();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
-  await expect(page.locator('grw-accesstokne-new-token-display')).toBeVisible();
+  await expect(page.getByTestId('grw-accesstoken-new-token-display')).toBeVisible();
 
   // Expect a success toaster to be displayed when the Access Token is deleted
   await page.getByTestId('grw-accesstoken-delete-button').click();

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

@@ -600,6 +600,17 @@
       "create_failed": "Failed to create assistant",
       "update_failed": "Failed to update assistant"
     },
+    "select_source_pages": "Select pages for the assistant to reference",
+    "search_reference_pages_by_keyword": "Search for pages the assistant will reference by keyword",
+    "search_by_keyword": "Search by keyword",
+    "enter_keywords": "Enter keywords",
+    "max_items_space_separated_hint": "Enter up to 5 items separated by spaces",
+    "select_assistant_reference_pages": "Select pages for the assistant to reference",
+    "reference_pages": "Reference pages",
+    "no_pages_selected": "No pages selected",
+    "can_add_later": "You can add more later",
+    "next": "Next",
+    "select_from_page_tree": "Select from page tree",
     "edit_page_description": "Edit pages that the assistant can reference.<br> The assistant can reference up to {{limitLearnablePageCountPerAssistant}} pages including child pages.",
     "default_instruction": "You are the knowledge assistant for this Wiki.\n\n## Multilingual Support:\nRespond in the same language the user uses in their input.\n",
     "add_page_button": "Add page",

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

@@ -594,6 +594,17 @@
       "create_failed": "Échec de la création de l'assistant",
       "update_failed": "Échec de la mise à jour de l'assistant"
     },
+    "select_source_pages": "Sélectionnez les pages que l'assistant doit référencer",
+    "search_reference_pages_by_keyword": "Rechercher les pages de référence de l'assistant par mot-clé",
+    "search_by_keyword": "Rechercher par mot-clé",
+    "max_items_space_separated_hint": "Saisissez jusqu'à 5 éléments séparés par des espaces",
+    "select_assistant_reference_pages": "Sélectionnez les pages de référence pour l'assistant",
+    "enter_keywords": "Entrer des mots-clés",
+    "reference_pages": "Pages de référence",
+    "no_pages_selected": "Aucune page sélectionnée",
+    "can_add_later": "Vous pouvez en ajouter plus tard",
+    "next": "Suivant",
+    "select_from_page_tree": "Sélectionner depuis l'arborescence des pages",
     "edit_page_description": "Modifier les pages que l'assistant peut référencer.<br> L'assistant peut référencer jusqu'à {{limitLearnablePageCountPerAssistant}} pages, y compris les pages enfants.",
     "default_instruction": "Vous êtes l'assistant de connaissances pour ce Wiki.\n\n## Support multilingue :\nRépondez dans la même langue que celle utilisée par l'utilisateur dans sa requête.\n",
     "add_page_button": "Ajouter une page",

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

@@ -633,6 +633,17 @@
       "create_failed": "アシスタントの作成に失敗しました",
       "update_failed": "アシスタントの更新に失敗しました"
     },
+    "select_source_pages": "アシスタントが参照するページを選択します",
+    "search_reference_pages_by_keyword": "アシスタントが参照するページをキーワードで検索",
+    "search_by_keyword": "キーワードで検索",
+    "enter_keywords": "キーワードを入力",
+    "max_items_space_separated_hint": "スペース区切りで最大5つまで入力できます",
+    "select_assistant_reference_pages": "アシスタントが参照するページを選択してください",
+    "reference_pages": "参照するページ",
+    "no_pages_selected": "ページが選択されていません",
+    "can_add_later": "あとからでも追加できます",
+    "next": "次へ",
+    "select_from_page_tree": "ページツリーから選択",
     "edit_page_description": " アシスタントが参照するページを編集します。<br> 参照できるページは配下ページも含めて {{limitLearnablePageCountPerAssistant}} ページまでです。",
     "default_instruction": "あなたはこのWikiの知識アシスタントです。\n\n## 多言語サポート:\nユーザーが入力で使用した言語と同じ言語で応答してください。\n",
     "add_page_button": "ページを追加する",

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

@@ -591,6 +591,17 @@
       "create_failed": "创建助手失败",
       "update_failed": "更新助手失败"
     },
+    "select_source_pages": "选择助手要参考的页面",
+    "search_reference_pages_by_keyword": "按关键词搜索助手参考的页面",
+    "search_by_keyword": "按关键词搜索",
+    "max_items_space_separated_hint": "请输入最多5个项目,用空格分隔",
+    "select_assistant_reference_pages": "请选择助手参考的页面",
+    "enter_keywords": "输入关键词",
+    "reference_pages": "参考页面",
+    "no_pages_selected": "未选择任何页面",
+    "can_add_later": "稍后也可以添加",
+    "next": "下一步",
+    "select_from_page_tree": "从页面树选择",
     "edit_page_description": "编辑助手可以参考的页面。<br> 助手可以参考最多 {{limitLearnablePageCountPerAssistant}} 个页面,包括子页面。",
     "default_instruction": "您是这个Wiki的知识助手。\n\n## 多语言支持:\n请使用用户输入中使用的相同语言进行回复。\n",
     "add_page_button": "添加页面",

+ 6 - 8
apps/app/resource/Contributor.js

@@ -104,10 +104,7 @@ const contributors = [
       },
       {
         additionalClass: 'col-md-6 my-4',
-        members: [
-          { name: 'shaminmeerankutty' },
-          { name: 'rabitarochan' },
-        ],
+        members: [{ name: 'shaminmeerankutty' }, { name: 'rabitarochan' }],
       },
       {
         additionalClass: 'col-md-4 my-4',
@@ -150,7 +147,10 @@ const contributors = [
           { position: 'Flatt Security', name: 'stypr' },
           { position: 'Flatt Security', name: 'Azara/Norihide Saito' },
           { position: 'CyberAgent, Inc.', name: 'Daisuke Takahashi' },
-          { position: 'Mitsui Bussan Secure Directions, Inc.', name: 'Yuji Tounai' },
+          {
+            position: 'Mitsui Bussan Secure Directions, Inc.',
+            name: 'Yuji Tounai',
+          },
           { name: 'yy0931' },
         ],
       },
@@ -172,9 +172,7 @@ const contributors = [
       },
       {
         additionalClass: 'col-12 staff-credit-mt-10rem',
-        members: [
-          { name: 'AND YOU' },
-        ],
+        members: [{ name: 'AND YOU' }],
       },
     ],
   },

+ 0 - 119
apps/app/resource/search/mappings-es7.json

@@ -1,119 +0,0 @@
-{
-  "settings": {
-    "analysis": {
-      "filter": {
-        "english_stop": {
-          "type":       "stop",
-          "stopwords":  "_english_"
-        }
-      },
-      "tokenizer": {
-        "edge_ngram_tokenizer": {
-          "type": "edge_ngram",
-          "min_gram": 2,
-          "max_gram": 20,
-          "token_chars": ["letter", "digit"]
-        }
-      },
-      "analyzer": {
-        "japanese": {
-          "tokenizer": "kuromoji_tokenizer",
-          "char_filter" : ["icu_normalizer"]
-        },
-        "english_edge_ngram": {
-          "tokenizer": "edge_ngram_tokenizer",
-          "filter": [
-            "lowercase",
-            "english_stop"
-          ]
-        }
-      }
-    }
-  },
-  "mappings": {
-    "properties" : {
-      "path": {
-        "type": "text",
-        "fields": {
-          "raw": {
-            "type": "text",
-            "analyzer": "keyword"
-          },
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "body": {
-        "type": "text",
-        "fields": {
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "body_embedded": {
-        "type": "dense_vector",
-        "dims": 768
-      },
-      "comments": {
-        "type": "text",
-        "fields": {
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "username": {
-        "type": "keyword"
-      },
-      "comment_count": {
-        "type": "integer"
-      },
-      "bookmark_count": {
-        "type": "integer"
-      },
-      "like_count": {
-        "type": "integer"
-      },
-      "grant": {
-        "type": "integer"
-      },
-      "granted_users": {
-        "type": "keyword"
-      },
-      "granted_groups": {
-        "type": "keyword"
-      },
-      "created_at": {
-        "type": "date",
-        "format": "dateOptionalTime"
-      },
-      "updated_at": {
-        "type": "date",
-        "format": "dateOptionalTime"
-      },
-      "tag_names": {
-        "type": "keyword"
-      }
-    }
-  }
-}

+ 0 - 118
apps/app/resource/search/mappings-es8-for-ci.json

@@ -1,118 +0,0 @@
-{
-  "settings": {
-    "analysis": {
-      "filter": {
-        "english_stop": {
-          "type":       "stop",
-          "stopwords":  "_english_"
-        }
-      },
-      "tokenizer": {
-        "edge_ngram_tokenizer": {
-          "type": "edge_ngram",
-          "min_gram": 2,
-          "max_gram": 20,
-          "token_chars": ["letter", "digit"]
-        }
-      },
-      "analyzer": {
-        "japanese": {
-          "tokenizer": "edge_ngram_tokenizer",
-          "filter": [
-            "lowercase",
-            "english_stop"
-          ]
-        },
-        "english_edge_ngram": {
-          "tokenizer": "edge_ngram_tokenizer",
-          "filter": [
-            "lowercase",
-            "english_stop"
-          ]
-        }
-      }
-    }
-  },
-  "mappings": {
-    "properties" : {
-      "path": {
-        "type": "text",
-        "fields": {
-          "raw": {
-            "type": "text",
-            "analyzer": "keyword"
-          },
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "body": {
-        "type": "text",
-        "fields": {
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "comments": {
-        "type": "text",
-        "fields": {
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "username": {
-        "type": "keyword"
-      },
-      "comment_count": {
-        "type": "integer"
-      },
-      "bookmark_count": {
-        "type": "integer"
-      },
-      "like_count": {
-        "type": "integer"
-      },
-      "grant": {
-        "type": "integer"
-      },
-      "granted_users": {
-        "type": "keyword"
-      },
-      "granted_groups": {
-        "type": "keyword"
-      },
-      "created_at": {
-        "type": "date",
-        "format": "date_optional_time"
-      },
-      "updated_at": {
-        "type": "date",
-        "format": "date_optional_time"
-      },
-      "tag_names": {
-        "type": "keyword"
-      }
-    }
-  }
-}

+ 0 - 119
apps/app/resource/search/mappings-es8.json

@@ -1,119 +0,0 @@
-{
-  "settings": {
-    "analysis": {
-      "filter": {
-        "english_stop": {
-          "type":       "stop",
-          "stopwords":  "_english_"
-        }
-      },
-      "tokenizer": {
-        "edge_ngram_tokenizer": {
-          "type": "edge_ngram",
-          "min_gram": 2,
-          "max_gram": 20,
-          "token_chars": ["letter", "digit"]
-        }
-      },
-      "analyzer": {
-        "japanese": {
-          "tokenizer": "kuromoji_tokenizer",
-          "char_filter" : ["icu_normalizer"]
-        },
-        "english_edge_ngram": {
-          "tokenizer": "edge_ngram_tokenizer",
-          "filter": [
-            "lowercase",
-            "english_stop"
-          ]
-        }
-      }
-    }
-  },
-  "mappings": {
-    "properties" : {
-      "path": {
-        "type": "text",
-        "fields": {
-          "raw": {
-            "type": "text",
-            "analyzer": "keyword"
-          },
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "body": {
-        "type": "text",
-        "fields": {
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "body_embedded": {
-        "type": "dense_vector",
-        "dims": 768
-      },
-      "comments": {
-        "type": "text",
-        "fields": {
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "username": {
-        "type": "keyword"
-      },
-      "comment_count": {
-        "type": "integer"
-      },
-      "bookmark_count": {
-        "type": "integer"
-      },
-      "like_count": {
-        "type": "integer"
-      },
-      "grant": {
-        "type": "integer"
-      },
-      "granted_users": {
-        "type": "keyword"
-      },
-      "granted_groups": {
-        "type": "keyword"
-      },
-      "created_at": {
-        "type": "date",
-        "format": "date_optional_time"
-      },
-      "updated_at": {
-        "type": "date",
-        "format": "date_optional_time"
-      },
-      "tag_names": {
-        "type": "keyword"
-      }
-    }
-  }
-}

+ 24 - 19
apps/app/src/client/components/Admin/AuditLog/ActivityTable.tsx

@@ -11,9 +11,9 @@ import { Tooltip } from 'reactstrap';
 
 import type { IActivityHasId } from '~/interfaces/activity';
 
-type Props = {
-  activityList: IActivityHasId[]
-}
+ type Props = {
+   activityList: IActivityHasId[]
+ }
 
 const formatDate = (date: Date): string => {
   return format(new Date(date), 'yyyy/MM/dd HH:mm:ss');
@@ -21,17 +21,18 @@ const formatDate = (date: Date): string => {
 
 export const ActivityTable : FC<Props> = (props: Props) => {
   const { t } = useTranslation();
-  const [tooltopOpen, setTooltipOpen] = useState(false);
+  const [activeTooltipId, setActiveTooltipId] = useState<string | null>(null);
 
-  const showToolTip = useCallback(() => {
-    setTooltipOpen(true);
+
+  const showToolTip = useCallback((id: string) => {
+    setActiveTooltipId(id);
     setTimeout(() => {
-      setTooltipOpen(false);
+      setActiveTooltipId(null);
     }, 1000);
-  }, [setTooltipOpen]);
+  }, []);
 
   return (
-    <div className="table-responsive text-nowrap h-100">
+    <div className="table-responsive admin-audit-log">
       <table className="table table-default table-bordered table-user-list">
         <thead>
           <tr>
@@ -62,16 +63,20 @@ export const ActivityTable : FC<Props> = (props: Props) => {
                 <td>{formatDate(activity.createdAt)}</td>
                 <td>{t(`admin:audit_log_action.${activity.action}`)}</td>
                 <td>{activity.ip}</td>
-                <td>
-                  {activity.endpoint}
-                  <CopyToClipboard text={activity.endpoint} onCopy={showToolTip}>
-                    <button type="button" className="btn btn-outline-secondary border-0 pull-right" id="tooltipTarget">
-                      <span className="material-symbols-outlined" aria-hidden="true">content_paste</span>
-                    </button>
-                  </CopyToClipboard>
-                  <Tooltip placement="top" isOpen={tooltopOpen} fade={false} target="tooltipTarget">
-                    copied!
-                  </Tooltip>
+                <td className="audit-log-url-cell">
+                  <div className="d-flex align-items-center">
+                    <span className="flex-grow-1 text-truncate">
+                      {activity.endpoint}
+                    </span>
+                    <CopyToClipboard text={activity.endpoint} onCopy={() => showToolTip(activity._id)}>
+                      <button type="button" className="btn btn-outline-secondary border-0 ms-2" id={`tooltipTarget-${activity._id}`}>
+                        <span className="material-symbols-outlined" aria-hidden="true">content_paste</span>
+                      </button>
+                    </CopyToClipboard>
+                    <Tooltip placement="top" isOpen={activeTooltipId === activity._id} fade={false} target={`tooltipTarget-${activity._id}`}>
+                      copied!
+                    </Tooltip>
+                  </div>
                 </td>
               </tr>
             );

+ 27 - 18
apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.jsx → apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.tsx

@@ -1,10 +1,9 @@
 import React, {
-  useState, useMemo, useCallback,
+  useState, useMemo, useCallback, type ReactNode, type CSSProperties,
 } from 'react';
 
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
@@ -15,9 +14,26 @@ import styles from './CopyDropdown.module.scss';
 
 const { encodeSpaces } = pagePathUtils;
 
+interface DropdownItemContentsProps {
+  title: string;
+  contents: ReactNode;
+  className?: string;
+  style?: CSSProperties;
+}
+
+interface CopyDropdownProps {
+  children: ReactNode;
+  dropdownToggleId: string;
+  pagePath: string;
+  pageId?: string;
+  dropdownToggleClassName?: string;
+  dropdownMenuContainer?: string | HTMLElement | React.RefObject<HTMLElement>;
+  isShareLinkMode?: boolean;
+}
+
 /* eslint-disable react/prop-types */
-const DropdownItemContents = ({
-  title, contents, className, style,
+const DropdownItemContents: React.FC<DropdownItemContentsProps> = ({
+  title, contents, className = '', style,
 }) => (
   <>
     <div className="h6 mt-1 mb-2"><strong>{title}</strong></div>
@@ -27,7 +43,7 @@ const DropdownItemContents = ({
 /* eslint-enable react/prop-types */
 
 
-export const CopyDropdown = (props) => {
+export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
   const [dropdownOpen, setDropdownOpen] = useState(false);
   const [tooltipOpen, setTooltipOpen] = useState(false);
   const [isParamsAppended, setParamsAppended] = useState(!props.isShareLinkMode);
@@ -105,7 +121,10 @@ export const CopyDropdown = (props) => {
    */
   const { t } = useTranslation('commons');
   const {
-    dropdownToggleId, pageId, dropdownToggleClassName, children, isShareLinkMode,
+    dropdownToggleId, pageId,
+    dropdownToggleClassName,
+    dropdownMenuContainer,
+    children, isShareLinkMode,
   } = props;
 
   const customSwitchForParamsId = `customSwitchForParams_${dropdownToggleId}`;
@@ -128,7 +147,7 @@ export const CopyDropdown = (props) => {
         <DropdownMenu
           className={`${styles['copy-clipboard-dropdown-menu']}`}
           strategy="fixed"
-          container="body"
+          container={dropdownMenuContainer}
         >
           <div className="d-flex align-items-center justify-content-between">
             <DropdownItem header className="px-3">
@@ -209,7 +228,7 @@ export const CopyDropdown = (props) => {
           { pageId && (
             <CopyToClipboard text={markdownLink} onCopy={showToolTip}>
               <DropdownItem className="px-3 text-wrap">
-                <DropdownItemContents title={t('copy_to_clipboard.Markdown link')} contents={markdownLink} isContentsWrap />
+                <DropdownItemContents title={t('copy_to_clipboard.Markdown link')} contents={markdownLink} />
               </DropdownItem>
             </CopyToClipboard>
           )}
@@ -223,13 +242,3 @@ export const CopyDropdown = (props) => {
     </>
   );
 };
-
-CopyDropdown.propTypes = {
-  children: PropTypes.node.isRequired,
-  dropdownToggleId: PropTypes.string.isRequired,
-  pagePath: PropTypes.string.isRequired,
-
-  pageId: PropTypes.string,
-  dropdownToggleClassName: PropTypes.string,
-  isShareLinkMode: PropTypes.bool,
-};

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

@@ -68,6 +68,7 @@ export const AccessTokenScopeList: React.FC<AccessTokenScopeListProps> = ({
           <div key={scopeKey} className={`row my-1 ${moduleClass}`}>
             <div className="col-md-5 indentation">
               <input
+                data-testid={`grw-accesstoken-checkbox-${scopeValue}`}
                 className={`form-check-input indentation indentation-level-${level}`}
                 type="checkbox"
                 id={scopeValue as string}

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

@@ -166,6 +166,7 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
           pagePath={currentPage.path}
           dropdownToggleId={`copydropdown-in-pagetitleheader-${currentPage._id}`}
           dropdownToggleClassName="p-1"
+          dropdownMenuContainer="body"
         >
           <span className="material-symbols-outlined fs-6">content_paste</span>
         </CopyDropdown>

+ 12 - 2
apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx

@@ -49,7 +49,11 @@ export const PagePathNavLayout = (props: Props): JSX.Element => {
       className={`${className} ${moduleClass}`}
       style={{ maxWidth }}
     >
-      <span className={`${formerLinkClassName ?? ''} ${styles['grw-former-link']}`}>{formerLink}</span>
+      {formerLink && (
+        <span className={`${formerLinkClassName ?? ''} ${styles['grw-former-link']} mb-2 d-block`}>
+          {formerLink}
+        </span>
+      )}
       <div className={containerLayoutClass}>
         <h1 className={`m-0 d-inline align-bottom ${latterLinkClassName}`}>
           {latterLink}
@@ -60,7 +64,13 @@ export const PagePathNavLayout = (props: Props): JSX.Element => {
               <span className="badge text-bg-secondary">WIP</span>
             )}
             <span className="grw-page-path-nav-copydropdown">
-              <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName="p-2">
+              <CopyDropdown
+                pageId={pageId}
+                pagePath={pagePath}
+                dropdownToggleId={copyDropdownId}
+                dropdownToggleClassName="p-2"
+                dropdownMenuContainer="body"
+              >
                 <span className="material-symbols-outlined">content_paste</span>
               </CopyDropdown>
             </span>

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

@@ -230,14 +230,22 @@ $slack-work-space-name-card-border: #efc1f6;
     }
     .date-range-picker {
       width: 188px;
+      height: auto;
     }
     .jump-page-input {
       width: 50px;
     }
+
+    .table-bordered {
+      table-layout: fixed;
+    }
+
   }
 
+
+
+
   .settings-table {
-    table-layout: fixed;
 
     .item-name {
       width: 150px;
@@ -252,6 +260,10 @@ $slack-work-space-name-card-border: #efc1f6;
     }
   }
 
+
+
+
+
 }
 
 

+ 33 - 8
apps/app/src/components/PageView/PageView.tsx

@@ -48,8 +48,6 @@ export const PageView = (props: Props): JSX.Element => {
 
   const commentsContainerRef = useRef<HTMLDivElement>(null);
 
-  const [isCommentsLoaded, setCommentsLoaded] = useState(false);
-
   const {
     pagePath, initialPage, rendererConfig, className,
   } = props;
@@ -72,21 +70,49 @@ export const PageView = (props: Props): JSX.Element => {
   const markdown = page?.revision?.body;
   const isSlide = useSlidesByFrontmatter(markdown, rendererConfig.isEnabledMarp);
 
+  const [currentPageId, setCurrentPageId] = useState<string | undefined>(page?._id);
+
+  useEffect(() => {
+    if (page?._id !== undefined) {
+      setCurrentPageId(page._id);
+    }
+  }, [page?._id]);
 
   // ***************************  Auto Scroll  ***************************
   useEffect(() => {
+    if (currentPageId == null) {
+      return;
+    }
+
     // do nothing if hash is empty
     const { hash } = window.location;
     if (hash.length === 0) {
       return;
     }
 
-    const targetId = hash.slice(1);
+    const contentContainer = document.getElementById('page-view-content-container');
+    if (contentContainer == null) return;
+
+    const targetId = decodeURIComponent(hash.slice(1));
+    const target = document.getElementById(targetId);
+    if (target != null) {
+      target.scrollIntoView();
+      return;
+    }
+
+    const observer = new MutationObserver(() => {
+      const target = document.getElementById(targetId);
+      if (target != null) {
+        target.scrollIntoView();
+        observer.disconnect();
+      }
+    });
+
+    observer.observe(contentContainer, { childList: true, subtree: true });
 
-    const target = document.getElementById(decodeURIComponent(targetId));
-    target?.scrollIntoView();
+    return () => observer.disconnect();
+  }, [currentPageId]);
 
-  }, [isCommentsLoaded]);
   // *******************************  end  *******************************
 
   const specialContents = useMemo(() => {
@@ -145,7 +171,6 @@ export const PageView = (props: Props): JSX.Element => {
                 pageId={page._id}
                 pagePath={pagePath}
                 revision={page.revision}
-                onLoaded={() => setCommentsLoaded(true)}
               />
             </div>
           ) }
@@ -168,7 +193,7 @@ export const PageView = (props: Props): JSX.Element => {
       {specialContents == null && (
         <>
           {(isUsersHomepagePath && page?.creator != null) && <UserInfo author={page.creator} />}
-          <div className="flex-expand-vert">
+          <div id="page-view-content-container" className="flex-expand-vert">
             <Contents />
           </div>
         </>

+ 1 - 1
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditInstruction.tsx

@@ -18,7 +18,7 @@ export const AiAssistantManagementEditInstruction = (props: Props): JSX.Element
 
   return (
     <>
-      <AiAssistantManagementHeader />
+      <AiAssistantManagementHeader labelTranslationKey="modal_ai_assistant.page_mode_title.instruction" />
 
       <ModalBody className="px-4">
         <p className="text-secondary py-1">

+ 31 - 30
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx

@@ -2,20 +2,18 @@ import React, { useCallback, type JSX } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import { ModalBody } from 'reactstrap';
+import SimpleBar from 'simplebar-react';
 
-import type { IPageForItem } from '~/interfaces/page';
 import { useLimitLearnablePageCountPerAssistant } from '~/stores-universal/context';
-import { usePageSelectModal } from '~/stores/modal';
 
-import type { SelectedPage } from '../../../../interfaces/selected-page';
+import type { SelectablePage } from '../../../../interfaces/selectable-page';
 
 import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
-import { SelectedPageList } from './SelectedPageList';
-
+import { PageSelectionMethodButtons } from './PageSelectionMethodButtons';
+import { SelectablePageList } from './SelectablePageList';
 
 type Props = {
-  selectedPages: SelectedPage[];
-  onSelect: (page: IPageForItem, isIncludeSubPage: boolean) => void;
+  selectedPages: SelectablePage[];
   onRemove: (pageId: string) => void;
 }
 
@@ -23,35 +21,38 @@ export const AiAssistantManagementEditPages = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { data: limitLearnablePageCountPerAssistant } = useLimitLearnablePageCountPerAssistant();
 
-  const { selectedPages, onSelect, onRemove } = props;
-
-  const { open: openPageSelectModal } = usePageSelectModal();
+  const { selectedPages, onRemove } = props;
 
-  const clickOpenPageSelectModalHandler = useCallback(() => {
-    openPageSelectModal({ onSelected: onSelect, isHierarchicalSelectionMode: true });
-  }, [onSelect, openPageSelectModal]);
+  const removePageHandler = useCallback((page: SelectablePage) => {
+    onRemove(page.path);
+  }, [onRemove]);
 
   return (
     <>
-      <AiAssistantManagementHeader />
+      <AiAssistantManagementHeader labelTranslationKey="modal_ai_assistant.page_mode_title.pages" />
 
       <ModalBody className="px-4">
-        <p
-          className="text-secondary py-1"
-          // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: t('modal_ai_assistant.edit_page_description', { limitLearnablePageCountPerAssistant }) }}
-        />
-
-        <button
-          type="button"
-          onClick={clickOpenPageSelectModalHandler}
-          className="btn btn-outline-primary w-100 mb-3 d-flex align-items-center justify-content-center"
-        >
-          <span className="material-symbols-outlined me-2">add</span>
-          {t('modal_ai_assistant.add_page_button')}
-        </button>
-
-        <SelectedPageList selectedPages={selectedPages} onRemove={onRemove} />
+        <div className="px-4">
+          <p
+            className="text-secondary"
+            // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{ __html: t('modal_ai_assistant.edit_page_description', { limitLearnablePageCountPerAssistant }) }}
+          />
+
+          <div className="mb-3">
+            <PageSelectionMethodButtons />
+          </div>
+
+          <SimpleBar style={{ maxHeight: '300px' }}>
+            <SelectablePageList
+              isEditable
+              method="delete"
+              methodButtonPosition="right"
+              pages={selectedPages}
+              onClickMethodButton={removePageHandler}
+            />
+          </SimpleBar>
+        </div>
       </ModalBody>
     </>
   );

+ 1 - 1
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx

@@ -98,7 +98,7 @@ export const AiAssistantManagementEditShare = (props: Props): JSX.Element => {
 
   return (
     <>
-      <AiAssistantManagementHeader />
+      <AiAssistantManagementHeader labelTranslationKey="modal_ai_assistant.page_mode_title.share" />
 
       <ModalBody className="px-4">
         <div className="form-check form-switch mb-4">

+ 28 - 7
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHeader.tsx

@@ -1,17 +1,31 @@
-import type { JSX } from 'react';
+import { type JSX } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import { ModalHeader } from 'reactstrap';
 
 import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
 
+type Props = {
+  labelTranslationKey: string;
+  hideBackButton?: boolean;
+  backButtonColor?: 'primary' | 'secondary';
+  backToPageMode?: AiAssistantManagementModalPageMode;
+}
+
+export const AiAssistantManagementHeader = (props: Props): JSX.Element => {
+  const {
+    labelTranslationKey,
+    hideBackButton,
+    backButtonColor = 'primary',
+    backToPageMode = AiAssistantManagementModalPageMode.HOME,
+  } = props;
 
-export const AiAssistantManagementHeader = (): JSX.Element => {
   const { t } = useTranslation();
-  const { data, close, changePageMode } = useAiAssistantManagementModal();
+  const { close, changePageMode } = useAiAssistantManagementModal();
 
   return (
     <ModalHeader
+      tag="h4"
       close={(
         <button type="button" className="btn p-0" onClick={close}>
           <span className="material-symbols-outlined">close</span>
@@ -19,10 +33,17 @@ export const AiAssistantManagementHeader = (): JSX.Element => {
       )}
     >
       <div className="d-flex align-items-center">
-        <button type="button" className="btn p-0 me-3" onClick={() => changePageMode(AiAssistantManagementModalPageMode.HOME)}>
-          <span className="material-symbols-outlined text-primary">chevron_left</span>
-        </button>
-        <span>{t(`modal_ai_assistant.page_mode_title.${data?.pageMode}`)}</span>
+        { hideBackButton
+          ? (
+            <span className="growi-custom-icons growi-ai-assistant-icon me-3 fs-4">growi_ai</span>
+          )
+          : (
+            <button type="button" className="btn p-0 me-3" onClick={() => changePageMode(backToPageMode)}>
+              <span className={`material-symbols-outlined text-${backButtonColor}`}>chevron_left</span>
+            </button>
+          )
+        }
+        <span className="fw-bold">{t(labelTranslationKey)}</span>
       </div>
     </ModalHeader>
   );

+ 23 - 11
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx

@@ -1,30 +1,32 @@
 import React, {
-  useCallback, useState, useMemo, type JSX,
+  useCallback, useState, useMemo, useRef, useEffect, type JSX,
 } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import {
-  ModalHeader, ModalBody, ModalFooter, Input,
+  ModalBody, ModalFooter, Input,
 } from 'reactstrap';
 
 import { AiAssistantShareScope, AiAssistantAccessScope } from '~/features/openai/interfaces/ai-assistant';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import { useCurrentUser, useLimitLearnablePageCountPerAssistant } from '~/stores-universal/context';
 
-import type { SelectedPage } from '../../../../interfaces/selected-page';
+import type { SelectablePage } from '../../../../interfaces/selectable-page';
 import { determineShareScope } from '../../../../utils/determine-share-scope';
 import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
 
+import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
 import { ShareScopeWarningModal } from './ShareScopeWarningModal';
 
 type Props = {
+  isActivePane: boolean;
   shouldEdit: boolean;
   name: string;
   description: string;
   instruction: string;
   shareScope: AiAssistantShareScope,
   accessScope: AiAssistantAccessScope,
-  selectedPages: SelectedPage[];
+  selectedPages: SelectablePage[];
   selectedUserGroupsForAccessScope: PopulatedGrantedGroup[],
   selectedUserGroupsForShareScope: PopulatedGrantedGroup[],
   onNameChange: (value: string) => void;
@@ -34,6 +36,7 @@ type Props = {
 
 export const AiAssistantManagementHome = (props: Props): JSX.Element => {
   const {
+    isActivePane,
     shouldEdit,
     name,
     description,
@@ -55,11 +58,11 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
 
   const [isShareScopeWarningModalOpen, setIsShareScopeWarningModalOpen] = useState(false);
 
+  const inputRef = useRef<HTMLInputElement>(null);
+
   const totalSelectedPageCount = useMemo(() => {
     return selectedPages.reduce((total, selectedPage) => {
-      const descendantCount = selectedPage.isIncludeSubPage
-        ? selectedPage.page.descendantCount ?? 0
-        : 0;
+      const descendantCount = selectedPage.descendantCount ?? 0;
       const pageCountWithDescendants = descendantCount + 1;
       return total + pageCountWithDescendants;
     }, 0);
@@ -114,12 +117,20 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
     await onUpsertAiAssistant();
   }, [accessScope, onUpsertAiAssistant, selectedUserGroupsForAccessScope, selectedUserGroupsForShareScope, shareScope]);
 
+  // Autofocus
+  useEffect(() => {
+    // Only when creating a new assistant
+    if (isActivePane && !shouldEdit) {
+      inputRef.current?.focus();
+    }
+  }, [isActivePane, shouldEdit]);
+
   return (
     <>
-      <ModalHeader tag="h4" toggle={closeAiAssistantManagementModal} className="pe-4">
-        <span className="growi-custom-icons growi-ai-assistant-icon me-3 fs-4">growi_ai</span>
-        <span className="fw-bold">{t(shouldEdit ? 'modal_ai_assistant.header.update_assistant' : 'modal_ai_assistant.header.add_new_assistant')}</span>
-      </ModalHeader>
+      <AiAssistantManagementHeader
+        hideBackButton
+        labelTranslationKey={shouldEdit ? 'modal_ai_assistant.header.update_assistant' : 'modal_ai_assistant.header.add_new_assistant'}
+      />
 
       <div className="px-4">
         <ModalBody>
@@ -131,6 +142,7 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
               className="border-0 border-bottom border-2 px-0 rounded-0"
               value={name}
               onChange={e => onNameChange(e.target.value)}
+              innerRef={inputRef}
             />
           </div>
 

+ 100 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementKeywordSearch.module.scss

@@ -0,0 +1,100 @@
+@use '@growi/core-styles/scss/variables/growi-official-colors';
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+
+
+.grw-ai-assistant-keyword-search :global {
+  .rbt {
+    .rbt-input-multi {
+      font-size: 1.2rem;
+      border: none;
+      border-bottom: 3px solid bs.$gray-200;
+      border-radius: 0;
+
+      &.focus {
+        border-color: var(--grw-primary-500);
+        box-shadow: none;
+      }
+    }
+
+    .rbt-menu {
+       display: none !important;
+    }
+
+    .rbt-token {
+      align-items: center;
+      justify-content: center;
+      border-radius: bs.$border-radius-xxl;
+
+      .rbt-token-label {
+        display: flex;
+        align-items: center;
+        font-weight: lighter;
+        text-align: center;
+      }
+
+      .rbt-token-remove-button {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+      }
+    }
+  }
+
+  .next-button {
+    width: 30%;
+  }
+}
+
+
+// == Colors
+@include bs.color-mode(light) {
+  .grw-ai-assistant-keyword-search :global {
+     .rbt {
+        .rbt-token {
+          background-color: var(--grw-primary-100);
+          .rbt-token-label {
+            color: var(--grw-primary-400);
+          }
+          .rbt-token-remove-button {
+            color: var(--grw-primary-400);
+          }
+        }
+
+        .rbt-token-active {
+          background-color: var(--grw-primary-200);
+          .rbt-token-label {
+            color: var(--grw-primary-500);
+          }
+          .rbt-token-remove-button {
+            color: var(--grw-primary-500);
+          }
+        }
+     }
+  }
+}
+
+@include bs.color-mode(dark) {
+  .grw-ai-assistant-keyword-search :global {
+     .rbt {
+        .rbt-token {
+          background-color: var(--grw-primary-800);
+          .rbt-token-label {
+            color: var(--grw-primary-200);
+          }
+          .rbt-token-remove-button {
+            color: var(--grw-primary-200);
+          }
+        }
+
+        .rbt-token-active {
+          background-color: var(--grw-primary-700);
+          .rbt-token-label {
+            color: var(--grw-primary-100);
+          }
+          .rbt-token-remove-button {
+            color: var(--grw-primary-100);
+          }
+        }
+     }
+  }
+}

+ 228 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementKeywordSearch.tsx

@@ -0,0 +1,228 @@
+import React, {
+  useRef, useMemo, useCallback, useState, useEffect, type KeyboardEvent,
+} from 'react';
+
+import type { IPageHasId } from '@growi/core';
+import { isGlobPatternPath } from '@growi/core/dist/utils/page-path-utils';
+import { type TypeaheadRef, Typeahead } from 'react-bootstrap-typeahead';
+import { useTranslation } from 'react-i18next';
+import {
+  ModalBody,
+} from 'reactstrap';
+import SimpleBar from 'simplebar-react';
+
+import { useSWRxSearch } from '~/stores/search';
+
+import type { SelectablePage } from '../../../../interfaces/selectable-page';
+import { useSelectedPages } from '../../../services/use-selected-pages';
+import {
+  useAiAssistantManagementModal, AiAssistantManagementModalPageMode,
+} from '../../../stores/ai-assistant';
+
+import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
+import { SelectablePageList } from './SelectablePageList';
+
+import styles from './AiAssistantManagementKeywordSearch.module.scss';
+
+const moduleClass = styles['grw-ai-assistant-keyword-search'] ?? '';
+
+type SelectedSearchKeyword = {
+  id: string
+  label: string
+}
+
+const isSelectedSearchKeyword = (value: unknown): value is SelectedSearchKeyword => {
+  return (value as SelectedSearchKeyword).label != null;
+};
+
+
+type Props = {
+  isActivePane: boolean
+  baseSelectedPages: SelectablePage[],
+  updateBaseSelectedPages: (pages: SelectablePage[]) => void;
+}
+
+export const AiAssistantKeywordSearch = (props: Props): JSX.Element => {
+  const { isActivePane, baseSelectedPages, updateBaseSelectedPages } = props;
+
+  const [selectedSearchKeywords, setSelectedSearchKeywords] = useState<Array<SelectedSearchKeyword>>([]);
+  const {
+    selectedPages, selectedPagesArray, addPage, removePage,
+  } = useSelectedPages(baseSelectedPages);
+
+  const joinedSelectedSearchKeywords = useMemo(() => {
+    return selectedSearchKeywords.map(item => item.label).join(' ');
+  }, [selectedSearchKeywords]);
+
+  const { t } = useTranslation();
+  const { data: searchResult } = useSWRxSearch(joinedSelectedSearchKeywords, null, {
+    limit: 10,
+    offset: 0,
+    includeUserPages: true,
+    includeTrashPages: false,
+  });
+
+  // Search results will include subordinate pages by default
+  const pagesWithGlobPath = useMemo((): IPageHasId[] | undefined => {
+    if (searchResult == null) {
+      return;
+    }
+
+    const pages = searchResult.data.map(item => item.data);
+    return pages.map((page) => {
+      const newPage = { ...page };
+      if (newPage.path === '/') {
+        newPage.path = '/*';
+        return newPage;
+      }
+      if (!isGlobPatternPath(newPage.path)) {
+        newPage.path = `${newPage.path}/*`;
+      }
+      return newPage;
+    });
+  }, [searchResult]);
+
+  const shownSearchResult = useMemo(() => {
+    return selectedSearchKeywords.length > 0 && searchResult != null && searchResult.data.length > 0;
+  }, [searchResult, selectedSearchKeywords.length]);
+
+
+  const { data: aiAssistantManagementModalData, changePageMode } = useAiAssistantManagementModal();
+  const isNewAiAssistant = aiAssistantManagementModalData?.aiAssistantData == null;
+
+  const typeaheadRef = useRef<TypeaheadRef>(null);
+
+  const changeHandler = useCallback((selected: Array<SelectedSearchKeyword>) => {
+    setSelectedSearchKeywords(selected);
+  }, []);
+
+  const keyDownHandler = useCallback((event: KeyboardEvent<HTMLElement>) => {
+    if (event.code !== 'Space') {
+      return;
+    }
+
+    if (selectedSearchKeywords.length >= 5) {
+      return;
+    }
+
+    event.preventDefault();
+
+    // fix: https://redmine.weseek.co.jp/issues/140689
+    // "event.isComposing" is not supported
+    const isComposing = event.nativeEvent.isComposing;
+    if (isComposing) {
+      return;
+    }
+
+    const initialItem = typeaheadRef?.current?.state?.initialItem;
+    const handleMenuItemSelect = typeaheadRef?.current?._handleMenuItemSelect;
+    if (initialItem == null || handleMenuItemSelect == null) {
+      return;
+    }
+
+    if (!isSelectedSearchKeyword(initialItem)) {
+      return;
+    }
+
+    const allLabels = selectedSearchKeywords.map(item => item.label);
+    if (allLabels.includes(initialItem.label)) {
+      return;
+    }
+
+    handleMenuItemSelect(initialItem, event);
+  }, [selectedSearchKeywords]);
+
+  const nextButtonClickHandler = useCallback(() => {
+    updateBaseSelectedPages(Array.from(selectedPages.values()));
+    changePageMode(isNewAiAssistant ? AiAssistantManagementModalPageMode.HOME : AiAssistantManagementModalPageMode.PAGES);
+  }, [changePageMode, isNewAiAssistant, selectedPages, updateBaseSelectedPages]);
+
+  // Autofocus
+  useEffect(() => {
+    if (isActivePane) {
+      typeaheadRef.current?.focus();
+    }
+  }, [isActivePane]);
+
+  return (
+    <div className={moduleClass}>
+      <AiAssistantManagementHeader
+        backButtonColor="secondary"
+        backToPageMode={baseSelectedPages.length === 0 ? AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD : AiAssistantManagementModalPageMode.PAGES}
+        labelTranslationKey={isNewAiAssistant ? 'modal_ai_assistant.header.add_new_assistant' : 'modal_ai_assistant.header.update_assistant'}
+      />
+
+      <ModalBody className="px-4">
+        <h4 className="text-center fw-bold mb-3 mt-2">
+          {t('modal_ai_assistant.search_reference_pages_by_keyword')}
+        </h4>
+
+        <div className="px-4">
+          <Typeahead
+            allowNew
+            multiple
+            options={[]}
+            selected={selectedSearchKeywords}
+            placeholder={t('modal_ai_assistant.enter_keywords')}
+            id="ai-assistant-keyword-search"
+            ref={typeaheadRef}
+            onChange={changeHandler}
+            onKeyDown={keyDownHandler}
+          />
+
+          <label htmlFor="ai-assistant-keyword-search" className="form-text text-muted mt-2">
+            {t('modal_ai_assistant.max_items_space_separated_hint')}
+          </label>
+        </div>
+
+        { shownSearchResult && (
+          <>
+            <h4 className="text-center fw-bold mb-3 mt-4">
+              {t('modal_ai_assistant.select_assistant_reference_pages')}
+            </h4>
+            <div className="px-4">
+              <SimpleBar className="page-list-container" style={{ maxHeight: '300px' }}>
+                <SelectablePageList
+                  isEditable
+                  pages={pagesWithGlobPath ?? []}
+                  method="add"
+                  onClickMethodButton={addPage}
+                  disablePagePaths={selectedPagesArray.map(page => page.path)}
+                />
+              </SimpleBar>
+            </div>
+          </>
+        )}
+
+        <h4 className="text-center fw-bold mb-3 mt-4">
+          {t('modal_ai_assistant.reference_pages')}
+        </h4>
+
+        <div className="px-4">
+          <SimpleBar className="page-list-container" style={{ maxHeight: '300px' }}>
+            <SelectablePageList
+              pages={selectedPagesArray}
+              method="remove"
+              onClickMethodButton={removePage}
+            />
+          </SimpleBar>
+          <label className="form-text text-muted mt-2">
+            {t('modal_ai_assistant.can_add_later')}
+          </label>
+
+        </div>
+
+        <div className="d-flex justify-content-center mt-4">
+          <button
+            disabled={selectedPages.size === 0}
+            type="button"
+            className="btn btn-primary rounded next-button"
+            onClick={nextButtonClickHandler}
+          >
+            {t('modal_ai_assistant.next')}
+          </button>
+        </div>
+      </ModalBody>
+    </div>
+  );
+};

+ 49 - 23
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx

@@ -2,21 +2,23 @@ import React, {
   useCallback, useState, useEffect, type JSX,
 } from 'react';
 
+import type { IPageHasId } from '@growi/core';
 import {
   type IGrantedGroup, isPopulated,
 } from '@growi/core';
+import { isGlobPatternPath } from '@growi/core/dist/utils/page-path-utils';
 import { useTranslation } from 'react-i18next';
 import { Modal, TabContent, TabPane } from 'reactstrap';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { UpsertAiAssistantData } from '~/features/openai/interfaces/ai-assistant';
 import { AiAssistantAccessScope, AiAssistantShareScope } from '~/features/openai/interfaces/ai-assistant';
-import type { IPagePathWithDescendantCount, IPageForItem } from '~/interfaces/page';
+import type { IPagePathWithDescendantCount } from '~/interfaces/page';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import { useSWRxPagePathsWithDescendantCount } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
-import type { SelectedPage } from '../../../../interfaces/selected-page';
+import type { SelectablePage } from '../../../../interfaces/selectable-page';
 import { removeGlobPath } from '../../../../utils/remove-glob-path';
 import { createAiAssistant, updateAiAssistant } from '../../../services/ai-assistant';
 import {
@@ -30,6 +32,9 @@ import { AiAssistantManagementEditInstruction } from './AiAssistantManagementEdi
 import { AiAssistantManagementEditPages } from './AiAssistantManagementEditPages';
 import { AiAssistantManagementEditShare } from './AiAssistantManagementEditShare';
 import { AiAssistantManagementHome } from './AiAssistantManagementHome';
+import { AiAssistantKeywordSearch } from './AiAssistantManagementKeywordSearch';
+import { AiAssistantManagementPageSelectionMethod } from './AiAssistantManagementPageSelectionMethod';
+import { AiAssistantManagementPageTreeSelection } from './AiAssistantManagementPageTreeSelection';
 
 import styles from './AiAssistantManagementModal.module.scss';
 
@@ -51,14 +56,13 @@ const convertToPopulatedGrantedGroups = (selectedGroups: IGrantedGroup[]): Popul
   return populatedGrantedGroups;
 };
 
-const convertToSelectedPages = (pagePathPatterns: string[], pagePathsWithDescendantCount: IPagePathWithDescendantCount[]): SelectedPage[] => {
+const convertToSelectedPages = (pagePathPatterns: string[], pagePathsWithDescendantCount: IPagePathWithDescendantCount[]): SelectablePage[] => {
   return pagePathPatterns.map((pagePathPattern) => {
-    const isIncludeSubPage = pagePathPattern.endsWith('/*');
-    const path = isIncludeSubPage ? pagePathPattern.slice(0, -2) : pagePathPattern;
-    const page = pagePathsWithDescendantCount.find(page => page.path === path);
+    const pathWithoutGlob = isGlobPatternPath(pagePathPattern) ? pagePathPattern.slice(0, -2) : pagePathPattern;
+    const page = pagePathsWithDescendantCount.find(p => p.path === pathWithoutGlob);
     return {
-      page: page ?? { path },
-      isIncludeSubPage,
+      ...page,
+      path: pagePathPattern,
     };
   });
 };
@@ -88,7 +92,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   const [selectedAccessScope, setSelectedAccessScope] = useState<AiAssistantAccessScope>(AiAssistantAccessScope.OWNER);
   const [selectedUserGroupsForAccessScope, setSelectedUserGroupsForAccessScope] = useState<PopulatedGrantedGroup[]>([]);
   const [selectedUserGroupsForShareScope, setSelectedUserGroupsForShareScope] = useState<PopulatedGrantedGroup[]>([]);
-  const [selectedPages, setSelectedPages] = useState<SelectedPage[]>([]);
+  const [selectedPages, setSelectedPages] = useState<SelectablePage[]>([]);
   const [instruction, setInstruction] = useState<string>(t('modal_ai_assistant.default_instruction'));
 
 
@@ -113,6 +117,14 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   }, [aiAssistant?.pagePathPatterns, pagePathsWithDescendantCount, shouldEdit]);
 
 
+  /*
+  *  For AiAssistantManagementKeywordSearch & AiAssistantManagementPageTreeSelection methods
+  */
+  const selectPageHandler = useCallback((pages: IPageHasId[]) => {
+    setSelectedPages(pages);
+  }, []);
+
+
   /*
   *  For AiAssistantManagementHome methods
   */
@@ -127,8 +139,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   const upsertAiAssistantHandler = useCallback(async() => {
     try {
       const pagePathPatterns = selectedPages
-        .map(selectedPage => (selectedPage.isIncludeSubPage ? `${selectedPage.page.path}/*` : selectedPage.page.path))
-        .filter((path): path is string => path !== undefined && path !== null);
+        .map(selectedPage => selectedPage.path);
 
       const grantedGroupsForShareScope = selectedShareScope === AiAssistantShareScope.GROUPS
         ? convertToGrantedGroups(selectedUserGroupsForShareScope)
@@ -167,8 +178,11 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
       toastError(shouldEdit ? t('modal_ai_assistant.toaster.update_failed') : t('modal_ai_assistant.toaster.create_failed'));
       logger.error(err);
     }
-  // eslint-disable-next-line max-len
-  }, [t, selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope, selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, aiAssistant?._id, mutateAiAssistants, closeAiAssistantManagementModal]);
+  }, [
+    selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope,
+    selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, t, mutateAiAssistants,
+    closeAiAssistantManagementModal, aiAssistant?._id, aiAssistantSidebarData?.aiAssistantData?._id, refreshAiAssistantData,
+  ]);
 
 
   /*
@@ -210,15 +224,8 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   /*
   *  For AiAssistantManagementEditPages methods
   */
-  const selectPageHandler = useCallback((page: IPageForItem, isIncludeSubPage: boolean) => {
-    const selectedPageIds = selectedPages.map(selectedPage => selectedPage.page.path);
-    if (page.path != null && !selectedPageIds.includes(page.path)) {
-      setSelectedPages([...selectedPages, { page, isIncludeSubPage }]);
-    }
-  }, [selectedPages]);
-
   const removePageHandler = useCallback((pagePath: string) => {
-    setSelectedPages(selectedPages.filter(selectedPage => selectedPage.page.path !== pagePath));
+    setSelectedPages(selectedPages.filter(selectedPage => selectedPage.path !== pagePath));
   }, [selectedPages]);
 
 
@@ -236,8 +243,28 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   return (
     <>
       <TabContent activeTab={pageMode}>
+        <TabPane tabId={AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD}>
+          <AiAssistantManagementPageSelectionMethod />
+        </TabPane>
+
+        <TabPane tabId={AiAssistantManagementModalPageMode.KEYWORD_SEARCH}>
+          <AiAssistantKeywordSearch
+            isActivePane={pageMode === AiAssistantManagementModalPageMode.KEYWORD_SEARCH}
+            baseSelectedPages={selectedPages}
+            updateBaseSelectedPages={selectPageHandler}
+          />
+        </TabPane>
+
+        <TabPane tabId={AiAssistantManagementModalPageMode.PAGE_TREE_SELECTION}>
+          <AiAssistantManagementPageTreeSelection
+            baseSelectedPages={selectedPages}
+            updateBaseSelectedPages={selectPageHandler}
+          />
+        </TabPane>
+
         <TabPane tabId={AiAssistantManagementModalPageMode.HOME}>
           <AiAssistantManagementHome
+            isActivePane={pageMode === AiAssistantManagementModalPageMode.HOME}
             shouldEdit={shouldEdit}
             name={name}
             description={description}
@@ -269,7 +296,6 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
         <TabPane tabId={AiAssistantManagementModalPageMode.PAGES}>
           <AiAssistantManagementEditPages
             selectedPages={selectedPages}
-            onSelect={selectPageHandler}
             onRemove={removePageHandler}
           />
         </TabPane>
@@ -293,7 +319,7 @@ export const AiAssistantManagementModal = (): JSX.Element => {
   const isOpened = aiAssistantManagementModalData?.isOpened ?? false;
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeAiAssistantManagementModal} className={moduleClass} scrollable>
+    <Modal size="lg" isOpen={isOpened} toggle={closeAiAssistantManagementModal} className={moduleClass}>
       { isOpened && (
         <AiAssistantManagementModalSubstance />
       ) }

+ 35 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageSelectionMethod.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  ModalBody,
+} from 'reactstrap';
+
+import { useAiAssistantManagementModal } from '../../../stores/ai-assistant';
+
+import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
+import { PageSelectionMethodButtons } from './PageSelectionMethodButtons';
+
+export const AiAssistantManagementPageSelectionMethod = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: aiAssistantManagementModalData } = useAiAssistantManagementModal();
+  const isNewAiAssistant = aiAssistantManagementModalData?.aiAssistantData == null;
+
+  return (
+    <>
+      <AiAssistantManagementHeader
+        hideBackButton={isNewAiAssistant}
+        labelTranslationKey={isNewAiAssistant ? 'modal_ai_assistant.header.add_new_assistant' : 'modal_ai_assistant.header.update_assistant'}
+      />
+
+      <ModalBody className="px-4">
+        <h4 className="text-center fw-bold mb-4 mt-2">
+          {t('modal_ai_assistant.select_source_pages')}
+        </h4>
+
+        <PageSelectionMethodButtons />
+
+      </ModalBody>
+    </>
+  );
+};

+ 11 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.module.scss

@@ -0,0 +1,11 @@
+.grw-ai-assistant-management-page-tree-selection :global {
+  .next-button {
+    width: 30%;
+  }
+
+  .page-tree-item {
+    .list-group-item {
+      padding: 0.4rem 1rem !important;
+    }
+  }
+}

+ 171 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.tsx

@@ -0,0 +1,171 @@
+import React, {
+  Suspense, useCallback, memo,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  ModalBody,
+} from 'reactstrap';
+import SimpleBar from 'simplebar-react';
+
+import { ItemsTree } from '~/client/components/ItemsTree';
+import ItemsTreeContentSkeleton from '~/client/components/ItemsTree/ItemsTreeContentSkeleton';
+import type { TreeItemProps } from '~/client/components/TreeItem';
+import { TreeItemLayout } from '~/client/components/TreeItem';
+import type { IPageForItem } from '~/interfaces/page';
+import { useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
+
+import { type SelectablePage, isSelectablePage } from '../../../../interfaces/selectable-page';
+import { useSelectedPages } from '../../../services/use-selected-pages';
+import { AiAssistantManagementModalPageMode, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
+
+import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
+import { SelectablePageList } from './SelectablePageList';
+
+import styles from './AiAssistantManagementPageTreeSelection.module.scss';
+
+const moduleClass = styles['grw-ai-assistant-management-page-tree-selection'] ?? '';
+
+const SelectablePageTree = memo((props: { onClickAddPageButton: (page: SelectablePage) => void }) => {
+  const { onClickAddPageButton } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
+
+  const pageTreeItemClickHandler = useCallback((page: IPageForItem) => {
+    if (!isSelectablePage(page)) {
+      return;
+    }
+
+    onClickAddPageButton(page);
+  }, [onClickAddPageButton]);
+
+  const PageTreeItem = (props: TreeItemProps) => {
+    const { itemNode } = props;
+    const { page } = itemNode;
+
+    const SelectPageButton = () => {
+      return (
+        <button
+          type="button"
+          className="border-0 rounded btn p-0"
+          onClick={(e) => {
+            e.stopPropagation();
+            pageTreeItemClickHandler(page);
+          }}
+        >
+          <span className="material-symbols-outlined p-0 me-2 text-primary">add_circle</span>
+        </button>
+      );
+    };
+
+    return (
+      <TreeItemLayout
+        {...props}
+        itemClass={PageTreeItem}
+        className="text-muted"
+        customHoveredEndComponents={[SelectPageButton]}
+      />
+    );
+  };
+
+  return (
+    <div className="page-tree-item">
+      <ItemsTree
+        targetPath="/"
+        isEnableActions={!isGuestUser}
+        isReadOnlyUser={!!isReadOnlyUser}
+        CustomTreeItem={PageTreeItem}
+      />
+    </div>
+  );
+});
+
+type Props = {
+  baseSelectedPages: SelectablePage[],
+  updateBaseSelectedPages: (pages: SelectablePage[]) => void;
+}
+
+export const AiAssistantManagementPageTreeSelection = (props: Props): JSX.Element => {
+  const { baseSelectedPages, updateBaseSelectedPages } = props;
+
+  const { t } = useTranslation();
+  const { data: aiAssistantManagementModalData, changePageMode } = useAiAssistantManagementModal();
+  const isNewAiAssistant = aiAssistantManagementModalData?.aiAssistantData == null;
+
+  const {
+    selectedPages, selectedPagesRef, selectedPagesArray, addPage, removePage,
+  } = useSelectedPages(baseSelectedPages);
+
+
+  const addPageButtonClickHandler = useCallback((page: SelectablePage) => {
+    const pagePathWithGlob = `${page.path}/*`;
+    if (selectedPagesRef.current == null || selectedPagesRef.current.has(pagePathWithGlob)) {
+      return;
+    }
+
+    const clonedPage = { ...page };
+    clonedPage.path = pagePathWithGlob;
+
+    addPage(clonedPage);
+  }, [
+    addPage,
+    selectedPagesRef, // Prevent flickering (use ref to avoid method recreation)
+  ]);
+
+  const nextButtonClickHandler = useCallback(() => {
+    updateBaseSelectedPages(Array.from(selectedPages.values()));
+    changePageMode(isNewAiAssistant ? AiAssistantManagementModalPageMode.HOME : AiAssistantManagementModalPageMode.PAGES);
+  }, [changePageMode, isNewAiAssistant, selectedPages, updateBaseSelectedPages]);
+
+  return (
+    <div className={moduleClass}>
+      <AiAssistantManagementHeader
+        backButtonColor="secondary"
+        backToPageMode={baseSelectedPages.length === 0 ? AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD : AiAssistantManagementModalPageMode.PAGES}
+        labelTranslationKey={isNewAiAssistant ? 'modal_ai_assistant.header.add_new_assistant' : 'modal_ai_assistant.header.update_assistant'}
+      />
+
+      <ModalBody className="px-4">
+        <h4 className="text-center fw-bold mb-3 mt-2">
+          {t('modal_ai_assistant.search_reference_pages_by_keyword')}
+        </h4>
+
+        <Suspense fallback={<ItemsTreeContentSkeleton />}>
+          <div className="px-4">
+            <SelectablePageTree onClickAddPageButton={addPageButtonClickHandler} />
+          </div>
+        </Suspense>
+
+        <h4 className="text-center fw-bold mb-3 mt-4">
+          {t('modal_ai_assistant.reference_pages')}
+        </h4>
+
+        <div className="px-4">
+          <SimpleBar className="page-list-container" style={{ maxHeight: '300px' }}>
+            <SelectablePageList
+              method="remove"
+              methodButtonPosition="right"
+              pages={selectedPagesArray}
+              onClickMethodButton={removePage}
+            />
+          </SimpleBar>
+          <label className="form-text text-muted mt-2">
+            {t('modal_ai_assistant.can_add_later')}
+          </label>
+        </div>
+
+        <div className="d-flex justify-content-center mt-4">
+          <button
+            type="button"
+            className="btn btn-primary rounded next-button"
+            disabled={selectedPages.size === 0}
+            onClick={nextButtonClickHandler}
+          >
+            {t('modal_ai_assistant.next')}
+          </button>
+        </div>
+      </ModalBody>
+    </div>
+  );
+};

+ 28 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/PageSelectionMethodButtons.module.scss

@@ -0,0 +1,28 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+
+// == Colors
+.page-selection-method-buttons :global {
+  .page-selection-method-btn {
+      &:hover {
+        color: var(--bs-primary);
+        background-color: rgba(var(--bs-primary-rgb), 0.1);
+        border-color: var(--bs-primary) !important;
+      }
+    }
+}
+
+@include bs.color-mode(light) {
+  .page-selection-method-buttons :global {
+    .page-selection-method-btn {
+      border: 2px solid bs.$gray-300;
+    }
+  }
+}
+
+@include bs.color-mode(dark) {
+  .page-selection-method-buttons :global {
+    .page-selection-method-btn {
+      border: 2px solid bs.$gray-700;
+    }
+  }
+}

+ 55 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/PageSelectionMethodButtons.tsx

@@ -0,0 +1,55 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
+
+import styles from './PageSelectionMethodButtons.module.scss';
+
+const moduleClass = styles['page-selection-method-buttons'] ?? '';
+
+const SelectionButton = (props: { icon: string, label: string, onClick: () => void }): JSX.Element => {
+  const { icon, label, onClick } = props;
+
+  return (
+    <button
+      type="button"
+      className="btn text-center py-4 w-100 page-selection-method-btn"
+      onClick={onClick}
+    >
+      <span
+        className="material-symbols-outlined d-block mb-3 fs-1"
+      >
+        {icon}
+      </span>
+      <div>{label}</div>
+    </button>
+  );
+};
+
+
+export const PageSelectionMethodButtons = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { changePageMode } = useAiAssistantManagementModal();
+
+  return (
+    <div className={moduleClass}>
+      <div className="row g-3">
+        <div className="col">
+          <SelectionButton
+            icon="manage_search"
+            label={t('modal_ai_assistant.search_by_keyword')}
+            onClick={() => changePageMode(AiAssistantManagementModalPageMode.KEYWORD_SEARCH)}
+          />
+        </div>
+        <div className="col">
+          <SelectionButton
+            icon="account_tree"
+            label={t('modal_ai_assistant.select_from_page_tree')}
+            onClick={() => changePageMode(AiAssistantManagementModalPageMode.PAGE_TREE_SELECTION)}
+          />
+        </div>
+      </div>
+    </div>
+  );
+};

+ 43 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePageList.module.scss

@@ -0,0 +1,43 @@
+@use '~/styles/variables' as var;
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+
+ .selectable-page-list :global {
+    .page-path {
+      display: inline-block;
+      max-width: 100%;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      vertical-align: middle;
+      border: 2px solid transparent;
+    }
+
+    .page-path-editable {
+      cursor: pointer;
+      &:hover {
+        border-color: var(--bs-primary-border-subtle);
+      }
+    }
+
+    .page-path-input {
+      border: 2px solid var(--bs-border-color);
+    }
+}
+
+
+// == Colors
+@include bs.color-mode(light) {
+  .selectable-page-list :global {
+    .page-list-item {
+      background-color: #{bs.$gray-100};
+    }
+  }
+}
+
+@include bs.color-mode(dark) {
+  .selectable-page-page-list :global {
+    .page-list-item {
+      background-color: #{bs.$gray-900};
+    }
+  }
+}

+ 249 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePageList.tsx

@@ -0,0 +1,249 @@
+import React, {
+  useMemo, memo, useState, useCallback, useRef, useEffect,
+} from 'react';
+
+import { pathUtils } from '@growi/core/dist/utils';
+import { useRect } from '@growi/ui/dist/utils';
+import { useTranslation } from 'react-i18next';
+import AutosizeInput from 'react-input-autosize';
+
+import { type SelectablePage } from '../../../../interfaces/selectable-page';
+import { isCreatablePagePathPattern } from '../../../../utils/is-creatable-page-path-pattern';
+
+import styles from './SelectablePageList.module.scss';
+
+const moduleClass = styles['selectable-page-list'] ?? '';
+
+type MethodButtonProps = {
+  page: SelectablePage;
+  disablePagePaths: string[];
+  method: 'add' | 'remove' | 'delete'
+  onClickMethodButton: (page: SelectablePage) => void;
+}
+
+const MethodButton = memo((props: MethodButtonProps) => {
+  const {
+    page,
+    disablePagePaths,
+    method,
+    onClickMethodButton,
+  } = props;
+
+  const iconName = useMemo(() => {
+    switch (method) {
+      case 'add':
+        return 'add_circle';
+      case 'remove':
+        return 'do_not_disturb_on';
+      case 'delete':
+        return 'delete';
+      default:
+        return '';
+    }
+  }, [method]);
+
+  const color = useMemo(() => {
+    switch (method) {
+      case 'add':
+        return 'text-primary';
+      case 'remove':
+        return 'text-secondary';
+      case 'delete':
+        return 'text-secondary';
+      default:
+        return '';
+    }
+  }, [method]);
+
+  return (
+    <button
+      type="button"
+      className={`btn border-0 ${color}`}
+      disabled={disablePagePaths.includes(page.path)}
+      onClick={(e) => {
+        e.stopPropagation();
+        onClickMethodButton(page);
+      }}
+    >
+      <span className="material-symbols-outlined">
+        {iconName}
+      </span>
+    </button>
+  );
+});
+
+
+type EditablePagePathProps = {
+  isEditable?: boolean;
+  page: SelectablePage;
+  disablePagePaths: string[];
+  methodButtonPosition?: 'left' | 'right';
+}
+
+const EditablePagePath = memo((props: EditablePagePathProps): JSX.Element => {
+  const {
+    page,
+    isEditable,
+    disablePagePaths = [],
+    methodButtonPosition = 'left',
+  } = props;
+
+  const [editingPagePath, setEditingPagePath] = useState<string | null>(null);
+  const [inputValue, setInputValue] = useState('');
+
+  const inputRef = useRef<HTMLInputElement & AutosizeInput | null>(null);
+  const editingContainerRef = useRef<HTMLDivElement>(null);
+  const [editingContainerRect] = useRect(editingContainerRef);
+
+  const isEditing = isEditable && editingPagePath === page.path;
+
+  const handlePagePathClick = useCallback((page: SelectablePage) => {
+    if (!isEditable || disablePagePaths.includes(page.path)) {
+      return;
+    }
+    setEditingPagePath(page.path);
+    setInputValue(page.path);
+  }, [disablePagePaths, isEditable]);
+
+  const handleInputBlur = useCallback(() => {
+    setEditingPagePath(null);
+  }, []);
+
+  const handleInputKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
+    if (e.key === 'Enter') {
+
+      // Validate page path
+      const pagePathWithSlash = pathUtils.addHeadingSlash(inputValue);
+      if (inputValue === '' || disablePagePaths.includes(pagePathWithSlash) || !isCreatablePagePathPattern(pagePathWithSlash)) {
+        handleInputBlur();
+        return;
+      }
+
+      // Update page path
+      page.path = pagePathWithSlash;
+
+      handleInputBlur();
+    }
+  }, [disablePagePaths, handleInputBlur, inputValue, page]);
+
+  // Autofocus
+  useEffect(() => {
+    if (editingPagePath != null && inputRef.current != null) {
+      inputRef.current.focus();
+    }
+  }, [editingPagePath]);
+
+  return (
+    <div
+      ref={editingContainerRef}
+      className={`flex-grow-1 ${methodButtonPosition === 'left' ? 'me-2' : 'mx-2'}`}
+      style={{ minWidth: 0 }}
+    >
+      {isEditing
+        ? (
+          <AutosizeInput
+            id="page-path-input"
+            inputClassName="page-path-input"
+            type="text"
+            ref={inputRef}
+            value={inputValue}
+            onBlur={handleInputBlur}
+            onChange={e => setInputValue(e.target.value)}
+            onKeyDown={handleInputKeyDown}
+            inputStyle={{ maxWidth: (editingContainerRect?.width ?? 0) - 10 }}
+          />
+        )
+        : (
+          <span
+            className={`page-path ${isEditable && !disablePagePaths.includes(page.path) ? 'page-path-editable' : ''}`}
+            onClick={() => handlePagePathClick(page)}
+            title={page.path}
+          >
+            {page.path}
+          </span>
+        )}
+    </div>
+  );
+});
+
+
+type SelectablePageListProps = {
+  pages: SelectablePage[],
+  method: 'add' | 'remove' | 'delete'
+  methodButtonPosition?: 'left' | 'right',
+  disablePagePaths?: string[],
+  isEditable?: boolean,
+  onClickMethodButton: (page: SelectablePage) => void,
+}
+
+export const SelectablePageList = (props: SelectablePageListProps): JSX.Element => {
+  const {
+    pages,
+    method,
+    methodButtonPosition = 'left',
+    disablePagePaths = [],
+    isEditable,
+    onClickMethodButton,
+  } = props;
+
+  const { t } = useTranslation();
+
+  if (pages.length === 0) {
+    return (
+      <div className={moduleClass}>
+        <div className="border-0 text-center page-list-item rounded py-3">
+          <p className="text-muted mb-0">{t('modal_ai_assistant.no_pages_selected')}</p>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className={`list-group ${moduleClass}`}>
+      {pages.map((page) => {
+        return (
+          <div
+            key={page.path}
+            className="list-group-item border-0 page-list-item d-flex align-items-center p-1 mb-2 rounded"
+          >
+
+            {methodButtonPosition === 'left'
+              && (
+                <MethodButton
+                  page={page}
+                  method={method}
+                  disablePagePaths={disablePagePaths}
+                  onClickMethodButton={onClickMethodButton}
+                />
+              )
+            }
+
+            <EditablePagePath
+              page={page}
+              isEditable={isEditable}
+              disablePagePaths={disablePagePaths}
+              methodButtonPosition={methodButtonPosition}
+            />
+
+            <span className={`badge bg-body-secondary rounded-pill ${methodButtonPosition === 'left' ? 'me-2' : ''}`}>
+              <span className="text-body-tertiary">
+                {page.descendantCount}
+              </span>
+            </span>
+
+            {methodButtonPosition === 'right'
+              && (
+                <MethodButton
+                  page={page}
+                  method={method}
+                  disablePagePaths={disablePagePaths}
+                  onClickMethodButton={onClickMethodButton}
+                />
+              )
+            }
+          </div>
+        );
+      })}
+    </div>
+  );
+};

+ 0 - 43
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectedPageList.tsx

@@ -1,43 +0,0 @@
-import { memo } from 'react';
-
-import type { SelectedPage } from '../../../../interfaces/selected-page';
-
-type SelectedPageListProps = {
-  selectedPages: SelectedPage[];
-  onRemove?: (pagePath?: string) => void;
-};
-
-const SelectedPageListBase: React.FC<SelectedPageListProps> = ({ selectedPages, onRemove }: SelectedPageListProps) => {
-  if (selectedPages.length === 0) {
-    return <></>;
-  }
-
-  return (
-    <div className="mb-3">
-      {selectedPages.map(({ page, isIncludeSubPage }) => (
-        <div
-          key={page.path}
-          className="mb-2 d-flex justify-content-between align-items-center bg-body-tertiary rounded py-2 px-3"
-        >
-          <div className="d-flex align-items-center overflow-hidden text-body">
-            { isIncludeSubPage
-              ? <>{`${page.path}/*`}</>
-              : <>{page.path}</>
-            }
-          </div>
-          {onRemove != null && page.path != null && (
-            <button
-              type="button"
-              className="btn p-0 ms-3 text-body-secondary"
-              onClick={() => onRemove(page.path)}
-            >
-              <span className="material-symbols-outlined fs-4">delete</span>
-            </button>
-          )}
-        </div>
-      ))}
-    </div>
-  );
-};
-
-export const SelectedPageList = memo(SelectedPageListBase);

+ 4 - 4
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx

@@ -5,11 +5,11 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import type { SelectedPage } from '../../../../interfaces/selected-page';
+import type { SelectablePage } from '../../../../interfaces/selectable-page';
 
 type Props = {
   isOpen: boolean,
-  selectedPages: SelectedPage[],
+  selectedPages: SelectablePage[],
   closeModal: () => void,
   onSubmit: () => Promise<void>,
 }
@@ -48,8 +48,8 @@ export const ShareScopeWarningModal = (props: Props): JSX.Element => {
         <div className="mb-4">
           <p className="mb-2 text-secondary">{t('share_scope_warning_modal.selected_pages_label')}</p>
           {selectedPages.map(selectedPage => (
-            <code key={selectedPage.page.path}>
-              {selectedPage.page.path}
+            <code key={selectedPage.path}>
+              {selectedPage.path}
             </code>
           ))}
         </div>

+ 1 - 1
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantList.tsx

@@ -183,7 +183,7 @@ export const AiAssistantList: React.FC<AiAssistantListProps> = ({
         </h3>
         <span
           className="material-symbols-outlined"
-        >{`keyboard_arrow_${isCollapsed ? 'up' : 'down'}`}
+        >{`keyboard_arrow_${isCollapsed ? 'down' : 'right'}`}
         </span>
       </button>
 

+ 72 - 0
apps/app/src/features/openai/client/services/use-selected-pages.tsx

@@ -0,0 +1,72 @@
+import {
+  useState, useCallback, useEffect, useMemo, useRef,
+} from 'react';
+
+import type { SelectablePage } from '../../interfaces/selectable-page';
+import { useAiAssistantManagementModal } from '../stores/ai-assistant';
+
+
+type UseSelectedPages = {
+  selectedPages: Map<string, SelectablePage>,
+  selectedPagesRef: React.RefObject<Map<string, SelectablePage>>,
+  selectedPagesArray: SelectablePage[],
+  addPage: (page: SelectablePage) => void,
+  removePage: (page: SelectablePage) => void,
+}
+
+export const useSelectedPages = (initialPages?: SelectablePage[]): UseSelectedPages => {
+  const [selectedPages, setSelectedPages] = useState<Map<string, SelectablePage>>(new Map());
+  const { data: aiAssistantManagementModalData } = useAiAssistantManagementModal();
+
+  const selectedPagesRef = useRef(selectedPages);
+
+  const selectedPagesArray = useMemo(() => {
+    return Array.from(selectedPages.values());
+  }, [selectedPages]);
+
+  useEffect(() => {
+    selectedPagesRef.current = selectedPages;
+  }, [selectedPages]);
+
+  useEffect(() => {
+    // Initialize each time PageMode is changed
+    if (initialPages != null && aiAssistantManagementModalData?.pageMode != null) {
+      const initialMap = new Map<string, SelectablePage>();
+      initialPages.forEach((page) => {
+        if (page.path != null) {
+          initialMap.set(page.path, page);
+        }
+      });
+      setSelectedPages(initialMap);
+    }
+  }, [aiAssistantManagementModalData?.pageMode, initialPages]);
+
+  const addPage = useCallback((page: SelectablePage) => {
+    setSelectedPages((prev) => {
+      const newMap = new Map(prev);
+      if (page.path != null) {
+        newMap.set(page.path, page);
+      }
+      return newMap;
+    });
+  }, []);
+
+  const removePage = useCallback((page: SelectablePage) => {
+    setSelectedPages((prev) => {
+      const newMap = new Map(prev);
+      if (page.path != null) {
+        newMap.delete(page.path);
+      }
+      return newMap;
+    });
+  }, []);
+
+
+  return {
+    selectedPages,
+    selectedPagesRef,
+    selectedPagesArray,
+    addPage,
+    removePage,
+  };
+};

+ 17 - 2
apps/app/src/features/openai/client/stores/ai-assistant.tsx

@@ -9,14 +9,21 @@ import { apiv3Get } from '~/client/util/apiv3-client';
 import { type AccessibleAiAssistantsHasId, type AiAssistantHasId } from '../../interfaces/ai-assistant';
 import type { IThreadRelationHasId } from '../../interfaces/thread-relation'; // IThreadHasId を削除
 
+
+/*
+*  useAiAssistantManagementModal
+*/
 export const AiAssistantManagementModalPageMode = {
   HOME: 'home',
   SHARE: 'share',
   PAGES: 'pages',
   INSTRUCTION: 'instruction',
+  PAGE_SELECTION_METHOD: 'page-selection-method',
+  KEYWORD_SEARCH: 'keyword-search',
+  PAGE_TREE_SELECTION: 'page-tree-selection',
 } as const;
 
-type AiAssistantManagementModalPageMode = typeof AiAssistantManagementModalPageMode[keyof typeof AiAssistantManagementModalPageMode];
+export type AiAssistantManagementModalPageMode = typeof AiAssistantManagementModalPageMode[keyof typeof AiAssistantManagementModalPageMode];
 
 type AiAssistantManagementModalStatus = {
   isOpened: boolean,
@@ -38,7 +45,15 @@ export const useAiAssistantManagementModal = (
 
   return {
     ...swrResponse,
-    open: useCallback((aiAssistantData) => { swrResponse.mutate({ isOpened: true, aiAssistantData }) }, [swrResponse]),
+    open: useCallback((aiAssistantData) => {
+      swrResponse.mutate({
+        isOpened: true,
+        aiAssistantData,
+        pageMode: aiAssistantData != null
+          ? AiAssistantManagementModalPageMode.HOME
+          : AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD,
+      });
+    }, [swrResponse]),
     close: useCallback(() => swrResponse.mutate({ isOpened: false, aiAssistantData: undefined }), [swrResponse]),
     changePageMode: useCallback((pageMode: AiAssistantManagementModalPageMode) => {
       swrResponse.mutate({ isOpened: swrResponse.data?.isOpened ?? false, pageMode, aiAssistantData: swrResponse.data?.aiAssistantData });

+ 10 - 0
apps/app/src/features/openai/interfaces/selectable-page.ts

@@ -0,0 +1,10 @@
+import type { IPageHasId } from '@growi/core';
+
+import type { IPageForItem } from '~/interfaces/page';
+
+export type SelectablePage = Partial<IPageHasId> & { path: string }
+
+// type guard
+export const isSelectablePage = (page: IPageForItem): page is SelectablePage => {
+  return page.path != null;
+};

+ 0 - 6
apps/app/src/features/openai/interfaces/selected-page.ts

@@ -1,6 +0,0 @@
-import type { IPageForItem } from '~/interfaces/page';
-
-export type SelectedPage = {
-  page: IPageForItem,
-  isIncludeSubPage: boolean,
-}

+ 2 - 7
apps/app/src/features/openai/server/routes/middlewares/upsert-ai-assistant-validator.ts

@@ -1,6 +1,6 @@
 import { GroupType } from '@growi/core';
-import { isGlobPatternPath, isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
 import { type ValidationChain, body } from 'express-validator';
+import { isCreatablePagePathPattern } from '../../../utils/is-creatable-page-path-pattern';
 
 import { AiAssistantShareScope, AiAssistantAccessScope } from '../../../interfaces/ai-assistant';
 
@@ -42,12 +42,7 @@ export const upsertAiAssistantValidator: ValidationChain[] = [
     .notEmpty()
     .withMessage('pagePathPatterns must not be empty')
     .custom((value: string) => {
-      // check if the value is a glob pattern path
-      if (value.includes('*')) {
-        return isGlobPatternPath(value) && isCreatablePage(value.replaceAll('*', ''));
-      }
-
-      return isCreatablePage(value);
+      return isCreatablePagePathPattern(value);
     }),
 
   body('grantedGroupsForShareScope')

+ 13 - 0
apps/app/src/features/openai/utils/is-creatable-page-path-pattern.ts

@@ -0,0 +1,13 @@
+import { pagePathUtils } from '@growi/core/dist/utils';
+import { removeGlobPath } from './remove-glob-path';
+
+export const isCreatablePagePathPattern = (pagePath: string): boolean => {
+  const isGlobPattern = pagePathUtils.isGlobPatternPath(pagePath);
+  if (isGlobPattern) {
+    // Remove glob pattern since glob paths are non-creatable in GROWI
+    const pathWithoutGlob = removeGlobPath([pagePath])[0];
+    return pagePathUtils.isCreatablePage(pathWithoutGlob);
+  }
+
+  return pagePathUtils.isCreatablePage(pagePath);
+};

+ 60 - 6
apps/app/src/features/opentelemetry/server/node-sdk.spec.ts

@@ -3,7 +3,7 @@ import { NodeSDK } from '@opentelemetry/sdk-node';
 
 import { configManager } from '~/server/service/config-manager';
 
-import { setupAdditionalResourceAttributes, initInstrumentation } from './node-sdk';
+import { setupAdditionalResourceAttributes, initInstrumentation, startOpenTelemetry } from './node-sdk';
 import { getResource } from './node-sdk-resource';
 
 // Only mock configManager as it's external to what we're testing
@@ -67,15 +67,10 @@ describe('node-sdk', () => {
 
   describe('initInstrumentation', () => {
     it('should call setupCustomMetrics when instrumentation is enabled', async() => {
-      const { setupCustomMetrics } = await import('./custom-metrics');
-
       // Mock instrumentation as enabled
       mockInstrumentationEnabled();
 
       await initInstrumentation();
-
-      // Verify setupCustomMetrics was called
-      expect(setupCustomMetrics).toHaveBeenCalledOnce();
     });
 
     it('should not call setupCustomMetrics when instrumentation is disabled', async() => {
@@ -203,4 +198,63 @@ describe('node-sdk', () => {
       await expect(setupAdditionalResourceAttributes()).resolves.toBeUndefined();
     });
   });
+
+  describe('startOpenTelemetry', () => {
+    it('should start SDK and call setupCustomMetrics when instrumentation is enabled and SDK instance exists', async() => {
+      const { setupCustomMetrics } = await import('./custom-metrics');
+
+      // Mock instrumentation as enabled
+      mockInstrumentationEnabled();
+
+      // Initialize SDK first
+      await initInstrumentation();
+
+      // Get SDK instance and mock its start method
+      const { __testing__ } = await import('./node-sdk');
+      const sdkInstance = __testing__.getSdkInstance();
+      expect(sdkInstance).toBeDefined();
+
+      if (sdkInstance != null) {
+        const startSpy = vi.spyOn(sdkInstance, 'start');
+
+        // Call startOpenTelemetry
+        startOpenTelemetry();
+
+        // Verify that start method was called
+        expect(startSpy).toHaveBeenCalledOnce();
+
+        // Verify that setupCustomMetrics was called
+        expect(setupCustomMetrics).toHaveBeenCalledOnce();
+      }
+    });
+
+    it('should not start SDK when instrumentation is disabled', async() => {
+      const { setupCustomMetrics } = await import('./custom-metrics');
+
+      // Mock instrumentation as disabled
+      mockInstrumentationDisabled();
+
+      // Initialize SDK (should not create instance)
+      await initInstrumentation();
+
+      // Call startOpenTelemetry
+      startOpenTelemetry();
+
+      // Verify that setupCustomMetrics was not called
+      expect(setupCustomMetrics).not.toHaveBeenCalled();
+    });
+
+    it('should not start SDK when SDK instance does not exist', async() => {
+      const { setupCustomMetrics } = await import('./custom-metrics');
+
+      // Mock instrumentation as enabled but don't initialize SDK
+      mockInstrumentationEnabled();
+
+      // Call startOpenTelemetry without initializing SDK
+      startOpenTelemetry();
+
+      // Verify that setupCustomMetrics was not called
+      expect(setupCustomMetrics).not.toHaveBeenCalled();
+    });
+  });
 });

+ 3 - 2
apps/app/src/features/opentelemetry/server/node-sdk.ts

@@ -72,8 +72,6 @@ For more information, see https://docs.growi.org/en/admin-guide/admin-cookbook/t
 
     const sdkConfig = generateNodeSDKConfiguration({ enableAnonymization });
 
-    setupCustomMetrics();
-
     sdkInstance = new NodeSDK(sdkConfig);
   }
 };
@@ -106,6 +104,9 @@ export const startOpenTelemetry = (): void => {
       throw new Error('OpenTelemetry instrumentation is not initialized');
     }
     sdkInstance.start();
+
+    // setup custom metrics after SDK start
+    setupCustomMetrics();
   }
 };
 

+ 1 - 11
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts

@@ -103,18 +103,8 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
    * @param isHtmlPath whether the tmp output path is for html files
    */
   getTmpOutputDir(pageBulkExportJob: PageBulkExportJobDocument, isHtmlPath = false): string {
-    const isGrowiCloud = configManager.getConfig('app:growiCloudUri') != null;
-    const appId = configManager.getConfig('app:growiAppIdForCloud')?.toString();
     const jobId = pageBulkExportJob._id.toString();
-
-    if (isGrowiCloud) {
-      if (appId == null) {
-        throw new Error('appId is required for bulk export on GROWI.cloud');
-      }
-    }
-
-    const basePath = path.join(this.tmpOutputRootDir, appId ?? '');
-    return isHtmlPath ? path.join(basePath, 'html', jobId) : path.join(basePath, jobId);
+    return isHtmlPath ? path.join(this.tmpOutputRootDir, 'html', jobId) : path.join(this.tmpOutputRootDir, jobId);
   }
 
   /**

+ 3 - 3
apps/app/src/linter-checker/test.js

@@ -2,7 +2,7 @@
  * VSCode の Eslint 設定チェック方法
  *
  * 1. .eslilntignore ファイル中の `/src/linter-checker/**` 行を消す
- * 
+ *
  * 2. VSCode で以下のエラーが表示されていることを確認
  *   - constructor で eslint(space-before-blocks)
  *   - ファイル末尾の ";" で eslint(eol-last)
@@ -15,9 +15,9 @@
  *
  */
 class EslintTest {
-  constructor(){
+  constructor() {
     this.i = 0;
   }
 }
 
-module.exports = EslintTest;
+module.exports = EslintTest;

+ 3 - 2
apps/app/src/migrations/19700101000000-foremost-1000-20241123211930-remove-index-for-ns-from-configs.js

@@ -3,12 +3,13 @@ import mongoose from 'mongoose';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
-
 const logger = loggerFactory('growi:migrate:remove-index-for-ns-from-configs');
 
 async function dropIndexIfExists(db, collectionName, indexName) {
   // check existence of the collection
-  const items = await db.listCollections({ name: collectionName }, { nameOnly: true }).toArray();
+  const items = await db
+    .listCollections({ name: collectionName }, { nameOnly: true })
+    .toArray();
   if (items.length === 0) {
     return;
   }

+ 3 - 2
apps/app/src/migrations/19700101000000-foremost-1010-20250109000000-generate-service-instance-id.js

@@ -5,7 +5,6 @@ import { configManager } from '~/server/service/config-manager';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
-
 const logger = loggerFactory('growi:migrate:generate-service-instance-id');
 
 module.exports = {
@@ -15,7 +14,9 @@ module.exports = {
 
     await configManager.loadConfigs();
 
-    await configManager.updateConfig('app:serviceInstanceId', uuidv4(), { skipPubsub: true });
+    await configManager.updateConfig('app:serviceInstanceId', uuidv4(), {
+      skipPubsub: true,
+    });
   },
 
   async down() {

+ 0 - 3
apps/app/src/migrations/20180926134048-make-email-unique.js

@@ -4,11 +4,9 @@ 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 = {
-
   async up(db, next) {
     logger.info('Start migration');
     await mongoose.connect(getMongoUri(), mongoOptions);
@@ -44,5 +42,4 @@ module.exports = {
     // do not rollback
     next();
   },
-
 };

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

@@ -12,12 +12,11 @@ const logger = loggerFactory('growi:migrate:init-serverurl');
  */
 function isAllValuesSame(array) {
   return !!array.reduce((a, b) => {
-    return (a === b) ? a : NaN;
+    return a === b ? a : NaN;
   });
 }
 
 module.exports = {
-
   async up(db) {
     logger.info('Apply migration');
     await mongoose.connect(getMongoUri(), mongoOptions);
@@ -28,7 +27,9 @@ module.exports = {
     });
     // exit if exists
     if (siteUrlConfig != null) {
-      logger.info('\'app:siteUrl\' is already exists. This migration terminates without any changes.');
+      logger.info(
+        "'app:siteUrl' is already exists. This migration terminates without any changes.",
+      );
       return;
     }
 
@@ -48,11 +49,15 @@ module.exports = {
       logger.info(configs);
 
       // extract domain
-      const siteUrls = configs.map((config) => {
-        // see https://regex101.com/r/Q0Isjo/2
-        const match = config.value.match(/^"(https?:\/\/[^/]+).*"$/);
-        return (match != null) ? match[1] : null;
-      }).filter((value) => { return value != null });
+      const siteUrls = configs
+        .map((config) => {
+          // see https://regex101.com/r/Q0Isjo/2
+          const match = config.value.match(/^"(https?:\/\/[^/]+).*"$/);
+          return match != null ? match[1] : null;
+        })
+        .filter((value) => {
+          return value != null;
+        });
 
       // determine serverUrl if all values are same
       if (siteUrls.length > 0 && isAllValuesSame(siteUrls)) {
@@ -82,5 +87,4 @@ module.exports = {
 
     logger.info('Migration has been successfully rollbacked');
   },
-
 };

+ 12 - 7
apps/app/src/migrations/20181019114028-abolish-page-group-relation.js

@@ -5,11 +5,12 @@ 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) {
-  const collections = await db.listCollections({ name: collectionName }).toArray();
+  const collections = await db
+    .listCollections({ name: collectionName })
+    .toArray();
   return collections.length > 0;
 }
 
@@ -28,14 +29,16 @@ async function isCollectionExists(db, collectionName) {
  *   - Page model has 'grantedGroup' field newly
  */
 module.exports = {
-
   async up(db) {
     logger.info('Apply migration');
     await mongoose.connect(getMongoUri(), mongoOptions);
 
-    const isPagegrouprelationsExists = await isCollectionExists(db, 'pagegrouprelations');
+    const isPagegrouprelationsExists = await isCollectionExists(
+      db,
+      'pagegrouprelations',
+    );
     if (!isPagegrouprelationsExists) {
-      logger.info("'pagegrouprelations' collection doesn't exist");   // eslint-disable-line
+      logger.info("'pagegrouprelations' collection doesn't exist"); // eslint-disable-line
       logger.info('Migration has successfully applied');
       return;
     }
@@ -44,7 +47,10 @@ module.exports = {
     const UserGroup = userGroupModelFactory();
 
     // retrieve all documents from 'pagegrouprelations'
-    const relations = await db.collection('pagegrouprelations').find().toArray();
+    const relations = await db
+      .collection('pagegrouprelations')
+      .find()
+      .toArray();
 
     /* eslint-disable no-await-in-loop */
     for (const relation of relations) {
@@ -115,5 +121,4 @@ module.exports = {
 
     logger.info('Migration has been successfully rollbacked');
   },
-
 };

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

@@ -16,7 +16,6 @@ const logger = loggerFactory('growi:migrate:add-config-app-installed');
  *     - value will be false if no users exist
  */
 module.exports = {
-
   async up(db) {
     logger.info('Apply migration');
     await mongoose.connect(getMongoUri(), mongoOptions);
@@ -29,7 +28,9 @@ module.exports = {
     });
     // exit if exists
     if (appInstalled != null) {
-      logger.info('\'app:appInstalled\' is already exists. This migration terminates without any changes.');
+      logger.info(
+        "'app:appInstalled' is already exists. This migration terminates without any changes.",
+      );
       return;
     }
 

+ 4 - 7
apps/app/src/migrations/20190619055421-adjust-page-grant.js

@@ -7,7 +7,6 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:migrate:adjust-page-grant');
 
 module.exports = {
-
   async up(db) {
     logger.info('Apply migration');
     await mongoose.connect(getMongoUri(), mongoOptions);
@@ -16,16 +15,14 @@ module.exports = {
 
     await Page.bulkWrite([
       {
-        updateMany:
-         {
-           filter: { grant: null },
-           update: { $set: { grant: Page.GRANT_PUBLIC } },
-         },
+        updateMany: {
+          filter: { grant: null },
+          update: { $set: { grant: Page.GRANT_PUBLIC } },
+        },
       },
     ]);
 
     logger.info('Migration has successfully applied');
-
   },
 
   down(db) {

+ 0 - 2
apps/app/src/migrations/20190624110950-fill-last-update-user.js

@@ -10,7 +10,6 @@ const logger = loggerFactory('growi:migrate:abolish-page-group-relation');
  * FIX https://github.com/weseek/growi/issues/1067
  */
 module.exports = {
-
   async up(db) {
     logger.info('Apply migration');
     await mongoose.connect(getMongoUri(), mongoOptions);
@@ -47,5 +46,4 @@ module.exports = {
   down(db) {
     // do not rollback
   },
-
 };

+ 3 - 1
apps/app/src/migrations/20191102223901-drop-pages-indices.js

@@ -7,7 +7,9 @@ const logger = loggerFactory('growi:migrate:drop-pages-indices');
 
 async function dropIndexIfExists(db, collectionName, indexName) {
   // check existence of the collection
-  const items = await db.listCollections({ name: collectionName }, { nameOnly: true }).toArray();
+  const items = await db
+    .listCollections({ name: collectionName }, { nameOnly: true })
+    .toArray();
   if (items.length === 0) {
     return;
   }

+ 0 - 2
apps/app/src/migrations/20191126173016-adjust-pages-path.js

@@ -7,7 +7,6 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:adjust-pages-path');
 
-
 module.exports = {
   async up(db) {
     logger.info('Apply migration');
@@ -18,7 +17,6 @@ module.exports = {
     // retrieve target data
     const pages = await Page.find({ path: /^(?!\/)/ });
 
-
     // create requests for bulkWrite
     const requests = pages.map((page) => {
       const adjustedPath = addHeadingSlash(page.path);

+ 6 - 2
apps/app/src/migrations/20191127023815-drop-wrong-index-of-page-tag-relation.js

@@ -3,11 +3,15 @@ import mongoose from 'mongoose';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:migrate:drop-wrong-index-of-page-tag-relation');
+const logger = loggerFactory(
+  'growi:migrate:drop-wrong-index-of-page-tag-relation',
+);
 
 async function dropIndexIfExists(db, collectionName, indexName) {
   // check existence of the collection
-  const items = await db.listCollections({ name: collectionName }, { nameOnly: true }).toArray();
+  const items = await db
+    .listCollections({ name: collectionName }, { nameOnly: true })
+    .toArray();
   if (items.length === 0) {
     return;
   }

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

@@ -5,8 +5,9 @@ import UserGroupRelation from '~/server/models/user-group-relation';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
-
-const logger = loggerFactory('growi:migrate:remove-deleteduser-from-relationgroup');
+const logger = loggerFactory(
+  'growi:migrate:remove-deleteduser-from-relationgroup',
+);
 
 module.exports = {
   async up(db) {
@@ -16,13 +17,14 @@ module.exports = {
     const User = userModelFactory();
 
     const deletedUsers = await User.find({ status: 4 }); // deleted user
-    const requests = await UserGroupRelation.remove({ relatedUser: deletedUsers });
+    const requests = await UserGroupRelation.remove({
+      relatedUser: deletedUsers,
+    });
 
     if (requests.size === 0) {
       return logger.info('This migration terminates without any changes.');
     }
     logger.info('Migration has successfully applied');
-
   },
 
   down(db, next) {

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

@@ -13,8 +13,14 @@ module.exports = {
     await mongoose.connect(getMongoUri(), mongoOptions);
 
     await Promise.all([
-      await Config.findOneAndUpdate({ key: 'customize:theme', value: JSON.stringify('default-dark') }, { value: JSON.stringify('default') }), // update default-dark
-      await Config.findOneAndUpdate({ key: 'customize:theme', value: JSON.stringify('blue-night') }, { value: JSON.stringify('mono-blue') }), // update blue-night
+      await Config.findOneAndUpdate(
+        { key: 'customize:theme', value: JSON.stringify('default-dark') },
+        { value: JSON.stringify('default') },
+      ), // update default-dark
+      await Config.findOneAndUpdate(
+        { key: 'customize:theme', value: JSON.stringify('blue-night') },
+        { value: JSON.stringify('mono-blue') },
+      ), // update blue-night
     ]);
 
     logger.info('Migration has successfully applied');

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

@@ -27,15 +27,9 @@ module.exports = {
       ),
 
       // update en-US -> en_US
-      User.updateMany(
-        { lang: 'en-US' },
-        { lang: 'en_US' },
-      ),
+      User.updateMany({ lang: 'en-US' }, { lang: 'en_US' }),
       // update ja -> ja_JP
-      User.updateMany(
-        { lang: 'ja' },
-        { lang: 'ja_JP' },
-      ),
+      User.updateMany({ lang: 'ja' }, { lang: 'ja_JP' }),
     ]);
 
     logger.info('Migration has successfully applied');

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

@@ -42,7 +42,7 @@ module.exports = {
     await mongoose.connect(getMongoUri(), mongoOptions);
 
     const theme = await Config.findOne({ key: 'customize:theme' });
-    const insertLayoutType = (theme.value === '"kibela"') ? 'kibela' : 'growi';
+    const insertLayoutType = theme.value === '"kibela"' ? 'kibela' : 'growi';
 
     const insertConfig = new Config({
       key: 'customize:layout',

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

@@ -56,7 +56,9 @@ module.exports = {
     logger.info('Rollback migration');
     await mongoose.connect(getMongoUri(), mongoOptions);
 
-    await Config.deleteMany({ key: { $in: ['mail:sesAccessKeyId', 'mail:sesSecretAccessKey'] } });
+    await Config.deleteMany({
+      key: { $in: ['mail:sesAccessKeyId', 'mail:sesSecretAccessKey'] },
+    });
 
     logger.info('Migration has been successfully rollbacked');
   },

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

@@ -20,15 +20,20 @@ module.exports = {
     });
 
     if (sesAccessKeyId == null) {
-      return logger.info('The key \'mail:sesAccessKeyId\' does not exist, value of transmission method will be set smtp automatically.');
+      return logger.info(
+        "The key 'mail:sesAccessKeyId' does not exist, value of transmission method will be set smtp automatically.",
+      );
     }
     if (transmissionMethod != null) {
-      return logger.info('The key \'mail:transmissionMethod\' already exists, there is no need to migrate.');
+      return logger.info(
+        "The key 'mail:transmissionMethod' already exists, there is no need to migrate.",
+      );
     }
 
-    const value = sesAccessKeyId.value != null
-      ? JSON.stringify('ses')
-      : JSON.stringify('smtp');
+    const value =
+      sesAccessKeyId.value != null
+        ? JSON.stringify('ses')
+        : JSON.stringify('smtp');
 
     await Config.create({
       ns: 'crowi',
@@ -36,7 +41,6 @@ module.exports = {
       value,
     });
     logger.info('Migration has successfully applied');
-
   },
 
   async down(db, client) {

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

@@ -3,7 +3,6 @@ import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
-
 const logger = loggerFactory('growi:migrate:remove-timeline-type');
 
 const mongoose = require('mongoose');

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

@@ -3,7 +3,6 @@ import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
-
 const logger = loggerFactory('growi:migrate:remove-timeline-type');
 
 const mongoose = require('mongoose');
@@ -40,7 +39,7 @@ module.exports = {
       return {
         updateOne: {
           filter: { key: awsConfig.oldValue },
-          update:  { key: awsConfig.newValue },
+          update: { key: awsConfig.newValue },
         },
       };
     });
@@ -59,7 +58,7 @@ module.exports = {
       return {
         updateOne: {
           filter: { key: awsConfig.newValue },
-          update:  { key: awsConfig.oldValue },
+          update: { key: awsConfig.oldValue },
         },
       };
     });

+ 5 - 2
apps/app/src/migrations/20210420160380-convert-double-to-date.js

@@ -1,7 +1,11 @@
 import mongoose from 'mongoose';
 
 import getPageModel from '~/server/models/page';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import {
+  getModelSafely,
+  getMongoUri,
+  mongoOptions,
+} from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:remove-crowi-lauout');
@@ -31,7 +35,6 @@ module.exports = {
     await Page.bulkWrite(operations);
 
     logger.info('Migration has successfully applied');
-
   },
 
   down(db) {

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

@@ -4,8 +4,9 @@ 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');
+const logger = loggerFactory(
+  'growi:migrate:slack-app-integration-set-default-value',
+);
 
 module.exports = {
   async up(db) {
@@ -16,8 +17,11 @@ module.exports = {
 
     // Add togetter command if supportedCommandsForBroadcastUse already exists
     const slackAppIntegrations = await SlackAppIntegration.find();
-    slackAppIntegrations.forEach(async(doc) => {
-      if (doc.supportedCommandsForSingleUse != null && !doc.supportedCommandsForSingleUse.includes('togetter')) {
+    slackAppIntegrations.forEach(async (doc) => {
+      if (
+        doc.supportedCommandsForSingleUse != null &&
+        !doc.supportedCommandsForSingleUse.includes('togetter')
+      ) {
         doc.supportedCommandsForSingleUse.push('togetter');
       }
       await doc.save();

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

@@ -1,12 +1,16 @@
-import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+import {
+  defaultSupportedCommandsNameForBroadcastUse,
+  defaultSupportedCommandsNameForSingleUse,
+} from '@growi/slack';
 import mongoose from 'mongoose';
 
 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:migrate-slack-app-integration-schema');
+const logger = loggerFactory(
+  'growi:migrate:migrate-slack-app-integration-schema',
+);
 
 // create default data
 const defaultDataForBroadcastUse = {};

+ 22 - 19
apps/app/src/migrations/20210921173042-add-is-trashed-field.js

@@ -1,7 +1,11 @@
 import mongoose from 'mongoose';
 
 import getPageModel from '~/server/models/page';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import {
+  getModelSafely,
+  getMongoUri,
+  mongoOptions,
+} from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:add-column-is-trashed');
@@ -11,11 +15,13 @@ const LIMIT = 1000;
 /**
  * set isPageTrashed of pagetagrelations included in updateIdList as true
  */
-const updateIsPageTrashed = async(db, updateIdList) => {
-  await db.collection('pagetagrelations').updateMany(
-    { relatedPage: { $in: updateIdList } },
-    { $set: { isPageTrashed: true } },
-  );
+const updateIsPageTrashed = async (db, updateIdList) => {
+  await db
+    .collection('pagetagrelations')
+    .updateMany(
+      { relatedPage: { $in: updateIdList } },
+      { $set: { isPageTrashed: true } },
+    );
 };
 
 module.exports = {
@@ -27,12 +33,13 @@ module.exports = {
     let updateDeletedPageIds = [];
 
     // set isPageTrashed as false temporarily
-    await db.collection('pagetagrelations').updateMany(
-      {},
-      { $set: { isPageTrashed: false } },
-    );
+    await db
+      .collection('pagetagrelations')
+      .updateMany({}, { $set: { isPageTrashed: false } });
 
-    for await (const deletedPage of Page.find({ status: Page.STATUS_DELETED }).select('_id').cursor()) {
+    for await (const deletedPage of Page.find({ status: Page.STATUS_DELETED })
+      .select('_id')
+      .cursor()) {
       updateDeletedPageIds.push(deletedPage._id);
       // excute updateMany by one thousand ids
       if (updateDeletedPageIds.length === LIMIT) {
@@ -47,7 +54,6 @@ module.exports = {
     }
 
     logger.info('Migration has successfully applied');
-
   },
 
   async down(db) {
@@ -55,16 +61,13 @@ module.exports = {
     await mongoose.connect(getMongoUri(), mongoOptions);
 
     try {
-      await db.collection('pagetagrelations').updateMany(
-        {},
-        { $unset: { isPageTrashed: '' } },
-      );
+      await db
+        .collection('pagetagrelations')
+        .updateMany({}, { $unset: { isPageTrashed: '' } });
       logger.info('Migration has been successfully rollbacked');
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.info('Migration has failed');
     }
-
   },
 };

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

@@ -18,9 +18,13 @@ module.exports = {
 
     // create operations
     const operations = slackAppIntegrations.map((doc) => {
-      const permissionsForSingleUseCommands = doc._doc.permissionsForSingleUseCommands;
+      const permissionsForSingleUseCommands =
+        doc._doc.permissionsForSingleUseCommands;
       const createValue = permissionsForSingleUseCommands.get('create', false);
-      const togetterValue = permissionsForSingleUseCommands.get('togetter', false);
+      const togetterValue = permissionsForSingleUseCommands.get(
+        'togetter',
+        false,
+      );
 
       const newPermissionsForSingleUseCommands = {
         note: createValue,
@@ -32,7 +36,8 @@ module.exports = {
           filter: { _id: doc._id },
           update: {
             $set: {
-              permissionsForSingleUseCommands: newPermissionsForSingleUseCommands,
+              permissionsForSingleUseCommands:
+                newPermissionsForSingleUseCommands,
             },
           },
         },
@@ -55,7 +60,8 @@ module.exports = {
 
     // create operations
     const operations = slackAppIntegrations.map((doc) => {
-      const permissionsForSingleUseCommands = doc._doc.permissionsForSingleUseCommands;
+      const permissionsForSingleUseCommands =
+        doc._doc.permissionsForSingleUseCommands;
       const noteValue = permissionsForSingleUseCommands.get('note', false);
       const keepValue = permissionsForSingleUseCommands.get('keep', false);
 
@@ -69,7 +75,8 @@ module.exports = {
           filter: { _id: doc._id },
           update: {
             $set: {
-              permissionsForSingleUseCommands: newPermissionsForSingleUseCommands,
+              permissionsForSingleUseCommands:
+                newPermissionsForSingleUseCommands,
             },
           },
         },

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

@@ -5,19 +5,26 @@ import { Config } from '~/server/models/config';
 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) {
     await mongoose.connect(getMongoUri(), mongoOptions);
 
-    const isExist = (await Config.count({ key: 'slackbot:withoutProxy:commandPermission' })) > 0;
+    const isExist =
+      (await Config.count({ key: 'slackbot:withoutProxy:commandPermission' })) >
+      0;
     if (!isExist) return;
 
-    const commandPermissionValue = await Config.findOne({ key: 'slackbot:withoutProxy:commandPermission' });
+    const commandPermissionValue = await Config.findOne({
+      key: 'slackbot:withoutProxy:commandPermission',
+    });
     // do nothing if data is 'null' or null
-    if (commandPermissionValue._doc.value === 'null' || commandPermissionValue._doc.value == null) return;
+    if (
+      commandPermissionValue._doc.value === 'null' ||
+      commandPermissionValue._doc.value == null
+    )
+      return;
 
     const commandPermission = JSON.parse(commandPermissionValue._doc.value);
 
@@ -55,12 +62,20 @@ module.exports = {
   async down(db, next) {
     await mongoose.connect(getMongoUri(), mongoOptions);
 
-    const isExist = (await Config.count({ key: 'slackbot:withoutProxy:commandPermission' })) > 0;
+    const isExist =
+      (await Config.count({ key: 'slackbot:withoutProxy:commandPermission' })) >
+      0;
     if (!isExist) return next();
 
-    const commandPermissionValue = await Config.findOne({ key: 'slackbot:withoutProxy:commandPermission' });
+    const commandPermissionValue = await Config.findOne({
+      key: 'slackbot:withoutProxy:commandPermission',
+    });
     // do nothing if data is 'null' or null
-    if (commandPermissionValue._doc.value === 'null' || commandPermissionValue._doc.value == null) return next();
+    if (
+      commandPermissionValue._doc.value === 'null' ||
+      commandPermissionValue._doc.value == null
+    )
+      return next();
 
     const commandPermission = JSON.parse(commandPermissionValue._doc.value);
 

+ 19 - 10
apps/app/src/migrations/20211129125654-initialize-private-legacy-pages-named-query.js

@@ -5,8 +5,9 @@ import NamedQuery from '~/server/models/named-query';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
-
-const logger = loggerFactory('growi:migrate:initialize-private-legacy-pages-named-query');
+const logger = loggerFactory(
+  'growi:migrate:initialize-private-legacy-pages-named-query',
+);
 
 module.exports = {
   async up(db, next) {
@@ -18,14 +19,18 @@ module.exports = {
         { delegatorName: SearchDelegatorName.PRIVATE_LEGACY_PAGES },
         { upsert: true },
       );
-    }
-    catch (err) {
-      logger.error('Failed to migrate named query for private legacy pages search delagator.', err);
+    } catch (err) {
+      logger.error(
+        'Failed to migrate named query for private legacy pages search delagator.',
+        err,
+      );
       throw err;
     }
 
     next();
-    logger.info('Successfully migrated named query for private legacy pages search delagator.');
+    logger.info(
+      'Successfully migrated named query for private legacy pages search delagator.',
+    );
   },
 
   async down(db, next) {
@@ -36,13 +41,17 @@ module.exports = {
         name: SearchDelegatorName.PRIVATE_LEGACY_PAGES,
         delegatorName: SearchDelegatorName.PRIVATE_LEGACY_PAGES,
       });
-    }
-    catch (err) {
-      logger.error('Failed to delete named query for private legacy pages search delagator.', err);
+    } catch (err) {
+      logger.error(
+        'Failed to delete named query for private legacy pages search delagator.',
+        err,
+      );
       throw err;
     }
 
     next();
-    logger.info('Successfully deleted named query for private legacy pages search delagator.');
+    logger.info(
+      'Successfully deleted named query for private legacy pages search delagator.',
+    );
   },
 };

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

@@ -1,16 +1,20 @@
+import mongoose from 'mongoose';
 import { Writable } from 'stream';
 import { pipeline } from 'stream/promises';
 
-import mongoose from 'mongoose';
-
 import getPageModel from '~/server/models/page';
 import { Revision } from '~/server/models/revision';
 import { createBatchStream } from '~/server/util/batch-stream';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import {
+  getModelSafely,
+  getMongoUri,
+  mongoOptions,
+} from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
-
-const logger = loggerFactory('growi:migrate:revision-path-to-page-id-schema-migration--fixed-7549');
+const logger = loggerFactory(
+  'growi:migrate:revision-path-to-page-id-schema-migration--fixed-7549',
+);
 
 const LIMIT = 300;
 
@@ -20,7 +24,10 @@ module.exports = {
     await mongoose.connect(getMongoUri(), mongoOptions);
     const Page = getModelSafely('Page') || getPageModel();
 
-    const pagesStream = await Page.find({ revision: { $ne: null } }, { _id: 1, path: 1 }).cursor({ batch_size: LIMIT });
+    const pagesStream = await Page.find(
+      { revision: { $ne: null } },
+      { _id: 1, path: 1 },
+    ).cursor({ batch_size: LIMIT });
     const batchStrem = createBatchStream(LIMIT);
 
     const migratePagesStream = new Writable({
@@ -30,10 +37,7 @@ module.exports = {
           return {
             updateMany: {
               filter: {
-                $and: [
-                  { path: page.path },
-                  { pageId: { $exists: false } },
-                ],
+                $and: [{ path: page.path }, { pageId: { $exists: false } }],
               },
               update: [
                 {
@@ -66,7 +70,10 @@ module.exports = {
     await mongoose.connect(getMongoUri(), mongoOptions);
     const Page = getModelSafely('Page') || getPageModel();
 
-    const pagesStream = await Page.find({ revision: { $ne: null } }, { _id: 1, path: 1 }).cursor({ batch_size: LIMIT });
+    const pagesStream = await Page.find(
+      { revision: { $ne: null } },
+      { _id: 1, path: 1 },
+    ).cursor({ batch_size: LIMIT });
     const batchStrem = createBatchStream(LIMIT);
 
     const migratePagesStream = new Writable({
@@ -76,11 +83,7 @@ module.exports = {
           return {
             updateMany: {
               filter: {
-                $and: [
-                  { pageId: page._id },
-                  { path: { $exists: false } },
-                ],
-
+                $and: [{ pageId: page._id }, { path: { $exists: false } }],
               },
               update: [
                 {

Some files were not shown because too many files changed in this diff