Browse Source

Merge branch 'master' into feat/opentelemetry

Yuki Takei 1 year ago
parent
commit
75e03f6077
100 changed files with 2735 additions and 1314 deletions
  1. 5 3
      .changeset/config.json
  2. 0 59
      .devcontainer/Dockerfile
  3. 3 22
      .devcontainer/compose.yml
  4. 34 33
      .devcontainer/devcontainer.json
  5. 17 0
      .devcontainer/postCreateCommand.sh
  6. 0 2
      .gitattributes
  7. 2 3
      .github/workflows/ci-app-prod.yml
  8. 16 47
      .github/workflows/ci-app.yml
  9. 23 57
      .github/workflows/ci-slackbot-proxy.yml
  10. 6 6
      .github/workflows/release-slackbot-proxy.yml
  11. 14 34
      .github/workflows/release-subpackages.yml
  12. 13 15
      .github/workflows/release.yml
  13. 88 79
      .github/workflows/reusable-app-prod.yml
  14. 5 15
      .github/workflows/reusable-app-reg-suit.yml
  15. 3 0
      .gitignore
  16. 0 0
      .npmrc
  17. 2 1
      .vscode/launch.json
  18. 8 1
      .vscode/settings.json
  19. 95 1
      CHANGELOG.md
  20. 7 7
      README.md
  21. 7 7
      README_JP.md
  22. 65 0
      apps/app/bin/swagger-jsdoc/definition-apiv3.js
  23. 1 1
      apps/app/bin/swagger-jsdoc/generate-spec-apiv1.sh
  24. 4 6
      apps/app/config/i18next.config.js
  25. 28 76
      apps/app/docker/Dockerfile
  26. 1 0
      apps/app/docker/Dockerfile.dockerignore
  27. 3 4
      apps/app/docker/README.md
  28. 1 8
      apps/app/docker/codebuild/buildspec.yml
  29. 5 2
      apps/app/next.config.js
  30. 4 0
      apps/app/nodemon.json
  31. 88 59
      apps/app/package.json
  32. 7 2
      apps/app/playwright.config.ts
  33. 1 1
      apps/app/playwright/utils/Login.ts
  34. 6 3
      apps/app/public/static/locales/en_US/translation.json
  35. 6 3
      apps/app/public/static/locales/fr_FR/translation.json
  36. 6 3
      apps/app/public/static/locales/ja_JP/translation.json
  37. 6 3
      apps/app/public/static/locales/zh_CN/translation.json
  38. BIN
      apps/app/resource/fonts/MaterialSymbolsOutlined-opsz,wght,FILL@20..48,300,0..1.woff2
  39. BIN
      apps/app/resource/fonts/PressStart2P-latin.woff2
  40. BIN
      apps/app/resource/fonts/SourceHanCodeJP-Regular-subset-jis2.woff2
  41. BIN
      apps/app/resource/fonts/SourceHanCodeJP-Regular-subset-main.woff2
  42. 3 3
      apps/app/resource/locales/en_US/sandbox-diagrams.md
  43. 247 0
      apps/app/resource/locales/en_US/sandbox-markdown.md
  44. 1 1
      apps/app/resource/locales/en_US/sandbox-math.md
  45. 96 77
      apps/app/resource/locales/en_US/sandbox.md
  46. 3 3
      apps/app/resource/locales/fr_FR/sandbox-diagrams.md
  47. 246 0
      apps/app/resource/locales/fr_FR/sandbox-markdown.md
  48. 1 1
      apps/app/resource/locales/fr_FR/sandbox-math.md
  49. 129 115
      apps/app/resource/locales/fr_FR/sandbox.md
  50. 3 3
      apps/app/resource/locales/ja_JP/sandbox-diagrams.md
  51. 234 0
      apps/app/resource/locales/ja_JP/sandbox-markdown.md
  52. 100 226
      apps/app/resource/locales/ja_JP/sandbox.md
  53. 3 3
      apps/app/resource/locales/zh_CN/sandbox-diagrams.md
  54. 245 0
      apps/app/resource/locales/zh_CN/sandbox-markdown.md
  55. 1 1
      apps/app/resource/locales/zh_CN/sandbox-math.md
  56. 125 112
      apps/app/resource/locales/zh_CN/sandbox.md
  57. 4 4
      apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx
  58. 7 1
      apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.jsx
  59. 0 1
      apps/app/src/client/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  60. 12 11
      apps/app/src/client/components/Bookmarks/DragAndDropWrapper.tsx
  61. 1 1
      apps/app/src/client/components/InfiniteScroll.tsx
  62. 11 29
      apps/app/src/client/components/InstallerForm.tsx
  63. 3 2
      apps/app/src/client/components/Me/InAppNotificationSettings.tsx
  64. 1 2
      apps/app/src/client/components/PageComment/CommentEditor.tsx
  65. 0 1
      apps/app/src/client/components/PageEditor/MarkdownTableDataImportForm.tsx
  66. 3 3
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  67. 1 1
      apps/app/src/client/components/PageEditor/markdown-drawio-util-for-editor.ts
  68. 1 1
      apps/app/src/client/components/PageEditor/markdown-table-util-for-editor.ts
  69. 2 3
      apps/app/src/client/components/SearchPage/SearchResultList.tsx
  70. 1 1
      apps/app/src/client/components/Sidebar/Custom/CustomSidebar.tsx
  71. 2 2
      apps/app/src/client/components/Sidebar/InAppNotification/PrimaryItemForNotification.tsx
  72. 11 0
      apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  73. 40 19
      apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx
  74. 16 1
      apps/app/src/client/components/TreeItem/TreeItemLayout.tsx
  75. 1 0
      apps/app/src/client/components/TreeItem/interfaces/index.ts
  76. 3 3
      apps/app/src/client/services/AdminHomeContainer.js
  77. 4 0
      apps/app/src/client/util/apiv3-client.ts
  78. 5 5
      apps/app/src/components/ReactMarkdownComponents/NextLink.tsx
  79. 9 6
      apps/app/src/features/callout/components/CalloutViewer.tsx
  80. 118 0
      apps/app/src/features/callout/services/callout.spec.ts
  81. 20 1
      apps/app/src/features/callout/services/callout.ts
  82. 59 26
      apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx
  83. 32 12
      apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.tsx
  84. 35 8
      apps/app/src/features/openai/server/models/vector-store-file-relation.ts
  85. 16 3
      apps/app/src/features/openai/server/models/vector-store.ts
  86. 24 4
      apps/app/src/features/openai/server/routes/message.ts
  87. 1 1
      apps/app/src/features/openai/server/routes/rebuild-vector-store.ts
  88. 1 1
      apps/app/src/features/openai/server/routes/thread.ts
  89. 26 28
      apps/app/src/features/openai/server/services/assistant/assistant.ts
  90. 4 0
      apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts
  91. 1 0
      apps/app/src/features/openai/server/services/client-delegator/interfaces.ts
  92. 4 0
      apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts
  93. 0 0
      apps/app/src/features/openai/server/services/markdown-splitter/markdown-splitter.spec.ts
  94. 14 7
      apps/app/src/features/openai/server/services/markdown-splitter/markdown-splitter.ts
  95. 0 0
      apps/app/src/features/openai/server/services/markdown-splitter/markdown-token-splitter.spec.ts
  96. 2 2
      apps/app/src/features/openai/server/services/markdown-splitter/markdown-token-splitter.ts
  97. 113 16
      apps/app/src/features/openai/server/services/openai.ts
  98. 29 0
      apps/app/src/features/openai/server/services/replace-annotation-with-page-link.ts
  99. 10 0
      apps/app/src/features/openai/server/services/thread-deletion-cron.ts
  100. 71 0
      apps/app/src/features/openai/server/services/vector-store-file-deletion-cron.ts

+ 5 - 3
.changeset/config.json

@@ -15,11 +15,13 @@
     "@growi/app",
     "@growi/app",
     "@growi/slackbot-proxy",
     "@growi/slackbot-proxy",
     "@growi/custom-icons",
     "@growi/custom-icons",
-    "@growi/markdown-splitter",
     "@growi/editor",
     "@growi/editor",
     "@growi/presentation",
     "@growi/presentation",
-    "@growi/preset-*",
-    "@growi/remark-*",
+    "@growi/preset-templates",
+    "@growi/preset-themes",
+    "@growi/remark-attachment-refs",
+    "@growi/remark-drawio",
+    "@growi/remark-lsx",
     "@growi/slack",
     "@growi/slack",
     "@growi/ui"
     "@growi/ui"
   ]
   ]

+ 0 - 59
.devcontainer/Dockerfile

@@ -1,59 +0,0 @@
-#-------------------------------------------------------------------------------------------------------------
-# Copyright (c) Microsoft Corporation. All rights reserved.
-# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
-#-------------------------------------------------------------------------------------------------------------
-
-FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-20
-
-# The node image includes a non-root user with sudo access. Use the
-# "remoteUser" property in devcontainer.json to use it. On Linux, update
-# these values to ensure the container user's UID/GID matches your local values.
-# See https://aka.ms/vscode-remote/containers/non-root-user for details.
-ARG USERNAME=node
-ARG USER_UID=1000
-ARG USER_GID=$USER_UID
-
-RUN mkdir -p /workspace/growi/node_modules
-RUN mkdir -p /workspace/growi/apps/app/node_modules
-RUN mkdir -p /workspace/growi/apps/slackbot-proxy/node_modules
-RUN mkdir -p /workspace/growi/apps/app/.next
-
-# [Optional] Update UID/GID if needed
-RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
-        groupmod --gid $USER_GID $USERNAME \
-        && usermod --uid $USER_UID --gid $USER_GID $USERNAME; \
-    fi
-RUN chown -R $USER_UID:$USER_GID /home/$USERNAME /workspace;
-
-# *************************************************************
-# * Uncomment this section to use RUN instructions to install *
-# * any needed dependencies after executing "apt-get update". *
-# * See https://docs.docker.com/engine/reference/builder/#run *
-# *************************************************************
-ENV DEBIAN_FRONTEND=noninteractive
-
-# Prepare to install Chrome for VRT
-RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
-RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
-
-RUN apt-get update \
-    && apt-get -y install --no-install-recommends \
-      git-lfs \
-      iputils-ping net-tools dnsutils \
-
-    # Uncomment below lines to install Chromium
-    # --- works only on AMD64 ---
-    # && apt-get -y install --no-install-recommends chromium \
-    #    libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb fonts-noto-cjk \
-
-    # Clean up
-    && apt-get autoremove -y \
-    && apt-get clean -y \
-    && rm -rf /var/lib/apt/lists/*
-ENV DEBIAN_FRONTEND=dialog
-
-RUN yarn global add turbo
-RUN yarn global add node-gyp
-
-# Uncomment to default to non-root user
-# USER $USER_UID

+ 3 - 22
.devcontainer/docker-compose.yml → .devcontainer/compose.yml

@@ -1,30 +1,12 @@
-#-------------------------------------------------------------------------------------------------------------
-# Copyright (c) Microsoft Corporation. All rights reserved.
-# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
-#-------------------------------------------------------------------------------------------------------------
-
-version: '3'
 services:
 services:
   node:
   node:
-    # Uncomment the next line to use a non-root user for all processes. You can also
-    # simply use the "remoteUser" property in devcontainer.json if you just want VS Code
-    # and its sub-processes (terminals, tasks, debugging) to execute as the user. On Linux,
-    # you may need to update USER_UID and USER_GID in .devcontainer/Dockerfile to match your
-    # user if not 1000. See https://aka.ms/vscode-remote/containers/non-root for details.
-    user: node
-
-    build:
-      context: .
-      dockerfile: Dockerfile
-
+    image: mcr.microsoft.com/devcontainers/base:ubuntu
     volumes:
     volumes:
       - ..:/workspace/growi:delegated
       - ..:/workspace/growi:delegated
+      - pnpm-store:/workspace/growi/.pnpm-store
       - node_modules:/workspace/growi/node_modules
       - node_modules:/workspace/growi/node_modules
-      - node_modules_app:/workspace/growi/apps/app/node_modules
-      - node_modules_slackbot-proxy:/workspace/growi/apps/slackbot-proxy/node_modules
       - buildcache_app:/workspace/growi/apps/app/.next
       - buildcache_app:/workspace/growi/apps/app/.next
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
-
     tty: true
     tty: true
     networks:
     networks:
     - default
     - default
@@ -63,9 +45,8 @@ services:
       - ../../growi-docker-compose/elasticsearch/v8/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
       - ../../growi-docker-compose/elasticsearch/v8/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
 
 
 volumes:
 volumes:
+  pnpm-store:
   node_modules:
   node_modules:
-  node_modules_app:
-  node_modules_slackbot-proxy:
   buildcache_app:
   buildcache_app:
 
 
 networks:
 networks:

+ 34 - 33
.devcontainer/devcontainer.json

@@ -1,44 +1,45 @@
-// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at:
-// https://github.com/microsoft/vscode-dev-containers/tree/v0.117.1/containers/javascript-node-12-mongo
-// If you want to run as a non-root user in the container, see .devcontainer/docker-compose.yml.
+// For format details, see https://aka.ms/devcontainer.json. For config options, see the
+// README at: https://github.com/devcontainers/templates/tree/main/src/ubuntu
 {
 {
   "name": "GROWI-Dev",
   "name": "GROWI-Dev",
-  "dockerComposeFile": "docker-compose.yml",
+  "dockerComposeFile": "compose.yml",
   "service": "node",
   "service": "node",
   "workspaceFolder": "/workspace/growi",
   "workspaceFolder": "/workspace/growi",
 
 
-  // Set *default* container specific settings.json values on container create.
-  "settings": {
-    "terminal.integrated.defaultProfile.linux": "bash"
+  "features": {
+    "ghcr.io/devcontainers/features/node:1": {
+      "version": "20.18.0"
+    }
   },
   },
 
 
-  // Add the IDs of extensions you want installed when the container is created.
-  "extensions": [
-    "dbaeumer.vscode-eslint",
-    "mhutchie.git-graph",
-    "eamodio.gitlens",
-    "github.vscode-pull-request-github",
-    "cschleiden.vscode-github-actions",
-    "cweijan.vscode-database-client2",
-    "mongodb.mongodb-vscode",
-    "msjsdiag.debugger-for-chrome",
-    "firefox-devtools.vscode-firefox-debug",
-    "editorconfig.editorconfig",
-    "shinnn.stylelint",
-    "stylelint.vscode-stylelint",
-    "vitest.explorer",
-    "ms-playwright.playwright"
-  ],
-
-  // Uncomment the next line if you want start specific services in your Docker Compose config.
-  // "runServices": [],
-
-  // Uncomment the line below if you want to keep your containers running after VS Code shuts down.
-  // "shutdownAction": "none",
+  // Use 'forwardPorts' to make a list of ports inside the container available locally.
+  // "forwardPorts": [],
 
 
   // Use 'postCreateCommand' to run commands after the container is created.
   // Use 'postCreateCommand' to run commands after the container is created.
-  "postCreateCommand": "git-lfs pull & turbo run bootstrap",
+  "postCreateCommand": "/bin/bash ./.devcontainer/postCreateCommand.sh",
+
+  // Configure tool-specific properties.
+  "customizations": {
+    "vscode": {
+      "extensions": [
+        "dbaeumer.vscode-eslint",
+        "mhutchie.git-graph",
+        "eamodio.gitlens",
+        "github.vscode-pull-request-github",
+        "cschleiden.vscode-github-actions",
+        "cweijan.vscode-database-client2",
+        "mongodb.mongodb-vscode",
+        "msjsdiag.debugger-for-chrome",
+        "firefox-devtools.vscode-firefox-debug",
+        "editorconfig.editorconfig",
+        "shinnn.stylelint",
+        "stylelint.vscode-stylelint",
+        "vitest.explorer",
+        "ms-playwright.playwright"
+      ],
+    }
+  },
 
 
-  // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
-  "remoteUser": "node"
+  // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
+  // "remoteUser": "root"
 }
 }

+ 17 - 0
.devcontainer/postCreateCommand.sh

@@ -0,0 +1,17 @@
+sudo chown -R vscode:vscode /workspace;
+
+# Instal additional packages
+sudo apt update
+sudo apt-get install -y --no-install-recommends \
+  iputils-ping net-tools dnsutils
+sudo apt-get clean -y
+
+# Setup pnpm
+SHELL=bash pnpm setup
+eval "$(cat /home/vscode/.bashrc)"
+
+# Install turbo
+pnpm install turbo --global
+
+# Install dependencies
+turbo run bootstrap

+ 0 - 2
.gitattributes

@@ -1,2 +0,0 @@
-*.gz filter=lfs diff=lfs merge=lfs -text
-*.woff2 filter=lfs diff=lfs merge=lfs -text

+ 2 - 3
.github/workflows/ci-app-prod.yml

@@ -5,7 +5,6 @@ on:
     branches:
     branches:
       - master
       - master
       - dev/7.*.x
       - dev/7.*.x
-      - dev/6.*.x
     paths:
     paths:
       - .github/mergify.yml
       - .github/mergify.yml
       - .github/workflows/ci-app-prod.yml
       - .github/workflows/ci-app-prod.yml
@@ -13,7 +12,7 @@ on:
       - .github/workflows/reusable-app-reg-suit.yml
       - .github/workflows/reusable-app-reg-suit.yml
       - tsconfig.base.json
       - tsconfig.base.json
       - turbo.json
       - turbo.json
-      - yarn.lock
+      - pnpm-lock.yaml
       - package.json
       - package.json
       - apps/app/**
       - apps/app/**
       - '!apps/app/docker/**'
       - '!apps/app/docker/**'
@@ -26,7 +25,7 @@ on:
       - .github/workflows/reusable-app-prod.yml
       - .github/workflows/reusable-app-prod.yml
       - .github/workflows/reusable-app-reg-suit.yml
       - .github/workflows/reusable-app-reg-suit.yml
       - tsconfig.base.json
       - tsconfig.base.json
-      - yarn.lock
+      - pnpm-lock.yaml
       - turbo.json
       - turbo.json
       - package.json
       - package.json
       - apps/app/**
       - apps/app/**

+ 16 - 47
.github/workflows/ci-app.yml

@@ -12,7 +12,7 @@ on:
       - .eslint*
       - .eslint*
       - tsconfig.base.json
       - tsconfig.base.json
       - turbo.json
       - turbo.json
-      - yarn.lock
+      - pnpm-lock.yaml
       - package.json
       - package.json
       - apps/app/**
       - apps/app/**
       - '!apps/app/docker/**'
       - '!apps/app/docker/**'
@@ -34,21 +34,12 @@ jobs:
     steps:
     steps:
       - uses: actions/checkout@v4
       - uses: actions/checkout@v4
 
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v4
       - uses: actions/setup-node@v4
         with:
         with:
           node-version: ${{ matrix.node-version }}
           node-version: ${{ matrix.node-version }}
-          cache: 'yarn'
-          cache-dependency-path: '**/yarn.lock'
-
-      - name: Cache/Restore node_modules
-        uses: actions/cache@v4
-        with:
-          path: |
-            **/node_modules
-            !**/node_modules/.cache/turbo
-          key: node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-          restore-keys: |
-            node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-
+          cache: 'pnpm'
 
 
       - name: Cache/Restore dist
       - name: Cache/Restore dist
         uses: actions/cache@v4
         uses: actions/cache@v4
@@ -63,9 +54,8 @@ jobs:
 
 
       - name: Install dependencies
       - name: Install dependencies
         run: |
         run: |
-          yarn global add turbo
-          yarn global add node-gyp
-          yarn --frozen-lockfile
+          pnpm add turbo --global
+          pnpm install --frozen-lockfile
 
 
       - name: Lint
       - name: Lint
         run: |
         run: |
@@ -98,21 +88,12 @@ jobs:
     steps:
     steps:
       - uses: actions/checkout@v4
       - uses: actions/checkout@v4
 
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v4
       - uses: actions/setup-node@v4
         with:
         with:
           node-version: ${{ matrix.node-version }}
           node-version: ${{ matrix.node-version }}
-          cache: 'yarn'
-          cache-dependency-path: '**/yarn.lock'
-
-      - name: Cache/Restore node_modules
-        uses: actions/cache@v4
-        with:
-          path: |
-            **/node_modules
-            !**/node_modules/.cache/turbo
-          key: node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-          restore-keys: |
-            node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-
+          cache: 'pnpm'
 
 
       - name: Cache/Restore dist
       - name: Cache/Restore dist
         uses: actions/cache@v4
         uses: actions/cache@v4
@@ -127,9 +108,8 @@ jobs:
 
 
       - name: Install dependencies
       - name: Install dependencies
         run: |
         run: |
-          yarn global add turbo
-          yarn global add node-gyp
-          yarn --frozen-lockfile
+          pnpm add turbo --global
+          pnpm install --frozen-lockfile
 
 
       - name: Test
       - name: Test
         run: |
         run: |
@@ -172,22 +152,12 @@ jobs:
     steps:
     steps:
       - uses: actions/checkout@v4
       - uses: actions/checkout@v4
 
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v4
       - uses: actions/setup-node@v4
         with:
         with:
           node-version: ${{ matrix.node-version }}
           node-version: ${{ matrix.node-version }}
-          cache: 'yarn'
-          cache-dependency-path: '**/yarn.lock'
-
-      - name: Cache/Restore node_modules
-        uses: actions/cache@v4
-        with:
-          path: |
-            **/node_modules
-            !**/node_modules/.cache/turbo
-          key: node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-          restore-keys: |
-            node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-
-            node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-
+          cache: 'pnpm'
 
 
       - name: Cache/Restore dist
       - name: Cache/Restore dist
         uses: actions/cache@v4
         uses: actions/cache@v4
@@ -202,9 +172,8 @@ jobs:
 
 
       - name: Install dependencies
       - name: Install dependencies
         run: |
         run: |
-          yarn global add turbo
-          yarn global add node-gyp
-          yarn --frozen-lockfile
+          pnpm add turbo --global
+          pnpm install --frozen-lockfile
 
 
       - name: turbo run launch-dev:ci
       - name: turbo run launch-dev:ci
         working-directory: ./apps/app
         working-directory: ./apps/app

+ 23 - 57
.github/workflows/ci-slackbot-proxy.yml

@@ -12,7 +12,7 @@ on:
       - .eslint*
       - .eslint*
       - tsconfig.base.json
       - tsconfig.base.json
       - turbo.json
       - turbo.json
-      - yarn.lock
+      - pnpm-lock.yaml
       - package.json
       - package.json
       - apps/slackbot-proxy/**
       - apps/slackbot-proxy/**
       - '!apps/slackbot-proxy/docker/**'
       - '!apps/slackbot-proxy/docker/**'
@@ -35,21 +35,12 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
+    - uses: pnpm/action-setup@v4
+
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Cache/Restore node_modules
-      uses: actions/cache@v4
-      with:
-        path: |
-          **/node_modules
-        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('apps/slackbot-proxy/package.json') }}
-        restore-keys: |
-          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
-          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+        cache: 'pnpm'
 
 
     - name: Restore dist
     - name: Restore dist
       uses: actions/cache/restore@v4
       uses: actions/cache/restore@v4
@@ -63,9 +54,8 @@ jobs:
 
 
     - name: Install dependencies
     - name: Install dependencies
       run: |
       run: |
-        yarn global add turbo
-        yarn global add node-gyp
-        yarn --frozen-lockfile
+        pnpm add turbo --global
+        pnpm install --frozen-lockfile
 
 
     - name: Lint
     - name: Lint
       run: |
       run: |
@@ -110,21 +100,12 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
+    - uses: pnpm/action-setup@v4
+
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Cache/Restore node_modules
-      uses: actions/cache@v4
-      with:
-        path: |
-          **/node_modules
-        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('apps/slackbot-proxy/package.json') }}
-        restore-keys: |
-          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
-          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+        cache: 'pnpm'
 
 
     - name: Restore dist
     - name: Restore dist
       uses: actions/cache/restore@v4
       uses: actions/cache/restore@v4
@@ -138,11 +119,10 @@ jobs:
 
 
     - name: Install dependencies
     - name: Install dependencies
       run: |
       run: |
-        yarn global add turbo
-        yarn global add node-gyp
-        yarn --frozen-lockfile
+        pnpm add turbo --global
+        pnpm install --frozen-lockfile
 
 
-    - name: yarn dev:ci
+    - name: turbo run dev:ci
       working-directory: ./apps/slackbot-proxy
       working-directory: ./apps/slackbot-proxy
       run: |
       run: |
         cp config/ci/.env.local.for-ci .env.development.local
         cp config/ci/.env.local.for-ci .env.development.local
@@ -198,36 +178,20 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
+    - uses: pnpm/action-setup@v4
+
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
+        cache: 'pnpm'
 
 
     - name: Install turbo
     - name: Install turbo
       run: |
       run: |
-        yarn global add turbo
-
-    - name: Prune repositories
-      run: |
-        turbo prune @growi/slackbot-proxy
-        rm -rf apps packages
-        mv out/* .
-
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v4
-      with:
-        path: |
-          **/node_modules
-        key: node_modules-slackbot-prxy-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          node_modules-slackbot-proxy-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+        pnpm add turbo --global
 
 
     - name: Install dependencies
     - name: Install dependencies
       run: |
       run: |
-        yarn global add node-gyp
-        yarn --frozen-lockfile
+        pnpm install --frozen-lockfile
 
 
     - name: Restore dist
     - name: Restore dist
       uses: actions/cache/restore@v4
       uses: actions/cache/restore@v4
@@ -244,15 +208,17 @@ jobs:
       run: |
       run: |
         turbo run build
         turbo run build
 
 
-    - name: Install dependencies for production
+    - name: Assembling all dependencies
       run: |
       run: |
-        yarn --production
+        rm -rf out
+        pnpm deploy out --prod --filter @growi/slackbot-proxy
+        rm -rf apps/slackbot-proxy/node_modules && mv out/node_modules apps/slackbot-proxy/node_modules
 
 
-    - name: yarn start:prod:ci
+    - name: pnpm run start:prod:ci
       working-directory: ./apps/slackbot-proxy
       working-directory: ./apps/slackbot-proxy
       run: |
       run: |
         cp config/ci/.env.local.for-ci .env.production.local
         cp config/ci/.env.local.for-ci .env.production.local
-        yarn start:prod:ci
+        pnpm run start:prod:ci
       env:
       env:
         SERVER_URI: http://localhost:8080
         SERVER_URI: http://localhost:8080
         TYPEORM_CONNECTION: mysql
         TYPEORM_CONNECTION: mysql

+ 6 - 6
.github/workflows/release-slackbot-proxy.yml

@@ -93,21 +93,21 @@ jobs:
       with:
       with:
         ref: ${{ github.event.pull_request.base.ref }}
         ref: ${{ github.event.pull_request.base.ref }}
 
 
+    - uses: pnpm/action-setup@v4
+
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
         node-version: '18'
         node-version: '18'
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
+        cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies
       run: |
       run: |
-        yarn global add turbo
-        yarn global add node-gyp
-        yarn --frozen-lockfile
+        pnpm add turbo --global
+        pnpm install --frozen-lockfile
 
 
     - name: Bump versions for next RC
     - name: Bump versions for next RC
       run: |
       run: |
-        turbo run version --filter=@growi/slackbot-proxy -- --prerelease
+        turbo run version:prerelease --filter=@growi/slackbot-proxy
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@2.0.1
       uses: myrotvorets/info-from-package-json-action@2.0.1

+ 14 - 34
.github/workflows/release-subpackages.yml

@@ -28,27 +28,17 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
+    - uses: pnpm/action-setup@v4
+
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
         node-version: '20'
         node-version: '20'
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v4
-      with:
-        path: |
-          **/node_modules
-        key: node_modules-release-subpackages-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          node_modules-release-subpackages-${{ runner.OS }}-node${{ inputs.node-version }}-
+        cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies
       run: |
       run: |
-        yarn global add turbo
-        yarn global add node-gyp
-        yarn --frozen-lockfile
+        pnpm add turbo --global
+        pnpm install --frozen-lockfile
 
 
     - name: Setup .npmrc
     - name: Setup .npmrc
       run: |
       run: |
@@ -61,14 +51,14 @@ jobs:
     - name: Retrieve changesets information
     - name: Retrieve changesets information
       id: changesets-status
       id: changesets-status
       run: |
       run: |
-        yarn changeset status --output status.json
+        pnpm changeset status --output status.json
         echo "CHANGESETS_LENGTH=$(jq -r '.changesets | length' status.json)" >> $GITHUB_OUTPUT
         echo "CHANGESETS_LENGTH=$(jq -r '.changesets | length' status.json)" >> $GITHUB_OUTPUT
         rm status.json
         rm status.json
 
 
     - name: Snapshot release to npm
     - name: Snapshot release to npm
       if: steps.changesets-status.outputs.CHANGESETS_LENGTH > 0
       if: steps.changesets-status.outputs.CHANGESETS_LENGTH > 0
       run: |
       run: |
-        yarn release-subpackages:snapshot
+        pnpm run release-subpackages:snapshot
       env:
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
         NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -81,35 +71,25 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
+    - uses: pnpm/action-setup@v4
+
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
         node-version: '20'
         node-version: '20'
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v4
-      with:
-        path: |
-          **/node_modules
-        key: node_modules-release-subpackages-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          node_modules-release-subpackages-${{ runner.OS }}-node${{ inputs.node-version }}-
+        cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies
       run: |
       run: |
-        yarn global add turbo
-        yarn global add node-gyp
-        yarn --frozen-lockfile
+        pnpm add turbo --global
+        pnpm install --frozen-lockfile
 
 
     - name: Create Release Pull Request or Publish to npm
     - name: Create Release Pull Request or Publish to npm
       id: changesets
       id: changesets
       uses: changesets/action@v1
       uses: changesets/action@v1
       with:
       with:
         title: Release Subpackages
         title: Release Subpackages
-        version: yarn version-subpackages
-        publish: yarn release-subpackages
+        version: pnpm run version-subpackages
+        publish: pnpm run release-subpackages
       env:
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
         NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

+ 13 - 15
.github/workflows/release.yml

@@ -22,22 +22,21 @@ jobs:
       with:
       with:
         ref: ${{ github.event.pull_request.base.ref }}
         ref: ${{ github.event.pull_request.base.ref }}
 
 
+    - uses: pnpm/action-setup@v4
+
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
         node-version: '20'
         node-version: '20'
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
+        cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies
       run: |
       run: |
-        yarn global add turbo
-        yarn global add node-gyp
-        yarn --frozen-lockfile
+        pnpm add turbo --global
+        pnpm install --frozen-lockfile
 
 
     - name: Bump versions
     - name: Bump versions
       run: |
       run: |
-        turbo run version --filter=@growi/app -- --patch
-        yarn upgrade --scope=@growi
+        turbo run version:patch --filter=@growi/app
         sh ./apps/app/bin/github-actions/update-readme.sh
         sh ./apps/app/bin/github-actions/update-readme.sh
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
@@ -162,23 +161,22 @@ jobs:
       with:
       with:
         ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
         ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
 
 
+    - uses: pnpm/action-setup@v4
+
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
         node-version: '20'
         node-version: '20'
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
+        cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies
       run: |
       run: |
-        yarn global add turbo
-        yarn global add node-gyp
-        yarn --frozen-lockfile
+        pnpm add turbo --global
+        pnpm install --frozen-lockfile
 
 
     - name: Bump versions for next RC
     - name: Bump versions for next RC
       run: |
       run: |
-        turbo run version --filter=@growi/app -- --prepatch
-        turbo run version --filter=@growi/slackbot-proxy -- --prepatch
-        yarn upgrade --scope=@growi
+        turbo run version:prepatch --filter=@growi/app
+        turbo run version:prepatch --filter=@growi/slackbot-proxy
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@2.0.1
       uses: myrotvorets/info-from-package-json-action@2.0.1

+ 88 - 79
.github/workflows/reusable-app-prod.yml

@@ -11,6 +11,18 @@ on:
     secrets:
     secrets:
       SLACK_WEBHOOK_URL:
       SLACK_WEBHOOK_URL:
         required: true
         required: true
+  workflow_dispatch:
+    inputs:
+      node-version:
+        required: true
+        type: string
+        default: 20.x
+      skip-e2e-test:
+        type: boolean
+        default: false
+    secrets:
+      SLACK_WEBHOOK_URL:
+        required: true
 
 
 jobs:
 jobs:
 
 
@@ -22,40 +34,21 @@ jobs:
 
 
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
-      with:
-        # retrieve local font files
-        lfs: true
+
+    - uses: pnpm/action-setup@v4
 
 
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
         node-version: ${{ inputs.node-version }}
         node-version: ${{ inputs.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
+        cache: 'pnpm'
 
 
     - name: Install turbo
     - name: Install turbo
       run: |
       run: |
-        yarn global add turbo
-
-    - name: Prune repositories
-      run: |
-        turbo prune @growi/app
-        rm -rf apps packages
-        mv out/* .
-
-    - name: Cache/Restore node_modules
-      uses: actions/cache@v4
-      with:
-        path: |
-          **/node_modules
-          !**/node_modules/.cache/turbo
-        key: node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+        pnpm add turbo --global
 
 
     - name: Install dependencies
     - name: Install dependencies
       run: |
       run: |
-        yarn global add node-gyp
-        yarn --frozen-lockfile
+        pnpm install --frozen-lockfile
 
 
     - name: Cache/Restore dist
     - name: Cache/Restore dist
       uses: actions/cache@v4
       uses: actions/cache@v4
@@ -76,10 +69,16 @@ jobs:
       env:
       env:
         ANALYZE: 1
         ANALYZE: 1
 
 
+    - name: Assembling all dependencies
+      run: |
+        rm -rf out
+        pnpm deploy out --prod --filter @growi/app
+        rm -rf apps/app/node_modules && mv out/node_modules apps/app/node_modules
+
     - name: Archive production files
     - name: Archive production files
       id: archive-prod-files
       id: archive-prod-files
       run: |
       run: |
-        tar -zcf production.tar.gz \
+        tar -zcf production.tar.gz --exclude ./apps/app/.next/cache \
           package.json \
           package.json \
           apps/app/.next \
           apps/app/.next \
           apps/app/config \
           apps/app/config \
@@ -88,9 +87,8 @@ jobs:
           apps/app/resource \
           apps/app/resource \
           apps/app/tmp \
           apps/app/tmp \
           apps/app/.env.production* \
           apps/app/.env.production* \
-          apps/app/package.json \
-          packages/*/dist \
-          packages/*/package.json
+          apps/app/node_modules \
+          apps/app/package.json
         echo "file=production.tar.gz" >> $GITHUB_OUTPUT
         echo "file=production.tar.gz" >> $GITHUB_OUTPUT
 
 
     - name: Upload production files as artifact
     - name: Upload production files as artifact
@@ -104,8 +102,7 @@ jobs:
       with:
       with:
         name: Bundle Analyzing Report (node${{ inputs.node-version }})
         name: Bundle Analyzing Report (node${{ inputs.node-version }})
         path: |
         path: |
-          apps/app/.next/analyze/client.html
-          apps/app/.next/analyze/server.html
+          apps/app/.next/analyze
 
 
     - name: Slack Notification
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
       uses: weseek/ghaction-slack-notification@master
@@ -137,50 +134,27 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
+    - uses: pnpm/action-setup@v4
+
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
         node-version: ${{ inputs.node-version }}
         node-version: ${{ inputs.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Install turbo
-      run: |
-        yarn global add turbo
-
-    - name: Prune repositories
-      run: |
-        turbo prune @growi/app
-        rm -rf apps packages
-        mv out/* .
-
-    - name: Restore node_modules
-      uses: actions/cache/restore@v4
-      with:
-        path: |
-          **/node_modules
-        # shared key with build-prod
-        key: node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
-
-    - name: Install dependencies
-      run: |
-        yarn --production
+        cache: 'pnpm'
 
 
     - name: Download production files artifact
     - name: Download production files artifact
       uses: actions/download-artifact@v4
       uses: actions/download-artifact@v4
       with:
       with:
         name: Production Files (node${{ inputs.node-version }})
         name: Production Files (node${{ inputs.node-version }})
 
 
-    - name: Extract procution files artifact
+    - name: Extract procution files
       run: |
       run: |
         tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
         tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
 
 
-    - name: yarn server:ci
+    - name: pnpm run server:ci
       working-directory: ./apps/app
       working-directory: ./apps/app
       run: |
       run: |
         cp config/ci/.env.local.for-ci .env.production.local
         cp config/ci/.env.local.for-ci .env.production.local
-        yarn server:ci
+        pnpm run server:ci
       env:
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
@@ -199,7 +173,9 @@ jobs:
   run-playwright:
   run-playwright:
     needs: [build-prod]
     needs: [build-prod]
 
 
-    if: ${{ !inputs.skip-e2e-test && startsWith(github.head_ref, 'mergify/merge-queue/') }}
+    if: |
+      github.event_name == 'workflow_dispatch' ||
+      (!inputs.skip-e2e-test && startsWith(github.head_ref, 'mergify/merge-queue/'))
 
 
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     container:
     container:
@@ -228,37 +204,27 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
+    - uses: pnpm/action-setup@v4
+
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
         node-version: ${{ inputs.node-version }}
         node-version: ${{ inputs.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Restore node_modules
-      uses: actions/cache/restore@v4
-      with:
-        path: |
-          **/node_modules
-        # saved key by build-prod
-        key: node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+        cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies
       run: |
       run: |
-        yarn global add node-gyp
-        yarn --frozen-lockfile
+        pnpm install --frozen-lockfile
 
 
     - name: Install Playwright browsers
     - name: Install Playwright browsers
       run: |
       run: |
-        yarn playwright install --with-deps ${{ matrix.browser }}
+        pnpm playwright install --with-deps ${{ matrix.browser }}
 
 
     - name: Download production files artifact
     - name: Download production files artifact
       uses: actions/download-artifact@v4
       uses: actions/download-artifact@v4
       with:
       with:
         name: Production Files (node${{ inputs.node-version }})
         name: Production Files (node${{ inputs.node-version }})
 
 
-    - name: Extract procution files artifact
+    - name: Extract procution files
       run: |
       run: |
         tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
         tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
 
 
@@ -271,8 +237,9 @@ jobs:
       if: ${{ matrix.browser == 'chromium' }}
       if: ${{ matrix.browser == 'chromium' }}
       working-directory: ./apps/app
       working-directory: ./apps/app
       run: |
       run: |
-        yarn playwright test --project=chromium/installer
+        pnpm playwright test --project=chromium/installer
       env:
       env:
+        DEBUG: pw:api
         HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
         HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
         MONGO_URI: mongodb://mongodb:27017/growi-playwright-installer
         MONGO_URI: mongodb://mongodb:27017/growi-playwright-installer
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
@@ -285,8 +252,9 @@ jobs:
     - name: Playwright Run
     - name: Playwright Run
       working-directory: ./apps/app
       working-directory: ./apps/app
       run: |
       run: |
-        yarn playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}
+        pnpm playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}
       env:
       env:
+        DEBUG: pw:api
         HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
         HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
         MONGO_URI: mongodb://mongodb:27017/growi-playwright
         MONGO_URI: mongodb://mongodb:27017/growi-playwright
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
@@ -299,12 +267,21 @@ jobs:
     - name: Playwright Run (--project=${browser}/guest-mode)
     - name: Playwright Run (--project=${browser}/guest-mode)
       working-directory: ./apps/app
       working-directory: ./apps/app
       run: |
       run: |
-        yarn playwright test --project=${{ matrix.browser }}/guest-mode --shard=${{ matrix.shard }}
+        pnpm playwright test --project=${{ matrix.browser }}/guest-mode --shard=${{ matrix.shard }}
       env:
       env:
+        DEBUG: pw:api
         HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
         HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
         MONGO_URI: mongodb://mongodb:27017/growi-playwright-guest-mode
         MONGO_URI: mongodb://mongodb:27017/growi-playwright-guest-mode
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
 
 
+    - name: Upload test results
+      if: always()
+      uses: actions/upload-artifact@v4
+      with:
+        name: blob-report-${{ matrix.shard }}
+        path: blob-report
+        retention-days: 30
+
     - name: Slack Notification
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
       uses: weseek/ghaction-slack-notification@master
       if: failure()
       if: failure()
@@ -314,3 +291,35 @@ jobs:
         channel: '#ci'
         channel: '#ci'
         isCompactMode: true
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+  report-playwright:
+    needs: [run-playwright]
+
+    if: always() && needs.run-playwright.result != 'skipped'
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v4
+
+    - uses: pnpm/action-setup@v4
+
+    - uses: actions/setup-node@v4
+      with:
+        node-version: ${{ inputs.node-version }}
+        cache: 'pnpm'
+
+    - name: Install dependencies
+      run: |
+        pnpm install --frozen-lockfile
+
+    - name: Merge into HTML Report
+      run: pnpm playwright merge-reports --reporter html ./all-blob-reports
+
+    - name: Upload HTML report
+      uses: actions/upload-artifact@v4
+      with:
+        name: html-report
+        path: playwright-report
+        retention-days: 30

+ 5 - 15
.github/workflows/reusable-app-reg-suit.yml

@@ -54,26 +54,16 @@ jobs:
         ref: ${{ inputs.checkout-ref }}
         ref: ${{ inputs.checkout-ref }}
         fetch-depth: 0
         fetch-depth: 0
 
 
+    - uses: pnpm/action-setup@v4
+
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
         node-version: ${{ inputs.node-version }}
         node-version: ${{ inputs.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Restore node_modules
-      uses: actions/cache/restore@v4
-      with:
-        path: |
-          **/node_modules
-        # saved key by launch-prod
-        key: node_modules-app-launch-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          node_modules-app-launch-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+        cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies
       run: |
       run: |
-        yarn global add node-gyp
-        yarn --frozen-lockfile
+        pnpm install --frozen-lockfile
 
 
     - name: Download screenshots taken by cypress
     - name: Download screenshots taken by cypress
       uses: actions/download-artifact@v4
       uses: actions/download-artifact@v4
@@ -85,7 +75,7 @@ jobs:
     - name: Run reg-suit
     - name: Run reg-suit
       working-directory: ./apps/app
       working-directory: ./apps/app
       run: |
       run: |
-        yarn reg:run
+        pnpm run reg:run
 
 
     - name: Slack Notification
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
       uses: weseek/ghaction-slack-notification@master

+ 3 - 0
.gitignore

@@ -4,6 +4,7 @@
 node_modules
 node_modules
 /.pnp
 /.pnp
 .pnp.js
 .pnp.js
+.pnpm-store
 
 
 # testing
 # testing
 coverage
 coverage
@@ -40,4 +41,6 @@ yarn-error.log*
 
 
 # turborepo
 # turborepo
 .turbo
 .turbo
+
+# pnpm deploy target dir
 out
 out

+ 0 - 0
.npmrc


+ 2 - 1
.vscode/launch.json

@@ -31,8 +31,9 @@
         "request": "launch",
         "request": "launch",
         "name": "Debug: Server",
         "name": "Debug: Server",
         "cwd": "${workspaceFolder}/apps/app",
         "cwd": "${workspaceFolder}/apps/app",
-        "runtimeExecutable": "yarn",
+        "runtimeExecutable": "pnpm",
         "runtimeArgs": [
         "runtimeArgs": [
+          "run",
           "dev"
           "dev"
         ],
         ],
         "skipFiles": [
         "skipFiles": [

+ 8 - 1
.vscode/settings.json

@@ -19,5 +19,12 @@
 
 
   "githubPullRequests.ignoredPullRequestBranches": [
   "githubPullRequests.ignoredPullRequestBranches": [
     "master"
     "master"
-  ]
+  ],
+
+  "typescript.tsdk": "node_modules/typescript/lib",
+  "typescript.enablePromptUseWorkspaceTsdk": true,
+  "typescript.preferences.autoImportFileExcludePatterns": ["node_modules/*"],
+  "typescript.validate.enable": true,
+  "typescript.surveys.enabled": false
+
 }
 }

+ 95 - 1
CHANGELOG.md

@@ -1,9 +1,103 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.22...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.1.1...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v7.1.1](https://github.com/weseek/growi/compare/v7.1.0...v7.1.1) - 2024-11-12
+
+### 💎 Features
+
+* feat(ai): Swtch summary mode (#9377) @yuki-takei
+* feat: Return sources when generating responses (Knowledge assistant) (#9362) @miya
+* feat: Set the maximum number of minutes until the request in an environment variable (#9347) @miya
+
+### 🚀 Improvement
+
+* imprv: GitHub Alert with directive syntax (#9392) @yuki-takei
+* imprv: Sidebar button displays tooltip (#9371) @reiji-h
+* imprv: Open the link of PageTreeItem in a new tab when the user middle click (#9365) @yuki-takei
+* support: Avoid using req.t() (#9149) @shironegi39
+* imprv: Tidy up /Sandbox (#9355) @yuki-takei
+* imprv: Reduce sanitizing (#9350) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Output TextDirective and LeafDirective HTML (#9388) @yuki-takei
+* fix: NextLink isCreatablePage always returns false (#9356) @yuki-takei
+* fix: Duplicate page names alert should not occur on a single page (#9348) @reiji-h
+* fix: i18n for security settings (#9379) @yuki-takei
+* fix: Output TextDirective and LeafDirective HTML (#9388) @yuki-takei
+* fix(i18n): i18n for server side (#9372) @yuki-takei
+* fix: Duplicate page names alert should not occur on a single page (#9348) @reiji-h
+* fix: NextLink isCreatablePage always returns false (#9356) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Welcome back new Christmas theme (#9374) @satof3
+* support: Type checking (#9393) @yuki-takei
+* support: Welcome back new Christmas theme (#9374) @satof3
+* support: Omit remark-toc (#9383) @yuki-takei
+* support: Stop managing font files with Git LFS (#9351) @yuki-takei
+* support: Avoid using req.t() (#9149) @shironegi39
+* support: Improve playwright report (#9363) @yuki-takei
+* support: Avoid using req.t() (#9149) @shironegi39
+* support: Stop managing font files with Git LFS (#9351) @yuki-takei
+
+## [v7.1.0](https://github.com/weseek/growi/compare/v7.0.23...v7.1.0) - 2024-10-31
+
+### BREAKING CHANGES
+
+* imprv: Update default value for S3_OBJECT_ACL (#9332) @yuki-takei
+
+### 💎 Features
+
+* feat: GROWI OpenAI Integration (#9246) @yuki-takei
+
+### 🚀 Improvement
+
+* imprv: Add GitHub Markdown alerts  (#9127) @reiji-h
+* imprv: Upgrade unified and remark-growi-directive (#9048) @reiji-h
+* imprv: ROM users can manage comments (#9101) @WNomunomu
+* imprv: Update default value for S3_OBJECT_ACL (#9332) @yuki-takei
+* imprv: Sandbox (#9330) @yuki-takei
+* support: JSDoc for OpenAPI document (#9311) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Couldn't show old revision (#9296) @yuki-takei
+* fix: Replace the word ROM (#9295) @satof3
+* fix: forgot-password API (#9257) @reiji-h
+* fix: Edit button appear for the side of header (#9270) @yuki-takei
+* fix: Ensure text-only paste for mixed content from various sources (#9096) @reiji-h
+* fix: Notification count badge (#9124) @shironegi39
+* fix(ogp): Set an unknown label when the user is not found (#9232) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Migrate to pnpm from yarn v1 (#9249) @yuki-takei
+* support: Omit MongoDB 4.x compatible code (#9334) @yuki-takei
+* support: Pull LFS files with turbo (#9325) @yuki-takei
+* support: Use `pnpm deploy` instead of `turbo prune` (#9323) @yuki-takei
+* support: Maintenance API docs generation (#9302) @yuki-takei
+* support: Improve typings for PageService (#9220) @yuki-takei
+* support: Typescriptize accessTokenParser (#9320) @yuki-takei
+* support: Migrate to pnpm from yarn v1 (#9249) @yuki-takei
+* support: JSDoc for OpenAPI document (#9311) @yuki-takei
+* support: Maintenance API docs generation (#9302) @yuki-takei
+* support: Omit docs route (#9299) @yuki-takei
+
+## [v7.0.23](https://github.com/weseek/growi/compare/v7.0.22...v7.0.23) - 2024-10-24
+
+### 🐛 Bug Fixes
+
+* fix: Couln't show old revision (#9296) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Maintenance API docs generation (#9302) @yuki-takei
+* support: Omit docs route (#9299) @yuki-takei
+
 ## [v7.0.22](https://github.com/weseek/growi/compare/v7.0.21...v7.0.22) - 2024-10-21
 ## [v7.0.22](https://github.com/weseek/growi/compare/v7.0.21...v7.0.22) - 2024-10-21
 
 
 ### 🐛 Bug Fixes
 ### 🐛 Bug Fixes

+ 7 - 7
README.md

@@ -81,9 +81,9 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 
 
 - Node.js v18.x or v20.x
 - Node.js v18.x or v20.x
 - npm 6.x
 - npm 6.x
-- yarn
+- pnpm 9.x
 - [Turborepo](https://turbo.build/repo)
 - [Turborepo](https://turbo.build/repo)
-- MongoDB 4.4 or above
+- MongoDB 6.0 or above
 
 
 ### Optional Dependencies
 ### Optional Dependencies
 
 
@@ -95,11 +95,11 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 
 
 ## Command details
 ## Command details
 
 
-| command           | desc                                                    |
-| ------------------| ------------------------------------------------------- |
-| `yarn app:build`  | Build GROWI app client                                  |
-| `yarn app:server` | Launch GROWI app server                                 |
-| `yarn start`      | Invoke `yarn app:build` and `yarn app:server`           |
+| command               | desc                                                    |
+| --------------------- | ------------------------------------------------------- |
+| `npm run app:build`   | Build GROWI app client                                  |
+| `npm run app:server`  | Launch GROWI app server                                 |
+| `npm run start`       | Invoke `npm run app:build` and `npm run app:server`     |
 
 
 For more info, see [GROWI Docs: List of npm Scripts](https://docs.growi.org/en/dev/startup-v5/start-development.html#list-of-npm-scripts).
 For more info, see [GROWI Docs: List of npm Scripts](https://docs.growi.org/en/dev/startup-v5/start-development.html#list-of-npm-scripts).
 
 

+ 7 - 7
README_JP.md

@@ -80,9 +80,9 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 
 
 - Node.js v18.x or v20.x
 - Node.js v18.x or v20.x
 - npm 6.x
 - npm 6.x
-- yarn
+- pnpm 9.x
 - [Turborepo](https://turbo.build/repo)
 - [Turborepo](https://turbo.build/repo)
-- MongoDB 4.4 以上
+- MongoDB 6.0 以上
 
 
 ### オプションの依存関係
 ### オプションの依存関係
 
 
@@ -94,11 +94,11 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 
 
 ## コマンド詳細
 ## コマンド詳細
 
 
-| コマンド          | 説明                                                    |
-| ------------------| ------------------------------------------------------- |
-| `yarn app:build`  | GROWI app クライアントをビルドします。                  |
-| `yarn app:server` | GROWI app サーバーを起動します。                        |
-| `yarn start`      | `yarn app:build` と `yarn app:server` を呼び出します。  |
+| コマンド              | 説明                                                            |
+| --------------------- | --------------------------------------------------------------- |
+| `npm run app:build`   | GROWI app クライアントをビルドします。                          |
+| `npm run app:server`  | GROWI app サーバーを起動します。                                |
+| `npm run start`       | `npm run app:build` と `npm run app:server` を呼び出します。    |
 
 
 詳しくは [GROWI Docs: npm スクリプトリスト](https://docs.growi.org/ja/dev/startup-v5/start-development.html#npm-%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%95%E3%82%9A%E3%83%88%E3%83%AA%E3%82%B9%E3%83%88)をご覧ください。
 詳しくは [GROWI Docs: npm スクリプトリスト](https://docs.growi.org/ja/dev/startup-v5/start-development.html#npm-%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%95%E3%82%9A%E3%83%88%E3%83%AA%E3%82%B9%E3%83%88)をご覧ください。
 
 

+ 65 - 0
apps/app/bin/swagger-jsdoc/definition-apiv3.js

@@ -25,4 +25,69 @@ module.exports = {
       },
       },
     },
     },
   },
   },
+  'x-tagGroups': [
+    {
+      name: 'User API',
+      tags: [
+        'Attachment',
+        'Bookmarks',
+        'Page',
+        'Pages',
+        'Revisions',
+        'ShareLinks',
+        'Users',
+        '',
+        '',
+      ],
+    },
+    {
+      name: 'User Personal Settings API',
+      tags: [
+        'GeneralSetting',
+        'EditorSetting',
+        'InAppNotificationSettings',
+        '',
+        '',
+        '',
+        '',
+        '',
+      ],
+    },
+    {
+      name: 'System Management API',
+      tags: [
+        'Home',
+        'AppSettings',
+        'SecuritySetting',
+        'MarkDownSetting',
+        'CustomizeSetting',
+        'Import',
+        'Export',
+        'MongoDB',
+        'NotificationSetting',
+        'SlackIntegrationSettings',
+        'SlackIntegrationSettings (with proxy)',
+        'SlackIntegrationSettings (without proxy)',
+        'SlackIntegrationLegacySetting',
+        'ShareLink Management',
+        'UserGroupRelations',
+        'UserGroups',
+        'Users Management',
+        'FullTextSearch Management',
+      ],
+    },
+    {
+      name: 'Public API',
+      tags: [
+        'Healthcheck',
+        'Statistics',
+        '',
+        '',
+        '',
+        '',
+        '',
+        '',
+      ],
+    },
+  ],
 };
 };

+ 1 - 1
apps/app/bin/swagger-jsdoc/generate-spec-apiv1.sh

@@ -10,6 +10,6 @@ OUT=${OUT:-"${APP_PATH}/tmp/openapi-spec-apiv1.json"}
 swagger-jsdoc \
 swagger-jsdoc \
   -o "${OUT}" \
   -o "${OUT}" \
   -d "${APP_PATH}/bin/swagger-jsdoc/definition-apiv1.js" \
   -d "${APP_PATH}/bin/swagger-jsdoc/definition-apiv1.js" \
-  "${APP_PATH}/src/server/routes/*/*.{js,ts}" \
+  "${APP_PATH}/src/server/routes/*.{js,ts}" \
   "${APP_PATH}/src/server/routes/attachment/**/*.{js,ts}" \
   "${APP_PATH}/src/server/routes/attachment/**/*.{js,ts}" \
   "${APP_PATH}/src/server/models/openapi/**/*.{js,ts}"
   "${APP_PATH}/src/server/models/openapi/**/*.{js,ts}"

+ 4 - 6
apps/app/config/i18next.config.js

@@ -1,6 +1,6 @@
-const { Lang, AllLang } = require('@growi/core');
+const { Lang, AllLang } = require('@growi/core/dist/interfaces');
 
 
-/** @type {Lang} */
+/** @type {import('@growi/core/dist/interfaces').Lang} */
 const defaultLang = Lang.en_US;
 const defaultLang = Lang.en_US;
 
 
 /** @type {import('i18next').InitOptions} */
 /** @type {import('i18next').InitOptions} */
@@ -10,7 +10,5 @@ const initOptions = {
   defaultNS: 'translation',
   defaultNS: 'translation',
 };
 };
 
 
-module.exports = {
-  defaultLang,
-  initOptions,
-};
+exports.defaultLang = defaultLang;
+exports.initOptions = initOptions;

+ 28 - 76
apps/app/docker/Dockerfile

@@ -1,4 +1,4 @@
-# syntax = docker/dockerfile:1.4
+# syntax = docker/dockerfile:1
 
 
 
 
 ##
 ##
@@ -6,89 +6,46 @@
 ##
 ##
 FROM node:20-slim AS base
 FROM node:20-slim AS base
 
 
-ENV optDir /opt
+ENV optDir=/opt
 
 
 WORKDIR ${optDir}
 WORKDIR ${optDir}
 
 
-RUN yarn global add turbo
-COPY . .
-RUN turbo prune @growi/app --docker
-
-
-##
-## deps-resolver
-##
-FROM node:20-slim AS deps-resolver
-
-ENV optDir /opt
+# install tools
+RUN apt-get update && apt-get install -y ca-certificates wget curl --no-install-recommends
 
 
-WORKDIR ${optDir}
-
-RUN set -eux; \
-	apt-get update; \
-	apt-get install -y python3 build-essential;
-
-# copy files
-COPY --from=base ${optDir}/out/json/ .
-COPY --from=base ${optDir}/out/yarn.lock ./yarn.lock
+# install pnpm
+RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" sh -
+ENV PNPM_HOME="/root/.local/share/pnpm"
+ENV PATH="$PNPM_HOME:$PATH"
 
 
-# setup (with network-timeout = 1 hour)
-RUN yarn config set network-timeout 3600000
-RUN yarn global add node-gyp
-RUN yarn --frozen-lockfile
-
-# make artifacts
-RUN tar -cf node_modules.tar \
-  node_modules \
-  apps/*/node_modules \
-  packages/*/node_modules
-
-
-
-##
-## deps-resolver-prod
-##
-FROM deps-resolver AS deps-resolver-prod
-
-RUN yarn --production
-# make artifacts
-RUN tar -cf node_modules.tar \
-  node_modules \
-  apps/*/node_modules \
-  packages/*/node_modules
+# install turbo
+RUN pnpm add turbo --global
 
 
 
 
 
 
 ##
 ##
 ## builder
 ## builder
 ##
 ##
-FROM node:20-slim AS builder
+FROM base AS builder
 
 
-ENV optDir /opt
+ENV optDir=/opt
 
 
 WORKDIR ${optDir}
 WORKDIR ${optDir}
 
 
-RUN yarn global add turbo
-
-# copy files
-COPY --from=base ${optDir}/out/full/ .
-COPY --from=base ${optDir}/out/yarn.lock ./yarn.lock
-COPY ["tsconfig.base.json", "./"]
-
-# copy dependent packages
-COPY --from=deps-resolver \
-  ${optDir}/node_modules.tar ${optDir}/
+COPY . .
 
 
-# extract node_modules.tar
-RUN tar -xf node_modules.tar
-RUN rm node_modules.tar
+RUN pnpm add node-gyp --global
+RUN pnpm install ---frozen-lockfile
 
 
 # build
 # build
 RUN turbo run clean
 RUN turbo run clean
-RUN turbo run build
+RUN turbo run build --filter @growi/app
 
 
 # make artifacts
 # make artifacts
-RUN tar -cf packages.tar \
+RUN pnpm deploy out --prod --filter @growi/app
+RUN rm -rf apps/app/node_modules && mv out/node_modules apps/app/node_modules
+RUN rm -rf apps/app/.next/cache
+RUN tar -zcf packages.tar.gz \
   package.json \
   package.json \
   apps/app/.next \
   apps/app/.next \
   apps/app/config \
   apps/app/config \
@@ -99,8 +56,7 @@ RUN tar -cf packages.tar \
   apps/app/.env.production* \
   apps/app/.env.production* \
   apps/app/next.config.js \
   apps/app/next.config.js \
   apps/app/package.json \
   apps/app/package.json \
-  packages/*/package.json \
-  packages/*/dist
+  apps/app/node_modules
 
 
 
 
 
 
@@ -108,12 +64,12 @@ RUN tar -cf packages.tar \
 ## release
 ## release
 ##
 ##
 FROM node:20-slim
 FROM node:20-slim
-LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
+LABEL maintainer="Yuki Takei <yuki@weseek.co.jp>"
 
 
-ENV NODE_ENV production
+ENV NODE_ENV="production"
 
 
-ENV optDir /opt
-ENV appDir ${optDir}/growi
+ENV optDir=/opt
+ENV appDir=${optDir}/growi
 
 
 # Add gosu
 # Add gosu
 # see: https://github.com/tianon/gosu/blob/1.13/INSTALL.md
 # see: https://github.com/tianon/gosu/blob/1.13/INSTALL.md
@@ -124,17 +80,13 @@ RUN set -eux; \
 # verify that the binary works
 # verify that the binary works
 	gosu nobody true
 	gosu nobody true
 
 
-COPY --from=deps-resolver-prod --chown=node:node \
-  ${optDir}/node_modules.tar ${appDir}/
 COPY --from=builder --chown=node:node \
 COPY --from=builder --chown=node:node \
-  ${optDir}/packages.tar ${appDir}/
+  ${optDir}/packages.tar.gz ${appDir}/
 
 
 # extract artifacts as 'node' user
 # extract artifacts as 'node' user
 USER node
 USER node
 WORKDIR ${appDir}
 WORKDIR ${appDir}
-RUN tar -xf node_modules.tar
-RUN tar -xf packages.tar
-RUN rm node_modules.tar packages.tar
+RUN tar -zxf packages.tar.gz && rm packages.tar.gz
 
 
 COPY --chown=node:node --chmod=700 apps/app/docker/docker-entrypoint.sh /
 COPY --chown=node:node --chmod=700 apps/app/docker/docker-entrypoint.sh /
 
 
@@ -145,4 +97,4 @@ VOLUME /data
 EXPOSE 3000
 EXPOSE 3000
 
 
 ENTRYPOINT ["/docker-entrypoint.sh"]
 ENTRYPOINT ["/docker-entrypoint.sh"]
-CMD ["yarn migrate && node -r dotenv-flow/config --expose_gc dist/server/app.js"]
+CMD ["npm run migrate && node -r dotenv-flow/config --expose_gc dist/server/app.js"]

+ 1 - 0
apps/app/docker/Dockerfile.dockerignore

@@ -2,6 +2,7 @@
 **/coverage
 **/coverage
 **/Dockerfile
 **/Dockerfile
 **/*.dockerignore
 **/*.dockerignore
+**/.pnpm-store
 **/.next
 **/.next
 **/.turbo
 **/.turbo
 out
 out

+ 3 - 4
apps/app/docker/README.md

@@ -10,10 +10,9 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`7.0.22`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.22/apps/app/docker/Dockerfile)
+* [`7.1.0`, `7.1`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.1.0/apps/app/docker/Dockerfile)
+* [`7.0.23`, `7.0` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.23/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
-* [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
-* [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)
 
 
 
 
 What is GROWI?
 What is GROWI?
@@ -27,7 +26,7 @@ see: [weseek/growi](https://github.com/weseek/growi)
 Requirements
 Requirements
 -------------
 -------------
 
 
-* MongoDB (>= 4.4)
+* MongoDB (>= 6.0)
 
 
 ### Optional Dependencies
 ### Optional Dependencies
 
 

+ 1 - 8
apps/app/docker/codebuild/buildspec.yml

@@ -10,11 +10,6 @@ env:
 phases:
 phases:
   pre_build:
   pre_build:
     commands:
     commands:
-      # install Git LFS
-      - curl -s https://packagecloud.io/install/repositories/github/git-lfs/script.rpm.sh | bash
-      - yum install -y git-lfs
-      # fetch LFS files
-      - git-lfs pull
       # login to docker.io
       # login to docker.io
       - echo ${DOCKER_REGISTRY_PASSWORD} | docker login --username wsmoogle --password-stdin
       - echo ${DOCKER_REGISTRY_PASSWORD} | docker login --username wsmoogle --password-stdin
   build:
   build:
@@ -27,6 +22,4 @@ phases:
 
 
 cache:
 cache:
   paths:
   paths:
-    - node_modules/**/*
-    - apps/*/node_modules/**/*
-    - packages/*/node_modules/**/*
+    - .pnpm-store/**/*

+ 5 - 2
apps/app/next.config.js

@@ -73,7 +73,6 @@ const getTranspilePackages = () => {
 const optimizePackageImports = [
 const optimizePackageImports = [
   '@growi/core',
   '@growi/core',
   '@growi/editor',
   '@growi/editor',
-  '@growi/markdown-splitter',
   '@growi/pluginkit',
   '@growi/pluginkit',
   '@growi/presentation',
   '@growi/presentation',
   '@growi/preset-themes',
   '@growi/preset-themes',
@@ -160,7 +159,11 @@ module.exports = async(phase, { defaultConfig }) => {
   }
   }
 
 
   const withBundleAnalyzer = require('@next/bundle-analyzer')({
   const withBundleAnalyzer = require('@next/bundle-analyzer')({
-    enabled: phase === PHASE_PRODUCTION_BUILD && process.env.ANALYZE === 'true',
+    enabled: phase === PHASE_PRODUCTION_BUILD
+      && (
+        process.env.ANALYZE === 'true'
+          || process.env.ANALYZE === '1'
+      ),
   });
   });
 
 
   return withBundleAnalyzer(withSuperjson()(nextConfig));
   return withBundleAnalyzer(withSuperjson()(nextConfig));

+ 4 - 0
apps/app/nodemon.json

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

+ 88 - 59
apps/app/package.json

@@ -1,40 +1,40 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.1.0-RC.0",
+  "version": "7.1.2-RC.0",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
     "build": "run-p build:*",
     "build": "run-p build:*",
-    "start": "yarn next start",
-    "build:client": "yarn next build",
-    "build:server": "yarn cross-env NODE_ENV=production tspc -p tsconfig.build.server.json",
+    "start": "next start",
+    "build:client": "next build",
+    "build:server": "cross-env NODE_ENV=production tspc -p tsconfig.build.server.json",
     "postbuild:server": "shx echo \"Listing files under transpiled\" && shx ls transpiled && shx rm -rf dist && shx mv transpiled/src dist && shx rm -rf transpiled",
     "postbuild:server": "shx echo \"Listing files under transpiled\" && shx ls transpiled && shx rm -rf dist && shx mv transpiled/src dist && shx rm -rf transpiled",
     "clean": "shx rm -rf dist transpiled",
     "clean": "shx rm -rf dist transpiled",
-    "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
-    "server:ci": "yarn server --ci",
-    "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
-    "styles-prebuilt": "vite build -c vite.styles-prebuilt.config.ts",
-    "migrate": "node -r dotenv-flow/config node_modules/.bin/migrate-mongo up -f config/migrate-mongo-config.js",
+    "server": "cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
+    "server:ci": "pnpm run server --ci",
+    "preserver": "cross-env NODE_ENV=production pnpm run migrate",
+    "pre:styles": "vite build -c vite.styles-prebuilt.config.ts",
+    "migrate": "node -r dotenv-flow/config node_modules/migrate-mongo/bin/migrate-mongo up -f config/migrate-mongo-config.js",
     "//// for development": "",
     "//// for development": "",
-    "dev": "yarn cross-env NODE_ENV=development nodemon --exec yarn ts-node --inspect src/server/app.ts",
-    "dev:styles-prebuilt": "yarn styles-prebuilt --mode dev",
-    "dev:migrate-mongo": "yarn cross-env NODE_ENV=development yarn ts-node node_modules/.bin/migrate-mongo",
-    "dev:migrate": "yarn dev:migrate:status > tmp/cache/migration-status.out && yarn dev:migrate:up",
-    "dev:migrate:create": "yarn dev:migrate-mongo create -f config/migrate-mongo-config.js",
-    "dev:migrate:status": "yarn dev:migrate-mongo status -f config/migrate-mongo-config.js",
-    "dev:migrate:up": "yarn dev:migrate-mongo up -f config/migrate-mongo-config.js",
-    "dev:migrate:down": "yarn dev:migrate-mongo down -f config/migrate-mongo-config.js",
+    "dev": "cross-env NODE_ENV=development nodemon --exec pnpm run ts-node --inspect src/server/app.ts",
+    "dev:pre:styles": "pnpm run pre:styles --mode dev",
+    "dev:migrate-mongo": "cross-env NODE_ENV=development pnpm run ts-node node_modules/migrate-mongo/bin/migrate-mongo",
+    "dev:migrate": "pnpm run dev:migrate:status > tmp/cache/migration-status.out && pnpm run dev:migrate:up",
+    "dev:migrate:create": "pnpm run dev:migrate-mongo create -f config/migrate-mongo-config.js",
+    "dev:migrate:status": "pnpm run dev:migrate-mongo status -f config/migrate-mongo-config.js",
+    "dev:migrate:up": "pnpm run dev:migrate-mongo up -f config/migrate-mongo-config.js",
+    "dev:migrate:down": "pnpm run dev:migrate-mongo down -f config/migrate-mongo-config.js",
     "//// for CI": "",
     "//// for CI": "",
-    "launch-dev:ci": "yarn cross-env NODE_ENV=development yarn dev:migrate && yarn ts-node src/server/app.ts --ci",
-    "lint:typecheck": "npx -y tspc",
-    "lint:eslint": "yarn eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
+    "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,jsx,ts,tsx}\"",
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:styles": "stylelint \"src/**/*.scss\"",
-    "lint:swagger2openapi:apiv3": "node node_modules/.bin/oas-validate tmp/openapi-spec-apiv3.json",
-    "lint:swagger2openapi:apiv1": "node node_modules/.bin/oas-validate tmp/openapi-spec-apiv1.json",
+    "lint:swagger2openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
+    "lint:swagger2openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
     "lint": "run-p lint:*",
     "lint": "run-p lint:*",
-    "prelint:swagger2openapi:apiv3": "yarn swagger2openapi:apiv3",
-    "prelint:swagger2openapi:apiv1": "yarn swagger2openapi:apiv1",
+    "prelint:swagger2openapi:apiv3": "pnpm run swagger2openapi:apiv3",
+    "prelint:swagger2openapi:apiv1": "pnpm run swagger2openapi:apiv1",
     "test": "run-p test:*",
     "test": "run-p test:*",
     "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest",
     "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest",
     "test:vitest": "vitest run --coverage",
     "test:vitest": "vitest run --coverage",
@@ -42,19 +42,22 @@
     "reg:run": "reg-suit run",
     "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",
     "previtest:run:integ": "vitest run -c test-with-vite/download-mongo-binary/vitest.config.ts test-with-vite/download-mongo-binary",
     "//// misc": "",
     "//// misc": "",
-    "console": "yarn repl",
-    "repl": "yarn cross-env NODE_ENV=development yarn ts-node src/server/repl.ts",
+    "console": "npm run repl",
+    "repl": "cross-env NODE_ENV=development npm run ts-node src/server/repl.ts",
     "swagger2openapi:apiv3": "sh bin/swagger-jsdoc/generate-spec-apiv3.sh",
     "swagger2openapi:apiv3": "sh bin/swagger-jsdoc/generate-spec-apiv3.sh",
     "swagger2openapi:apiv1": "sh bin/swagger-jsdoc/generate-spec-apiv1.sh",
     "swagger2openapi:apiv1": "sh bin/swagger-jsdoc/generate-spec-apiv1.sh",
     "ts-node": "node -r ts-node/register/transpile-only -r tsconfig-paths/register -r dotenv-flow/config",
     "ts-node": "node -r ts-node/register/transpile-only -r tsconfig-paths/register -r dotenv-flow/config",
-    "version": "yarn version --no-git-tag-version --non-interactive --preid=RC"
+    "version:patch": "pnpm version patch",
+    "version:prerelease": "pnpm version prerelease --preid=RC",
+    "version:prepatch": "pnpm version prepatch --preid=RC",
+    "version:preminor": "pnpm version preminor --preid=RC",
+    "version:premajor": "pnpm version premajor --preid=RC"
   },
   },
   "// comments for dependencies": {
   "// comments for dependencies": {
     "@aws-skd/*": "fix version above 3.186.0 that is required by mongodb@4.16.0",
     "@aws-skd/*": "fix version above 3.186.0 that is required by mongodb@4.16.0",
     "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM.",
     "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM.",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "next-themes": "0.3.0 causes a type error: https://github.com/pacocoursey/next-themes/issues/122",
     "next-themes": "0.3.0 causes a type error: https://github.com/pacocoursey/next-themes/issues/122",
-    "remark-wiki-link": "!!DO NOT REMOVE!! including 'mdast-util-wiki-link' and 'micromark-extension-wiki-link' required by pukiwiki-like-linker",
     "string-width": "5.0.0 or above exports only ESM."
     "string-width": "5.0.0 or above exports only ESM."
   },
   },
   "dependencies": {
   "dependencies": {
@@ -65,20 +68,21 @@
     "@azure/openai": "^2.0.0-beta.2",
     "@azure/openai": "^2.0.0-beta.2",
     "@azure/storage-blob": "^12.16.0",
     "@azure/storage-blob": "^12.16.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
+    "@cspell/dynamic-import": "^8.15.4",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/core": "link:../../packages/core",
-    "@growi/pluginkit": "link:../../packages/pluginkit",
-    "@growi/presentation": "link:../../packages/presentation",
-    "@growi/preset-templates": "link:../../packages/preset-templates",
-    "@growi/preset-themes": "link:../../packages/preset-themes",
-    "@growi/remark-attachment-refs": "link:../../packages/remark-attachment-refs",
-    "@growi/remark-drawio": "link:../../packages/remark-drawio",
-    "@growi/remark-growi-directive": "link:../../packages/remark-growi-directive",
-    "@growi/remark-lsx": "link:../../packages/remark-lsx",
-    "@growi/slack": "link:../../packages/slack",
+    "@growi/core": "workspace:^",
+    "@growi/pluginkit": "workspace:^",
+    "@growi/presentation": "workspace:^",
+    "@growi/preset-templates": "workspace:^",
+    "@growi/preset-themes": "workspace:^",
+    "@growi/remark-attachment-refs": "workspace:^",
+    "@growi/remark-drawio": "workspace:^",
+    "@growi/remark-growi-directive": "workspace:^",
+    "@growi/remark-lsx": "workspace:^",
+    "@growi/slack": "workspace:^",
     "@keycloak/keycloak-admin-client": "^18.0.0",
     "@keycloak/keycloak-admin-client": "^18.0.0",
     "@opentelemetry/api": "^1.8.0",
     "@opentelemetry/api": "^1.8.0",
     "@opentelemetry/auto-instrumentations-node": "^0.44.0",
     "@opentelemetry/auto-instrumentations-node": "^0.44.0",
@@ -89,8 +93,6 @@
     "@opentelemetry/sdk-trace-node": "^1.23.0",
     "@opentelemetry/sdk-trace-node": "^1.23.0",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@slack/webhook": "^6.0.0",
-    "@types/jest": "^29.5.2",
-    "@types/ldapjs": "^2.2.5",
     "JSONStream": "^1.3.5",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "array.prototype.flatmap": "^1.2.2",
@@ -101,12 +103,13 @@
     "browser-bunyan": "^1.8.0",
     "browser-bunyan": "^1.8.0",
     "bson-objectid": "^2.0.4",
     "bson-objectid": "^2.0.4",
     "bunyan": "^1.8.15",
     "bunyan": "^1.8.15",
-    "check-node-version": "^4.1.0",
+    "check-node-version": "^4.2.1",
     "compression": "^1.7.4",
     "compression": "^1.7.4",
     "connect-flash": "~0.1.1",
     "connect-flash": "~0.1.1",
     "connect-mongo": "^4.6.0",
     "connect-mongo": "^4.6.0",
     "connect-redis": "^4.0.4",
     "connect-redis": "^4.0.4",
     "cookie-parser": "^1.4.5",
     "cookie-parser": "^1.4.5",
+    "cross-env": "^7.0.0",
     "csurf": "^1.11.0",
     "csurf": "^1.11.0",
     "csv-to-markdown-table": "^1.4.1",
     "csv-to-markdown-table": "^1.4.1",
     "date-fns": "^3.6.0",
     "date-fns": "^3.6.0",
@@ -114,6 +117,7 @@
     "detect-indent": "^7.0.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
     "diff": "^5.0.0",
     "diff_match_patch": "^0.1.1",
     "diff_match_patch": "^0.1.1",
+    "dotenv-flow": "^3.2.0",
     "ejs": "^3.1.10",
     "ejs": "^3.1.10",
     "esa-node": "^0.2.2",
     "esa-node": "^0.2.2",
     "escape-string-regexp": "^4.0.0",
     "escape-string-regexp": "^4.0.0",
@@ -127,21 +131,32 @@
     "extensible-custom-error": "^0.0.7",
     "extensible-custom-error": "^0.0.7",
     "form-data": "^4.0.0",
     "form-data": "^4.0.0",
     "graceful-fs": "^4.1.11",
     "graceful-fs": "^4.1.11",
+    "hast-util-sanitize": "^5.0.1",
     "hast-util-select": "^6.0.2",
     "hast-util-select": "^6.0.2",
+    "hastscript": "^8.0.0",
     "helmet": "^4.6.0",
     "helmet": "^4.6.0",
     "http-errors": "^2.0.0",
     "http-errors": "^2.0.0",
-    "i18next": "^23.10.1",
+    "i18next": "^23.16.5",
     "i18next-resources-to-backend": "^1.2.1",
     "i18next-resources-to-backend": "^1.2.1",
     "is-absolute-url": "^4.0.1",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
     "is-iso-date": "^0.0.1",
+    "js-tiktoken": "^1.0.15",
+    "js-yaml": "^4.1.0",
+    "katex": "^0.16.11",
     "ldapjs": "^3.0.2",
     "ldapjs": "^3.0.2",
     "lucene-query-parser": "^1.2.0",
     "lucene-query-parser": "^1.2.0",
     "markdown-table": "^3.0.3",
     "markdown-table": "^3.0.3",
     "md5": "^2.2.1",
     "md5": "^2.2.1",
+    "mdast-util-from-markdown": "^2.0.1",
+    "mdast-util-gfm-table": "^2.0.0",
+    "mdast-util-wiki-link": "^0.1.2",
     "mermaid": "^11.2.0",
     "mermaid": "^11.2.0",
     "method-override": "^3.0.0",
     "method-override": "^3.0.0",
+    "micromark-extension-gfm-table": "^2.1.0",
+    "micromark-extension-wiki-link": "^0.0.4",
     "migrate-mongo": "^11.0.0",
     "migrate-mongo": "^11.0.0",
     "mkdirp": "^1.0.3",
     "mkdirp": "^1.0.3",
+    "mongodb": "^4.17.2",
     "mongoose": "^6.11.3",
     "mongoose": "^6.11.3",
     "mongoose-gridfs": "^1.2.42",
     "mongoose-gridfs": "^1.2.42",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-paginate-v2": "^1.3.9",
@@ -151,7 +166,7 @@
     "mustache": "^4.2.0",
     "mustache": "^4.2.0",
     "next": "^14.2.13",
     "next": "^14.2.13",
     "next-dynamic-loading-props": "^0.1.1",
     "next-dynamic-loading-props": "^0.1.1",
-    "next-i18next": "^15.2.0",
+    "next-i18next": "^15.3.1",
     "next-superjson": "^0.0.4",
     "next-superjson": "^0.0.4",
     "next-themes": "^0.2.1",
     "next-themes": "^0.2.1",
     "nocache": "^4.0.0",
     "nocache": "^4.0.0",
@@ -167,6 +182,7 @@
     "passport-ldapauth": "^3.0.1",
     "passport-ldapauth": "^3.0.1",
     "passport-local": "^1.0.0",
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
     "passport-saml": "^3.2.0",
+    "prop-types": "^15.8.1",
     "qs": "^6.11.1",
     "qs": "^6.11.1",
     "rate-limiter-flexible": "^2.3.7",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react": "^18.2.0",
@@ -176,7 +192,7 @@
     "react-disable": "^0.1.1",
     "react-disable": "^0.1.1",
     "react-dom": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-error-boundary": "^3.1.4",
     "react-error-boundary": "^3.1.4",
-    "react-i18next": "^14.1.0",
+    "react-i18next": "^15.1.1",
     "react-image-crop": "^8.3.0",
     "react-image-crop": "^8.3.0",
     "react-markdown": "^9.0.1",
     "react-markdown": "^9.0.1",
     "react-multiline-clamp": "^2.0.0",
     "react-multiline-clamp": "^2.0.0",
@@ -187,18 +203,19 @@
     "reactstrap": "^9.2.2",
     "reactstrap": "^9.2.2",
     "reconnecting-websocket": "^4.4.0",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "redis": "^3.0.2",
-    "rehype-katex": "^7.0.0",
+    "rehype-katex": "^7.0.1",
     "rehype-raw": "^7.0.0",
     "rehype-raw": "^7.0.0",
     "rehype-sanitize": "^6.0.0",
     "rehype-sanitize": "^6.0.0",
     "rehype-slug": "^6.0.0",
     "rehype-slug": "^6.0.0",
     "rehype-toc": "^3.0.2",
     "rehype-toc": "^3.0.2",
     "remark-breaks": "^4.0.0",
     "remark-breaks": "^4.0.0",
-    "remark-emoji": "^5.0.0",
+    "remark-directive": "^3.0.0",
     "remark-frontmatter": "^5.0.0",
     "remark-frontmatter": "^5.0.0",
     "remark-gfm": "^4.0.0",
     "remark-gfm": "^4.0.0",
     "remark-math": "^6.0.0",
     "remark-math": "^6.0.0",
-    "remark-toc": "^9.0.0",
-    "remark-wiki-link": "^2.0.1",
+    "remark-parse": "^11.0.0",
+    "remark-rehype": "^11.1.1",
+    "remark-stringify": "^11.0.0",
     "sanitize-filename": "^1.6.3",
     "sanitize-filename": "^1.6.3",
     "socket.io": "^4.7.5",
     "socket.io": "^4.7.5",
     "stream-to-promise": "^3.0.0",
     "stream-to-promise": "^3.0.0",
@@ -207,12 +224,18 @@
     "swagger-jsdoc": "^6.2.8",
     "swagger-jsdoc": "^6.2.8",
     "swr": "^2.2.2",
     "swr": "^2.2.2",
     "throttle-debounce": "^5.0.0",
     "throttle-debounce": "^5.0.0",
+    "ts-deepmerge": "^6.2.0",
+    "tslib": "^2.8.0",
     "uglifycss": "^0.0.29",
     "uglifycss": "^0.0.29",
+    "uid-safe": "^2.1.5",
+    "unified": "^11.0.0",
+    "unist-util-visit": "^5.0.0",
     "universal-bunyan": "^0.9.2",
     "universal-bunyan": "^0.9.2",
     "unstated": "^2.1.1",
     "unstated": "^2.1.1",
     "unzip-stream": "^0.3.2",
     "unzip-stream": "^0.3.2",
     "url-join": "^4.0.0",
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
     "usehooks-ts": "^2.6.0",
+    "uuid": "^11.0.3",
     "validator": "^13.7.0",
     "validator": "^13.7.0",
     "ws": "^8.17.1",
     "ws": "^8.17.1",
     "xss": "^1.0.15",
     "xss": "^1.0.15",
@@ -227,11 +250,11 @@
     "mongodb": "mongoose which is used requires mongo@4.16.0."
     "mongodb": "mongoose which is used requires mongo@4.16.0."
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@growi/core-styles": "link:../../packages/core-styles",
-    "@growi/custom-icons": "link:../../packages/custom-icons",
-    "@growi/editor": "link:../../packages/editor",
-    "@growi/markdown-splitter": "link:../../packages/markdown-splitter",
-    "@growi/ui": "link:../../packages/ui",
+    "@emoji-mart/data": "^1.2.1",
+    "@growi/core-styles": "workspace:^",
+    "@growi/custom-icons": "workspace:^",
+    "@growi/editor": "workspace:^",
+    "@growi/ui": "workspace:^",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@next/bundle-analyzer": "^14.1.3",
     "@next/bundle-analyzer": "^14.1.3",
     "@popperjs/core": "^2.11.8",
     "@popperjs/core": "^2.11.8",
@@ -242,15 +265,22 @@
     "@testing-library/react": "^16.0.1",
     "@testing-library/react": "^16.0.1",
     "@testing-library/user-event": "^14.5.2",
     "@testing-library/user-event": "^14.5.2",
     "@types/express": "^4.17.21",
     "@types/express": "^4.17.21",
+    "@types/hast": "^3.0.4",
     "@types/jest": "^29.5.2",
     "@types/jest": "^29.5.2",
+    "@types/ldapjs": "^2.2.5",
+    "@types/mdast": "^4.0.4",
     "@types/node-cron": "^3.0.11",
     "@types/node-cron": "^3.0.11",
+    "@types/react": "^18.2.14",
+    "@types/react-dom": "^18.2.6",
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",
     "@types/react-stickynode": "^4.0.3",
     "@types/testing-library__dom": "^7.5.0",
     "@types/testing-library__dom": "^7.5.0",
     "@types/throttle-debounce": "^5.0.1",
     "@types/throttle-debounce": "^5.0.1",
+    "@types/unist": "^3.0.3",
     "@types/unzip-stream": "^0.3.4",
     "@types/unzip-stream": "^0.3.4",
     "@types/url-join": "^4.0.2",
     "@types/url-join": "^4.0.2",
+    "@types/uuid": "^10.0.0",
     "babel-loader": "^8.2.5",
     "babel-loader": "^8.2.5",
     "bootstrap": "=5.3.2",
     "bootstrap": "=5.3.2",
     "connect-browser-sync": "^2.1.0",
     "connect-browser-sync": "^2.1.0",
@@ -263,32 +293,31 @@
     "handsontable": "=6.2.2",
     "handsontable": "=6.2.2",
     "happy-dom": "^15.7.4",
     "happy-dom": "^15.7.4",
     "i18next-chained-backend": "^4.6.2",
     "i18next-chained-backend": "^4.6.2",
-    "i18next-hmr": "^3.0.4",
-    "i18next-http-backend": "^2.5.0",
+    "i18next-hmr": "^3.1.3",
+    "i18next-http-backend": "^2.6.2",
     "i18next-localstorage-backend": "^4.2.0",
     "i18next-localstorage-backend": "^4.2.0",
     "jest": "^29.5.0",
     "jest": "^29.5.0",
     "jest-date-mock": "^1.0.8",
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",
     "jest-localstorage-mock": "^2.4.14",
     "load-css-file": "^1.0.0",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
     "material-icons": "^1.11.3",
-    "mongodb": "4.16.0",
+    "mdast-util-directive": "^3.0.0",
+    "mdast-util-find-and-replace": "^3.0.1",
     "mongodb-memory-server-core": "^9.1.1",
     "mongodb-memory-server-core": "^9.1.1",
     "morgan": "^1.10.0",
     "morgan": "^1.10.0",
     "null-loader": "^4.0.1",
     "null-loader": "^4.0.1",
-    "plantuml-encoder": "^1.2.5",
     "pretty-bytes": "^6.1.1",
     "pretty-bytes": "^6.1.1",
     "react-copy-to-clipboard": "^5.0.1",
     "react-copy-to-clipboard": "^5.0.1",
     "react-dnd": "^14.0.5",
     "react-dnd": "^14.0.5",
     "react-dnd-html5-backend": "^14.1.0",
     "react-dnd-html5-backend": "^14.1.0",
     "react-dropzone": "^14.2.3",
     "react-dropzone": "^14.2.3",
+    "react-hook-form": "^7.45.4",
     "react-hotkeys": "^2.0.0",
     "react-hotkeys": "^2.0.0",
     "react-input-autosize": "^3.0.0",
     "react-input-autosize": "^3.0.0",
     "react-toastify": "^9.1.3",
     "react-toastify": "^9.1.3",
     "rehype-rewrite": "^4.0.2",
     "rehype-rewrite": "^4.0.2",
     "remark-github-admonitions-to-directives": "^2.0.0",
     "remark-github-admonitions-to-directives": "^2.0.0",
-    "replacestream": "^4.0.3",
     "sass": "^1.53.0",
     "sass": "^1.53.0",
-    "simple-load-script": "^1.0.2",
     "simplebar-react": "^2.3.6",
     "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.7.5",
     "socket.io-client": "^4.7.5",
     "source-map-loader": "^4.0.1",
     "source-map-loader": "^4.0.1",

+ 7 - 2
apps/app/playwright.config.ts

@@ -48,10 +48,15 @@ export default defineConfig({
   /* Opt out of parallel tests on CI. */
   /* Opt out of parallel tests on CI. */
   workers: process.env.CI ? 1 : undefined,
   workers: process.env.CI ? 1 : undefined,
   /* Reporter to use. See https://playwright.dev/docs/test-reporters */
   /* Reporter to use. See https://playwright.dev/docs/test-reporters */
-  reporter: process.env.CI ? 'github' : 'list',
+  reporter: process.env.CI
+    ? [
+      ['github'],
+      ['blob'],
+    ]
+    : 'list',
 
 
   webServer: {
   webServer: {
-    command: 'yarn server',
+    command: 'pnpm run server',
     url: 'http://localhost:3000',
     url: 'http://localhost:3000',
     reuseExistingServer: !process.env.CI,
     reuseExistingServer: !process.env.CI,
     stdout: 'ignore',
     stdout: 'ignore',

+ 1 - 1
apps/app/playwright/utils/Login.ts

@@ -8,7 +8,7 @@ export const login = async(page: Page): Promise<void> => {
   // Perform authentication steps. Replace these actions with your own.
   // Perform authentication steps. Replace these actions with your own.
   await page.goto('/admin');
   await page.goto('/admin');
 
 
-  const loginForm = await page.$('form#login-form');
+  const loginForm = await page.getByRole('form');
 
 
   if (loginForm != null) {
   if (loginForm != null) {
     await page.getByLabel('Username or E-mail').fill('admin');
     await page.getByLabel('Username or E-mail').fill('admin');

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

@@ -30,7 +30,7 @@
   "Tags": "Tags",
   "Tags": "Tags",
   "Close": "Close",
   "Close": "Close",
   "Shortcuts": "Shortcuts",
   "Shortcuts": "Shortcuts",
-  "CustomSidebar": "Custom Sidebar",
+  "Custom Sidebar": "Custom Sidebar",
   "eg": "e.g.",
   "eg": "e.g.",
   "add": "Add",
   "add": "Add",
   "Undo": "Undo",
   "Undo": "Undo",
@@ -161,6 +161,7 @@
   "not_allowed_to_see_this_page": "You cannot see this page",
   "not_allowed_to_see_this_page": "You cannot see this page",
   "Confirm": "Confirm",
   "Confirm": "Confirm",
   "Successfully requested": "Successfully requested.",
   "Successfully requested": "Successfully requested.",
+  "source": "Source",
   "input_validation": {
   "input_validation": {
     "target": {
     "target": {
       "page_name": "Page name",
       "page_name": "Page name",
@@ -169,8 +170,8 @@
     },
     },
     "message": {
     "message": {
       "error_message": "Some values ​​are incorrect",
       "error_message": "Some values ​​are incorrect",
-      "required": "%s is required",
-      "invalid_syntax": "The syntax of %s is invalid.",
+      "required": "'{{param}}' is required",
+      "invalid_syntax": "The syntax of {{syntax}} is invalid.",
       "title_required": "Title is required.",
       "title_required": "Title is required.",
       "field_required": "{{target}} is required"
       "field_required": "{{target}} is required"
     }
     }
@@ -489,6 +490,8 @@
     "title": "Knowledge Assistant",
     "title": "Knowledge Assistant",
     "title_beta_label": "(Beta)",
     "title_beta_label": "(Beta)",
     "placeholder": "Ask me anything.",
     "placeholder": "Ask me anything.",
+    "summary_mode_label": "Summary mode",
+    "summary_mode_help": "Concise answer within 2-3 sentences",
     "caution_against_hallucination": "Please verify the information and check the sources.",
     "caution_against_hallucination": "Please verify the information and check the sources.",
     "progress_label": "Generating answers",
     "progress_label": "Generating answers",
     "failed_to_create_or_retrieve_thread": "Failed to create or retrieve thread",
     "failed_to_create_or_retrieve_thread": "Failed to create or retrieve thread",

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

@@ -30,7 +30,7 @@
   "Tags": "Étiquettes",
   "Tags": "Étiquettes",
   "Close": "Fermer",
   "Close": "Fermer",
   "Shortcuts": "Raccourcis",
   "Shortcuts": "Raccourcis",
-  "CustomSidebar": "Navigation latérale",
+  "Custom Sidebar": "Navigation latérale",
   "eg": "e.g.",
   "eg": "e.g.",
   "add": "Ajouter",
   "add": "Ajouter",
   "Undo": "Annuler",
   "Undo": "Annuler",
@@ -161,6 +161,7 @@
   "not_allowed_to_see_this_page": "Vous ne pouvez pas voir cette page",
   "not_allowed_to_see_this_page": "Vous ne pouvez pas voir cette page",
   "Confirm": "Confirmer",
   "Confirm": "Confirmer",
   "Successfully requested": "Demande envoyée.",
   "Successfully requested": "Demande envoyée.",
+  "source": "Source",
   "input_validation": {
   "input_validation": {
     "target": {
     "target": {
       "page_name": "Nom de la page",
       "page_name": "Nom de la page",
@@ -169,8 +170,8 @@
     },
     },
     "message": {
     "message": {
       "error_message": "Des champs sont invalides",
       "error_message": "Des champs sont invalides",
-      "required": "%s est requis",
-      "invalid_syntax": "La syntaxe de %s est invalide.",
+      "required": "'{{param}}' est requis",
+      "invalid_syntax": "La syntaxe de {{syntax}} est invalide.",
       "title_required": "Titre requis.",
       "title_required": "Titre requis.",
       "field_required": "{{target}} est requis"
       "field_required": "{{target}} est requis"
     }
     }
@@ -483,6 +484,8 @@
     "title": "Assistant de Connaissance",
     "title": "Assistant de Connaissance",
     "title_beta_label": "(Bêta)",
     "title_beta_label": "(Bêta)",
     "placeholder": "Demandez-moi n'importe quoi.",
     "placeholder": "Demandez-moi n'importe quoi.",
+    "summary_mode_label": "Mode résumé",
+    "summary_mode_help": "Réponse concise en 2-3 phrases",
     "caution_against_hallucination": "Veuillez vérifier les informations et consulter les sources.",
     "caution_against_hallucination": "Veuillez vérifier les informations et consulter les sources.",
     "progress_label": "Génération des réponses",
     "progress_label": "Génération des réponses",
     "failed_to_create_or_retrieve_thread": "Échec de la création ou de la récupération du fil de discussion",
     "failed_to_create_or_retrieve_thread": "Échec de la création ou de la récupération du fil de discussion",

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

@@ -30,7 +30,7 @@
   "Tags": "タグ",
   "Tags": "タグ",
   "Close": "閉じる",
   "Close": "閉じる",
   "Shortcuts": "ショートカット",
   "Shortcuts": "ショートカット",
-  "CustomSidebar": "カスタムサイドバー",
+  "Custom Sidebar": "カスタムサイドバー",
   "eg": "例:",
   "eg": "例:",
   "add": "追加",
   "add": "追加",
   "Undo": "元に戻す",
   "Undo": "元に戻す",
@@ -162,6 +162,7 @@
   "not_allowed_to_see_this_page": "このページは閲覧できません",
   "not_allowed_to_see_this_page": "このページは閲覧できません",
   "Confirm": "確認",
   "Confirm": "確認",
   "Successfully requested": "正常に処理を受け付けました",
   "Successfully requested": "正常に処理を受け付けました",
+  "source": "出典",
   "input_validation": {
   "input_validation": {
     "target": {
     "target": {
       "page_name": "ページ名",
       "page_name": "ページ名",
@@ -170,8 +171,8 @@
     },
     },
     "message": {
     "message": {
       "error_message": "いくつかの値が設定されていません",
       "error_message": "いくつかの値が設定されていません",
-      "required": "%sに値を入力してください",
-      "invalid_syntax": "%sの構文が不正です",
+      "required": "'{{param}}' に値を入力してください",
+      "invalid_syntax": "{{syntax}} の構文が不正です",
       "title_required": "タイトルを入力してください",
       "title_required": "タイトルを入力してください",
       "field_required": "{{target}}に値を入力してください"
       "field_required": "{{target}}に値を入力してください"
     }
     }
@@ -522,6 +523,8 @@
     "title": "ナレッジアシスタント",
     "title": "ナレッジアシスタント",
     "title_beta_label": "(ベータ)",
     "title_beta_label": "(ベータ)",
     "placeholder": "ききたいことを入力してください",
     "placeholder": "ききたいことを入力してください",
+    "summary_mode_label": "要約モード",
+    "summary_mode_help": "2~3文以内の簡潔な回答",
     "caution_against_hallucination": "情報が正しいか出典を確認しましょう",
     "caution_against_hallucination": "情報が正しいか出典を確認しましょう",
     "progress_label": "回答を生成しています",
     "progress_label": "回答を生成しています",
     "failed_to_create_or_retrieve_thread": "スレッドの作成または取得に失敗しました",
     "failed_to_create_or_retrieve_thread": "スレッドの作成または取得に失敗しました",

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

@@ -30,7 +30,7 @@
   "Tags": "标签",
   "Tags": "标签",
   "Close": "Close",
   "Close": "Close",
   "Shortcuts": "快捷方式",
   "Shortcuts": "快捷方式",
-  "CustomSidebar": "Custom Sidebar",
+  "Custom Sidebar": "Custom Sidebar",
   "eg": "e.g.",
   "eg": "e.g.",
   "add": "添加",
   "add": "添加",
   "Undo": "撤销",
   "Undo": "撤销",
@@ -168,6 +168,7 @@
   "Confirm": "确定",
   "Confirm": "确定",
   "Successfully requested": "进程成功接受",
   "Successfully requested": "进程成功接受",
   "copied_to_clipboard": "它已复制到剪贴板。",
   "copied_to_clipboard": "它已复制到剪贴板。",
+  "source": "消息来源",
   "input_validation": {
   "input_validation": {
     "target": {
     "target": {
       "page_name": "页面名称",
       "page_name": "页面名称",
@@ -176,8 +177,8 @@
     },
     },
     "message": {
     "message": {
       "error_message": "有些值不正确",
       "error_message": "有些值不正确",
-      "required": "%s 是必需的",
-      "invalid_syntax": "%s的语法无效。",
+      "required": "'{{param}}' 是必需的",
+      "invalid_syntax": "{{syntax}} 的语法无效。",
       "title_required": "标题是必需的。",
       "title_required": "标题是必需的。",
       "field_required": "{{target}} 是必需的"
       "field_required": "{{target}} 是必需的"
     }
     }
@@ -478,6 +479,8 @@
     "title": "知识助手",
     "title": "知识助手",
     "title_beta_label": "(测试版)",
     "title_beta_label": "(测试版)",
     "placeholder": "问我任何问题。",
     "placeholder": "问我任何问题。",
+    "summary_mode_label": "摘要模式",
+    "summary_mode_help": "简洁回答在2-3句话内",
     "caution_against_hallucination": "请核实信息并检查来源。",
     "caution_against_hallucination": "请核实信息并检查来源。",
     "progress_label": "生成答案中",
     "progress_label": "生成答案中",
     "failed_to_create_or_retrieve_thread": "创建或获取线程失败",
     "failed_to_create_or_retrieve_thread": "创建或获取线程失败",

BIN
apps/app/resource/fonts/MaterialSymbolsOutlined-opsz,wght,FILL@20..48,300,0..1.woff2


BIN
apps/app/resource/fonts/PressStart2P-latin.woff2


BIN
apps/app/resource/fonts/SourceHanCodeJP-Regular-subset-jis2.woff2


BIN
apps/app/resource/fonts/SourceHanCodeJP-Regular-subset-main.woff2


+ 3 - 3
apps/app/resource/locales/en_US/sandbox-diagrams.md

@@ -1,4 +1,4 @@
-# :pencil: diagrams.net(Draw.io)
+# :pencil2: diagrams.net(Draw.io)
 
 
 See [diagrams.net](https://diagrams.net)
 See [diagrams.net](https://diagrams.net)
 
 
@@ -23,7 +23,7 @@ See [diagrams.net](https://diagrams.net)
 
 
 
 
 
 
-# :pencil: PlantUML
+# :pencil2: PlantUML
 
 
 See [PlantUML](http://plantuml.com/).
 See [PlantUML](http://plantuml.com/).
 
 
@@ -151,7 +151,7 @@ State3 --> [*] : Aborted
 
 
 
 
 
 
-# :pencil: Mermaid
+# :pencil2: Mermaid
 
 
 ## Pie graph
 ## Pie graph
 
 

+ 247 - 0
apps/app/resource/locales/en_US/sandbox-markdown.md

@@ -0,0 +1,247 @@
+# Alerts
+
+> [!NOTE]
+> Useful information that users should know, even when skimming content.
+
+> [!TIP]
+> Helpful advice for doing things better or more easily.
+
+> [!IMPORTANT]
+> Key information users need to know to achieve their goal.
+
+> [!WARNING]
+> Urgent info that needs immediate user attention to avoid problems.
+
+> [!CAUTION]
+> Advises about risks or negative outcomes of certain actions.
+
+
+```markdown
+> [!NOTE]
+> Useful information that users should know, even when skimming content.
+
+> [!TIP]
+> Helpful advice for doing things better or more easily.
+
+> [!IMPORTANT]
+> Key information users need to know to achieve their goal.
+
+> [!WARNING]
+> Urgent info that needs immediate user attention to avoid problems.
+
+> [!CAUTION]
+> Advises about risks or negative outcomes of certain actions.
+```
+
+You can also use [directive syntax](https://talk.commonmark.org/t/generic-directives-plugins-syntax/444).
+
+:::note
+Useful information that users should know, even when skimming content.
+:::
+
+:::tip[Custom Label]
+Useful information that users should know, even when skimming content.
+:::
+
+```markdown
+:::note
+Useful information that users should know, even when skimming content.
+:::
+
+:::tip[Custom Label]
+Useful information that users should know, even when skimming content.
+:::
+```
+
+
+# Quote text
+- Use quoted expressions by putting `>` at the beginning of the paragraph
+    - Multiple quotations can be expressed by using a sequence of `>` characters
+- Lists and other elements can be used together within the blockquotes
+
+#### Example
+> - Quotation
+> - Quotation
+>> Multiple quotations need to insert more `>`
+
+```markdown
+> - Quotation
+> - Quotation
+>> Multiple quotations need to insert more `>`
+```
+
+
+# Code
+- It is possible to express the code by adding it in three `` ` ``
+
+#### Example
+
+```markdown
+Add codes here  
+
+Line breaks and paragraphs can be reflected in the code as-is
+```
+
+#### Example (source code)
+
+```javascript:mersenne-twister.js
+function MersenneTwister(seed) {
+  if (arguments.length == 0) {
+    seed = new Date().getTime();
+  }
+
+  this._mt = new Array(624);
+  this.setSeed(seed);
+}
+```
+
+## Inline Code
+- Enclose words in `` ` `` to make inline code
+
+#### Example
+Here is the `inline code` 
+
+
+
+# Task List
+- Insert an unchecked checkbox list by writing `[] `
+    - Check the checkbox by writing `[x]`
+
+#### Example
+- [ ] Task 1
+    - [x] Task 1-1
+    - [ ] Task 1-2
+- [x] Task 2
+
+
+# Horizontal lines
+- Insert the horizontal line with three or more consecutive asterisks `*` or underscores `_`
+
+#### Example
+Below is a horizontal line
+***
+
+Below is a horizontal line
+___
+
+```markdown
+Below is a horizontal line
+***
+
+Below is a horizontal line
+___
+```
+
+
+# Footnotes
+
+You can add footnotes to your content by using this bracket syntax:
+
+Here is a simple footnote[^1].
+
+A footnote can also have multiple lines[^2].
+
+[^1]: My reference.
+[^2]: To add line breaks within a footnote, prefix new lines with 2 spaces.
+  This is a second line.
+
+```markdown
+Here is a simple footnote[^1].
+
+A footnote can also have multiple lines[^2].
+
+[^1]: My reference.
+[^2]: To add line breaks within a footnote, prefix new lines with 2 spaces.
+  This is a second line.
+```
+
+
+# emoji
+
+You can add emojis to your text by typing the emoji name after a colon `:`.
+
+- :+1: GOOD!
+- :white_check_mark: Check
+- :lock: Lock
+
+When you type two or more characters after the colon, an emoji suggestion list will appear. This list will narrow down as you continue typing. Once you find the emoji you are looking for, press Tab or Enter to insert the highlighted emoji.
+
+For a list of available emojis, refer to the "[Emoji Cheat Sheet](https://github.com/ikatyang/emoji-cheat-sheet/blob/master/README.md)".
+
+
+# Table
+### General syntax
+#### Example
+
+| Left align | Right align | Center align |
+|:-----------|------------:|:------------:|
+| This       | This        | This         |
+| column     | column      | column       |
+| will       | will        | will         |
+| be         | be          | be           |
+| left       | right       | center       |
+| aligned    | aligned     | aligned      |
+
+```markdown
+| Left align | Right align | Center align |
+|:-----------|------------:|:------------:|
+| This       | This        | This         |
+| column     | column      | column       |
+| will       | will        | will         |
+| be         | be          | be           |
+| left       | right       | center       |
+| aligned    | aligned     | aligned      |
+```
+
+### CSV / TSV
+
+#### Example
+
+``` tsv
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
+
+~~~
+``` csv
+Content Cell,Content Cell
+Content Cell,Content Cell
+```
+~~~
+
+~~~
+``` tsv
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
+~~~
+
+
+### CSV / TSV (with header)
+
+
+#### Example
+
+``` tsv-h
+First Header	Second Header
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
+
+~~~
+``` csv-h
+First Header,Second Header
+Content Cell,Content Cell
+Content Cell,Content Cell
+```
+~~~
+
+~~~
+``` tsv-h
+First Header	Second Header
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
+~~~
+
+

+ 1 - 1
apps/app/resource/locales/en_US/sandbox-math.md

@@ -1,4 +1,4 @@
-# :pencil: Math
+# :pencil2: Math
 
 
 See [KaTeX](https://katex.org/).
 See [KaTeX](https://katex.org/).
 
 

+ 96 - 77
apps/app/resource/locales/en_US/sandbox.md

@@ -1,18 +1,50 @@
-# What is Sandbox?
-- In this page, you will find tips that help you to master GROWI 
-- Feel free to enrich the content of your pages with the references under this hierarchy
+# Welcome to the GROWI Sandbox!
 
 
+> [!NOTE]
+> **What is a Sandbox?**
+> 
+> This is a practice page that you can freely edit. It's the perfect place to try new things!
 
 
-# :closed_book:Headings & Paragraphs
+
+## :beginner: For Beginners
+
+With GROWI, you can easily create visually appealing pages using a notation called "Markdown".  
+By using Markdown, you can do things like this!
+
+- Emphasize text with **bold** or *italic*
+- Create bulleted or numbered lists
+- [Insert links](#-link)
+- Create tables
+- Add code blocks
+
+Various other decorations are also possible.
+
+## Let's Try It!
+
+1. Feel free to edit this page
+1. There's no need to fear making mistakes
+1. You can always revert changes
+1. You can also learn from others' edits
+
+> [!IMPORTANT]
+> **For Administrators**
+> 
+> The sandbox is an important place for learning:
+> - As a first step for new members to get used to GROWI
+> - As a practice ground for Markdown
+> - As a communication tool within the team
+>     - Even if this page becomes cluttered, it is a sign of active learning. Regular cleanups are good, but it is recommended to maintain its nature as a free experimentation space.
+
+
+# :closed_book: Headings & Paragraphs
 - By inserting headings and paragraphs, you can make the text on the page easier to read
 - By inserting headings and paragraphs, you can make the text on the page easier to read
 
 
 ## Headers
 ## Headers
 - Add `#` before the heading text to create a heading 
 - Add `#` before the heading text to create a heading 
     - Depending on the number of `#`, the typeface size of headings would be different shown in the View screen 
     - Depending on the number of `#`, the typeface size of headings would be different shown in the View screen 
-    - Check the View screen on the right side to understand the effect of headings
 - The number of `#` will decide the hierarchy level and help you to organize the contents
 - The number of `#` will decide the hierarchy level and help you to organize the contents
 
 
-```
+```markdown
 # First-level heading
 # First-level heading
 ## Second-level heading
 ## Second-level heading
 ### Third-level heading
 ### Third-level heading
@@ -26,55 +58,44 @@
     - You can also change this in the Setting to break the line without half-width spaces
     - You can also change this in the Setting to break the line without half-width spaces
         - Change the line break setting in the `Markdown Settings` sector of the admin page
         - Change the line break setting in the `Markdown Settings` sector of the admin page
 
 
-#### Without line break
+#### Example: Without line break
 Paragraph 1
 Paragraph 1
 Paragraph 2
 Paragraph 2
 
 
-#### With line break
+#### Example: With line break
 Paragraph 1  
 Paragraph 1  
 Paragraph 2
 Paragraph 2
 
 
 ## Block
 ## Block
-- Paragraphs can be created by inserting a blank table in the text
+- Paragraphs can be created by inserting a blank line in the text
 - Passage can be broken into sentences and make them easier to read
 - Passage can be broken into sentences and make them easier to read
 
 
-#### Without paragraph
-Paragraph 1  
-Paragraph 2
-
-#### With paragraph
-Paragraph 1  
-
-Paragraph 2
-
+#### Example: Without paragraph
+Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
 
 
-# :green_book: Styling Text
-- Various styles can be applied to enrich the textual expression of a sentence
-    - These styles also can be easily applied by selecting the toolbar icon at the bottom of the Edit screen
-
-## Italic
-- Enclose the text with an asterisk `*` or an underscore `_`.
+#### Example: With paragraph
+Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
 
 
-#### Examples
-- This sentence indicates emphasis with *Italic*
-- This sentence indicates emphasis with _Italic_ 
+Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
 
 
-## Bold
-- Enclose the text with two asterisks `*` or two underscores `_`
 
 
-#### Example
-- This sentence indicates emphasis with **Bold** 
-- This sentence indicates emphasis with __Bold__
+# :blue_book: Styling Text
 
 
-## Italic & Bold
-- Enclose the text with three asterisks `*` or three underscores `_`
+- Various styles can be applied to enrich the textual expression of a sentence
+    - These styles can also be easily applied by selecting the toolbar icon at the bottom of the Edit screen
 
 
-#### Example
-- This sentence indicates emphasis with ***Italic & Bold***
-- This sentence indicates emphasis witH ___Italic & Bold___
+| Style                     | Syntax                 | Keyboard Shortcut | Example                                   | Output                                 |
+| ------------------------- | ---------------------- | ----------------- | ----------------------------------------- | -------------------------------------- |
+| Bold                      | `** **` or `__ __`     | (TBD)             | `**This is bold text**`                   | **This is bold text**                  |
+| Italic                    | `* *` or `_ _`         | (TBD)             | `_This text is italicized_`               | *This text is italicized*              |
+| Strikethrough             | `~~ ~~`                | (TBD)             | `~~This was mistaken text~~`             | ~~This was  mistaken text~~            |
+| Bold and nested italic | `** **` and `_ _`     | None              | `**This text is _extremely_ important**`  | **This text is _extremely_ important** |
+| All Bold and Italic   | `*** ***`              | None              | `***All this text is important***`       | ***All this text is important***      |
+| Subscript                 | `<sub> </sub>`         | None              | `This is a <sub>subscript</sub> text`       | This is a <sub>subscript</sub> text      |
+| Superscript               | `<sup> </sup>`         | None              | `This is a <sup>superscript</sup> text`     | This is a <sup>superscript</sup> text    |
 
 
 
 
-# :orange_book: Insert Lists
+# :green_book: Insert Lists
 ## Bulleted List
 ## Bulleted List
 - Insert a bulleted list by starting a line with a hyphen `-`, a plus `+`, or an asterisk `*`
 - Insert a bulleted list by starting a line with a hyphen `-`, a plus `+`, or an asterisk `*`
 
 
@@ -88,6 +109,8 @@ Paragraph 2
 
 
 ## Numbered List
 ## Numbered List
 - `Number.` at the beginning of a line to insert a numbered list
 - `Number.` at the beginning of a line to insert a numbered list
+    - Numbers are automatically assigned
+
 - Numbered list and bulleted list can also be combined for use
 - Numbered list and bulleted list can also be combined for use
 
 
 #### Example
 #### Example
@@ -99,60 +122,56 @@ Paragraph 2
 1. This sentence is present in the bulleted list
 1. This sentence is present in the bulleted list
     - This sentence is present in the bulleted list
     - This sentence is present in the bulleted list
 
 
-## Task List
-- Insert an unchecked checkbox list by writing `[] `
-    - Check the checkbox by writing `[x]`
 
 
-#### Example
-- [ ] Task 1
-    - [x] Task 1-1
-    - [ ] Task 1-2
-- [x] Task 2
+# :ledger: Link
 
 
+## Auto link
+Just write the URL and the link will be generated automatically.
 
 
-# :blue_book: Others
-## Blockquotes
-- Use quoted expressions by putting `>` at the beginning of the paragraph
-    - Multiple quotations can be expressed by using a sequence of `>` characters
-- Lists and other elements can be used together within the blockquotes
+### Example
 
 
-#### Example
-> - Quotation
-> - Quotation
->> Multiple quotations need to insert more `>`
+https://www.google.co.jp
 
 
-## Code
-- It is possible to express the code by adding it in three `` ` ``
+```markdown
+https://www.google.co.jp
+```
 
 
-#### Example
+## Label and link
+Insert a link by writing `[label](URL)`
+
+### Example
+- [Google](https://www.google.co.jp/)
+- [Sandbox is here](/Sandbox)
+
+```markdown
+- [Google](https://www.google.co.jp/)
+- [Sandbox is here](/Sandbox)
 ```
 ```
-Add codes here  
-Line breaks and paragraphs can be reflected in the code
 
 
-- List also can be used in code
-    - List also can be used in code
+## Flexible link syntax
+
+Flexible link syntax make it easy to write a link by page path, a relative page link and link label and URL.
+
+- [[/Sandbox]]
+- [[./Math]]
+- [[How to write formulas?>./Math]]
+
+```markdown
+- [[/Sandbox]]
+- [[./Math]]
+- [[How to write formulas?>./Math]]
 ```
 ```
 
 
-## Inline Code
-- Enclose words in `` ` `` to make inline code
 
 
-#### Example
-Here is the `inline code` 
+# :notebook: More Applications
 
 
-## Horizontal lines
-- Insert the horizontal line with three or more consecutive asterisks `*` or underscores `_`
+- [Learn more about Markdown](/Sandbox/Markdown)
 
 
-#### Example
-Below is a horizontal line
-***
+- [Further decorate your page (Bootstrap5)](/Sandbox/Bootstrap5)
 
 
-Below is a horizontal line
-___
+- [How to represent diagrams (Diagrams)](/Sandbox/Diagrams)
 
 
+- [How to represent mathematical formulas (Math)](/Sandbox/Math)
 
 
-# :ledger: More Applications
-- [Bootstrap5](/Sandbox/Bootstrap5)
 
 
-- [Diagrams](/Sandbox/Diagrams)
 
 
-- [Math](/Sandbox/Math)

+ 3 - 3
apps/app/resource/locales/fr_FR/sandbox-diagrams.md

@@ -1,4 +1,4 @@
-# :pencil: diagrams.net(Draw.io)
+# :pencil2: diagrams.net(Draw.io)
 
 
 See [diagrams.net](https://diagrams.net)
 See [diagrams.net](https://diagrams.net)
 
 
@@ -23,7 +23,7 @@ See [diagrams.net](https://diagrams.net)
 
 
 
 
 
 
-# :pencil: PlantUML
+# :pencil2: PlantUML
 
 
 See [PlantUML](http://plantuml.com/).
 See [PlantUML](http://plantuml.com/).
 
 
@@ -151,7 +151,7 @@ State3 --> [*] : Aborted
 
 
 
 
 
 
-# :pencil: Mermaid
+# :pencil2: Mermaid
 
 
 ## Pie graph
 ## Pie graph
 
 

+ 246 - 0
apps/app/resource/locales/fr_FR/sandbox-markdown.md

@@ -0,0 +1,246 @@
+# Alerts
+
+> [!NOTE]
+> Useful information that users should know, even when skimming content.
+
+> [!TIP]
+> Helpful advice for doing things better or more easily.
+
+> [!IMPORTANT]
+> Key information users need to know to achieve their goal.
+
+> [!WARNING]
+> Urgent info that needs immediate user attention to avoid problems.
+
+> [!CAUTION]
+> Advises about risks or negative outcomes of certain actions.
+
+
+```markdown
+> [!NOTE]
+> Useful information that users should know, even when skimming content.
+
+> [!TIP]
+> Helpful advice for doing things better or more easily.
+
+> [!IMPORTANT]
+> Key information users need to know to achieve their goal.
+
+> [!WARNING]
+> Urgent info that needs immediate user attention to avoid problems.
+
+> [!CAUTION]
+> Advises about risks or negative outcomes of certain actions.
+```
+
+Vous pouvez également utiliser la [syntaxe de directive](https://talk.commonmark.org/t/generic-directives-plugins-syntax/444).
+
+:::note
+Useful information that users should know, even when skimming content.
+:::
+
+:::tip[Custom Label]
+Useful information that users should know, even when skimming content.
+:::
+
+```markdown
+:::note
+Useful information that users should know, even when skimming content.
+:::
+
+:::tip[Custom Label]
+Useful information that users should know, even when skimming content.
+:::
+```
+
+
+# Autres
+## Citations
+- Utilisez des expressions entre guillemets en mettant `>` au début du paragraphe
+    - Plusieurs citations peuvent être exprimées en utilisant une séquence de caractères `>`
+- Des listes et d'autres éléments peuvent être utilisés ensemble dans les citations
+
+#### Exemple
+> - Citation
+> - Citation
+>> Plusieurs citations doivent insérer plus de `>`
+
+```markdown
+> - Citation
+> - Citation
+>> Plusieurs citations doivent insérer plus de `>`
+```
+
+# Code
+- Il est possible d'exprimer le code en l'ajoutant en trois `` ` ``
+
+#### Exemple
+
+```markdown
+Ajoutez des codes ici
+
+Les sauts de ligne et les paragraphes peuvent être reflétés dans le code tel quel
+```
+
+#### Exemple (code source)
+
+```javascript:mersenne-twister.js
+function MersenneTwister(seed) {
+  if (arguments.length == 0) {
+    seed = new Date().getTime();
+  }
+
+  this._mt = new Array(624);
+  this.setSeed(seed);
+}
+```
+
+## Code en ligne
+- Entourez les mots de `` ` `` pour créer du code en ligne
+
+#### Exemple
+Voici le `code en ligne`
+
+
+
+# Liste des tâches
+- Insérer une liste de cases à cocher non cochées en écrivant `[]`
+    - Cocher la case à cocher en écrivant `[x]`
+
+#### Exemple
+- [ ] Tâche 1
+    - [x] Tâche 1-1
+    - [ ] Tâche 1-2
+- [x] Tâche 2
+
+
+# Lignes horizontales
+- Insérer la ligne horizontale avec trois astérisques consécutifs ou plus `*` ou des traits de soulignement `_`
+
+#### Exemple
+Ci-dessous se trouve une ligne horizontale
+***
+
+Ci-dessous se trouve une ligne horizontale
+___
+
+```markdown
+Ci-dessous se trouve une ligne horizontale
+***
+
+Ci-dessous se trouve une ligne horizontale
+___
+```
+
+
+# Footnotes
+
+You can add footnotes to your content by using this bracket syntax:
+
+Here is a simple footnote[^1].
+
+A footnote can also have multiple lines[^2].
+
+[^1]: My reference.
+[^2]: To add line breaks within a footnote, prefix new lines with 2 spaces.
+  This is a second line.
+
+```markdown
+Here is a simple footnote[^1].
+
+A footnote can also have multiple lines[^2].
+
+[^1]: My reference.
+[^2]: To add line breaks within a footnote, prefix new lines with 2 spaces.
+  This is a second line.
+```
+
+
+# emoji
+
+Vous pouvez ajouter des emojis à votre texte en tapant le nom de l'emoji après un deux-points `:`.
+
+- :+1: BON!
+- :white_check_mark: Vérifié
+- :lock: Verrouillé
+
+Lorsque vous tapez deux caractères ou plus après le deux-points, une liste de suggestions d'emojis apparaîtra. Cette liste se réduira au fur et à mesure que vous continuez à taper. Une fois que vous avez trouvé l'emoji que vous recherchez, appuyez sur Tab ou Entrée pour insérer l'emoji sélectionné.
+
+Pour une liste des emojis disponibles, consultez le "[Emoji Cheat Sheet](https://github.com/ikatyang/emoji-cheat-sheet/blob/master/README.md)".
+
+
+## Tableau
+
+### Syntaxe générale
+
+#### Exemple
+
+| Left align | Right align | Center align |
+|:-----------|------------:|:------------:|
+| This       | This        | This         |
+| column     | column      | column       |
+| will       | will        | will         |
+| be         | be          | be           |
+| left       | right       | center       |
+| aligned    | aligned     | aligned      |
+
+```markdown
+| Left align | Right align | Center align |
+|:-----------|------------:|:------------:|
+| This       | This        | This         |
+| column     | column      | column       |
+| will       | will        | will         |
+| be         | be          | be           |
+| left       | right       | center       |
+| aligned    | aligned     | aligned      |
+```
+
+### CSV / TSV
+
+#### Exemple
+
+``` tsv
+Cellule de contenu Cellule de contenu
+Cellule de contenu Cellule de contenu
+```
+
+~~~
+``` csv
+Cellule de contenu,Cellule de contenu
+Cellule de contenu,Cellule de contenu
+```
+~~~
+
+~~~
+``` tsv
+Cellule de contenu Cellule de contenu
+Cellule de contenu Cellule de contenu
+```
+~~~
+
+### CSV / TSV (avec en-tête)
+
+#### Exemple
+
+``` tsv-h
+Premier en-tête Deuxième en-tête
+Cellule de contenu Cellule de contenu
+Cellule de contenu Cellule de contenu
+```
+
+~~~
+``` csv-h
+Premier en-tête Deuxième en-tête
+Cellule de contenu,Cellule de contenu
+Cellule de contenu,Cellule de contenu
+```
+~~~
+
+~~~
+``` tsv-h
+Premier en-tête Deuxième en-tête
+Cellule de contenu Cellule de contenu
+Cellule de contenu Contenu Cellule
+```
+~~~
+

+ 1 - 1
apps/app/resource/locales/fr_FR/sandbox-math.md

@@ -1,4 +1,4 @@
-# :pencil: Math
+# :pencil2: Math
 
 
 See [KaTeX](https://katex.org/).
 See [KaTeX](https://katex.org/).
 
 

+ 129 - 115
apps/app/resource/locales/fr_FR/sandbox.md

@@ -1,158 +1,172 @@
-# What is Sandbox?
-- In this page, you will find tips that help you to master GROWI 
-- Feel free to enrich the content of your pages with the references under this hierarchy
+# Bienvenue dans le bac à sable GROWI !
 
 
+> [!NOTE]
+> **Qu'est-ce qu'un bac à sable ?**
+> 
+> Ceci est une page de pratique que vous pouvez éditer librement. C'est l'endroit idéal pour essayer de nouvelles choses !
 
 
-# :closed_book:Headings & Paragraphs
-- By inserting headings and paragraphs, you can make the text on the page easier to read
 
 
-## Headers
-- Add `#` before the heading text to create a heading 
-    - Depending on the number of `#`, the typeface size of headings would be different shown in the View screen 
-    - Check the View screen on the right side to understand the effect of headings
-- The number of `#` will decide the hierarchy level and help you to organize the contents
+## :beginner: Pour les débutants
 
 
-```
-# First-level heading
-## Second-level heading
-### Third-level heading
-#### Forth-level heading
-##### Fifth-level heading
-###### Sixth-level heading
-```
+Avec GROWI, vous pouvez facilement créer des pages visuellement attrayantes en utilisant une notation appelée "Markdown".  
+En utilisant Markdown, vous pouvez faire des choses comme ça !
+
+- Mettre en évidence du texte avec du **gras** ou de l'*italique*
+- Créer des listes à puces ou numérotées
+- [Insérer des liens](#-lien)
+- Créer des tableaux
+- Ajouter des blocs de code
+
+Diverses autres décorations sont également possibles.
+
+## Essayons-le !
 
 
-## Break
-- Insert two half-width spaces at the end of the sentence you want to break
-    - You can also change this in the Setting to break the line without half-width spaces
-        - Change the line break setting in the `Markdown Settings` sector of the admin page
+1. N'hésitez pas à éditer cette page
+1. Il n'y a pas besoin de craindre de faire des erreurs
+1. Vous pouvez toujours revenir en arrière sur les modifications
+1. Vous pouvez également apprendre des modifications des autres
 
 
-#### Without line break
-Paragraph 1
-Paragraph 2
+> [!IMPORTANT]
+> **Pour les administrateurs**
+> 
+> Le bac à sable est un lieu important pour l'apprentissage :
+> - Comme première étape pour que les nouveaux membres s'habituent à GROWI
+> - Comme terrain de pratique pour Markdown
+> - Comme outil de communication au sein de l'équipe
+>     - Même si cette page devient encombrée, c'est un signe d'apprentissage actif. Des nettoyages réguliers sont bons, mais il est recommandé de maintenir sa nature d'espace d'expérimentation libre.
 
 
-#### With line break
-Paragraph 1  
-Paragraph 2
 
 
-## Block
-- Paragraphs can be created by inserting a blank table in the text
-- Passage can be broken into sentences and make them easier to read
+# :closed_book: Titres et paragraphes
+- En insérant des titres et des paragraphes, vous pouvez rendre le texte de la page plus facile à lire
 
 
-#### Without paragraph
-Paragraph 1  
-Paragraph 2
+## En-têtes
+- Ajoutez `#` avant le texte du titre pour créer un titre
+    - En fonction du nombre de `#`, la taille de la police des titres sera différente de celle affichée dans l'écran d'affichage
+- Le nombre de `#` déterminera le niveau de hiérarchie et vous aidera à organiser le contenu
 
 
-#### With paragraph
-Paragraph 1  
+```markdown
+# Titre de premier niveau
+## Titre de deuxième niveau
+### Titre de troisième niveau
+#### Titre de quatrième niveau
+##### Titre de cinquième niveau
+###### Titre de sixième niveau
+```
+
+## Saut
+- Insérez deux espaces de demi-largeur à la fin de la phrase que vous souhaitez couper
+    - Vous pouvez également modifier cela dans le paramètre pour couper la ligne sans demi-largeur espaces
+        - Modifiez le paramètre de saut de ligne dans le secteur « Paramètres Markdown » de la page d'administration
 
 
-Paragraph 2
+#### Exemple : Sans saut de ligne
+Paragraphe 1
+Paragraphe 2
 
 
+#### Exemple : Avec saut de ligne
+Paragraphe 1  
+Paragraphe 2
 
 
-# :green_book: Styling Text
-- Various styles can be applied to enrich the textual expression of a sentence
-    - These styles also can be easily applied by selecting the toolbar icon at the bottom of the Edit screen
+## Bloc
+- Les paragraphes peuvent être créés en insérant une ligne vide dans le texte
+- Le passage peut être divisé en phrases et les rendre plus faciles à lire
 
 
-## Italic
-- Enclose the text with an asterisk `*` or an underscore `_`.
+#### Exemple : Sans paragraphe
+Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
 
 
-#### Examples
-- This sentence indicates emphasis with *Italic*
-- This sentence indicates emphasis with _Italic_ 
+#### Exemple : Avec paragraphe
+Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
 
 
-## Bold
-- Enclose the text with two asterisks `*` or two underscores `_`
+Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
 
 
-#### Example
-- This sentence indicates emphasis with **Bold** 
-- This sentence indicates emphasis with __Bold__
 
 
-## Italic & Bold
-- Enclose the text with three asterisks `*` or three underscores `_`
+# :blue_book: Style du texte
+- Différents styles peuvent être appliqués pour enrichir l'expression textuelle d'une phrase
+    - Ces styles peuvent également être facilement appliqués en sélectionnant l'icône de la barre d'outils en bas de l'écran d'édition
 
 
-#### Example
-- This sentence indicates emphasis with ***Italic & Bold***
-- This sentence indicates emphasis witH ___Italic & Bold___
+| Style                     | Syntax                 | Keyboard Shortcut | Example                                   | Output                                 |
+| ------------------------- | ---------------------- | ----------------- | ----------------------------------------- | -------------------------------------- |
+| Bold                      | `** **` or `__ __`     | (TBD)             | `**This is bold text**`                   | **This is bold text**                  |
+| Italic                    | `* *` or `_ _`         | (TBD)             | `_This text is italicized_`               | *This text is italicized*              |
+| Strikethrough             | `~~ ~~`                | (TBD)             | `~~This was mistaken text~~`             | ~~This was  mistaken text~~            |
+| Bold and nested italic | `** **` and `_ _`     | None              | `**This text is _extremely_ important**`  | **This text is _extremely_ important** |
+| All Bold and Italic   | `*** ***`              | None              | `***All this text is important***`       | ***All this text is important***      |
+| Subscript                 | `<sub> </sub>`         | None              | `This is a <sub>subscript</sub> text`       | This is a <sub>subscript</sub> text      |
+| Superscript               | `<sup> </sup>`         | None              | `This is a <sup>superscript</sup> text`     | This is a <sup>superscript</sup> text    |
 
 
 
 
-# :orange_book: Insert Lists
-## Bulleted List
-- Insert a bulleted list by starting a line with a hyphen `-`, a plus `+`, or an asterisk `*`
+# :green_book: Insérer des listes
+## Liste à puces
+- Insérer une liste à puces en commençant une ligne par un trait d'union `-`, un plus `+` ou un astérisque `*`
 
 
-#### Example
-- This sentence is present in the bulleted list
-    - This sentence is present in the bulleted list
-        - This sentence is present in the bulleted list
-        - This sentence is present in the bulleted list
-- This sentence is present in the bulleted list
-    - This sentence is present in the bulleted list
+#### Exemple
+- Cette phrase est présente dans la liste à puces
+    - Cette phrase est présente dans la liste à puces
+        - Cette phrase est présente dans la liste à puces
+        - Cette phrase est présente dans la liste à puces
+- Cette phrase est présente dans la liste à puces
+    - Cette phrase est présente dans la liste à puces
 
 
-## Numbered List
-- `Number.` at the beginning of a line to insert a numbered list
-- Numbered list and bulleted list can also be combined for use
+## Liste numérotée
+- `Number.` au début d'une ligne pour insérer une liste numérotée
+    - Les numéros sont automatiquement attribués
 
 
-#### Example
-1. This sentence is present in the numbered list
-    1. This sentence is present in the numbered list
-    1. This sentence is present in the numbered list
-    1. This sentence is present in the numbered list
-        - This sentence is present in the bulleted list 
-1. This sentence is present in the bulleted list
-    - This sentence is present in the bulleted list
+- La liste numérotée et la liste à puces peuvent également être combinées pour être utilisées
 
 
-## Task List
-- Insert an unchecked checkbox list by writing `[] `
-    - Check the checkbox by writing `[x]`
+#### Exemple
+1. Cette phrase est présente dans la liste numérotée
+    1. Cette phrase est présente dans la liste numérotée
+    1. Cette phrase est présente dans la liste numérotée
+    1. Cette phrase est présente dans la liste numérotée
+        - Cette phrase est présente dans la liste à puces
+1. Cette phrase est présente dans la liste à puces
+    - Cette phrase est présente dans la liste à puces
 
 
-#### Example
-- [ ] Task 1
-    - [x] Task 1-1
-    - [ ] Task 1-2
-- [x] Task 2
 
 
+# :ledger: Lien
 
 
-# :blue_book: Others
-## Blockquotes
-- Use quoted expressions by putting `>` at the beginning of the paragraph
-    - Multiple quotations can be expressed by using a sequence of `>` characters
-- Lists and other elements can be used together within the blockquotes
+## Lien automatique
+Il suffit d'écrire l'URL et le lien sera généré automatiquement.
 
 
-#### Example
-> - Quotation
-> - Quotation
->> Multiple quotations need to insert more `>`
+### Exemple
 
 
-## Code
-- It is possible to express the code by adding it in three `` ` ``
+https://www.google.co.jp
 
 
-#### Example
+```markdown
+https://www.google.co.jp
 ```
 ```
-Add codes here  
-Line breaks and paragraphs can be reflected in the code
 
 
-- List also can be used in code
-    - List also can be used in code
+## Libellé et lien
+Insérez un lien en écrivant `[label](URL)`
+
+### Exemple
+- [Google](https://www.google.co.jp/)
+- [Sandbox est ici](/Sandbox)
+
+```markdown
+- [Google](https://www.google.co.jp/)
+- [Sandbox est ici](/Sandbox)
 ```
 ```
 
 
-## Inline Code
-- Enclose words in `` ` `` to make inline code
+## Syntaxe de lien flexible
 
 
-#### Example
-Here is the `inline code` 
+La syntaxe de lien flexible permet d'écrire facilement un lien par chemin de page, un lien de page relatif et un libellé de lien et une URL.
 
 
-## Horizontal lines
-- Insert the horizontal line with three or more consecutive asterisks `*` or underscores `_`
+- [[/Sandbox]]
+- [[./Math]]
+- [[Comment écrire des formules ?>./Math]]
 
 
-#### Example
-Below is a horizontal line
-***
+```markdown
+- [[/Sandbox]]
+- [[./Math]]
+- [[Comment écrire des formules ?>./Math]]
+```
 
 
-Below is a horizontal line
-___
 
 
+# :notebook: Autres applications
+- [En savoir plus sur Markdown](/Sandbox/Markdown)
 
 
-# :ledger: More Applications
-- [Bootstrap5](/Sandbox/Bootstrap5)
+- [Décorez davantage votre page (Bootstrap5)](/Sandbox/Bootstrap5)
 
 
-- [Diagrams](/Sandbox/Diagrams)
+- [Comment représenter des diagrammes (Diagrams)](/Sandbox/Diagrams)
 
 
-- [Math](/Sandbox/Math)
+- [Comment représenter des formules mathématiques (Math)](/Sandbox/Math)

+ 3 - 3
apps/app/resource/locales/ja_JP/sandbox-diagrams.md

@@ -2,7 +2,7 @@
 - GROWI では各種機能を活用することで様々な図形の表現が可能です
 - GROWI では各種機能を活用することで様々な図形の表現が可能です
   - 各種機能の特色を活かして図形の表現をしましょう
   - 各種機能の特色を活かして図形の表現をしましょう
 
 
-# :pencil: Diagrams.net(旧 Draw.io)
+# :pencil2: Diagrams.net(旧 Draw.io)
 - 図形の挿入時に全般的にご利用いただきやすい図形の挿入方法となります
 - 図形の挿入時に全般的にご利用いただきやすい図形の挿入方法となります
   - サービスの詳細は [こちら](https://www.drawio.com/) をご確認ください
   - サービスの詳細は [こちら](https://www.drawio.com/) をご確認ください
 - Edit 画面下部のツールバーより専用の編集画面を用いて図形を編集することが可能です
 - Edit 画面下部のツールバーより専用の編集画面を用いて図形を編集することが可能です
@@ -28,7 +28,7 @@ pLzXsqNMti76NH0Pwggu8d4Jzx3eCO/R05/M+XevvTtWnIgTcapK0wCCNGN8ZmSq/oVxwy2t6dwYU1H2
 ```
 ```
 
 
 
 
-# :pencil: Mermaid
+# :pencil2: Mermaid
 - Mermaidとは、Markdownテキストでグラフを作成できるダイアグラムツールです
 - Mermaidとは、Markdownテキストでグラフを作成できるダイアグラムツールです
   - サービスの詳細は [こちら](https://mermaid.js.org/) をご確認ください
   - サービスの詳細は [こちら](https://mermaid.js.org/) をご確認ください
 
 
@@ -79,7 +79,7 @@ mindmap
 ```
 ```
 
 
 
 
-# :pencil: PlantUML
+# :pencil2: PlantUML
 - PlantUML はオープンソースの UML 描画ツールです
 - PlantUML はオープンソースの UML 描画ツールです
   - サービスの詳細は [こちら](https://plantuml.com/) をご確認ください
   - サービスの詳細は [こちら](https://plantuml.com/) をご確認ください
 
 

+ 234 - 0
apps/app/resource/locales/ja_JP/sandbox-markdown.md

@@ -0,0 +1,234 @@
+# アラート
+
+> [!NOTE]
+> Useful information that users should know, even when skimming content.
+
+> [!TIP]
+> Helpful advice for doing things better or more easily.
+
+> [!IMPORTANT]
+> Key information users need to know to achieve their goal.
+
+> [!WARNING]
+> Urgent info that needs immediate user attention to avoid problems.
+
+> [!CAUTION]
+> Advises about risks or negative outcomes of certain actions.
+
+
+```markdown
+> [!NOTE]
+> Useful information that users should know, even when skimming content.
+
+> [!TIP]
+> Helpful advice for doing things better or more easily.
+
+> [!IMPORTANT]
+> Key information users need to know to achieve their goal.
+
+> [!WARNING]
+> Urgent info that needs immediate user attention to avoid problems.
+
+> [!CAUTION]
+> Advises about risks or negative outcomes of certain actions.
+```
+
+[directive](https://talk.commonmark.org/t/generic-directives-plugins-syntax/444) を使って記述することもできます。
+
+:::note
+Useful information that users should know, even when skimming content.
+:::
+
+:::tip[Custom Label]
+Useful information that users should know, even when skimming content.
+:::
+
+```markdown
+:::note
+Useful information that users should know, even when skimming content.
+:::
+
+:::tip[Custom Label]
+Useful information that users should know, even when skimming content.
+:::
+```
+
+
+# テキストの引用
+- 行頭に `>` を記述することで引用表現を記述できます
+    - 多重引用の際は `>` を複数個連続で記述することで表現できます
+
+#### 例
+> - 引用符
+> - 引用符
+>> 複数の引用符にはさらに `>` を挿入する必要があります
+
+```markdown
+> - 引用する文章が入ります
+> - 引用する文章が入ります
+>> 多重引用を表現するにはさらに `>` を挿入します
+```
+
+
+# コード
+- `` ` `` 3つで囲むことでコードの表現をすることが可能です
+
+#### 例
+
+```markdown
+ここにコードを追加
+
+改行と段落はそのまま反映されます
+```
+#### 例 (ソースコード)
+
+```javascript:mersenne-twister.js
+function MersenneTwister(seed) {
+  if (arguments.length == 0) {
+    seed = new Date().getTime();
+  }
+
+  this._mt = new Array(624);
+  this.setSeed(seed);
+}
+```
+
+## インライン コード
+- `` ` `` で単語を囲むとインラインコードになります
+
+#### 例
+こちらは `インラインコード` です
+
+
+# タスク リスト
+- `[] ` を記述することでリストに対して未チェックのチェックボックスを挿入することができます
+    - `[x] ` を記述することでチェック済みのチェックボックスを挿入することができます
+
+#### 例
+- [ ] タスク 1
+    - [x] タスク 1-1
+    - [ ] タスク 1-2
+- [x] タスク2
+
+
+# 水平線
+- 3 つ以上の連続したアスタリスク `*` またはアンダースコア `_` で水平線を挿入します
+
+#### 例
+以下は水平線です
+***
+
+以下は水平線です
+___
+
+```markdown
+以下は水平線です
+***
+
+以下は水平線です
+___
+```
+
+
+# 脚注
+角かっこ構文を使用して、コンテンツに脚注を追加できます。
+
+シンプルな脚注[^1].
+
+複数行にわたる脚注も追加できます[^myfootnote2].
+
+[^1]: 注記はこのように書きます.
+[^myfootnote2]: 注記を改行するには、新しい行頭にで2つの連続したスペースをいれます。
+  こちらが2行目です。
+
+
+```markdown
+シンプルな脚注[^1].
+
+複数行にわたる脚注も追加できます[^myfootnote2].
+
+[^1]: 注記はこのように書きます.
+[^myfootnote2]: 注記を改行するには、新しい行頭にで2つの連続したスペースをいれます。
+  こちらが2行目です。
+```
+
+
+# 絵文字
+
+`:EMOJICODE:` とコロンの後に絵文字の名前を入力することで、文章に絵文字を追加できます。
+
+- :+1: GOOD!
+- :white_check_mark: チェック
+- :lock: 鍵マーク
+
+`:` に続いて2文字以上入力すると、絵文字のサジェストリストが表示されます。このリストは、入力を進めるにつれて絞り込まれていくので、探している絵文字が見つかり次第、Tab または Enter を押して、ハイライトされているものを入力してください。
+
+使用可能な絵文字の一覧は、「[絵文字チートシート](https://github.com/ikatyang/emoji-cheat-sheet/blob/master/README.md)」を参照してください。
+
+
+# 表
+### Markdown 標準
+- Markdown で記載できる標準的な形式の表です
+
+#### 例
+| 左揃え               |               右揃え |        中央揃え        |
+| :------------------- | -------------------: | :--------------------: |
+| この列は             |             この列は |        この列は        |
+| 左揃えで表示されます | 右揃えで表示されます | 中央揃えで表示されます |
+
+```markdown
+| 左揃え               |               右揃え |        中央揃え        |
+| :------------------- | -------------------: | :--------------------: |
+| この列は             |             この列は |        この列は        |
+| 左揃えで表示されます | 右揃えで表示されます | 中央揃えで表示されます |
+```
+
+### CSV / TSV
+#### 例
+
+``` tsv
+10:00	集合
+10:20	移動
+```
+
+~~~
+``` csv
+11:00,MTG
+12:00,昼食
+```
+~~~
+
+~~~
+``` tsv
+10:00	集合
+10:20	移動
+```
+~~~
+
+### CSV / TSV(ヘッダー付き)
+#### 例
+``` tsv-h
+時間	行動
+10:00	集合
+10:20	移動
+```
+
+~~~
+``` csv-h
+時間,行動
+11:00,MTG
+12:00,昼食
+```
+~~~
+
+~~~
+``` tsv-h
+時間	行動
+10:00	集合
+10:20	移動
+```
+~~~
+
+
+
+

+ 100 - 226
apps/app/resource/locales/ja_JP/sandbox.md

@@ -1,18 +1,50 @@
-# Sandbox(サンドボックス)とは
-- この階層下では、GROWI をより便利に活用するための活用術や活用ヒントを掲載しています
-- この階層下のページ内容を組織内で自由に書き換えて GROWI の理解度を深めるために活用しましょう!
+# GROWI の砂場へようこそ!
 
 
+> [!NOTE]
+> **サンドボックスとは?**
+> 
+> ここは自由に編集できる練習用のページです。新しいことを試すのに最適な場所です!
 
 
-# :memo:見出しや段落
-- 見出しや段落を挿入することで、ページ内の文章にメリハリがつき読みやすい文章を作成することが可能です
 
 
-## 見出し(Headers)
-- 行頭に `#` をレベルの数だけ記述することで見出しを作成することが可能です
-    - 各見出しに応じて View 画面に表示される際のデザインも異なります
-    - 各見出しに応じて View 画面右側に表示される目次が生成されます
-- このページ内にもたくさんの見出しが活用されており、`#` の数に応じて内容をグルーピングすることで可能です
+## :beginner: はじめての方へ
 
 
-```
+GROWI では「マークダウン」という記法で簡単に見栄えの良いページを作ることができます。  
+マークダウンを使うと、こんなことができます!
+
+- **太字**や*斜体*で文字を強調
+- 箇条書きや番号付きリストの作成
+- [リンクの挿入](#-リンク)
+- 表の作成
+- コードブロックの追加
+
+その他、様々な装飾が可能です。
+
+## 試してみましょう!
+
+1. このページを自由に編集してください
+1. 失敗を恐れる必要はありません
+1. 元に戻すこともできます
+1. 他の人の編集も参考になります
+
+> [!IMPORTANT]
+> **管理者の方へ**
+> 
+> 砂場は学習のための大切な場所です:
+> - 新しいメンバーがGROWIに慣れるための第一歩として
+> - マークダウンの練習場として
+> - チーム内のコミュニケーションツールとして
+>     - このページが乱雑になっても、それは活発な学習の証です。定期的なクリーンアップは良いですが、自由な実験の場としての性質は維持することをおすすめします。
+
+
+# :closed_book: 見出しと段落
+- 見出しと段落を挿入すると、ページ上のテキストを読みやすくすることができます
+
+## ヘッダー
+- 見出しを作成するには、見出しテキストの前に `#` を追加します
+- `#` の数に応じて、View 画面に表示される見出しの書体サイズが変わります
+- このページ内にもたくさんの見出しが活用されており、`#` の数に応じて内容をグルーピングすることができます
+
+```markdown
 # 見出し1
 # 見出し1
 ## 見出し2
 ## 見出し2
 ### 見出し3
 ### 見出し3
@@ -23,63 +55,54 @@
 
 
 ## 改行(Br)
 ## 改行(Br)
 - 改行したい文章の行末に半角スペースを2つ挿入することで改行をすることができます
 - 改行したい文章の行末に半角スペースを2つ挿入することで改行をすることができます
-    - こちらの挙動は、設定画面から半角スペースなしで改行が反映されるように設定を変更することが可能です
-        - 「マークダウン設定_Line Break設定(/admin/markdown)」から変更が可能です
+    - 管理画面から半角スペースなしで改行が反映されるように設定を変更することも可能です
+        - 「マークダウン設定」から変更できま
 
 
-#### 改行がない場合
+#### 例: 改行なし
 文章 1 の内容が入ります
 文章 1 の内容が入ります
 文章 2 の内容が入ります
 文章 2 の内容が入ります
 
 
-#### 改行がある場合
+#### 例: 改行あり
 文章 1 の内容が入ります  
 文章 1 の内容が入ります  
 文章 2 の内容が入ります
 文章 2 の内容が入ります
 
 
-## 段落(Block)
-- 文章内で空白表を挿入することで段落を作成することが可能で
-- 段落を作成することで文章の節目を作成し読みやすい文章を作成することができます
+## ブロック
+- テキストに空白行を挿入することで段落を作成できま
+- 文章を文に分割して読みやすくすることができます
 
 
-#### 段落がない場合
-文章 1 の内容が入ります  
-文章 2 の内容が入ります
+#### 例: 段落なし
+あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。
+またそのなかでいっしょになったたくさんのひとたち、ファゼーロとロザーロ、羊飼のミーロや、顔の赤いこどもたち、地主のテーモ、山猫博士のボーガント・デストゥパーゴなど、いまこの暗い巨きな石の建物のなかで考えていると、みんなむかし風のなつかしい青い幻燈のように思われます。
 
 
-#### 段落がある場合
-文章 1 の内容が入ります  
-
-文章 2 の内容が入ります
+#### 例: 段落がある場合
 
 
+あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。
 
 
-# :memo:文字の強調
-- 各種記述方法を適用させることで文内の文字の表現を豊かにすることが可能です
-    - これらの表現は Edit 画面下部のツールバーから該当のアイコンを選択することで簡単に適用させることも可能です
+またそのなかでいっしょになったたくさんのひとたち、ファゼーロとロザーロ、羊飼のミーロや、顔の赤いこどもたち、地主のテーモ、山猫博士のボーガント・デストゥパーゴなど、いまこの暗い巨きな石の建物のなかで考えていると、みんなむかし風のなつかしい青い幻燈のように思われます。
 
 
-## 斜体(Italic)
-- アスタリスク `*` もしくはアンダースコア `_` 1つで該当の文字列を囲みます
 
 
-#### 活用例
-- この文章は *斜体が適用* されます  
-- この文章は _斜体が適用_ されます
 
 
-## 太字(Bold)
-- アスタリスク `*` もしくはアンダースコア `_` 2つで該当の文字列を囲みます
+# :blue_book: テキストのスタイル設定
+- さまざまなスタイルを適用して、文章のテキスト表現を豊かにすることができます
+- これらのスタイルは、編集画面の下部にあるツールバー アイコンを選択して簡単に適用することもできます
 
 
-#### 活用例
-- この文章は **強調が適用** されます  
-- この文章は __強調が適用__ されます
+| スタイル                   | 構文                   | キーボードショートカット | 例                                       | 出力                                   |
+| -------------------------- | ---------------------- | ------------------------ | ---------------------------------------- | -------------------------------------- |
+| 太字                       | `** **` または `__ __` | (準備中)                 | `**これは太字のテキストです**`           | **これは太字のテキストです**           |
+| [斜体]                     | `* *` または `_ _`     | (準備中)                 | `_このテキストは斜体です_`               | *このテキストは斜体です*               |
+| 取り消し線                 | `~~ ~~`                | (準備中)                 | `~~これは間違ったテキストでした~~`       | ~~これは間違ったテキストでした~~       |
+| 太字および太字中にある斜体 | `** **` および `_ _`   | なし                     | `**This text is _extremely_ important**` | **This text is _extremely_ important** |
+| 全体が太字か斜体           | `*** ***`              | なし                     | `***このテキストはすべて重要です***`     | ***このテキストはすべて重要です***     |
+| 下付き                     | `<sub> </sub>`         | なし                     | `これは<sub>下付き</sub>テキストです`    | これは<sub>下付き</sub>テキストです    |
+| 上付き                     | `<sup> </sup>`         | なし                     | `これは<sup>上付き</sup>テキストです`    | これは<sup>上付き</sup>テキストです    |
 
 
-## 斜体 & 太字(Italic & Bold)
-- アスタリスク `*` もしくはアンダースコア `_` 3つで該当の文字列を囲みます
 
 
-#### 活用例
-- この文章は ***斜体 & 太字が適用*** されます  
-- この文章は ___斜体 & 太字が適用___ されます
 
 
-
-# :memo:リストの挿入
+# :green_book: リストの挿入
 ## 箇条書きリスト
 ## 箇条書きリスト
 - ハイフン `-`、プラス `+`、アスタリスク `*` を行頭に記述することで、箇条書きのリストを挿入することでができます
 - ハイフン `-`、プラス `+`、アスタリスク `*` を行頭に記述することで、箇条書きのリストを挿入することでができます
-    - タブを活用することで前の行のリストに紐づくリストを挿入することも可能です
 
 
-#### 活用
+#### 例
 - この文章は箇条書きリストで表現しています
 - この文章は箇条書きリストで表現しています
     - この文章は箇条書きリストで表現しています
     - この文章は箇条書きリストで表現しています
         - この文章は箇条書きリストで表現しています
         - この文章は箇条書きリストで表現しています
@@ -89,10 +112,10 @@
 
 
 ## 番号付きリスト
 ## 番号付きリスト
 - `番号.` を行頭に記述することで、番号付きのリストを挿入することができます
 - `番号.` を行頭に記述することで、番号付きのリストを挿入することができます
-    - タブを活用することで前の行のリストに紐づくリストを挿入することも可能で
-- 番号付きリストと箇条書きリストを組み合わせて活用することも可能で
+    - 番号は自動で採番されま
+- 番号付きリストと箇条書きリストを組み合わせて使用​​することもできま
 
 
-#### 活用
+#### 例
 1. この文章は番号付きリストで表現しています
 1. この文章は番号付きリストで表現しています
     1. この文章は番号付きリストで表現しています
     1. この文章は番号付きリストで表現しています
     1. この文章は番号付きリストで表現しています
     1. この文章は番号付きリストで表現しています
@@ -101,201 +124,52 @@
 1. この文章は箇条書きリストで表現しています
 1. この文章は箇条書きリストで表現しています
     - この文章は箇条書きリストで表現しています  
     - この文章は箇条書きリストで表現しています  
 
 
-## タスクリスト
-- `[] ` を記述することでリストに対して未チェックのチェックボックスを挿入することができます
-    - `[x] ` を記述することでチェック済みのチェックボックスを挿入することができます
-
-#### 活用例
-- [ ] タスク 1
-    - [x] タスク 1-1
-    - [ ] タスク 1-2
-- [x] タスク2
 
 
 
 
-# :memo:表の挿入
-## Markdown 標準
-- Markdown で記載できる標準的な形式の表です
+# :ledger: リンク
 
 
-#### 活用例
-| 左揃え               |               右揃え |        中央揃え        |
-| :------------------- | -------------------: | :--------------------: |
-| この列は             |             この列は |        この列は        |
-| 左揃えで表示されます | 右揃えで表示されます | 中央揃えで表示されます |
+## 自動リンク
+URL を記述するだけで、リンクが自動的に生成されます。
 
 
-## TSV
-#### 活用例
-``` tsv
-10:00	集合
-10:20	移動
-```
+### 例
 
 
-## TSV(ヘッダー付き)
-#### 活用例
-``` tsv-h
-時間	行動
-10:00	集合
-10:20	移動
-```
+https://www.google.co.jp
 
 
-## CSV
-#### 活用例
-``` csv
-11:00,MTG
-12:00,昼食
+```markdown
+https://www.google.co.jp
 ```
 ```
 
 
-## CSV(ヘッダー付き)
-#### 活用例
-``` csv-h
-時間,行動
-11:00,MTG
-12:00,昼食
-```
-
-
-# :memo:リンクの挿入
-## Markdown 標準
-- Markdown で記載できる標準的な形式のリンクです
-- `[表示されるテキスト](リンク先のURL)`でリンクに変換されます
-
-#### 活用例
-[Google](https://www.google.co.jp/)
-
-## Pukiwiki like linker
-- もっとも柔軟なリンクの形式です
-- 記述中のページを基点とした相対リンクと、表示テキストに対するリンクを同時に実現できます
-
-#### 活用例
-Bootstrap によるページの装飾方法の記述方法は [[こちらをご確認ください>./Bootstrap5]]
-
-
-# :memo:画像の挿入
-## 画像(Images)の挿入
-- `![Alt文字列](URL)` で`<img>`タグを挿入できます
-
-#### 活用例
-![Minion](https://octodex.github.com/images/minion.png)
-
-## 画像のサイズ指定
-- 画像の大きさなどを指定する場合はimgタグを使用します
+## ラベルとリンク
+`[label](URL)` と記述してリンクを挿入します
 
 
-#### 活用例
-<img src="https://octodex.github.com/images/dojocat.jpg" width="500px">
+### 例
+- [Google](https://www.google.co.jp/)
+- [砂場ページはこちら](/Sandbox)
 
 
-
-# :memo:コンテンツやページの表示
-## 目次(ToC)
-- いくつかの `#` 記号に続けて `ToC` を記述することでページ内に目次を生成することができます
-    - `ToC` は `Table of Contents` または `Table-of-Contents` でも適用されます
-- 生成される目次は、ページ内で `ToC` を記述した以降の部分の目次となります
-
-#### 活用例
-##### ToC
-
-## 配下ページの表示(lsx)
-- ページ内に `$lsx()` を記述することで配下に作成されているページを表示することができます
-- 各種オプションを指定することで表示される配下ページを操作することができます
-    - lsx の詳細は [GROWI 公式ドキュメント](https://docs.growi.org/ja/guide/features/lsx.html) をご確認ください
-
-#### 活用例
-$lsx()
-
-# :memo:その他の基本的な表現
-## 引用(Blockquotes)
-- 行頭に `>` を記述することで引用表現をすることが可能です
-    - 多重引用の際は `>` を複数個連続で記述することで表現が可能です
-- 引用内でリストなどの要素を併用することも可能です
-
-#### 活用例
-> - 引用する文章が入ります
-> - 引用する文章が入ります
->> 多重引用したい文章の場合は複数個の挿入が必要です
-
-## コード(Code)
-- `` ` `` 3つで囲むことでコードの表現をすることが可能です
-
-#### 活用例
-```
-コードが入ります  
-改行や段落をコード内で反映させることが可能です
-
-- リストもコード内での表現が可能です
-    - リストもコード内での表現が可能です
+```markdown
+- [Google](https://www.google.co.jp/)
+- [砂場ページはこちら](/Sandbox)
 ```
 ```
 
 
-## インラインコード
-- `` ` `` で単語を囲むとインラインコードになります
-
-#### 活用例
-こちらは `インラインコード` です
+## 柔軟なリンク構文
 
 
-## シンタックスハイライトとファイル名
-- [highlight.js Demo](https://highlightjs.org/static/demo/) の common カテゴリ内の言語に対応しています
+柔軟なリンク構文により、ページパスによるリンク、相対ページリンク、リンクラベルとURLによるリンクを簡単に記述できます。
 
 
-#### 活用例 
-```javascript:mersenne-twister.js
-function MersenneTwister(seed) {
-  if (arguments.length == 0) {
-    seed = new Date().getTime();
-  }
+- [[/Sandbox]]
+- [[./Math]]
+- [[数式の書き方は?>./Math]]
 
 
-  this._mt = new Array(624);
-  this.setSeed(seed);
-}
+```markdown
+- [[/Sandbox]]
+- [[./Math]]
+- [[数式の書き方は?>./Math]]
 ```
 ```
 
 
-## pre 整形済みテキスト
-- 半角スペース4個もしくはタブで、コードブロックを pre 表示できます
-
-#### 活用例
-    class Hoge
-        def hoge
-            print 'hoge'
-        end
-    end
-
-## 水平線(Hr)
-- アスタリスク `*` もしくはアンダースコア `_` を3つ以上連続して記述することで水平線を挿入できます
-
-#### 活用例
-以下に水平線が挿入されます
-***
-
-以下に水平線が挿入されます
-___
-
-## 脚注(Footnote)
-- 脚注 `[^1]` と脚注への参照 `[^1]:` を作成することができます
-
-#### 活用例
-脚注への参照[^1]を書くことができます。
-
-長い脚注は[^longnote]のように書くことができます。
-
-[^1]: 1つめの脚注への参照です。
-
-[^longnote]: 脚注を複数ブロックで書く例です。
-
-    後続の段落はインデントされて、前の脚注に属します。
-
-## 絵文字(Emoji)
-:smiley: :smile: :laughing: :innocent: :drooling_face:
-
-:family: :man-boy: :man-girl: :man-girl-girl: :woman-girl-girl:
-
-:+1: :-1: :open_hands: :raised_hands: :point_right:
-
-:apple: :green_apple: :strawberry: :cake: :hamburger:
-
-:basketball: :football: :baseball: :volleyball: :8ball:
-
-:hearts: :broken_heart: :heartbeat: :heartpulse: :heart_decoration:
-
-:watch: :gear: :gem: :wrench: :email:
+# :notebook: さらにアプリケーションを活用
 
 
+- [もっとマークダウンについて学ぶ](/Sandbox/Markdown)
 
 
-# :memo:さらに応用的な表現
-- [ページの装飾方法(Bootstrap5)](/Sandbox/Bootstrap5)
+- [さらにページの装飾をする(Bootstrap5)](/Sandbox/Bootstrap5)
 
 
 - [図形の表現方法(Diagrams)](/Sandbox/Diagrams)
 - [図形の表現方法(Diagrams)](/Sandbox/Diagrams)
 
 

+ 3 - 3
apps/app/resource/locales/zh_CN/sandbox-diagrams.md

@@ -1,4 +1,4 @@
-# :pencil: diagrams.net(Draw.io)
+# :pencil2: diagrams.net(Draw.io)
 
 
 See [diagrams.net](https://diagrams.net)
 See [diagrams.net](https://diagrams.net)
 
 
@@ -23,7 +23,7 @@ See [diagrams.net](https://diagrams.net)
 
 
 
 
 
 
-# :pencil: PlantUML
+# :pencil2: PlantUML
 
 
 See [PlantUML](http://plantuml.com/).
 See [PlantUML](http://plantuml.com/).
 
 
@@ -151,7 +151,7 @@ State3 --> [*] : Aborted
 
 
 
 
 
 
-# :pencil: Mermaid
+# :pencil2: Mermaid
 
 
 ## Pie graph
 ## Pie graph
 
 

+ 245 - 0
apps/app/resource/locales/zh_CN/sandbox-markdown.md

@@ -0,0 +1,245 @@
+# Alert
+
+> [!NOTE]
+> Useful information that users should know, even when skimming content.
+
+> [!TIP]
+> Helpful advice for doing things better or more easily.
+
+> [!IMPORTANT]
+> Key information users need to know to achieve their goal.
+
+> [!WARNING]
+> Urgent info that needs immediate user attention to avoid problems.
+
+> [!CAUTION]
+> Advises about risks or negative outcomes of certain actions.
+
+
+```markdown
+> [!NOTE]
+> Useful information that users should know, even when skimming content.
+
+> [!TIP]
+> Helpful advice for doing things better or more easily.
+
+> [!IMPORTANT]
+> Key information users need to know to achieve their goal.
+
+> [!WARNING]
+> Urgent info that needs immediate user attention to avoid problems.
+
+> [!CAUTION]
+> Advises about risks or negative outcomes of certain actions.
+```
+
+您还可以使用[directive 语法](https://talk.commonmark.org/t/generic-directives-plugins-syntax/444)。
+
+:::note
+Useful information that users should know, even when skimming content.
+:::
+
+:::tip[Custom Label]
+Useful information that users should know, even when skimming content.
+:::
+
+```markdown
+:::note
+Useful information that users should know, even when skimming content.
+:::
+
+:::tip[Custom Label]
+Useful information that users should know, even when skimming content.
+:::
+```
+
+
+# 引用
+- 在段落开头放置 `>` 即可使用带引号的表达式
+    - 可以使用一系列 `>` 字符来表示多个引号
+- 列表和其他元素可以在区块引用中一起使用
+
+#### 示例
+> - 引号
+> - 引号
+>> 多个引号需要插入更多 `>`
+
+```markdown
+> - 引号
+> - 引号
+>> 多个引号需要插入更多 `>`
+```
+
+
+# 代码
+- 可以通过在三个 `` ` `` 中添加代码来表示代码
+
+####示例
+
+```markdown
+在此处添加代码
+
+换行符和段落可以按原样反映在代码中
+```
+
+#### 示例(源代码)
+
+```javascript:mersenne-twister.js
+function MersenneTwister(seed) {
+  if (arguments.length == 0) {
+    seed = new Date().getTime();
+  }
+
+  this._mt = new Array(624);
+  this.setSeed(seed);
+}
+```
+
+## 内联代码
+- 将单词括在 `` ` `` 中以制作内联代码
+
+#### 示例
+以下是 `内联代码`
+
+
+
+# 任务列表
+- 通过写入 `[] ` 插入未选中的复选框列表
+    - 通过写入 `[x]` 选中复选框
+
+#### 示例
+- [ ] 任务 1
+    - [x] 任务 1-1
+    - [ ] 任务 1-2
+- [x] 任务 2
+
+
+# 水平线
+- 用三个或更多连续的星号 `*` 或下划线 `_` 插入水平线
+
+#### 示例
+下面是一条水平线
+***
+下面是一条水平线
+___
+
+```markdown
+下面是一条水平线
+***
+下面是一条水平线
+___
+```
+
+
+# 脚注
+
+您可以使用此括号语法为您的内容添加脚注:
+
+Here is a simple footnote[^1].
+
+A footnote can also have multiple lines[^2].
+
+[^1]: My reference.
+[^2]: To add line breaks within a footnote, prefix new lines with 2 spaces.
+  This is a second line.
+
+```markdown
+Here is a simple footnote[^1].
+
+A footnote can also have multiple lines[^2].
+
+[^1]: My reference.
+[^2]: To add line breaks within a footnote, prefix new lines with 2 spaces.
+  This is a second line.
+```
+
+
+# 表情符号
+
+您可以通过在冒号 `:` 后输入表情符号名称来添加表情符号。
+
+- :+1: 好!
+- :white_check_mark: 检查
+- :lock: 锁定
+
+当您在冒号后输入两个或更多字符时,会出现一个表情符号建议列表。随着您继续输入,这个列表会逐渐缩小范围。一旦找到您要查找的表情符号,按 Tab 或 Enter 键插入高亮显示的表情符号。
+
+有关可用表情符号的列表,请参阅 "[Emoji Cheat Sheet](https://github.com/ikatyang/emoji-cheat-sheet/blob/master/README.md)"。
+
+
+
+# 表格
+
+### 通用语法
+
+#### 示例
+
+| Left align | Right align | Center align |
+|:-----------|------------:|:------------:|
+| This       | This        | This         |
+| column     | column      | column       |
+| will       | will        | will         |
+| be         | be          | be           |
+| left       | right       | center       |
+| aligned    | aligned     | aligned      |
+
+```markdown
+| Left align | Right align | Center align |
+|:-----------|------------:|:------------:|
+| This       | This        | This         |
+| column     | column      | column       |
+| will       | will        | will         |
+| be         | be          | be           |
+| left       | right       | center       |
+| aligned    | aligned     | aligned      |
+```
+
+### CSV / TSV
+
+#### 示例
+
+``` tsv
+内容单元格 内容单元格
+内容单元格 内容单元格
+```
+
+~~~
+``` csv
+内容单元格,内容单元格
+内容单元格,内容单元格
+```
+~~~
+
+~~~
+``` tsv
+内容单元格 内容单元格
+内容单元格 内容单元格
+```
+~~~
+
+### CSV / TSV (带标题)
+
+#### 示例
+
+``` tsv-h
+第一个标题 第二个标题
+内容单元格 内容单元格
+内容单元格 内容单元格
+```
+
+~~~
+``` csv-h
+第一个标题,第二个标题
+内容单元格,内容单元格
+内容单元格,内容单元格
+```
+~~~
+
+~~~
+``` tsv-h
+第一个标题 第二个标题
+内容单元格 内容单元格
+内容单元格 内容单元格
+```
+~~~
+

+ 1 - 1
apps/app/resource/locales/zh_CN/sandbox-math.md

@@ -1,4 +1,4 @@
-# :pencil: Math
+# :pencil2: Math
 
 
 See [KaTeX](https://katex.org/).
 See [KaTeX](https://katex.org/).
 
 

+ 125 - 112
apps/app/resource/locales/zh_CN/sandbox.md

@@ -1,160 +1,173 @@
-# 什么是沙盒?
-- 在本页中,您可以找到帮助您掌握 GROWI 的技巧。
-- 您可以在此层级下的参考资料中丰富您的网页内容
+# 欢迎来到 GROWI 沙盒!
 
 
+> [!NOTE]
+> **什么是沙盒?**
+>
+> 这是一个您可以自由编辑的练习页面。它是尝试新事物的绝佳场所!
 
 
-# :closed_book:标题和段落
-- 通过插入标题和段落,可以使页面上的文字更易于阅读
+## :beginner: 初学者指南
 
 
-## 标题
-- 在标题文字前添加 `#` 以创建标题 
-    - 在 "视图 "屏幕中,标题的字体大小会因 "#"的数量而异 
-    - 查看右侧的 "视图 "屏幕,了解标题的效果
-- `#`的数量将决定层次结构的级别,并帮助您组织内容
+使用 GROWI,您可以使用名为“Markdown”的符号轻松创建具有视觉吸引力的页面。
 
 
-```
-# 一级标题
-## 二级标题
-### 三级标题
-#### 第四级标题
-##### 第五级标题
-###### 第六级标题
-```
+通过使用 Markdown,您可以做这样的事情!
 
 
-### 断句
-- 在要换行的句子末尾插入两个半宽空格
-    - 您也可以在 "设置 "中进行更改,使换行不使用半宽空格
-        - 在管理页面的 "Markdown 设置 "部分更改换行设置
+- 用**粗体**或*斜体*强调文本
+- 创建项目符号或编号列表
+- [插入链接](#-link)
+- 创建表格
+- 添加代码块
 
 
-#### 无换行
-段落 1
-第 2 段
+还可以使用各种其他装饰。
 
 
-#### 有换行符
-段落 1  
-第 2 段
+## 让我们尝试一下!
 
 
-## 段落
-- 在文本中插入空白表格即可创建段落
-- 可将段落分成若干句子,使其更易于阅读
+1. 随意编辑此页面
+1. 无需担心犯错
+1. 您随时可以撤销更改
+1. 您还可以从其他人的编辑中学习
 
 
-#### 无段落
-段落 1  
-第 2 段
+> [!IMPORTANT]
+> **对于管理员**
+>
+> 沙盒是学习的重要场所:
+> - 作为新成员习惯 GROWI 的第一步
+> - 作为 Markdown 的练习场
+> - 作为团队内部的沟通工具
+> - 即使此页面变得杂乱无章,这也是积极学习的标志。定期清理是好的,但建议保持其作为自由实验空间的性质。
 
 
-#### 有段落
-第 1 段  
 
 
+# :closed_book: 标题和段落
+- 通过插入标题和段落,您可以使页面上的文本更易于阅读
+
+## 标题
+- 在标题文本前添加 `#` 以创建标题
+- 根据 `#` 的数量,标题的字体大小在视图屏幕中显示不同
+- `#` 的数量将决定层次结构级别并帮助您组织内容
+
+```markdown
+# 第一级标题
+## 第二级标题
+### 第三级标题
+#### 第四级标题
+##### 第五级标题
+###### 第六级标题
+```
+
+## 换行
+- 在要换行的句子末尾插入两个半角空格
+    - 您也可以在设置中更改此设置以换行而不使用半角空格
+        - 更改换行设置在管理页面的“Markdown 设置”部分
+        
+#### 示例:没有换行
+第 1 段
 第 2 段
 第 2 段
 
 
+#### 示例:有换行符
+第 1 段  
+第 2 段
 
 
-# :green_book: 文本样式
-- 可以使用各种样式来丰富句子的文字表达方式
-    - 选择 "编辑 "屏幕底部的工具栏图标,也可以轻松应用这些样式
+## 块
+- 可以通过在文本中插入空行来创建段落
+- 可以将段落分成句子,使它们更易于阅读
 
 
-##斜体
-- 用星号`*`或下划线`_`括住文本。
+#### 示例:没有段落
+Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
 
 
-#### 示例
-- 这句话用*斜体*表示强调
-- 这句话用 _Italic_ 表示强调 
+#### 示例:用段落
+Lorem ipsum dolor sat amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut Labore et dolore magna aliqua。 Ut enim ad minim veniam, quis nostrud exeritation ullamco labouris nisi ut aliquip ex ea commodo consequat.
 
 
-## 粗体
-- 用两个星号`*`或两个下划线`_`括住文本。
+Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur。 Excepteur sint occaecat cupidatat non proident,sunt in culpa qui officia deserunt mollit anim id est laborum。
 
 
-#### 示例
-- 这句话用 ** 粗体** 表示强调 
-- 这句话用__粗体__表示强调
 
 
-## 斜体和粗体
-- 用三个星号`*`或三个下划线`_`括起来
+# :blue_book: 文本样式
+- 可以应用各种样式来丰富句子的文本表达
+    - 也可以通过选择编辑屏幕底部的工具栏图标轻松应用这些样式
 
 
-#### 示例
-- 本句用***斜体和粗体***表示强调
-- 本句用____斜体和粗体____表示强调
+| Style | 语法 | 键盘快捷键 | 示例 | 输出 |
+|-------|------|------------|------|------|
+| 加粗 | `** **` 或 `__ __` | (TBD) | `**这是粗体文本**` | **这是粗体文本** |
+| 斜体 | `* *` 或 `_ _` | (TBD) | `_这是斜体文本_` | *这是斜体文本* |
+| 删除线 | `~~ ~~` | 无 | `~~这是错误文本~~` | ~~这是错误文本~~ |
+| 粗体和嵌入的斜体 | `** **` 和 `_ _` | (TBD) | `**This text is _extremely_ important**` | **This text is _extremely_ important** |
+| 全部粗体和斜体 | `*** ***` | 无 | ***所有这些文本都很重要*** | ***所有这些文本都很重要*** |
+| 下标 | `<sub> </sub>` | 无 | This is a <sub>subscript</sub> text | 这是<sub>下标</sub>文本 |
+| 上标 | `<sup> </sup>` | 无 | This is a <sup>superscript</sup> text | 这是<sup>上标</sup>文本 |
 
 
 
 
-# :orange_book: 插入列表
-## 缩略图列表
-- 用连字符 `-`、加号 `+` 或星号 `*` 开头一行,插入一个项目符号列表
+# :green_book: 插入列表
+## 项目符号列表
+- 通过在行首使用连字符 `-`、加号 `+` 或星号 `*` 插入项目符号列表
 
 
 #### 示例
 #### 示例
-- 这句话出现在项目符号列表中
-    - 这句话出现在项目符号列表中
-        - 这句话出现在项目符号列表中
-        - 这句话出现在项目符号列表中
-- 此句出现在项目符号列表中
-    - 此句子出现在项目符号列表中
+- 本句在项目符号列表中
+    - 本句在项目符号列表中
+        - 本句在项目符号列表中
+        - 本句在项目符号列表中
+- 本句在项目符号列表中
+    - 本句在项目符号列表中
 
 
 ## 编号列表
 ## 编号列表
-- 在行首添加 `Number.` 以插入编号列表
-- 编号列表和项目符号列表也可合并使用
+- 在行首使用 `Number.` 插入编号列表
+- 编号自动分配
 
 
-#### 示例
-1. 编号列表中有这样一句话
-    1. 编号列表中包含这句话
-    1. 该句子出现在编号表中
-    1. 此句出现在编号列表中
-        - 此句出现在项目符号列表中 
-1. 此句出现在项目符号列表中
-    - 此句出现在项目符号列表中
-
-##任务列表
-- 通过书写 `[] ` 插入未选中复选框列表
-    - 通过书写 `[x]` 选中复选框
+- 编号列表和项目符号列表也可组合使用
 
 
 #### 示例
 #### 示例
-- [ ] 任务 1
-    - [x] 任务 1-1
-    - [ ] 任务 1-2
-- [x] 任务 2
+1. 本句在编号列表中
+    1. 本句在编号列表中
+    1. 此句子出现在编号列表中
+    1. 此句子出现在编号列表中
+        - 此句子出现在项目符号列表中
+1. 此句子出现在项目符号列表中
+    - 此句子出现在项目符号列表中
 
 
 
 
-# :blue_book: 其他
-### 引号
-- 在段落开头加上`>`,使用引号表达式
-    - 使用`>`字符序列可表达多个引号
-- 列表和其他元素可在方括号内一起使用
 
 
-#### 示例
-> - 引号
-> - 引号
->> 多个引号需要插入更多的 `>` 字符
+# :ledger: 链接
 
 
-## 代码
-- 可以通过将代码添加到三个 `` `` `` 中来表达代码
+## 自动链接
+只需输入 URL,链接就会自动生成。
 
 
-#### 示例
-```
-在此处添加代码  
-代码中可以体现换行和段落
+### 示例
+
+https://www.google.co.jp
 
 
-- 代码中也可使用列表
-    - 也可在代码中使用列表
+```markdown
+https://www.google.co.jp
 ```
 ```
 
 
-## 内联代码
+## 标签和链接
+通过输入 `[label](URL)` 插入链接
 
 
+### 示例
+- [Google](https://www.google.co.jp/)
+- [Sandbox is here](/Sandbox)
 
 
+```markdown
+- [Google](https://www.google.co.jp/)
+- [Sandbox is here](/Sandbox)
+```
 
 
+## 灵活的链接语法
 
 
-#### 示例
-以下是内联代码 
+灵活的链接语法使通过页面路径、相对页面链接和链接标签和 URL 编写链接变得容易。
 
 
-## 水平线
-- 用三个或三个以上连续的星号`*`或下划线`_`插入水平线
+- [[/Sandbox]]
+- [[./Math]]
+- [[如何写公式?>./Math]]
 
 
-#### 示例
-以下是水平线
-***
+```markdown
+- [[/Sandbox]]
+- [[./Math]]
+- [[如何写公式?>./Math]]
+```
 
 
-下面是水平线
-___
 
 
+# :notebook: 更多应用
+- [了解更多关于 Markdown](/Sandbox/Markdown)
 
 
-# :ledger: 更多应用
-- [Bootstrap5](/Sandbox/Bootstrap5)
+- [进一步装饰你的页面 (Bootstrap5)](/Sandbox/Bootstrap5)
 
 
-- [Diagrams](/Sandbox/Diagrams)
+- [如何表示图表 (Diagrams)](/Sandbox/Diagrams)
 
 
-- [Math](/Sandbox/Math)
+- [如何表示数学公式 (Math)](/Sandbox/Math)

+ 4 - 4
apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx

@@ -13,10 +13,10 @@ const SystemInformationTable = (props: Props) => {
   const { adminHomeContainer } = props;
   const { adminHomeContainer } = props;
 
 
   const {
   const {
-    growiVersion, nodeVersion, npmVersion, yarnVersion,
+    growiVersion, nodeVersion, npmVersion, pnpmVersion,
   } = adminHomeContainer.state;
   } = adminHomeContainer.state;
 
 
-  if (growiVersion == null || nodeVersion == null || npmVersion == null || yarnVersion == null) {
+  if (growiVersion == null || nodeVersion == null || npmVersion == null || pnpmVersion == null) {
     return <></>;
     return <></>;
   }
   }
 
 
@@ -36,8 +36,8 @@ const SystemInformationTable = (props: Props) => {
           <td>{ npmVersion }</td>
           <td>{ npmVersion }</td>
         </tr>
         </tr>
         <tr>
         <tr>
-          <th>yarn</th>
-          <td>{ yarnVersion }</td>
+          <th>pnpm</th>
+          <td>{ pnpmVersion }</td>
         </tr>
         </tr>
       </tbody>
       </tbody>
     </table>
     </table>

+ 7 - 1
apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.jsx

@@ -33,12 +33,18 @@ class SamlSecurityManagementContents extends React.Component {
 
 
     try {
     try {
       await adminSamlSecurityContainer.updateSamlSetting();
       await adminSamlSecurityContainer.updateSamlSetting();
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
       toastSuccess(t('security_settings.SAML.updated_saml'));
       toastSuccess(t('security_settings.SAML.updated_saml'));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
+
+    try {
+      await adminGeneralSecurityContainer.retrieveSetupStratedies();
+    }
+    catch (err) {
+      toastError(err);
+    }
   }
   }
 
 
   render() {
   render() {

+ 0 - 1
apps/app/src/client/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -120,7 +120,6 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
       <select
       <select
         name="actionName"
         name="actionName"
         className="form-control"
         className="form-control"
-        placeholder="select"
         value={actionName ?? ''}
         value={actionName ?? ''}
         onChange={handleActionChange}
         onChange={handleActionChange}
       >
       >

+ 12 - 11
apps/app/src/client/components/Bookmarks/DragAndDropWrapper.tsx

@@ -1,8 +1,8 @@
-import React, { ReactNode } from 'react';
+import type { ReactNode } from 'react';
 
 
 import { useDrag, useDrop } from 'react-dnd';
 import { useDrag, useDrop } from 'react-dnd';
 
 
-import { DragItemDataType } from '~/interfaces/bookmark-info';
+import type { DragItemDataType } from '~/interfaces/bookmark-info';
 
 
 type DragAndDropWrapperProps = {
 type DragAndDropWrapperProps = {
   item?: Partial<DragItemDataType>
   item?: Partial<DragItemDataType>
@@ -53,20 +53,21 @@ export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element
     }),
     }),
   }));
   }));
 
 
-
-  const getRef = (c: HTMLDivElement | null) => {
+  const getCallback = (c: HTMLDivElement | null) => {
     if (useDragMode && useDropMode) {
     if (useDragMode && useDropMode) {
-      return [dragRef(c), dropRef(c)];
-    } if (useDragMode) {
-      return dragRef(c);
-    } if (useDropMode) {
-      return dropRef(c);
+      dragRef(c);
+      dropRef(c);
+    }
+    else if (useDragMode) {
+      dragRef(c);
+    }
+    else if (useDropMode) {
+      dropRef(c);
     }
     }
-    return null;
   };
   };
 
 
   return (
   return (
-    <div ref={c => getRef(c)} className={`grw-drag-drop-container ${isOver ? 'grw-accept-drop-item' : ''}`}>
+    <div ref={getCallback} className={`grw-drag-drop-container ${isOver ? 'grw-accept-drop-item' : ''}`}>
       {children}
       {children}
     </div>
     </div>
   );
   );

+ 1 - 1
apps/app/src/client/components/InfiniteScroll.tsx

@@ -27,7 +27,7 @@ const useIntersection = <E extends HTMLElement>(): [boolean, Ref<E>] => {
     }
     }
     return;
     return;
   }, [element]);
   }, [element]);
-  return [intersecting, el => el && setElement(el)];
+  return [intersecting, (el) => { if (el != null) setElement(el); }];
 };
 };
 
 
 const LoadingIndicator = (): React.ReactElement => {
 const LoadingIndicator = (): React.ReactElement => {

+ 11 - 29
apps/app/src/client/components/InstallerForm.tsx

@@ -34,24 +34,11 @@ const InstallerForm = memo((props: Props): JSX.Element => {
 
 
   const isSupportedLang = AllLang.includes(i18n.language as Lang);
   const isSupportedLang = AllLang.includes(i18n.language as Lang);
 
 
-  const [isValidUserName, setValidUserName] = useState(true);
   const [isLoading, setIsLoading] = useState(false);
   const [isLoading, setIsLoading] = useState(false);
   const [currentLocale, setCurrentLocale] = useState(isSupportedLang ? i18n.language : Lang.en_US);
   const [currentLocale, setCurrentLocale] = useState(isSupportedLang ? i18n.language : Lang.en_US);
 
 
   const [registerErrors, setRegisterErrors] = useState<IErrorV3[]>([]);
   const [registerErrors, setRegisterErrors] = useState<IErrorV3[]>([]);
 
 
-  const checkUserName = useCallback(async(event) => {
-    const axios = require('axios').create({
-      headers: {
-        'Content-Type': 'application/json',
-        'X-Requested-With': 'XMLHttpRequest',
-      },
-      responseType: 'json',
-    });
-    const res = await axios.get('/_api/v3/check-username', { params: { username: event.target.value } });
-    setValidUserName(res.data.valid);
-  }, []);
-
   const onClickLanguageItem = useCallback((locale) => {
   const onClickLanguageItem = useCallback((locale) => {
     i18n.changeLanguage(locale);
     i18n.changeLanguage(locale);
     setCurrentLocale(locale);
     setCurrentLocale(locale);
@@ -101,13 +88,8 @@ const InstallerForm = memo((props: Props): JSX.Element => {
     }
     }
   }, [currentLocale, router, t]);
   }, [currentLocale, router, t]);
 
 
-  const hasErrorClass = isValidUserName ? '' : ' has-error';
-  const unavailableUserId = isValidUserName
-    ? ''
-    : <span><span className="material-symbols-outlined">block</span>{ t('installer.unavaliable_user_id') }</span>;
-
   return (
   return (
-    <div data-testid="installerForm" className={`${moduleClass} nologin-dialog py-3 px-4 rounded-4 rounded-top-0 mx-auto${hasErrorClass}`}>
+    <div data-testid="installerForm" className={`${moduleClass} nologin-dialog py-3 px-4 rounded-4 rounded-top-0 mx-auto`}>
       <div className="row mt-3">
       <div className="row mt-3">
         <div className="col-md-12">
         <div className="col-md-12">
           <p className="alert alert-success">
           <p className="alert alert-success">
@@ -120,13 +102,15 @@ const InstallerForm = memo((props: Props): JSX.Element => {
 
 
         {
         {
           registerErrors != null && registerErrors.length > 0 && (
           registerErrors != null && registerErrors.length > 0 && (
-            <p className="alert alert-danger text-center">
-              {registerErrors.map(err => (
-                <span>
-                  {tWithOpt(err.message, err.args)}<br />
-                </span>
-              ))}
-            </p>
+            <div className="col-12">
+              <div className="alert alert-danger text-center">
+                {registerErrors.map(err => (
+                  <span>
+                    {tWithOpt(err.message, err.args)}<br />
+                  </span>
+                ))}
+              </div>
+            </div>
           )
           )
         }
         }
 
 
@@ -179,7 +163,7 @@ const InstallerForm = memo((props: Props): JSX.Element => {
             </div>
             </div>
           </div>
           </div>
 
 
-          <div className={`input-group mb-3${hasErrorClass}`}>
+          <div className="input-group mb-3">
             <label className="p-2 text-white opacity-75" aria-label={t('User ID')} htmlFor="tiUsername">
             <label className="p-2 text-white opacity-75" aria-label={t('User ID')} htmlFor="tiUsername">
               <span className="material-symbols-outlined" aria-hidden>person</span>
               <span className="material-symbols-outlined" aria-hidden>person</span>
             </label>
             </label>
@@ -189,11 +173,9 @@ const InstallerForm = memo((props: Props): JSX.Element => {
               className="form-control rounded"
               className="form-control rounded"
               placeholder={t('User ID')}
               placeholder={t('User ID')}
               name="registerForm[username]"
               name="registerForm[username]"
-              // onBlur={checkUserName} // need not to check username before installation -- 2020.07.24 Yuki Takei
               required
               required
             />
             />
           </div>
           </div>
-          <p className="form-text">{ unavailableUserId }</p>
 
 
           <div className="input-group mb-3">
           <div className="input-group mb-3">
             <label className="p-2 text-white opacity-75" aria-label={t('Name')} htmlFor="tiName">
             <label className="p-2 text-white opacity-75" aria-label={t('Name')} htmlFor="tiName">

+ 3 - 2
apps/app/src/client/components/Me/InAppNotificationSettings.tsx

@@ -1,7 +1,6 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
 import React, { useState, useEffect, useCallback } from 'react';
 import React, { useState, useEffect, useCallback } from 'react';
 
 
-import pullAllBy from 'lodash/pullAllBy';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
@@ -28,7 +27,9 @@ const isCheckedRule = (ruleName: string, subscribeRules: SubscribeRule[]) => (
 
 
 const updateIsEnabled = (subscribeRules: SubscribeRule[], ruleName: string, isChecked: boolean) => {
 const updateIsEnabled = (subscribeRules: SubscribeRule[], ruleName: string, isChecked: boolean) => {
   const target = [{ name: ruleName, isEnabled: isChecked }];
   const target = [{ name: ruleName, isEnabled: isChecked }];
-  return pullAllBy(subscribeRules, target, 'name').concat(target);
+  return subscribeRules
+    .filter(rule => rule.name !== ruleName)
+    .concat(target);
 };
 };
 
 
 
 

+ 1 - 2
apps/app/src/client/components/PageComment/CommentEditor.tsx

@@ -9,7 +9,6 @@ import { CodeMirrorEditorComment } from '@growi/editor/dist/client/components/Co
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useResolvedThemeForEditor } from '@growi/editor/dist/client/stores/use-resolved-theme';
 import { useResolvedThemeForEditor } from '@growi/editor/dist/client/stores/use-resolved-theme';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
-import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import {
 import {
@@ -209,7 +208,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     });
     });
   }, [codeMirrorEditor, pageId]);
   }, [codeMirrorEditor, pageId]);
 
 
-  const cmProps = useMemo<ReactCodeMirrorProps>(() => ({
+  const cmProps = useMemo(() => ({
     onChange: async(value: string) => {
     onChange: async(value: string) => {
       const dirtyNum = await evaluateEditorDirtyMap(editorKey, value);
       const dirtyNum = await evaluateEditorDirtyMap(editorKey, value);
       mutateIsEnabledUnsavedWarning(dirtyNum > 0);
       mutateIsEnabledUnsavedWarning(dirtyNum > 0);

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

@@ -57,7 +57,6 @@ export const MarkdownTableDataImportForm = (props: MarkdownTableDataImportFormPr
         <select
         <select
           id="data-import-form-type-select"
           id="data-import-form-type-select"
           className="form-select"
           className="form-select"
-          placeholder="select"
           value={dataFormat}
           value={dataFormat}
           onChange={(e) => { return setDataFormat(e.target.value) }}
           onChange={(e) => { return setDataFormat(e.target.value) }}
         >
         >

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

@@ -6,14 +6,14 @@ import React, {
 import type EventEmitter from 'events';
 import type EventEmitter from 'events';
 import nodePath from 'path';
 import nodePath from 'path';
 
 
-import { type IPageHasId, Origin } from '@growi/core';
+import { Origin } from '@growi/core';
+import type { IPageHasId } from '@growi/core/dist/interfaces';
 import { pathUtils } from '@growi/core/dist/utils';
 import { pathUtils } from '@growi/core/dist/utils';
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import { CodeMirrorEditorMain } from '@growi/editor/dist/client/components/CodeMirrorEditorMain';
 import { CodeMirrorEditorMain } from '@growi/editor/dist/client/components/CodeMirrorEditorMain';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useResolvedThemeForEditor } from '@growi/editor/dist/client/stores/use-resolved-theme';
 import { useResolvedThemeForEditor } from '@growi/editor/dist/client/stores/use-resolved-theme';
 import { useRect } from '@growi/ui/dist/utils';
 import { useRect } from '@growi/ui/dist/utils';
-import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 import detectIndent from 'detect-indent';
 import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { throttle, debounce } from 'throttle-debounce';
 import { throttle, debounce } from 'throttle-debounce';
@@ -267,7 +267,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   }, [codeMirrorEditor, pageId]);
   }, [codeMirrorEditor, pageId]);
 
 
 
 
-  const cmProps = useMemo<ReactCodeMirrorProps>(() => ({
+  const cmProps = useMemo(() => ({
     onChange: (value: string) => {
     onChange: (value: string) => {
       setMarkdownPreviewWithDebounce(value);
       setMarkdownPreviewWithDebounce(value);
     },
     },

+ 1 - 1
apps/app/src/client/components/PageEditor/markdown-drawio-util-for-editor.ts

@@ -1,4 +1,4 @@
-import { EditorView } from '@codemirror/view';
+import type { EditorView } from '@growi/editor';
 
 
 const lineBeginPartOfDrawioRE = /^```(\s.*)drawio$/;
 const lineBeginPartOfDrawioRE = /^```(\s.*)drawio$/;
 const lineEndPartOfDrawioRE = /^```$/;
 const lineEndPartOfDrawioRE = /^```$/;

+ 1 - 1
apps/app/src/client/components/PageEditor/markdown-table-util-for-editor.ts

@@ -1,4 +1,4 @@
-import type { EditorView } from '@codemirror/view';
+import type { EditorView } from '@growi/editor';
 import { MarkdownTable } from '@growi/editor';
 import { MarkdownTable } from '@growi/editor';
 
 
 // https://regex101.com/r/7BN2fR/10
 // https://regex101.com/r/7BN2fR/10

+ 2 - 3
apps/app/src/client/components/SearchPage/SearchResultList.tsx

@@ -5,7 +5,7 @@ import React, {
 
 
 import {
 import {
   type IPageInfoForListing, type IPageWithMeta, isIPageInfoForListing,
   type IPageInfoForListing, type IPageWithMeta, isIPageInfoForListing,
-} from '@growi/core';
+} from '@growi/core/dist/interfaces';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import type { ISelectable, ISelectableAll } from '~/client/interfaces/selectable-all';
 import type { ISelectable, ISelectableAll } from '~/client/interfaces/selectable-all';
@@ -130,8 +130,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
         return (
         return (
           <PageListItemL
           <PageListItemL
             key={page.data._id}
             key={page.data._id}
-            // eslint-disable-next-line no-return-assign
-            ref={c => itemsRef.current[i] = c}
+            ref={(c) => { itemsRef.current[i] = c }}
             page={page}
             page={page}
             isEnableActions={!isGuestUser}
             isEnableActions={!isGuestUser}
             isReadOnlyUser={!!isReadOnlyUser}
             isReadOnlyUser={!!isReadOnlyUser}

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

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

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

@@ -4,9 +4,9 @@ import { SidebarContentsType } from '~/interfaces/ui';
 import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 import { useDefaultSocket } from '~/stores/socket-io';
 import { useDefaultSocket } from '~/stores/socket-io';
 
 
-import { PrimaryItem, type Props } from '../SidebarNav/PrimaryItem';
+import { PrimaryItem, type PrimaryItemProps } from '../SidebarNav/PrimaryItem';
 
 
-type PrimaryItemForNotificationProps = Omit<Props, 'onClick' | 'label' | 'iconName' | 'contents' | 'badgeContents' >
+type PrimaryItemForNotificationProps = Omit<PrimaryItemProps, 'onClick' | 'label' | 'iconName' | 'contents' | 'badgeContents' >
 
 
 // TODO(after v7 release): https://redmine.weseek.co.jp/issues/138463
 // TODO(after v7 release): https://redmine.weseek.co.jp/issues/138463
 export const PrimaryItemForNotification = memo((props: PrimaryItemForNotificationProps) => {
 export const PrimaryItemForNotification = memo((props: PrimaryItemForNotificationProps) => {

+ 11 - 0
apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -80,6 +80,16 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
     router.push(link);
     router.push(link);
   }, [router]);
   }, [router]);
 
 
+  const itemSelectedByWheelClickHandler = useCallback((page: IPageForItem) => {
+    if (page.path == null || page._id == null) {
+      return;
+    }
+
+    const url = pathUtils.returnPathForURL(page.path, page._id);
+
+    window.open(url, '_blank');
+  }, []);
+
   const [, drag] = useDrag({
   const [, drag] = useDrag({
     type: 'PAGE_TREE',
     type: 'PAGE_TREE',
     item: { page },
     item: { page },
@@ -186,6 +196,7 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
       onClick={itemSelectedHandler}
       onClick={itemSelectedHandler}
       onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
       onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
       onClickDeleteMenuItem={props.onClickDeleteMenuItem}
       onClickDeleteMenuItem={props.onClickDeleteMenuItem}
+      onWheelClick={itemSelectedByWheelClickHandler}
       onRenamed={props.onRenamed}
       onRenamed={props.onRenamed}
       itemRef={itemRef}
       itemRef={itemRef}
       itemClass={PageTreeItem}
       itemClass={PageTreeItem}

+ 40 - 19
apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx

@@ -1,8 +1,11 @@
-import { FC, useCallback } from 'react';
+import { useCallback } from 'react';
 
 
-import { SidebarContentsType, SidebarMode } from '~/interfaces/ui';
-import { useCollapsedContentsOpened, useCurrentSidebarContents } from '~/stores/ui';
+import { useTranslation } from 'next-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
 
 
+import type { SidebarContentsType } from '~/interfaces/ui';
+import { SidebarMode } from '~/interfaces/ui';
+import { useCollapsedContentsOpened, useCurrentSidebarContents, useIsMobile } from '~/stores/ui';
 
 
 const useIndicator = (sidebarMode: SidebarMode, isSelected: boolean): string => {
 const useIndicator = (sidebarMode: SidebarMode, isSelected: boolean): string => {
   const { data: isCollapsedContentsOpened } = useCollapsedContentsOpened();
   const { data: isCollapsedContentsOpened } = useCollapsedContentsOpened();
@@ -14,7 +17,7 @@ const useIndicator = (sidebarMode: SidebarMode, isSelected: boolean): string =>
   return isSelected ? 'active' : '';
   return isSelected ? 'active' : '';
 };
 };
 
 
-export type Props = {
+export type PrimaryItemProps = {
   contents: SidebarContentsType,
   contents: SidebarContentsType,
   label: string,
   label: string,
   iconName: string,
   iconName: string,
@@ -24,7 +27,7 @@ export type Props = {
   onClick?: () => void,
   onClick?: () => void,
 }
 }
 
 
-export const PrimaryItem: FC<Props> = (props: Props) => {
+export const PrimaryItem = (props: PrimaryItemProps): JSX.Element => {
   const {
   const {
     contents, label, iconName, sidebarMode, badgeContents,
     contents, label, iconName, sidebarMode, badgeContents,
     onClick, onHover,
     onClick, onHover,
@@ -33,6 +36,8 @@ export const PrimaryItem: FC<Props> = (props: Props) => {
   const { data: currentContents, mutateAndSave: mutateContents } = useCurrentSidebarContents();
   const { data: currentContents, mutateAndSave: mutateContents } = useCurrentSidebarContents();
 
 
   const indicatorClass = useIndicator(sidebarMode, contents === currentContents);
   const indicatorClass = useIndicator(sidebarMode, contents === currentContents);
+  const { data: isMobile } = useIsMobile();
+  const { t } = useTranslation();
 
 
   const selectThisItem = useCallback(() => {
   const selectThisItem = useCallback(() => {
     mutateContents(contents, false);
     mutateContents(contents, false);
@@ -62,19 +67,35 @@ export const PrimaryItem: FC<Props> = (props: Props) => {
   const labelForTestId = label.toLowerCase().replace(' ', '-');
   const labelForTestId = label.toLowerCase().replace(' ', '-');
 
 
   return (
   return (
-    <button
-      type="button"
-      data-testid={`grw-sidebar-nav-primary-${labelForTestId}`}
-      className={`btn btn-primary ${indicatorClass}`}
-      onClick={itemClickedHandler}
-      onMouseEnter={mouseEnteredHandler}
-    >
-      <div className="position-relative">
-        { badgeContents != null && (
-          <span className="position-absolute badge rounded-pill bg-primary">{badgeContents}</span>
-        )}
-        <span className="material-symbols-outlined">{iconName}</span>
-      </div>
-    </button>
+    <>
+      <button
+        type="button"
+        data-testid={`grw-sidebar-nav-primary-${labelForTestId}`}
+        className={`btn btn-primary ${indicatorClass}`}
+        onClick={itemClickedHandler}
+        onMouseEnter={mouseEnteredHandler}
+        id={labelForTestId}
+      >
+        <div className="position-relative">
+          { badgeContents != null && (
+            <span className="position-absolute badge rounded-pill bg-primary">{badgeContents}</span>
+          )}
+          <span className="material-symbols-outlined">{iconName}</span>
+        </div>
+      </button>
+      {
+        isMobile === false ? (
+          <UncontrolledTooltip
+            autohide
+            placement="right"
+            target={labelForTestId}
+            fade={false}
+          >
+            {t(label)}
+          </UncontrolledTooltip>
+        ) : <></>
+      }
+    </>
   );
   );
 };
 };
+PrimaryItem.displayName = 'PrimaryItem';

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

@@ -28,7 +28,8 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
     indentSize = 10,
     indentSize = 10,
     itemLevel: baseItemLevel = 1,
     itemLevel: baseItemLevel = 1,
     itemNode, targetPathOrId, isOpen: _isOpen = false,
     itemNode, targetPathOrId, isOpen: _isOpen = false,
-    onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser, isWipPageShown = true,
+    onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, onWheelClick,
+    isEnableActions, isReadOnlyUser, isWipPageShown = true,
     itemRef, itemClass,
     itemRef, itemClass,
     showAlternativeContent,
     showAlternativeContent,
   } = props;
   } = props;
@@ -51,6 +52,19 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
 
 
   }, [onClick, page]);
   }, [onClick, page]);
 
 
+  const itemMouseupHandler = useCallback((e: MouseEvent) => {
+    // DO NOT handle the event when e.currentTarget and e.target is different
+    if (e.target !== e.currentTarget) {
+      return;
+    }
+
+    if (e.button === 1) {
+      e.preventDefault();
+      onWheelClick?.(page);
+    }
+
+  }, [onWheelClick, page]);
+
 
 
   // descendantCount
   // descendantCount
   const { getDescCount } = usePageTreeDescCountMap();
   const { getDescCount } = usePageTreeDescCountMap();
@@ -141,6 +155,7 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
           border-0 py-0 ps-0 d-flex align-items-center rounded-1`}
           border-0 py-0 ps-0 d-flex align-items-center rounded-1`}
         id={`grw-pagetree-list-${page._id}`}
         id={`grw-pagetree-list-${page._id}`}
         onClick={itemClickHandler}
         onClick={itemClickHandler}
+        onMouseUp={itemMouseupHandler}
         aria-current={isSelected ? true : undefined}
         aria-current={isSelected ? true : undefined}
       >
       >
 
 

+ 1 - 0
apps/app/src/client/components/TreeItem/interfaces/index.ts

@@ -34,4 +34,5 @@ export type TreeItemProps = TreeItemBaseProps & {
   showAlternativeContent?: boolean,
   showAlternativeContent?: boolean,
   customAlternativeComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
   customAlternativeComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
   onClick?(page: IPageForItem): void,
   onClick?(page: IPageForItem): void,
+  onWheelClick?(page: IPageForItem): void,
 };
 };

+ 3 - 3
apps/app/src/client/services/AdminHomeContainer.js

@@ -31,7 +31,7 @@ export default class AdminHomeContainer extends Container {
       growiVersion: null,
       growiVersion: null,
       nodeVersion: null,
       nodeVersion: null,
       npmVersion: null,
       npmVersion: null,
-      yarnVersion: null,
+      pnpmVersion: null,
       copyState: this.copyStateValues.DEFAULT,
       copyState: this.copyStateValues.DEFAULT,
       installedPlugins: null,
       installedPlugins: null,
       isV5Compatible: null,
       isV5Compatible: null,
@@ -64,7 +64,7 @@ export default class AdminHomeContainer extends Container {
         growiVersion: adminHomeParams.growiVersion,
         growiVersion: adminHomeParams.growiVersion,
         nodeVersion: adminHomeParams.nodeVersion,
         nodeVersion: adminHomeParams.nodeVersion,
         npmVersion: adminHomeParams.npmVersion,
         npmVersion: adminHomeParams.npmVersion,
-        yarnVersion: adminHomeParams.yarnVersion,
+        pnpmVersion: adminHomeParams.pnpmVersion,
         envVars: adminHomeParams.envVars,
         envVars: adminHomeParams.envVars,
         isV5Compatible: adminHomeParams.isV5Compatible,
         isV5Compatible: adminHomeParams.isV5Compatible,
         isMaintenanceMode: adminHomeParams.isMaintenanceMode,
         isMaintenanceMode: adminHomeParams.isMaintenanceMode,
@@ -103,7 +103,7 @@ export default class AdminHomeContainer extends Container {
 |GROWI     |${this.state.growiVersion}|
 |GROWI     |${this.state.growiVersion}|
 |node.js   |${this.state.nodeVersion}|
 |node.js   |${this.state.nodeVersion}|
 |npm       |${this.state.npmVersion}|
 |npm       |${this.state.npmVersion}|
-|yarn      |${this.state.yarnVersion}|
+|pnpm      |${this.state.pnpmVersion}|
 |Using Docker|yes/no|
 |Using Docker|yes/no|
 |Using [growi-docker-compose][growi-docker-compose]|yes/no|
 |Using [growi-docker-compose][growi-docker-compose]|yes/no|
 
 

+ 4 - 0
apps/app/src/client/util/apiv3-client.ts

@@ -17,10 +17,14 @@ const apiv3ErrorHandler = (_err: any): any[] => {
   // extract api errors from general 400 err
   // extract api errors from general 400 err
   const err = _err.response ? _err.response.data.errors : _err;
   const err = _err.response ? _err.response.data.errors : _err;
   const errs = toArrayIfNot(err);
   const errs = toArrayIfNot(err);
+  const errorInfo = _err.response ? _err.response.data.info : undefined;
 
 
   for (const err of errs) {
   for (const err of errs) {
     logger.error(err.message);
     logger.error(err.message);
   }
   }
+  if (errorInfo != null) {
+    logger.error('additional info:', errorInfo);
+  }
 
 
   return errs;
   return errs;
 };
 };

+ 5 - 5
apps/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -26,7 +26,7 @@ const isExternalLink = (href: string, siteUrl: string | undefined): boolean => {
 
 
 const isCreatablePage = (href: string) => {
 const isCreatablePage = (href: string) => {
   try {
   try {
-    const url = new URL(href);
+    const url = new URL(href, 'http://example.com');
     const pathName = url.pathname;
     const pathName = url.pathname;
     return pagePathUtils.isCreatablePage(pathName);
     return pagePathUtils.isCreatablePage(pathName);
   }
   }
@@ -45,7 +45,7 @@ type Props = Omit<LinkProps, 'href'> & {
 
 
 export const NextLink = (props: Props): JSX.Element => {
 export const NextLink = (props: Props): JSX.Element => {
   const {
   const {
-    id, href, children, className, ...rest
+    id, href, children, className, onClick, ...rest
   } = props;
   } = props;
 
 
   const { data: siteUrl } = useSiteUrl();
   const { data: siteUrl } = useSiteUrl();
@@ -61,7 +61,7 @@ export const NextLink = (props: Props): JSX.Element => {
 
 
   if (isExternalLink(href, siteUrl)) {
   if (isExternalLink(href, siteUrl)) {
     return (
     return (
-      <a id={id} href={href} className={className} target="_blank" rel="noopener noreferrer" {...dataAttributes}>
+      <a id={id} href={href} className={className} target="_blank" onClick={onClick} rel="noopener noreferrer" {...dataAttributes}>
         {children}&nbsp;<span className="growi-custom-icons">external_link</span>
         {children}&nbsp;<span className="growi-custom-icons">external_link</span>
       </a>
       </a>
     );
     );
@@ -70,13 +70,13 @@ export const NextLink = (props: Props): JSX.Element => {
   // when href is an anchor link or not-creatable path
   // when href is an anchor link or not-creatable path
   if (isAnchorLink(href) || !isCreatablePage(href)) {
   if (isAnchorLink(href) || !isCreatablePage(href)) {
     return (
     return (
-      <a id={id} href={href} className={className} {...dataAttributes}>{children}</a>
+      <a id={id} href={href} className={className} onClick={onClick} {...dataAttributes}>{children}</a>
     );
     );
   }
   }
 
 
   return (
   return (
     <Link {...rest} href={href} prefetch={false} legacyBehavior>
     <Link {...rest} href={href} prefetch={false} legacyBehavior>
-      <a href={href} className={className} {...dataAttributes}>{children}</a>
+      <a href={href} className={className} {...dataAttributes} onClick={onClick}>{children}</a>
     </Link>
     </Link>
   );
   );
 };
 };

+ 9 - 6
apps/app/src/features/callout/components/CalloutViewer.tsx

@@ -13,7 +13,7 @@ type CALLOUT_TO = {
   [key in Callout]: string;
   [key in Callout]: string;
 }
 }
 
 
-const CALLOUT_TO_TITLE: CALLOUT_TO = {
+const CALLOUT_TO_TYPE: CALLOUT_TO = {
   note: 'Note',
   note: 'Note',
   tip: 'Tip',
   tip: 'Tip',
   important: 'Important',
   important: 'Important',
@@ -36,12 +36,15 @@ const CALLOUT_TO_ICON: CALLOUT_TO = {
 type CalloutViewerProps = {
 type CalloutViewerProps = {
   children: ReactNode,
   children: ReactNode,
   node: Element,
   node: Element,
-  name: string
+  type: string,
+  label?: string,
 }
 }
 
 
 export const CalloutViewer = React.memo((props: CalloutViewerProps): JSX.Element => {
 export const CalloutViewer = React.memo((props: CalloutViewerProps): JSX.Element => {
 
 
-  const { node, name, children } = props;
+  const {
+    node, type, label, children,
+  } = props;
 
 
   if (node == null) {
   if (node == null) {
     return <></>;
     return <></>;
@@ -49,13 +52,13 @@ export const CalloutViewer = React.memo((props: CalloutViewerProps): JSX.Element
 
 
   return (
   return (
     <div className={`${moduleClass} callout-viewer`}>
     <div className={`${moduleClass} callout-viewer`}>
-      <div className={`callout callout-${CALLOUT_TO_TITLE[name].toLowerCase()}`}>
+      <div className={`callout callout-${CALLOUT_TO_TYPE[type].toLowerCase()}`}>
         <div className="callout-indicator">
         <div className="callout-indicator">
           <div className="callout-hint">
           <div className="callout-hint">
-            <span className="material-symbols-outlined">{CALLOUT_TO_ICON[name]}</span>
+            <span className="material-symbols-outlined">{CALLOUT_TO_ICON[type]}</span>
           </div>
           </div>
           <div className="callout-title">
           <div className="callout-title">
-            {CALLOUT_TO_TITLE[name]}
+            {label ?? CALLOUT_TO_TYPE[type]}
           </div>
           </div>
         </div>
         </div>
         <div className="callout-content">
         <div className="callout-content">

+ 118 - 0
apps/app/src/features/callout/services/callout.spec.ts

@@ -0,0 +1,118 @@
+import type { ContainerDirective } from 'mdast-util-directive';
+import remarkDirective from 'remark-directive';
+import remarkParse from 'remark-parse';
+import { unified } from 'unified';
+import { visit } from 'unist-util-visit';
+import { describe, it, expect } from 'vitest';
+
+import * as callout from './callout';
+
+describe('remarkPlugin', () => {
+  it('should transform containerDirective to callout', () => {
+    const processor = unified()
+      .use(remarkParse)
+      .use(remarkDirective)
+      .use(callout.remarkPlugin);
+
+    const markdown = `
+:::info
+This is an info callout.
+:::
+    `;
+
+    const tree = processor.parse(markdown);
+    processor.runSync(tree);
+
+    let calloutNode;
+    visit(tree, 'containerDirective', (node) => {
+      calloutNode = node;
+    });
+
+    expect(calloutNode).toBeDefined();
+
+    assert(calloutNode?.data?.hName != null);
+    assert(calloutNode?.data?.hProperties != null);
+
+    expect(calloutNode.data.hName).toBe('callout');
+    expect(calloutNode.data.hProperties.type).toBe('info');
+    expect(calloutNode.data.hProperties.label).not.toBeDefined();
+
+    assert('children' in calloutNode.children[0]);
+    assert('value' in calloutNode.children[0].children[0]);
+
+    expect(calloutNode.children.length).toBe(1);
+    expect(calloutNode.children[0].children[0].value).toBe('This is an info callout.');
+  });
+
+  it('should transform containerDirective to callout with custom label', () => {
+    const processor = unified()
+      .use(remarkParse)
+      .use(remarkDirective)
+      .use(callout.remarkPlugin);
+
+    const markdown = `
+:::info[CUSTOM LABEL]
+This is an info callout.
+:::
+    `;
+
+    const tree = processor.parse(markdown);
+    processor.runSync(tree);
+
+    let calloutNode: ContainerDirective | undefined;
+    visit(tree, 'containerDirective', (node) => {
+      calloutNode = node;
+    });
+
+    expect(calloutNode).toBeDefined();
+
+    assert(calloutNode?.data?.hName != null);
+    assert(calloutNode?.data?.hProperties != null);
+
+    expect(calloutNode.data.hName).toBe('callout');
+    expect(calloutNode.data.hProperties.type).toBe('info');
+    expect(calloutNode.data.hProperties.label).toBe('CUSTOM LABEL');
+
+    assert('children' in calloutNode.children[0]);
+    assert('value' in calloutNode.children[0].children[0]);
+
+    expect(calloutNode.children.length).toBe(1);
+    expect(calloutNode.children[0].children[0].value).toBe('This is an info callout.');
+  });
+
+  it('should transform containerDirective to callout with empty label', () => {
+    const processor = unified()
+      .use(remarkParse)
+      .use(remarkDirective)
+      .use(callout.remarkPlugin);
+
+    const markdown = `
+:::info[]
+This is an info callout.
+:::
+    `;
+
+    const tree = processor.parse(markdown);
+    processor.runSync(tree);
+
+    let calloutNode: ContainerDirective | undefined;
+    visit(tree, 'containerDirective', (node) => {
+      calloutNode = node;
+    });
+
+    expect(calloutNode).toBeDefined();
+
+    assert(calloutNode?.data?.hName != null);
+    assert(calloutNode?.data?.hProperties != null);
+
+    expect(calloutNode.data.hName).toBe('callout');
+    expect(calloutNode.data.hProperties.type).toBe('info');
+    expect(calloutNode.data.hProperties.label).not.toBeDefined();
+
+    assert('children' in calloutNode.children[0]);
+    assert('value' in calloutNode.children[0].children[0]);
+
+    expect(calloutNode.children.length).toBe(1);
+    expect(calloutNode.children[0].children[0].value).toBe('This is an info callout.');
+  });
+});

+ 20 - 1
apps/app/src/features/callout/services/callout.ts

@@ -1,3 +1,4 @@
+import type { Paragraph, Text } from 'mdast';
 import type { ContainerDirective } from 'mdast-util-directive';
 import type { ContainerDirective } from 'mdast-util-directive';
 import type { Plugin } from 'unified';
 import type { Plugin } from 'unified';
 import { visit } from 'unist-util-visit';
 import { visit } from 'unist-util-visit';
@@ -8,11 +9,26 @@ export const remarkPlugin: Plugin = () => {
   return (tree) => {
   return (tree) => {
     visit(tree, 'containerDirective', (node: ContainerDirective) => {
     visit(tree, 'containerDirective', (node: ContainerDirective) => {
       if (AllCallout.some(name => name === node.name.toLowerCase())) {
       if (AllCallout.some(name => name === node.name.toLowerCase())) {
+        const type = node.name.toLowerCase();
         const data = node.data ?? (node.data = {});
         const data = node.data ?? (node.data = {});
+
+        // extract directive label
+        const paragraphs = (node.children ?? []).filter((child): child is Paragraph => child.type === 'paragraph');
+        const paragraphForDirectiveLabel = paragraphs.find(p => p.data?.directiveLabel);
+        const label = paragraphForDirectiveLabel != null && paragraphForDirectiveLabel.children.length > 0
+          ? (paragraphForDirectiveLabel.children[0] as Text).value
+          : undefined;
+        // remove directive label from children
+        if (paragraphForDirectiveLabel != null) {
+          node.children.splice(node.children.indexOf(paragraphForDirectiveLabel), 1);
+        }
+
         data.hName = 'callout';
         data.hName = 'callout';
         data.hProperties = {
         data.hProperties = {
-          name: node.name,
+          type,
+          label,
         };
         };
+
       }
       }
     });
     });
   };
   };
@@ -20,4 +36,7 @@ export const remarkPlugin: Plugin = () => {
 
 
 export const sanitizeOption = {
 export const sanitizeOption = {
   tagNames: ['callout'],
   tagNames: ['callout'],
+  attributes: {
+    callout: ['type', 'label'],
+  },
 };
 };

+ 59 - 26
apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx

@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
 import {
 import {
   Collapse,
   Collapse,
   Modal, ModalBody, ModalFooter, ModalHeader,
   Modal, ModalBody, ModalFooter, ModalHeader,
+  UncontrolledTooltip,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
@@ -34,6 +35,7 @@ type Message = {
 
 
 type FormData = {
 type FormData = {
   input: string;
   input: string;
+  summaryMode?: boolean;
 };
 };
 
 
 const AiChatModalSubstance = (): JSX.Element => {
 const AiChatModalSubstance = (): JSX.Element => {
@@ -43,6 +45,7 @@ const AiChatModalSubstance = (): JSX.Element => {
   const form = useForm<FormData>({
   const form = useForm<FormData>({
     defaultValues: {
     defaultValues: {
       input: '',
       input: '',
+      summaryMode: true,
     },
     },
   });
   });
 
 
@@ -97,7 +100,7 @@ const AiChatModalSubstance = (): JSX.Element => {
     setMessageLogs(msgs => [...msgs, newUserMessage]);
     setMessageLogs(msgs => [...msgs, newUserMessage]);
 
 
     // reset form
     // reset form
-    form.reset();
+    form.reset({ input: '', summaryMode: data.summaryMode });
     setErrorMessage(undefined);
     setErrorMessage(undefined);
 
 
     // add an empty assistant message
     // add an empty assistant message
@@ -109,7 +112,7 @@ const AiChatModalSubstance = (): JSX.Element => {
       const response = await fetch('/_api/v3/openai/message', {
       const response = await fetch('/_api/v3/openai/message', {
         method: 'POST',
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
         headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ userMessage: data.input, threadId }),
+        body: JSON.stringify({ userMessage: data.input, threadId, summaryMode: data.summaryMode }),
       });
       });
 
 
       if (!response.ok) {
       if (!response.ok) {
@@ -215,32 +218,62 @@ const AiChatModalSubstance = (): JSX.Element => {
       </ModalBody>
       </ModalBody>
 
 
       <ModalFooter className="flex-column align-items-start pt-0 pb-3 pb-lg-4 px-3 px-lg-4">
       <ModalFooter className="flex-column align-items-start pt-0 pb-3 pb-lg-4 px-3 px-lg-4">
-        <form onSubmit={form.handleSubmit(submit)} className="flex-fill hstack gap-2 align-items-end m-0">
-          <Controller
-            name="input"
-            control={form.control}
-            render={({ field }) => (
-              <ResizableTextarea
-                {...field}
-                required
-                className="form-control textarea-ask"
-                style={{ resize: 'none' }}
-                rows={1}
-                placeholder={!form.formState.isSubmitting ? t('modal_aichat.placeholder') : ''}
-                onKeyDown={keyDownHandler}
-                disabled={form.formState.isSubmitting}
-              />
-            )}
-          />
-          <button
-            type="submit"
-            className="btn btn-submit no-border"
-            disabled={form.formState.isSubmitting || isGenerating}
-          >
-            <span className="material-symbols-outlined">send</span>
-          </button>
+        <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-3">
+          <div className="flex-fill hstack gap-2 align-items-end m-0">
+            <Controller
+              name="input"
+              control={form.control}
+              render={({ field }) => (
+                <ResizableTextarea
+                  {...field}
+                  required
+                  className="form-control textarea-ask"
+                  style={{ resize: 'none' }}
+                  rows={1}
+                  placeholder={!form.formState.isSubmitting ? t('modal_aichat.placeholder') : ''}
+                  onKeyDown={keyDownHandler}
+                  disabled={form.formState.isSubmitting}
+                />
+              )}
+            />
+            <button
+              type="submit"
+              className="btn btn-submit no-border"
+              disabled={form.formState.isSubmitting || isGenerating}
+            >
+              <span className="material-symbols-outlined">send</span>
+            </button>
+          </div>
+          <div className="form-check form-switch">
+            <input
+              id="swSummaryMode"
+              type="checkbox"
+              role="switch"
+              className="form-check-input"
+              {...form.register('summaryMode')}
+              disabled={form.formState.isSubmitting || isGenerating}
+            />
+            <label className="form-check-label" htmlFor="swSummaryMode">
+              {t('modal_aichat.summary_mode_label')}
+            </label>
+
+            {/* Help */}
+            <a
+              id="tooltipForHelpOfSummaryMode"
+              role="button"
+              className="ms-1"
+            >
+              <span className="material-symbols-outlined fs-6" style={{ lineHeight: 'unset' }}>help</span>
+            </a>
+            <UncontrolledTooltip
+              target="tooltipForHelpOfSummaryMode"
+            >
+              {t('modal_aichat.summary_mode_help')}
+            </UncontrolledTooltip>
+          </div>
         </form>
         </form>
 
 
+
         {form.formState.errors.input != null && (
         {form.formState.errors.input != null && (
           <div className="mt-4 bg-danger bg-opacity-10 rounded-3 p-2 w-100">
           <div className="mt-4 bg-danger bg-opacity-10 rounded-3 p-2 w-100">
             <div>
             <div>

+ 32 - 12
apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.tsx

@@ -1,6 +1,13 @@
+import { useCallback } from 'react';
+
+import type { LinkProps } from 'next/link';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import ReactMarkdown from 'react-markdown';
 import ReactMarkdown from 'react-markdown';
 
 
+import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
+
+import { useRagSearchModal } from '../../../client/stores/rag-search';
+
 import styles from './MessageCard.module.scss';
 import styles from './MessageCard.module.scss';
 
 
 const moduleClass = styles['message-card'] ?? '';
 const moduleClass = styles['message-card'] ?? '';
@@ -19,8 +26,20 @@ const UserMessageCard = ({ children }: { children: string }): JSX.Element => (
 
 
 const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
 const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
 
 
-const AssistantMessageCard = ({ children }: { children: string }): JSX.Element => {
+const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => {
+  const { close: closeRagSearchModal } = useRagSearchModal();
+
+  const onClick = useCallback(() => {
+    closeRagSearchModal();
+  }, [closeRagSearchModal]);
 
 
+  return (
+    <NextLink href={props.href} onClick={onClick} className="link-primary">
+      {props.children}
+    </NextLink>
+  );
+};
+const AssistantMessageCard = ({ children }: { children: string }): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   return (
   return (
@@ -29,17 +48,18 @@ const AssistantMessageCard = ({ children }: { children: string }): JSX.Element =
         <div className="me-2 me-lg-3">
         <div className="me-2 me-lg-3">
           <span className="growi-custom-icons grw-ai-icon rounded-pill">growi_ai</span>
           <span className="growi-custom-icons grw-ai-icon rounded-pill">growi_ai</span>
         </div>
         </div>
-
-        { children.length > 0
-          ? (
-            <ReactMarkdown>{children}</ReactMarkdown>
-          )
-          : (
-            <span className="text-thinking">
-              {t('modal_aichat.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
-            </span>
-          )
-        }
+        <div>
+          { children.length > 0
+            ? (
+              <ReactMarkdown components={{ a: NextLinkWrapper }}>{children}</ReactMarkdown>
+            )
+            : (
+              <span className="text-thinking">
+                {t('modal_aichat.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
+              </span>
+            )
+          }
+        </div>
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 35 - 8
apps/app/src/features/openai/server/models/vector-store-file-relation.ts

@@ -5,20 +5,23 @@ import { type Model, type Document, Schema } from 'mongoose';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
 
 export interface VectorStoreFileRelation {
 export interface VectorStoreFileRelation {
-  pageId: mongoose.Types.ObjectId;
+  vectorStoreRelationId: mongoose.Types.ObjectId;
+  page: mongoose.Types.ObjectId;
   fileIds: string[];
   fileIds: string[];
+  isAttachedToVectorStore: boolean;
 }
 }
 
 
 interface VectorStoreFileRelationDocument extends VectorStoreFileRelation, Document {}
 interface VectorStoreFileRelationDocument extends VectorStoreFileRelation, Document {}
 
 
 interface VectorStoreFileRelationModel extends Model<VectorStoreFileRelation> {
 interface VectorStoreFileRelationModel extends Model<VectorStoreFileRelation> {
   upsertVectorStoreFileRelations(vectorStoreFileRelations: VectorStoreFileRelation[]): Promise<void>;
   upsertVectorStoreFileRelations(vectorStoreFileRelations: VectorStoreFileRelation[]): Promise<void>;
+  markAsAttachedToVectorStore(pageIds: Types.ObjectId[]): Promise<void>;
 }
 }
 
 
 export const prepareVectorStoreFileRelations = (
 export const prepareVectorStoreFileRelations = (
-    pageId: Types.ObjectId, fileId: string, relationsMap: Map<string, VectorStoreFileRelation>,
+    vectorStoreRelationId: Types.ObjectId, page: Types.ObjectId, fileId: string, relationsMap: Map<string, VectorStoreFileRelation>,
 ): Map<string, VectorStoreFileRelation> => {
 ): Map<string, VectorStoreFileRelation> => {
-  const pageIdStr = pageId.toHexString();
+  const pageIdStr = page.toHexString();
   const existingData = relationsMap.get(pageIdStr);
   const existingData = relationsMap.get(pageIdStr);
 
 
   // If the data exists, add the fileId to the fileIds array
   // If the data exists, add the fileId to the fileIds array
@@ -28,8 +31,10 @@ export const prepareVectorStoreFileRelations = (
   // If the data doesn't exist, create a new one and add it to the map
   // If the data doesn't exist, create a new one and add it to the map
   else {
   else {
     relationsMap.set(pageIdStr, {
     relationsMap.set(pageIdStr, {
-      pageId,
+      vectorStoreRelationId,
+      page,
       fileIds: [fileId],
       fileIds: [fileId],
+      isAttachedToVectorStore: false,
     });
     });
   }
   }
 
 
@@ -37,25 +42,39 @@ export const prepareVectorStoreFileRelations = (
 };
 };
 
 
 const schema = new Schema<VectorStoreFileRelationDocument, VectorStoreFileRelationModel>({
 const schema = new Schema<VectorStoreFileRelationDocument, VectorStoreFileRelationModel>({
-  pageId: {
+  vectorStoreRelationId: {
+    type: Schema.Types.ObjectId,
+    ref: 'VectorStore',
+    required: true,
+  },
+  page: {
     type: Schema.Types.ObjectId,
     type: Schema.Types.ObjectId,
     ref: 'Page',
     ref: 'Page',
     required: true,
     required: true,
-    unique: true,
   },
   },
   fileIds: [{
   fileIds: [{
     type: String,
     type: String,
     required: true,
     required: true,
   }],
   }],
+  isAttachedToVectorStore: {
+    type: Boolean,
+    default: false, // File is not attached to the Vector Store at the time it is uploaded
+    required: true,
+  },
 });
 });
 
 
+// define unique compound index
+schema.index({ vectorStoreRelationId: 1, page: 1 }, { unique: true });
+
 schema.statics.upsertVectorStoreFileRelations = async function(vectorStoreFileRelations: VectorStoreFileRelation[]): Promise<void> {
 schema.statics.upsertVectorStoreFileRelations = async function(vectorStoreFileRelations: VectorStoreFileRelation[]): Promise<void> {
   await this.bulkWrite(
   await this.bulkWrite(
     vectorStoreFileRelations.map((data) => {
     vectorStoreFileRelations.map((data) => {
       return {
       return {
         updateOne: {
         updateOne: {
-          filter: { pageId: data.pageId },
-          update: { $addToSet: { fileIds: { $each: data.fileIds } } },
+          filter: { page: data.page, vectorStoreRelationId: data.vectorStoreRelationId },
+          update: {
+            $addToSet: { fileIds: { $each: data.fileIds } },
+          },
           upsert: true,
           upsert: true,
         },
         },
       };
       };
@@ -63,4 +82,12 @@ schema.statics.upsertVectorStoreFileRelations = async function(vectorStoreFileRe
   );
   );
 };
 };
 
 
+// Used when attached to VectorStore
+schema.statics.markAsAttachedToVectorStore = async function(pageIds: Types.ObjectId[]): Promise<void> {
+  await this.updateMany(
+    { page: { $in: pageIds } },
+    { $set: { isAttachedToVectorStore: true } },
+  );
+};
+
 export default getOrCreateModel<VectorStoreFileRelationDocument, VectorStoreFileRelationModel>('VectorStoreFileRelation', schema);
 export default getOrCreateModel<VectorStoreFileRelationDocument, VectorStoreFileRelationModel>('VectorStoreFileRelation', schema);

+ 16 - 3
apps/app/src/features/openai/server/models/vector-store.ts

@@ -11,10 +11,13 @@ export type VectorStoreScopeType = typeof VectorStoreScopeType[keyof typeof Vect
 const VectorStoreScopeTypes = Object.values(VectorStoreScopeType);
 const VectorStoreScopeTypes = Object.values(VectorStoreScopeType);
 interface VectorStore {
 interface VectorStore {
   vectorStoreId: string
   vectorStoreId: string
-  scorpeType: VectorStoreScopeType
+  scopeType: VectorStoreScopeType
+  isDeleted: boolean
 }
 }
 
 
-export interface VectorStoreDocument extends VectorStore, Document {}
+export interface VectorStoreDocument extends VectorStore, Document {
+  markAsDeleted(): Promise<void>
+}
 
 
 type VectorStoreModel = Model<VectorStore>
 type VectorStoreModel = Model<VectorStore>
 
 
@@ -24,11 +27,21 @@ const schema = new Schema<VectorStoreDocument, VectorStoreModel>({
     required: true,
     required: true,
     unique: true,
     unique: true,
   },
   },
-  scorpeType: {
+  scopeType: {
     enum: VectorStoreScopeTypes,
     enum: VectorStoreScopeTypes,
     type: String,
     type: String,
     required: true,
     required: true,
   },
   },
+  isDeleted: {
+    type: Boolean,
+    default: false,
+    required: true,
+  },
 });
 });
 
 
+schema.methods.markAsDeleted = async function(): Promise<void> {
+  this.isDeleted = true;
+  await this.save();
+};
+
 export default getOrCreateModel<VectorStoreDocument, VectorStoreModel>('VectorStore', schema);
 export default getOrCreateModel<VectorStoreDocument, VectorStoreModel>('VectorStore', schema);

+ 24 - 4
apps/app/src/features/openai/server/routes/message.ts

@@ -1,3 +1,4 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler, Response } from 'express';
 import type { Request, RequestHandler, Response } from 'express';
 import type { ValidationChain } from 'express-validator';
 import type { ValidationChain } from 'express-validator';
@@ -7,6 +8,7 @@ import type { MessageDelta } from 'openai/resources/beta/threads/messages.mjs';
 
 
 import { getOrCreateChatAssistant } from '~/features/openai/server/services/assistant';
 import { getOrCreateChatAssistant } from '~/features/openai/server/services/assistant';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -14,6 +16,7 @@ import loggerFactory from '~/utils/logger';
 import { MessageErrorCode, type StreamErrorCode } from '../../interfaces/message-error';
 import { MessageErrorCode, type StreamErrorCode } from '../../interfaces/message-error';
 import { openaiClient } from '../services';
 import { openaiClient } from '../services';
 import { getStreamErrorCode } from '../services/getStreamErrorCode';
 import { getStreamErrorCode } from '../services/getStreamErrorCode';
+import { replaceAnnotationWithPageLink } from '../services/replace-annotation-with-page-link';
 
 
 import { certifyAiService } from './middlewares/certify-ai-service';
 import { certifyAiService } from './middlewares/certify-ai-service';
 
 
@@ -23,14 +26,16 @@ const logger = loggerFactory('growi:routes:apiv3:openai:message');
 type ReqBody = {
 type ReqBody = {
   userMessage: string,
   userMessage: string,
   threadId?: string,
   threadId?: string,
+  summaryMode?: boolean,
 }
 }
 
 
-type Req = Request<undefined, Response, ReqBody>
+type Req = Request<undefined, Response, ReqBody> & {
+  user: IUserHasId,
+}
 
 
 type PostMessageHandlersFactory = (crowi: Crowi) => RequestHandler[];
 type PostMessageHandlersFactory = (crowi: Crowi) => RequestHandler[];
 
 
 export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) => {
 export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) => {
-  const accessTokenParser = require('~/server/middlewares/access-token-parser')(crowi);
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
 
   const validator: ValidationChain[] = [
   const validator: ValidationChain[] = [
@@ -61,7 +66,15 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
 
 
         stream = openaiClient.beta.threads.runs.stream(thread.id, {
         stream = openaiClient.beta.threads.runs.stream(thread.id, {
           assistant_id: assistant.id,
           assistant_id: assistant.id,
-          additional_messages: [{ role: 'user', content: req.body.userMessage }],
+          additional_messages: [
+            {
+              role: 'assistant',
+              content: req.body.summaryMode
+                ? 'Turn on summary mode: I will try to answer concisely, aiming for 1-3 sentences.'
+                : 'I will turn off summary mode and answer.',
+            },
+            { role: 'user', content: req.body.userMessage },
+          ],
         });
         });
 
 
       }
       }
@@ -77,7 +90,14 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
         'Cache-Control': 'no-cache, no-transform',
         'Cache-Control': 'no-cache, no-transform',
       });
       });
 
 
-      const messageDeltaHandler = (delta: MessageDelta) => {
+      const messageDeltaHandler = async(delta: MessageDelta) => {
+        const content = delta.content?.[0];
+
+        // If annotation is found
+        if (content?.type === 'text' && content?.text?.annotations != null) {
+          await replaceAnnotationWithPageLink(content, req.user.lang);
+        }
+
         res.write(`data: ${JSON.stringify(delta)}\n\n`);
         res.write(`data: ${JSON.stringify(delta)}\n\n`);
       };
       };
 
 

+ 1 - 1
apps/app/src/features/openai/server/routes/rebuild-vector-store.ts

@@ -3,6 +3,7 @@ import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import type { ValidationChain } from 'express-validator';
 
 
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -16,7 +17,6 @@ const logger = loggerFactory('growi:routes:apiv3:openai:rebuild-vector-store');
 type RebuildVectorStoreFactory = (crowi: Crowi) => RequestHandler[];
 type RebuildVectorStoreFactory = (crowi: Crowi) => RequestHandler[];
 
 
 export const rebuildVectorStoreHandlersFactory: RebuildVectorStoreFactory = (crowi) => {
 export const rebuildVectorStoreHandlersFactory: RebuildVectorStoreFactory = (crowi) => {
-  const accessTokenParser = require('~/server/middlewares/access-token-parser')(crowi);
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
 
 

+ 1 - 1
apps/app/src/features/openai/server/routes/thread.ts

@@ -5,6 +5,7 @@ import { body } from 'express-validator';
 import { filterXSS } from 'xss';
 import { filterXSS } from 'xss';
 
 
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -20,7 +21,6 @@ type CreateThreadReq = Request<undefined, ApiV3Response, { threadId?: string }>
 type CreateThreadFactory = (crowi: Crowi) => RequestHandler[];
 type CreateThreadFactory = (crowi: Crowi) => RequestHandler[];
 
 
 export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
 export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
-  const accessTokenParser = require('~/server/middlewares/access-token-parser')(crowi);
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
 
   const validator: ValidationChain[] = [
   const validator: ValidationChain[] = [

+ 26 - 28
apps/app/src/features/openai/server/services/assistant/assistant.ts

@@ -36,35 +36,40 @@ const findAssistantByName = async(assistantName: string): Promise<OpenAI.Beta.As
 
 
 const getOrCreateAssistant = async(type: AssistantType): Promise<OpenAI.Beta.Assistant> => {
 const getOrCreateAssistant = async(type: AssistantType): Promise<OpenAI.Beta.Assistant> => {
   const appSiteUrl = configManager.getConfig('crowi', 'app:siteUrl');
   const appSiteUrl = configManager.getConfig('crowi', 'app:siteUrl');
-  const assistantName = `GROWI ${type} Assistant for ${appSiteUrl} ${configManager.getConfig('crowi', 'openai:assistantNameSuffix')}`;
+  const assistantNameSuffix = configManager.getConfig('crowi', 'openai:assistantNameSuffix');
+  const assistantName = `GROWI ${type} Assistant for ${appSiteUrl}${assistantNameSuffix != null ? ` ${assistantNameSuffix}` : ''}`;
 
 
-  const assistantOnRemote = await findAssistantByName(assistantName);
-  if (assistantOnRemote != null) {
-    return assistantOnRemote;
-  }
+  const assistant = await findAssistantByName(assistantName)
+    ?? (
+      await openaiClient.beta.assistants.create({
+        name: assistantName,
+        model: 'gpt-4o',
+      }));
 
 
-  const newAssistant = await openaiClient.beta.assistants.create({
-    name: assistantName,
-    model: 'gpt-4o',
+  // update instructions
+  const instructions = configManager.getConfig('crowi', 'openai:chatAssistantInstructions');
+  openaiClient.beta.assistants.update(assistant.id, {
+    instructions,
+    tools: [{ type: 'file_search' }],
   });
   });
 
 
-  return newAssistant;
+  return assistant;
 };
 };
 
 
-let searchAssistant: OpenAI.Beta.Assistant | undefined;
-export const getOrCreateSearchAssistant = async(): Promise<OpenAI.Beta.Assistant> => {
-  if (searchAssistant != null) {
-    return searchAssistant;
-  }
+// let searchAssistant: OpenAI.Beta.Assistant | undefined;
+// export const getOrCreateSearchAssistant = async(): Promise<OpenAI.Beta.Assistant> => {
+//   if (searchAssistant != null) {
+//     return searchAssistant;
+//   }
 
 
-  searchAssistant = await getOrCreateAssistant(AssistantType.SEARCH);
-  openaiClient.beta.assistants.update(searchAssistant.id, {
-    instructions: configManager.getConfig('crowi', 'openai:searchAssistantInstructions'),
-    tools: [{ type: 'file_search' }],
-  });
+//   searchAssistant = await getOrCreateAssistant(AssistantType.SEARCH);
+//   openaiClient.beta.assistants.update(searchAssistant.id, {
+//     instructions: configManager.getConfig('crowi', 'openai:searchAssistantInstructions'),
+//     tools: [{ type: 'file_search' }],
+//   });
 
 
-  return searchAssistant;
-};
+//   return searchAssistant;
+// };
 
 
 
 
 let chatAssistant: OpenAI.Beta.Assistant | undefined;
 let chatAssistant: OpenAI.Beta.Assistant | undefined;
@@ -73,13 +78,6 @@ export const getOrCreateChatAssistant = async(): Promise<OpenAI.Beta.Assistant>
     return chatAssistant;
     return chatAssistant;
   }
   }
 
 
-  const instructions = configManager.getConfig('crowi', 'openai:chatAssistantInstructions');
-
   chatAssistant = await getOrCreateAssistant(AssistantType.CHAT);
   chatAssistant = await getOrCreateAssistant(AssistantType.CHAT);
-  openaiClient.beta.assistants.update(chatAssistant.id, {
-    instructions,
-    tools: [{ type: 'file_search' }],
-  });
-
   return chatAssistant;
   return chatAssistant;
 };
 };

+ 4 - 0
apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts

@@ -48,6 +48,10 @@ export class AzureOpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.beta.vectorStores.retrieve(vectorStoreId);
     return this.client.beta.vectorStores.retrieve(vectorStoreId);
   }
   }
 
 
+  async deleteVectorStore(vectorStoreId: string): Promise<OpenAI.Beta.VectorStores.VectorStoreDeleted> {
+    return this.client.beta.vectorStores.del(vectorStoreId);
+  }
+
   async uploadFile(file: Uploadable): Promise<OpenAI.Files.FileObject> {
   async uploadFile(file: Uploadable): Promise<OpenAI.Files.FileObject> {
     return this.client.files.create({ file, purpose: 'assistants' });
     return this.client.files.create({ file, purpose: 'assistants' });
   }
   }

+ 1 - 0
apps/app/src/features/openai/server/services/client-delegator/interfaces.ts

@@ -9,6 +9,7 @@ export interface IOpenaiClientDelegator {
   deleteThread(threadId: string): Promise<OpenAI.Beta.Threads.ThreadDeleted>
   deleteThread(threadId: string): Promise<OpenAI.Beta.Threads.ThreadDeleted>
   retrieveVectorStore(vectorStoreId: string): Promise<OpenAI.Beta.VectorStores.VectorStore>
   retrieveVectorStore(vectorStoreId: string): Promise<OpenAI.Beta.VectorStores.VectorStore>
   createVectorStore(scopeType:VectorStoreScopeType): Promise<OpenAI.Beta.VectorStores.VectorStore>
   createVectorStore(scopeType:VectorStoreScopeType): Promise<OpenAI.Beta.VectorStores.VectorStore>
+  deleteVectorStore(vectorStoreId: string): Promise<OpenAI.Beta.VectorStores.VectorStoreDeleted>
   uploadFile(file: Uploadable): Promise<OpenAI.Files.FileObject>
   uploadFile(file: Uploadable): Promise<OpenAI.Files.FileObject>
   createVectorStoreFileBatch(vectorStoreId: string, fileIds: string[]): Promise<OpenAI.Beta.VectorStores.FileBatches.VectorStoreFileBatch>
   createVectorStoreFileBatch(vectorStoreId: string, fileIds: string[]): Promise<OpenAI.Beta.VectorStores.FileBatches.VectorStoreFileBatch>
   deleteFile(fileId: string): Promise<OpenAI.Files.FileDeleted>;
   deleteFile(fileId: string): Promise<OpenAI.Files.FileDeleted>;

+ 4 - 0
apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts

@@ -50,6 +50,10 @@ export class OpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.beta.vectorStores.retrieve(vectorStoreId);
     return this.client.beta.vectorStores.retrieve(vectorStoreId);
   }
   }
 
 
+  async deleteVectorStore(vectorStoreId: string): Promise<OpenAI.Beta.VectorStores.VectorStoreDeleted> {
+    return this.client.beta.vectorStores.del(vectorStoreId);
+  }
+
   async uploadFile(file: Uploadable): Promise<OpenAI.Files.FileObject> {
   async uploadFile(file: Uploadable): Promise<OpenAI.Files.FileObject> {
     return this.client.files.create({ file, purpose: 'assistants' });
     return this.client.files.create({ file, purpose: 'assistants' });
   }
   }

+ 0 - 0
packages/markdown-splitter/src/services/markdown-splitter.spec.ts → apps/app/src/features/openai/server/services/markdown-splitter/markdown-splitter.spec.ts


+ 14 - 7
packages/markdown-splitter/src/services/markdown-splitter.ts → apps/app/src/features/openai/server/services/markdown-splitter/markdown-splitter.ts

@@ -1,12 +1,13 @@
+import { dynamicImport } from '@cspell/dynamic-import';
 import type { TiktokenModel } from 'js-tiktoken';
 import type { TiktokenModel } from 'js-tiktoken';
 import { encodingForModel } from 'js-tiktoken';
 import { encodingForModel } from 'js-tiktoken';
 import yaml from 'js-yaml';
 import yaml from 'js-yaml';
-import remarkFrontmatter from 'remark-frontmatter'; // Frontmatter processing
-import remarkGfm from 'remark-gfm'; // GFM processing
-import remarkParse from 'remark-parse';
-import type { Options as StringifyOptions } from 'remark-stringify';
-import remarkStringify from 'remark-stringify';
-import { unified } from 'unified';
+import type * as RemarkFrontmatter from 'remark-frontmatter';
+import type * as RemarkGfm from 'remark-gfm';
+import type * as RemarkParse from 'remark-parse';
+import type * as RemarkStringify from 'remark-stringify';
+import type * as Unified from 'unified';
+
 
 
 export type MarkdownFragment = {
 export type MarkdownFragment = {
   label: string;
   label: string;
@@ -59,12 +60,18 @@ export async function splitMarkdownIntoFragments(markdownText: string, model: Ti
 
 
   const encoder = encodingForModel(model);
   const encoder = encodingForModel(model);
 
 
+  const remarkParse = (await dynamicImport<typeof RemarkParse>('remark-parse', __dirname)).default;
+  const remarkFrontmatter = (await dynamicImport<typeof RemarkFrontmatter>('remark-frontmatter', __dirname)).default;
+  const remarkGfm = (await dynamicImport<typeof RemarkGfm>('remark-gfm', __dirname)).default;
+  const remarkStringify = (await dynamicImport<typeof RemarkStringify>('remark-stringify', __dirname)).default;
+  const unified = (await dynamicImport<typeof Unified>('unified', __dirname)).unified;
+
   const parser = unified()
   const parser = unified()
     .use(remarkParse)
     .use(remarkParse)
     .use(remarkFrontmatter, ['yaml'])
     .use(remarkFrontmatter, ['yaml'])
     .use(remarkGfm); // Enable GFM extensions
     .use(remarkGfm); // Enable GFM extensions
 
 
-  const stringifyOptions: StringifyOptions = {
+  const stringifyOptions: RemarkStringify.Options = {
     bullet: '-', // Set list bullet to hyphen
     bullet: '-', // Set list bullet to hyphen
     rule: '-', // Use hyphen for horizontal rules
     rule: '-', // Use hyphen for horizontal rules
   };
   };

+ 0 - 0
packages/markdown-splitter/src/services/markdown-token-splitter.spec.ts → apps/app/src/features/openai/server/services/markdown-splitter/markdown-token-splitter.spec.ts


+ 2 - 2
packages/markdown-splitter/src/services/markdown-token-splitter.ts → apps/app/src/features/openai/server/services/markdown-splitter/markdown-token-splitter.ts

@@ -105,7 +105,7 @@ export async function splitMarkdownIntoChunks(
 
 
   // Split markdown text into chunks
   // Split markdown text into chunks
   const markdownFragments = await splitMarkdownIntoFragments(markdownText, model);
   const markdownFragments = await splitMarkdownIntoFragments(markdownText, model);
-  const chunks = [] as string[];
+  const chunks: string[] = [];
 
 
   // Group the chunks based on token count
   // Group the chunks based on token count
   const fragmentGroupes = groupMarkdownFragments(markdownFragments, maxToken);
   const fragmentGroupes = groupMarkdownFragments(markdownFragments, maxToken);
@@ -162,7 +162,7 @@ export async function splitMarkdownIntoChunks(
             const charCountForSplit = Math.floor((remainingTokenCount / fragmenTokenCount) * fragmentCharCount);
             const charCountForSplit = Math.floor((remainingTokenCount / fragmenTokenCount) * fragmentCharCount);
 
 
             // Split content based on character count
             // Split content based on character count
-            const splitContents = [];
+            const splitContents: string[] = [];
             for (let i = 0; i < fragment.text.length; i += charCountForSplit) {
             for (let i = 0; i < fragment.text.length; i += charCountForSplit) {
               splitContents.push(fragment.text.slice(i, i + charCountForSplit));
               splitContents.push(fragment.text.slice(i, i + charCountForSplit));
             }
             }

+ 113 - 16
apps/app/src/features/openai/server/services/openai.ts

@@ -21,6 +21,7 @@ import loggerFactory from '~/utils/logger';
 import { OpenaiServiceTypes } from '../../interfaces/ai';
 import { OpenaiServiceTypes } from '../../interfaces/ai';
 
 
 import { getClient } from './client-delegator';
 import { getClient } from './client-delegator';
+// import { splitMarkdownIntoChunks } from './markdown-splitter/markdown-token-splitter';
 import { oepnaiApiErrorHandler } from './openai-api-error-handler';
 import { oepnaiApiErrorHandler } from './openai-api-error-handler';
 
 
 const BATCH_SIZE = 100;
 const BATCH_SIZE = 100;
@@ -29,12 +30,16 @@ const logger = loggerFactory('growi:service:openai');
 
 
 let isVectorStoreForPublicScopeExist = false;
 let isVectorStoreForPublicScopeExist = false;
 
 
+type VectorStoreFileRelationsMap = Map<string, VectorStoreFileRelation>
+
 export interface IOpenaiService {
 export interface IOpenaiService {
   getOrCreateThread(userId: string, vectorStoreId?: string, threadId?: string): Promise<OpenAI.Beta.Threads.Thread | undefined>;
   getOrCreateThread(userId: string, vectorStoreId?: string, threadId?: string): Promise<OpenAI.Beta.Threads.Thread | undefined>;
   getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument>;
   getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument>;
-  deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void>;
+  deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
+  deleteObsolatedVectorStoreRelations(): Promise<void> // for CronJob
   createVectorStoreFile(pages: PageDocument[]): Promise<void>;
   createVectorStoreFile(pages: PageDocument[]): Promise<void>;
-  deleteVectorStoreFile(pageId: Types.ObjectId): Promise<void>;
+  deleteVectorStoreFile(vectorStoreRelationId: Types.ObjectId, pageId: Types.ObjectId): Promise<void>;
+  deleteObsoleteVectorStoreFile(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
   rebuildVectorStoreAll(): Promise<void>;
   rebuildVectorStoreAll(): Promise<void>;
   rebuildVectorStore(page: HydratedDocument<PageDocument>): Promise<void>;
   rebuildVectorStore(page: HydratedDocument<PageDocument>): Promise<void>;
 }
 }
@@ -103,7 +108,7 @@ class OpenaiService implements IOpenaiService {
   }
   }
 
 
   public async getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument> {
   public async getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument> {
-    const vectorStoreDocument = await VectorStoreModel.findOne({ scorpeType: VectorStoreScopeType.PUBLIC });
+    const vectorStoreDocument: VectorStoreDocument | null = await VectorStoreModel.findOne({ scopeType: VectorStoreScopeType.PUBLIC, isDeleted: false });
 
 
     if (vectorStoreDocument != null && isVectorStoreForPublicScopeExist) {
     if (vectorStoreDocument != null && isVectorStoreForPublicScopeExist) {
       return vectorStoreDocument;
       return vectorStoreDocument;
@@ -118,7 +123,7 @@ class OpenaiService implements IOpenaiService {
         return vectorStoreDocument;
         return vectorStoreDocument;
       }
       }
       catch (err) {
       catch (err) {
-        await oepnaiApiErrorHandler(err, { notFoundError: async() => { await vectorStoreDocument.remove() } });
+        await oepnaiApiErrorHandler(err, { notFoundError: vectorStoreDocument.markAsDeleted });
         throw new Error(err);
         throw new Error(err);
       }
       }
     }
     }
@@ -126,34 +131,66 @@ class OpenaiService implements IOpenaiService {
     const newVectorStore = await this.client.createVectorStore(VectorStoreScopeType.PUBLIC);
     const newVectorStore = await this.client.createVectorStore(VectorStoreScopeType.PUBLIC);
     const newVectorStoreDocument = await VectorStoreModel.create({
     const newVectorStoreDocument = await VectorStoreModel.create({
       vectorStoreId: newVectorStore.id,
       vectorStoreId: newVectorStore.id,
-      scorpeType: VectorStoreScopeType.PUBLIC,
-    });
+      scopeType: VectorStoreScopeType.PUBLIC,
+    }) as VectorStoreDocument;
 
 
     isVectorStoreForPublicScopeExist = true;
     isVectorStoreForPublicScopeExist = true;
 
 
     return newVectorStoreDocument;
     return newVectorStoreDocument;
   }
   }
 
 
+  // TODO: https://redmine.weseek.co.jp/issues/156643
+  // private async uploadFileByChunks(pageId: Types.ObjectId, body: string, vectorStoreFileRelationsMap: VectorStoreFileRelationsMap) {
+  //   const chunks = await splitMarkdownIntoChunks(body, 'gpt-4o');
+  //   for await (const [index, chunk] of chunks.entries()) {
+  //     try {
+  //       const file = await toFile(Readable.from(chunk), `${pageId}-chunk-${index}.md`);
+  //       const uploadedFile = await this.client.uploadFile(file);
+  //       prepareVectorStoreFileRelations(pageId, uploadedFile.id, vectorStoreFileRelationsMap);
+  //     }
+  //     catch (err) {
+  //       logger.error(err);
+  //     }
+  //   }
+  // }
+
   private async uploadFile(pageId: Types.ObjectId, body: string): Promise<OpenAI.Files.FileObject> {
   private async uploadFile(pageId: Types.ObjectId, body: string): Promise<OpenAI.Files.FileObject> {
     const file = await toFile(Readable.from(body), `${pageId}.md`);
     const file = await toFile(Readable.from(body), `${pageId}.md`);
     const uploadedFile = await this.client.uploadFile(file);
     const uploadedFile = await this.client.uploadFile(file);
     return uploadedFile;
     return uploadedFile;
   }
   }
 
 
+  private async deleteVectorStore(vectorStoreScopeType: VectorStoreScopeType): Promise<void> {
+    const vectorStoreDocument: VectorStoreDocument | null = await VectorStoreModel.findOne({ scopeType: vectorStoreScopeType, isDeleted: false });
+    if (vectorStoreDocument == null) {
+      return;
+    }
+
+    try {
+      await this.client.deleteVectorStore(vectorStoreDocument.vectorStoreId);
+      await vectorStoreDocument.markAsDeleted();
+    }
+    catch (err) {
+      await oepnaiApiErrorHandler(err, { notFoundError: vectorStoreDocument.markAsDeleted });
+      throw new Error(err);
+    }
+  }
+
   async createVectorStoreFile(pages: Array<HydratedDocument<PageDocument>>): Promise<void> {
   async createVectorStoreFile(pages: Array<HydratedDocument<PageDocument>>): Promise<void> {
-    const vectorStoreFileRelationsMap: Map<string, VectorStoreFileRelation> = new Map();
+    const vectorStore = await this.getOrCreateVectorStoreForPublicScope();
+    const vectorStoreFileRelationsMap: VectorStoreFileRelationsMap = new Map();
     const processUploadFile = async(page: PageDocument) => {
     const processUploadFile = async(page: PageDocument) => {
       if (page._id != null && page.grant === PageGrant.GRANT_PUBLIC && page.revision != null) {
       if (page._id != null && page.grant === PageGrant.GRANT_PUBLIC && page.revision != null) {
         if (isPopulated(page.revision) && page.revision.body.length > 0) {
         if (isPopulated(page.revision) && page.revision.body.length > 0) {
           const uploadedFile = await this.uploadFile(page._id, page.revision.body);
           const uploadedFile = await this.uploadFile(page._id, page.revision.body);
-          prepareVectorStoreFileRelations(page._id, uploadedFile.id, vectorStoreFileRelationsMap);
+          prepareVectorStoreFileRelations(vectorStore._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap);
           return;
           return;
         }
         }
 
 
         const pagePopulatedToShowRevision = await page.populateDataToShowRevision();
         const pagePopulatedToShowRevision = await page.populateDataToShowRevision();
         if (pagePopulatedToShowRevision.revision != null && pagePopulatedToShowRevision.revision.body.length > 0) {
         if (pagePopulatedToShowRevision.revision != null && pagePopulatedToShowRevision.revision.body.length > 0) {
           const uploadedFile = await this.uploadFile(page._id, pagePopulatedToShowRevision.revision.body);
           const uploadedFile = await this.uploadFile(page._id, pagePopulatedToShowRevision.revision.body);
-          prepareVectorStoreFileRelations(page._id, uploadedFile.id, vectorStoreFileRelationsMap);
+          prepareVectorStoreFileRelations(vectorStore._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap);
         }
         }
       }
       }
     };
     };
@@ -177,30 +214,58 @@ class OpenaiService implements IOpenaiService {
       return;
       return;
     }
     }
 
 
+    const pageIds = pages.map(page => page._id);
+
     try {
     try {
       // Save vector store file relation
       // Save vector store file relation
       await VectorStoreFileRelationModel.upsertVectorStoreFileRelations(vectorStoreFileRelations);
       await VectorStoreFileRelationModel.upsertVectorStoreFileRelations(vectorStoreFileRelations);
 
 
       // Create vector store file
       // Create vector store file
-      const vectorStore = await this.getOrCreateVectorStoreForPublicScope();
       const createVectorStoreFileBatchResponse = await this.client.createVectorStoreFileBatch(vectorStore.vectorStoreId, uploadedFileIds);
       const createVectorStoreFileBatchResponse = await this.client.createVectorStoreFileBatch(vectorStore.vectorStoreId, uploadedFileIds);
       logger.debug('Create vector store file', createVectorStoreFileBatchResponse);
       logger.debug('Create vector store file', createVectorStoreFileBatchResponse);
+
+      // Set isAttachedToVectorStore: true when the uploaded file is attached to VectorStore
+      await VectorStoreFileRelationModel.markAsAttachedToVectorStore(pageIds);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
 
 
       // Delete all uploaded files if createVectorStoreFileBatch fails
       // Delete all uploaded files if createVectorStoreFileBatch fails
-      const pageIds = pages.map(page => page._id);
       for await (const pageId of pageIds) {
       for await (const pageId of pageIds) {
-        await this.deleteVectorStoreFile(pageId);
+        await this.deleteVectorStoreFile(vectorStore._id, pageId);
       }
       }
     }
     }
 
 
   }
   }
 
 
-  async deleteVectorStoreFile(pageId: Types.ObjectId): Promise<void> {
+  // Deletes all VectorStore documents that are marked as deleted (isDeleted: true) and have no associated VectorStoreFileRelation documents
+  async deleteObsolatedVectorStoreRelations(): Promise<void> {
+    const deletedVectorStoreRelations = await VectorStoreModel.find({ isDeleted: true });
+    if (deletedVectorStoreRelations.length === 0) {
+      return;
+    }
+
+    const currentVectorStoreRelationIds: Types.ObjectId[] = await VectorStoreFileRelationModel.aggregate([
+      {
+        $group: {
+          _id: '$vectorStoreRelationId',
+          relationCount: { $sum: 1 },
+        },
+      },
+      { $match: { relationCount: { $gt: 0 } } },
+      { $project: { _id: 1 } },
+    ]);
+
+    if (currentVectorStoreRelationIds.length === 0) {
+      return;
+    }
+
+    await VectorStoreModel.deleteMany({ _id: { $nin: currentVectorStoreRelationIds }, isDeleted: true });
+  }
+
+  async deleteVectorStoreFile(vectorStoreRelationId: Types.ObjectId, pageId: Types.ObjectId, apiCallInterval?: number): Promise<void> {
     // Delete vector store file and delete vector store file relation
     // Delete vector store file and delete vector store file relation
-    const vectorStoreFileRelation = await VectorStoreFileRelationModel.findOne({ pageId });
+    const vectorStoreFileRelation = await VectorStoreFileRelationModel.findOne({ vectorStoreRelationId, page: pageId });
     if (vectorStoreFileRelation == null) {
     if (vectorStoreFileRelation == null) {
       return;
       return;
     }
     }
@@ -211,8 +276,13 @@ class OpenaiService implements IOpenaiService {
         const deleteFileResponse = await this.client.deleteFile(fileId);
         const deleteFileResponse = await this.client.deleteFile(fileId);
         logger.debug('Delete vector store file', deleteFileResponse);
         logger.debug('Delete vector store file', deleteFileResponse);
         deletedFileIds.push(fileId);
         deletedFileIds.push(fileId);
+        if (apiCallInterval != null) {
+          // sleep
+          await new Promise(resolve => setTimeout(resolve, apiCallInterval));
+        }
       }
       }
       catch (err) {
       catch (err) {
+        await oepnaiApiErrorHandler(err, { notFoundError: async() => { deletedFileIds.push(fileId) } });
         logger.error(err);
         logger.error(err);
       }
       }
     }
     }
@@ -228,8 +298,34 @@ class OpenaiService implements IOpenaiService {
     await vectorStoreFileRelation.save();
     await vectorStoreFileRelation.save();
   }
   }
 
 
+  async deleteObsoleteVectorStoreFile(limit: number, apiCallInterval: number): Promise<void> {
+    // Retrieves all VectorStore documents that are marked as deleted
+    const deletedVectorStoreRelations = await VectorStoreModel.find({ isDeleted: true });
+    if (deletedVectorStoreRelations.length === 0) {
+      return;
+    }
+
+    // Retrieves VectorStoreFileRelation documents associated with deleted VectorStore documents
+    const obsoleteVectorStoreFileRelations = await VectorStoreFileRelationModel.find(
+      { vectorStoreRelationId: { $in: deletedVectorStoreRelations.map(deletedVectorStoreRelation => deletedVectorStoreRelation._id) } },
+    ).limit(limit);
+    if (obsoleteVectorStoreFileRelations.length === 0) {
+      return;
+    }
+
+    // Delete obsolete VectorStoreFile
+    for await (const vectorStoreFileRelation of obsoleteVectorStoreFileRelations) {
+      try {
+        await this.deleteVectorStoreFile(vectorStoreFileRelation.vectorStoreRelationId, vectorStoreFileRelation.page, apiCallInterval);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    }
+  }
+
   async rebuildVectorStoreAll() {
   async rebuildVectorStoreAll() {
-    // TODO: https://redmine.weseek.co.jp/issues/154364
+    await this.deleteVectorStore(VectorStoreScopeType.PUBLIC);
 
 
     // Create all public pages VectorStoreFile
     // Create all public pages VectorStoreFile
     const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
     const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
@@ -252,7 +348,8 @@ class OpenaiService implements IOpenaiService {
   }
   }
 
 
   async rebuildVectorStore(page: HydratedDocument<PageDocument>) {
   async rebuildVectorStore(page: HydratedDocument<PageDocument>) {
-    await this.deleteVectorStoreFile(page._id);
+    const vectorStore = await this.getOrCreateVectorStoreForPublicScope();
+    await this.deleteVectorStoreFile(vectorStore._id, page._id);
     await this.createVectorStoreFile([page]);
     await this.createVectorStoreFile([page]);
   }
   }
 
 

+ 29 - 0
apps/app/src/features/openai/server/services/replace-annotation-with-page-link.ts

@@ -0,0 +1,29 @@
+// See: https://platform.openai.com/docs/assistants/tools/file-search#step-5-create-a-run-and-check-the-output
+
+import type { IPageHasId, Lang } from '@growi/core/dist/interfaces';
+import type { MessageContentDelta } from 'openai/resources/beta/threads/messages.mjs';
+
+import VectorStoreFileRelationModel from '~/features/openai/server/models/vector-store-file-relation';
+import { getTranslation } from '~/server/service/i18next';
+
+export const replaceAnnotationWithPageLink = async(messageContentDelta: MessageContentDelta, lang?: Lang): Promise<void> => {
+  if (messageContentDelta?.type === 'text' && messageContentDelta?.text?.annotations != null) {
+    const annotations = messageContentDelta?.text?.annotations;
+    for await (const annotation of annotations) {
+      if (annotation.type === 'file_citation' && annotation.text != null) {
+
+        const vectorStoreFileRelation = await VectorStoreFileRelationModel
+          .findOne({ fileIds: { $in: [annotation.file_citation?.file_id] } })
+          .populate<{page: Pick<IPageHasId, 'path' | '_id'>}>('page', 'path');
+
+        if (vectorStoreFileRelation != null) {
+          const { t } = await getTranslation({ lang });
+          messageContentDelta.text.value = messageContentDelta.text.value?.replace(
+            annotation.text,
+            ` [${t('source')}: [${vectorStoreFileRelation.page.path}](/${vectorStoreFileRelation.page._id})]`,
+          );
+        }
+      }
+    }
+  }
+};

+ 10 - 0
apps/app/src/features/openai/server/services/thread-deletion-cron.ts

@@ -2,6 +2,7 @@ import nodeCron from 'node-cron';
 
 
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+import { getRandomIntInRange } from '~/utils/rand';
 
 
 import { getOpenaiService, type IOpenaiService } from './openai';
 import { getOpenaiService, type IOpenaiService } from './openai';
 
 
@@ -15,10 +16,14 @@ class ThreadDeletionCronService {
 
 
   threadDeletionCronExpression: string;
   threadDeletionCronExpression: string;
 
 
+  threadDeletionCronMaxMinutesUntilRequest: number;
+
   threadDeletionBarchSize: number;
   threadDeletionBarchSize: number;
 
 
   threadDeletionApiCallInterval: number;
   threadDeletionApiCallInterval: number;
 
 
+  sleep = (msec: number): Promise<void> => new Promise(resolve => setTimeout(resolve, msec));
+
   startCron(): void {
   startCron(): void {
     const isAiEnabled = configManager.getConfig('crowi', 'app:aiEnabled');
     const isAiEnabled = configManager.getConfig('crowi', 'app:aiEnabled');
     if (!isAiEnabled) {
     if (!isAiEnabled) {
@@ -32,6 +37,7 @@ class ThreadDeletionCronService {
 
 
     this.openaiService = openaiService;
     this.openaiService = openaiService;
     this.threadDeletionCronExpression = configManager.getConfig('crowi', 'openai:threadDeletionCronExpression');
     this.threadDeletionCronExpression = configManager.getConfig('crowi', 'openai:threadDeletionCronExpression');
+    this.threadDeletionCronMaxMinutesUntilRequest = configManager.getConfig('crowi', 'app:openaiThreadDeletionCronMaxMinutesUntilRequest');
     this.threadDeletionBarchSize = configManager.getConfig('crowi', 'openai:threadDeletionBarchSize');
     this.threadDeletionBarchSize = configManager.getConfig('crowi', 'openai:threadDeletionBarchSize');
     this.threadDeletionApiCallInterval = configManager.getConfig('crowi', 'openai:threadDeletionApiCallInterval');
     this.threadDeletionApiCallInterval = configManager.getConfig('crowi', 'openai:threadDeletionApiCallInterval');
 
 
@@ -48,6 +54,10 @@ class ThreadDeletionCronService {
   private generateCronJob() {
   private generateCronJob() {
     return nodeCron.schedule(this.threadDeletionCronExpression, async() => {
     return nodeCron.schedule(this.threadDeletionCronExpression, async() => {
       try {
       try {
+        // Random fractional sleep to distribute request timing among GROWI apps
+        const randomMilliseconds = getRandomIntInRange(0, this.threadDeletionCronMaxMinutesUntilRequest) * 60 * 1000;
+        await this.sleep(randomMilliseconds);
+
         await this.executeJob();
         await this.executeJob();
       }
       }
       catch (e) {
       catch (e) {

+ 71 - 0
apps/app/src/features/openai/server/services/vector-store-file-deletion-cron.ts

@@ -0,0 +1,71 @@
+import nodeCron from 'node-cron';
+
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+import { getRandomIntInRange } from '~/utils/rand';
+
+import { getOpenaiService, type IOpenaiService } from './openai';
+
+const logger = loggerFactory('growi:service:vector-store-file-deletion-cron');
+
+class VectorStoreFileDeletionCronService {
+
+  cronJob: nodeCron.ScheduledTask;
+
+  openaiService: IOpenaiService;
+
+  vectorStoreFileDeletionCronExpression: string;
+
+  vectorStoreFileDeletionCronMaxMinutesUntilRequest: number;
+
+  vectorStoreFileDeletionBarchSize: number;
+
+  vectorStoreFileDeletionApiCallInterval: number;
+
+  sleep = (msec: number): Promise<void> => new Promise(resolve => setTimeout(resolve, msec));
+
+  startCron(): void {
+    const isAiEnabled = configManager.getConfig('crowi', 'app:aiEnabled');
+    if (!isAiEnabled) {
+      return;
+    }
+
+    const openaiService = getOpenaiService();
+    if (openaiService == null) {
+      throw new Error('OpenAI service is not initialized');
+    }
+
+    this.openaiService = openaiService;
+    this.vectorStoreFileDeletionCronExpression = configManager.getConfig('crowi', 'openai:vectorStoreFileDeletionCronExpression');
+    this.vectorStoreFileDeletionCronMaxMinutesUntilRequest = configManager.getConfig('crowi', 'app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest');
+    this.vectorStoreFileDeletionBarchSize = configManager.getConfig('crowi', 'openai:vectorStoreFileDeletionBarchSize');
+    this.vectorStoreFileDeletionApiCallInterval = configManager.getConfig('crowi', 'openai:vectorStoreFileDeletionApiCallInterval');
+
+    this.cronJob?.stop();
+    this.cronJob = this.generateCronJob();
+    this.cronJob.start();
+  }
+
+  private async executeJob(): Promise<void> {
+    await this.openaiService.deleteObsolatedVectorStoreRelations();
+    await this.openaiService.deleteObsoleteVectorStoreFile(this.vectorStoreFileDeletionBarchSize, this.vectorStoreFileDeletionApiCallInterval);
+  }
+
+  private generateCronJob() {
+    return nodeCron.schedule(this.vectorStoreFileDeletionCronExpression, async() => {
+      try {
+        // Random fractional sleep to distribute request timing among GROWI apps
+        const randomMilliseconds = getRandomIntInRange(0, this.vectorStoreFileDeletionCronMaxMinutesUntilRequest) * 60 * 1000;
+        await this.sleep(randomMilliseconds);
+
+        await this.executeJob();
+      }
+      catch (e) {
+        logger.error(e);
+      }
+    });
+  }
+
+}
+
+export default VectorStoreFileDeletionCronService;

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