Kaynağa Gözat

Merge branch 'master' into feat/6982-textlint

Steven Fukase 4 yıl önce
ebeveyn
işleme
9570239f72
100 değiştirilmiş dosya ile 1575 ekleme ve 707 silme
  1. 1 0
      .devcontainer/devcontainer.json
  2. 0 1
      .devcontainer/docker-compose.yml
  3. 4 0
      .eslintrc.js
  4. 2 2
      .github/workflows/ci-slackbot-proxy.yml
  5. 12 9
      .github/workflows/ci.yml
  6. 1 1
      .github/workflows/release-rc.yml
  7. 2 2
      .github/workflows/release.yml
  8. 4 0
      .markdownlint.yml
  9. 20 0
      .vscode/launch.json
  10. 9 0
      CHANGES.md
  11. 21 3
      package.json
  12. 2 2
      packages/app/.env.development
  13. 1 1
      packages/app/.env.production
  14. 1 0
      packages/app/.eslintignore
  15. 1 1
      packages/app/.gitignore
  16. 22 14
      packages/app/bin/generate-plugin-definitions-source.ts
  17. 2 0
      packages/app/config/ci/.env.local.for-ci
  18. 7 5
      packages/app/config/webpack.common.js
  19. 34 24
      packages/app/docker/Dockerfile
  20. 3 3
      packages/app/docker/docker-entrypoint.sh
  21. 1 0
      packages/app/jest.config.js
  22. 12 20
      packages/app/package.json
  23. 3 7
      packages/app/resource/locales/en_US/admin/admin.json
  24. 3 6
      packages/app/resource/locales/ja_JP/admin/admin.json
  25. 311 313
      packages/app/resource/locales/zh_CN/admin/admin.json
  26. 20 1
      packages/app/src/client/models/Linker.js
  27. 4 2
      packages/app/src/client/plugin.js
  28. 1 1
      packages/app/src/client/services/AdminGitHubSecurityContainer.js
  29. 1 1
      packages/app/src/client/services/AdminGoogleSecurityContainer.js
  30. 1 1
      packages/app/src/client/services/AdminOidcSecurityContainer.js
  31. 1 1
      packages/app/src/client/services/AdminSamlSecurityContainer.js
  32. 1 1
      packages/app/src/client/services/AdminTwitterSecurityContainer.js
  33. 3 1
      packages/app/src/client/services/PageContainer.js
  34. 1 1
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  35. 5 1
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  36. 144 0
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  37. 5 1
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  38. 21 1
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  39. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  40. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.jsx
  41. 1 1
      packages/app/src/components/Admin/Users/UserTable.jsx
  42. 3 1
      packages/app/src/components/ComparePathsTable.jsx
  43. 3 1
      packages/app/src/components/ContentLinkButtons.jsx
  44. 1 1
      packages/app/src/components/CreateTemplateModal.jsx
  45. 3 1
      packages/app/src/components/DuplicatedPathsTable.jsx
  46. 1 1
      packages/app/src/components/Fab.jsx
  47. 4 2
      packages/app/src/components/Navbar/AuthorInfo.jsx
  48. 1 1
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  49. 1 1
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  50. 3 1
      packages/app/src/components/Page/CopyDropdown.jsx
  51. 3 1
      packages/app/src/components/Page/PageManagement.jsx
  52. 1 1
      packages/app/src/components/Page/TrashPageAlert.jsx
  53. 1 1
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.jsx
  54. 1 1
      packages/app/src/components/PageAttachment/PageAttachmentList.jsx
  55. 1 1
      packages/app/src/components/PageComment/Comment.jsx
  56. 1 1
      packages/app/src/components/PageComment/CommentEditor.jsx
  57. 1 1
      packages/app/src/components/PageComment/DeleteCommentModal.jsx
  58. 6 4
      packages/app/src/components/PageCreateModal.jsx
  59. 6 19
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  60. 1 1
      packages/app/src/components/PageHistory/Revision.jsx
  61. 2 3
      packages/app/src/components/PageList/Page.jsx
  62. 0 24
      packages/app/src/components/PageList/PagePath.jsx
  63. 1 1
      packages/app/src/components/PagePathAutoComplete.jsx
  64. 3 1
      packages/app/src/components/RevisionComparer/RevisionComparer.jsx
  65. 2 3
      packages/app/src/components/SearchTypeahead.jsx
  66. 3 3
      packages/app/src/components/ShareLink/ShareLinkForm.jsx
  67. 2 2
      packages/app/src/components/Sidebar/RecentChanges.jsx
  68. 1 1
      packages/app/src/components/User/UserInfo.jsx
  69. 1 1
      packages/app/src/components/User/UserPictureList.jsx
  70. 1 1
      packages/app/src/migrations/20191126173016-adjust-pages-path.js
  71. 3 6
      packages/app/src/models/linked-page-path.js
  72. 6 1
      packages/app/src/server/crowi/express-init.js
  73. 1 1
      packages/app/src/server/crowi/index.js
  74. 3 4
      packages/app/src/server/middlewares/admin-required.js
  75. 5 3
      packages/app/src/server/models/page.js
  76. 2 0
      packages/app/src/server/models/slack-app-integration.js
  77. 22 0
      packages/app/src/server/models/vo/slackbot-error.js
  78. 38 0
      packages/app/src/server/plugins/plugin-utils-v4.ts
  79. 15 7
      packages/app/src/server/plugins/plugin-utils.js
  80. 16 11
      packages/app/src/server/plugins/plugin.service.js
  81. 2 1
      packages/app/src/server/routes/apiv3/admin-home.js
  82. 2 2
      packages/app/src/server/routes/apiv3/healthcheck.js
  83. 3 2
      packages/app/src/server/routes/apiv3/page.js
  84. 7 2
      packages/app/src/server/routes/apiv3/pages.js
  85. 2 2
      packages/app/src/server/routes/apiv3/search.js
  86. 75 11
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  87. 76 63
      packages/app/src/server/routes/apiv3/slack-integration.js
  88. 2 2
      packages/app/src/server/routes/apiv3/statistics.js
  89. 2 1
      packages/app/src/server/routes/page.js
  90. 2 2
      packages/app/src/server/service/app.ts
  91. 1 1
      packages/app/src/server/service/customize.ts
  92. 3 2
      packages/app/src/server/service/export.js
  93. 3 1
      packages/app/src/server/service/global-notification/global-notification-slack.js
  94. 3 1
      packages/app/src/server/service/page.js
  95. 48 0
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  96. 37 38
      packages/app/src/server/service/slack-command-handler/create.js
  97. 66 0
      packages/app/src/server/service/slack-command-handler/respond-if-slackbot-error.js
  98. 199 30
      packages/app/src/server/service/slack-command-handler/search.js
  99. 10 0
      packages/app/src/server/service/slack-command-handler/slack-command-handler.js
  100. 173 2
      packages/app/src/server/service/slack-command-handler/togetter.js

+ 1 - 0
.devcontainer/devcontainer.json

@@ -16,6 +16,7 @@
 	"extensions": [
 	"extensions": [
 		"dbaeumer.vscode-eslint",
 		"dbaeumer.vscode-eslint",
 		"eamodio.gitlens",
 		"eamodio.gitlens",
+    "firsttris.vscode-jest-runner",
 		"msjsdiag.debugger-for-chrome",
 		"msjsdiag.debugger-for-chrome",
 		"firefox-devtools.vscode-firefox-debug",
 		"firefox-devtools.vscode-firefox-debug",
 		"editorconfig.editorconfig",
 		"editorconfig.editorconfig",

+ 0 - 1
.devcontainer/docker-compose.yml

@@ -25,7 +25,6 @@ services:
       - ..:/workspace/growi:delegated
       - ..:/workspace/growi:delegated
       - node_modules:/workspace/growi/node_modules
       - node_modules:/workspace/growi/node_modules
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
-      - ../../node_modules:/workspace/node_modules:delegated
 
 
     tty: true
     tty: true
 
 

+ 4 - 0
.eslintrc.js

@@ -26,5 +26,9 @@ module.exports = {
         FunctionExpression: { body: 1, parameters: 2 },
         FunctionExpression: { body: 1, parameters: 2 },
       },
       },
     ],
     ],
+    'jest/no-standalone-expect': [
+      'error',
+      { additionalTestBlockFunctions: ['each.test'] },
+    ],
   },
   },
 };
 };

+ 2 - 2
.github/workflows/ci-slackbot-proxy.yml

@@ -120,7 +120,7 @@ jobs:
     - name: yarn dev:ci
     - name: yarn dev:ci
       working-directory: ./packages/slackbot-proxy
       working-directory: ./packages/slackbot-proxy
       run: |
       run: |
-        cp config/ci/.env.local.for-ci .env.local
+        cp config/ci/.env.local.for-ci .env.development.local
         yarn dev:ci
         yarn dev:ci
       env:
       env:
         TYPEORM_CONNECTION: mysql
         TYPEORM_CONNECTION: mysql
@@ -211,7 +211,7 @@ jobs:
     - name: yarn start:prod:ci
     - name: yarn start:prod:ci
       working-directory: ./packages/slackbot-proxy
       working-directory: ./packages/slackbot-proxy
       run: |
       run: |
-        cp config/ci/.env.local.for-ci .env.local
+        cp config/ci/.env.local.for-ci .env.production.local
         yarn start:prod:ci
         yarn start:prod:ci
       env:
       env:
         TYPEORM_CONNECTION: mysql
         TYPEORM_CONNECTION: mysql

+ 12 - 9
.github/workflows/ci.yml

@@ -51,10 +51,10 @@ jobs:
         yarn list --depth=0
         yarn list --depth=0
     - name: lerna run lint for plugins
     - name: lerna run lint for plugins
       run: |
       run: |
-        yarn lerna run lint --scope @growi/plugin-pukiwiki-like-linker
+        yarn lerna run lint --scope @growi/plugin-*
     - name: lerna run lint for app
     - name: lerna run lint for app
       run: |
       run: |
-        yarn lerna run lint --scope @growi/app
+        yarn lerna run lint --scope @growi/app --scope @growi/core --scope @growi/ui
 
 
     - name: Slack Notification
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
       uses: weseek/ghaction-slack-notification@master
@@ -130,6 +130,12 @@ jobs:
       env:
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.ports['27017'] }}/growi_test
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.ports['27017'] }}/growi_test
 
 
+    - name: Upload coverage report as artifact
+      uses: actions/upload-artifact@v2
+      with:
+        name: Coverage Report
+        path: packages/app/coverage
+
     - name: Slack Notification
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
       uses: weseek/ghaction-slack-notification@master
       if: failure()
       if: failure()
@@ -206,7 +212,7 @@ jobs:
     - name: yarn dev:ci
     - name: yarn dev:ci
       working-directory: ./packages/app
       working-directory: ./packages/app
       run: |
       run: |
-        cp config/ci/.env.local.for-ci .env.local
+        cp config/ci/.env.local.for-ci .env.development.local
         yarn dev:ci
         yarn dev:ci
       env:
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_dev
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_dev
@@ -281,7 +287,7 @@ jobs:
         yarn list --depth=0
         yarn list --depth=0
     - name: Build
     - name: Build
       run: |
       run: |
-        yarn lerna run build --scope @growi/slack --scope @growi/app
+        yarn lerna run build --scope @growi/core --scope @growi/slack --scope @growi/plugin-* --scope @growi/app
     - name: lerna bootstrap --production
     - name: lerna bootstrap --production
       run: |
       run: |
         npx lerna bootstrap -- --production
         npx lerna bootstrap -- --production
@@ -297,20 +303,17 @@ jobs:
     - name: yarn server:ci
     - name: yarn server:ci
       working-directory: ./packages/app
       working-directory: ./packages/app
       run: |
       run: |
+        cp config/ci/.env.local.for-ci .env.production.local
         yarn server:ci
         yarn server:ci
       env:
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
     - name: yarn server:ci with MongoDB 3.6
     - name: yarn server:ci with MongoDB 3.6
       working-directory: ./packages/app
       working-directory: ./packages/app
       run: |
       run: |
+        cp config/ci/.env.local.for-ci .env.production.local
         yarn server:ci
         yarn server:ci
       env:
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
-    - name: Upload report as artifact
-      uses: actions/upload-artifact@v2
-      with:
-        name: Report
-        path: report
 
 
     - name: Slack Notification
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
       uses: weseek/ghaction-slack-notification@master

+ 1 - 1
.github/workflows/release-rc.yml

@@ -55,7 +55,7 @@ jobs:
       uses: docker/build-push-action@v2
       uses: docker/build-push-action@v2
       with:
       with:
         context: .
         context: .
-        file: ./docker/Dockerfile
+        file: ./packages/app/docker/Dockerfile
         platforms: linux/amd64
         platforms: linux/amd64
         push: true
         push: true
         cache-from: type=local,src=/tmp/.buildx-cache
         cache-from: type=local,src=/tmp/.buildx-cache

+ 2 - 2
.github/workflows/release.yml

@@ -113,7 +113,7 @@ jobs:
       uses: docker/build-push-action@v2
       uses: docker/build-push-action@v2
       with:
       with:
         context: .
         context: .
-        file: ./docker/Dockerfile
+        file: ./packages/app/docker/Dockerfile
         platforms: linux/amd64
         platforms: linux/amd64
         push: true
         push: true
         cache-from: type=local,src=/tmp/.buildx-cache
         cache-from: type=local,src=/tmp/.buildx-cache
@@ -131,7 +131,7 @@ jobs:
         username: wsmoogle
         username: wsmoogle
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
         repository: weseek/growi
         repository: weseek/growi
-        readme-filepath: ./docker/README.md
+        readme-filepath: ./packages/app/docker/README.md
 
 
     - name: Slack Notification
     - name: Slack Notification
       uses: weseek/ghaction-release-slack-notification@master
       uses: weseek/ghaction-release-slack-notification@master

+ 4 - 0
.markdownlint.yml

@@ -7,3 +7,7 @@ no-multiple-blanks: false
 no-duplicate-heading: false
 no-duplicate-heading: false
 no-inline-html: false
 no-inline-html: false
 no-trailing-punctuation: false
 no-trailing-punctuation: false
+
+MD002: false
+MD012: false
+MD041: false

+ 20 - 0
.vscode/launch.json

@@ -44,6 +44,26 @@
         "url": "http://localhost:3000",
         "url": "http://localhost:3000",
         "webRoot": "${workspaceFolder}/packages/app/public",
         "webRoot": "${workspaceFolder}/packages/app/public",
         "pathMappings": [
         "pathMappings": [
+          {
+            "url": "webpack:///core",
+            "path": "${workspaceFolder}/packages/core"
+          },
+          {
+            "url": "webpack:///plugin-attachment-refs",
+            "path": "${workspaceFolder}/packages/plugin-attachment-refs"
+          },
+          {
+            "url": "webpack:///plugin-pukiwiki-like-linker",
+            "path": "${workspaceFolder}/packages/plugin-pukiwiki-like-linker"
+          },
+          {
+            "url": "webpack:///plugin-lsx",
+            "path": "${workspaceFolder}/packages/plugin-lsx"
+          },
+          {
+            "url": "webpack:///ui",
+            "path": "${workspaceFolder}/packages/ui"
+          },
           {
           {
             "url": "webpack:///src",
             "url": "webpack:///src",
             "path": "${workspaceFolder}/packages/app/src"
             "path": "${workspaceFolder}/packages/app/src"

+ 9 - 0
CHANGES.md

@@ -2,10 +2,19 @@
 
 
 ## v4.3.3-RC
 ## v4.3.3-RC
 
 
+* Fix: Encode spaces in page path in LinkEditModal
+* Fix: Layout is broken when editing users page ([#4128](https://github.com/weseek/growi/issues/4128))
+* Support: Create @growi/core package
+* Support: Create @growi/ui package
+* Support: Improve error handling for @growi/slackbot-proxy
+* Support: Include official plugins as sub packages
 * Support: Upgrade libs
 * Support: Upgrade libs
     * @slack/web-api
     * @slack/web-api
+    * date-fns
     * escape-string-regexp
     * escape-string-regexp
+    * helmet
     * morgan
     * morgan
+    * socket.io
 
 
 ## v4.3.2
 ## v4.3.2
 
 

+ 21 - 3
package.json

@@ -21,8 +21,12 @@
   },
   },
   "private": true,
   "private": true,
   "workspaces": {
   "workspaces": {
-    "packages": ["packages/*"],
-    "nohoist": ["**/slackbot-proxy/bootstrap"]
+    "packages": [
+      "packages/*"
+    ],
+    "nohoist": [
+      "**/slackbot-proxy/bootstrap"
+    ]
   },
   },
   "scripts": {
   "scripts": {
     "start": "yarn app:server",
     "start": "yarn app:server",
@@ -37,8 +41,17 @@
     "server:prod": "echo !!! CAUTION !!! ==> The script 'server:prod' is deprecated. Use 'yarn app:build' instead. && yarn app:server"
     "server:prod": "echo !!! CAUTION !!! ==> The script 'server:prod' is deprecated. Use 'yarn app:build' instead. && yarn app:server"
   },
   },
   "dependencies": {
   "dependencies": {
+    "cross-env": "^7.0.0",
+    "dotenv-flow": "^3.2.0",
+    "npm-run-all": "^4.1.5",
+    "ts-node": "^9.1.1",
+    "tsconfig-paths": "^3.9.0",
+    "typescript": "^4.2.3"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@types/jest": "^26.0.22",
+    "@types/node": "^14.14.35",
+    "@types/rewire": "^2.5.28",
     "@typescript-eslint/eslint-plugin": "^4.28.5",
     "@typescript-eslint/eslint-plugin": "^4.28.5",
     "@typescript-eslint/parser": "^4.28.5",
     "@typescript-eslint/parser": "^4.28.5",
     "eslint": "^7.31.0",
     "eslint": "^7.31.0",
@@ -48,7 +61,12 @@
     "eslint-plugin-jest": "^24.3.2",
     "eslint-plugin-jest": "^24.3.2",
     "eslint-plugin-react": "^7.24.0",
     "eslint-plugin-react": "^7.24.0",
     "eslint-plugin-react-hooks": "^4.2.0",
     "eslint-plugin-react-hooks": "^4.2.0",
-    "lerna": "^4.0.0"
+    "jest": "^27.0.6",
+    "jest-date-mock": "^1.0.8",
+    "jest-localstorage-mock": "^2.4.14",
+    "lerna": "^4.0.0",
+    "rewire": "^5.0.0",
+    "ts-jest": "^27.0.4"
   },
   },
   "engines": {
   "engines": {
     "node": "^12 || ^14",
     "node": "^12 || ^14",

+ 2 - 2
packages/app/.env.development

@@ -3,7 +3,7 @@
 ## https://nextjs.org/docs/basic-features/environment-variables
 ## https://nextjs.org/docs/basic-features/environment-variables
 ##
 ##
 FILE_UPLOAD=mongodb
 FILE_UPLOAD=mongodb
-# MONGO_GRIDFS_TOTAL_LIMIT=10485760 # 10MB
+# MONGO_GRIDFS_TOTAL_LIMIT=10485760
 MATHJAX=1
 MATHJAX=1
 # NO_CDN=true
 # NO_CDN=true
 MONGO_URI="mongodb://mongo:27017/growi"
 MONGO_URI="mongodb://mongo:27017/growi"
@@ -17,7 +17,7 @@ HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 # PUBLISH_OPEN_API=true
 # PUBLISH_OPEN_API=true
 # USER_UPPER_LIMIT=0
 # USER_UPPER_LIMIT=0
 # DEV_HTTPS=true
 # DEV_HTTPS=true
-# FORCE_WIKI_MODE=private # 'public', 'private', undefined
+# FORCE_WIKI_MODE=private
 # PROMSTER_ENABLED=true
 # PROMSTER_ENABLED=true
 # SLACK_SIGNING_SECRET=''
 # SLACK_SIGNING_SECRET=''
 # SLACK_BOT_TOKEN=''
 # SLACK_BOT_TOKEN=''

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

@@ -2,4 +2,4 @@
 ## Handled by Next.js with dotenv or dotenv-flow
 ## Handled by Next.js with dotenv or dotenv-flow
 ## https://nextjs.org/docs/basic-features/environment-variables
 ## https://nextjs.org/docs/basic-features/environment-variables
 ##
 ##
-FORMAT_NODE_LOG=false # default: true
+FORMAT_NODE_LOG=false

+ 1 - 0
packages/app/.eslintignore

@@ -1,3 +1,4 @@
+/dist/**
 /public/**
 /public/**
 /src/client/legacy/thirdparty-js/**
 /src/client/legacy/thirdparty-js/**
 /src/client/util/reveal/plugins/markdown.js
 /src/client/util/reveal/plugins/markdown.js

+ 1 - 1
packages/app/.gitignore

@@ -13,7 +13,7 @@
 
 
 # dist (for GROWI v4.x and below)
 # dist (for GROWI v4.x and below)
 /public/*.chunk.js
 /public/*.chunk.js
-/public/*.chunk.js.LICENSE
+/public/*.chunk.js.LICENSE.txt
 /public/*.bundle.js
 /public/*.bundle.js
 /public/manifest.json
 /public/manifest.json
 /public/dll
 /public/dll

+ 22 - 14
packages/app/bin/generate-plugin-definitions-source.ts

@@ -7,6 +7,8 @@ import fs from 'graceful-fs';
 import normalize from 'normalize-path';
 import normalize from 'normalize-path';
 import swig from 'swig-templates';
 import swig from 'swig-templates';
 
 
+import { PluginDefinitionV4 } from '@growi/core';
+
 import PluginUtils from '../src/server/plugins/plugin-utils';
 import PluginUtils from '../src/server/plugins/plugin-utils';
 import loggerFactory from '../src/utils/logger';
 import loggerFactory from '../src/utils/logger';
 import { resolveFromRoot } from '../src/utils/project-dir-utils';
 import { resolveFromRoot } from '../src/utils/project-dir-utils';
@@ -23,26 +25,32 @@ const OUT = resolveFromRoot('tmp/plugins/plugin-definitions.js');
 const pluginNames: string[] = pluginUtils.listPluginNames();
 const pluginNames: string[] = pluginUtils.listPluginNames();
 logger.info('Detected plugins: ', pluginNames);
 logger.info('Detected plugins: ', pluginNames);
 
 
-// get definitions
-const definitions = pluginNames
-  .map((name) => {
-    return pluginUtils.generatePluginDefinition(name, true);
-  })
-  .map((definition) => {
-    if (definition == null) {
-      return null;
+async function main(): Promise<void> {
+
+  // get definitions
+  const definitions: PluginDefinitionV4[] = [];
+  for (const pluginName of pluginNames) {
+    // eslint-disable-next-line no-await-in-loop
+    const definition = await pluginUtils.generatePluginDefinition(pluginName, true);
+    if (definition != null) {
+      definitions.push(definition);
     }
     }
+  }
 
 
+  definitions.map((definition) => {
     // convert backslash to slash
     // convert backslash to slash
     definition.entries = definition.entries.map((entryPath) => {
     definition.entries = definition.entries.map((entryPath) => {
       return normalize(entryPath);
       return normalize(entryPath);
     });
     });
     return definition;
     return definition;
-  })
-  .filter(definition => definition != null);
+  });
+
+  const compiledTemplate = swig.compileFile(TEMPLATE);
+  const code = compiledTemplate({ definitions });
+
+  // write
+  fs.writeFileSync(OUT, code);
 
 
-const compiledTemplate = swig.compileFile(TEMPLATE);
-const code = compiledTemplate({ definitions });
+}
 
 
-// write
-fs.writeFileSync(OUT, code);
+main();

+ 2 - 0
packages/app/config/ci/.env.local.for-ci

@@ -1,2 +1,4 @@
+FORMAT_NODE_LOG=true
+
 # disable Elasticsearch
 # disable Elasticsearch
 ELASTICSEARCH_URI=
 ELASTICSEARCH_URI=

+ 7 - 5
packages/app/config/webpack.common.js

@@ -9,6 +9,7 @@ const webpack = require('webpack');
   */
   */
 const WebpackAssetsManifest = require('webpack-assets-manifest');
 const WebpackAssetsManifest = require('webpack-assets-manifest');
 const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
 const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
+const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
 
 
 /*
 /*
   * Webpack configuration
   * Webpack configuration
@@ -62,11 +63,12 @@ module.exports = (options) => {
     },
     },
     resolve: {
     resolve: {
       extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
       extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
-      // modules: ((options.resolve && options.resolve.modules) || []).concat([path.resolve(__dirname, 'node_modules')]),
-      alias: {
-        '~': path.resolve(__dirname, '../src'), // src
-        '^': path.resolve(__dirname, '../'), // project root
-      },
+      plugins: [
+        new TsconfigPathsPlugin({
+          configFile: path.resolve(__dirname, '../tsconfig.build.client.json'),
+          extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
+        }),
+      ],
     },
     },
     node: {
     node: {
       fs: 'empty',
       fs: 'empty',

+ 34 - 24
packages/app/docker/Dockerfile

@@ -17,16 +17,21 @@ COPY ./package.json .
 COPY ./yarn.lock .
 COPY ./yarn.lock .
 COPY ./lerna.json .
 COPY ./lerna.json .
 COPY ./packages/app/package.json packages/app/
 COPY ./packages/app/package.json packages/app/
+COPY ./packages/core/package.json packages/core/
+COPY ./packages/plugin-attachment-refs/package.json packages/plugin-attachment-refs/
+COPY ./packages/plugin-lsx/package.json packages/plugin-lsx/
+COPY ./packages/plugin-pukiwiki-like-linker/package.json packages/plugin-pukiwiki-like-linker/
 COPY ./packages/slack/package.json packages/slack/
 COPY ./packages/slack/package.json packages/slack/
+COPY ./packages/ui/package.json packages/ui/
 
 
 # setup
 # setup
 RUN yarn config set network-timeout 300000
 RUN yarn config set network-timeout 300000
 RUN npx lerna bootstrap
 RUN npx lerna bootstrap
 
 
 # make artifacts
 # make artifacts
-RUN tar cf node_modules.tar node_modules \
-  packages/app/node_modules \
-  packages/slack/node_modules
+RUN tar cf node_modules.tar \
+  node_modules \
+  packages/*/node_modules
 
 
 
 
 
 
@@ -39,9 +44,9 @@ FROM deps-resolver AS deps-resolver-prod
 RUN yarn install --production
 RUN yarn install --production
 
 
 # make artifacts
 # make artifacts
-RUN tar cf node_modules.tar node_modules \
-  packages/app/node_modules \
-  packages/slack/node_modules
+RUN tar cf node_modules.tar \
+  node_modules \
+  packages/*/node_modules
 
 
 
 
 
 
@@ -88,16 +93,14 @@ COPY ./package.json ./
 COPY ./yarn.lock ./
 COPY ./yarn.lock ./
 COPY ./lerna.json ./
 COPY ./lerna.json ./
 COPY ./tsconfig.base.json ./
 COPY ./tsconfig.base.json ./
-COPY ./babel.config.js ./
-COPY ./bin ./bin
-COPY ./config ./config
-COPY ./public ./public
-COPY ./resource ./resource
-COPY ./src ./src
-COPY ./tmp ./tmp
 # copy all related packages
 # copy all related packages
-COPY packages/slack packages/slack
 COPY packages/app packages/app
 COPY packages/app packages/app
+COPY packages/core packages/core
+COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
+COPY packages/plugin-lsx packages/plugin-lsx
+COPY packages/plugin-pukiwiki-like-linker packages/plugin-pukiwiki-like-linker
+COPY packages/slack packages/slack
+COPY packages/ui packages/ui
 
 
 # build
 # build
 RUN yarn lerna run build
 RUN yarn lerna run build
@@ -106,15 +109,18 @@ RUN yarn lerna run build
 RUN tar cf packages.tar \
 RUN tar cf packages.tar \
   package.json \
   package.json \
   yarn.lock \
   yarn.lock \
-  config \
-  public \
-  resource \
-  src \
-  tmp \
+  tsconfig.base.json \
   packages/app/package.json \
   packages/app/package.json \
-  packages/slack/package.json \
-  packages/slack/dist
-
+  packages/app/config \
+  packages/app/dist \
+  packages/app/public \
+  packages/app/resource \
+  packages/app/tmp \
+  packages/app/.env.production \
+  packages/app/tsconfig.base.json \
+  packages/app/tsconfig.json \
+  packages/*/package.json \
+  packages/*/dist
 
 
 
 
 
 
@@ -124,6 +130,8 @@ RUN tar cf packages.tar \
 FROM node:14-slim
 FROM node:14-slim
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 
+ENV NODE_ENV production
+
 ENV appDir /opt/growi
 ENV appDir /opt/growi
 
 
 # Add gosu
 # Add gosu
@@ -154,12 +162,14 @@ RUN rm node_modules.tar packages.tar
 
 
 USER root
 USER root
 
 
-COPY docker/docker-entrypoint.sh /
+COPY packages/app/docker/docker-entrypoint.sh /
 RUN chmod 700 /docker-entrypoint.sh
 RUN chmod 700 /docker-entrypoint.sh
 RUN chown node:node ${appDir}
 RUN chown node:node ${appDir}
 
 
+WORKDIR ${appDir}/packages/app
+
 VOLUME /data
 VOLUME /data
 EXPOSE 3000
 EXPOSE 3000
 
 
 ENTRYPOINT ["/tini", "-e", "143", "--", "/docker-entrypoint.sh"]
 ENTRYPOINT ["/tini", "-e", "143", "--", "/docker-entrypoint.sh"]
-CMD ["yarn", "server:prod"]
+CMD ["node", "-r", "dotenv-flow/config", "--expose_gc", "dist/server/app.js"]

+ 3 - 3
packages/app/docker/docker-entrypoint.sh

@@ -4,11 +4,11 @@ set -e
 
 
 # Support `FILE_UPLOAD=local`
 # Support `FILE_UPLOAD=local`
 mkdir -p /data/uploads
 mkdir -p /data/uploads
-if [ ! -e "$appDir/public/uploads" ]; then
-  ln -s /data/uploads $appDir/public/uploads
+if [ ! -e "./public/uploads" ]; then
+  ln -s /data/uploads ./public/uploads
 fi
 fi
 
 
 chown -R node:node /data/uploads
 chown -R node:node /data/uploads
-chown -h node:node $appDir/public/uploads
+chown -h node:node ./public/uploads
 
 
 gosu node $@
 gosu node $@

+ 1 - 0
packages/app/jest.config.js

@@ -5,6 +5,7 @@
 const MODULE_NAME_MAPPING = {
 const MODULE_NAME_MAPPING = {
   '^\\^/(.+)$': '<rootDir>/$1',
   '^\\^/(.+)$': '<rootDir>/$1',
   '^~/(.+)$': '<rootDir>/src/$1',
   '^~/(.+)$': '<rootDir>/src/$1',
+  '^@growi/(.+)$': '<rootDir>/../$1/src',
 };
 };
 
 
 module.exports = {
 module.exports = {

+ 12 - 20
packages/app/package.json

@@ -8,8 +8,9 @@
     "build": "run-p build:*",
     "build": "run-p build:*",
     "build:client": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js --profile --bail",
     "build:client": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js --profile --bail",
     "build:server": "cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
     "build:server": "cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
-    "prebuild": "run-p resources:*",
-    "postbuild": "npx shx mv transpiled/src dist && npx shx rm -r transpiled",
+    "clean": "npx shx rm -rf dist transpiled",
+    "prebuild": "run-p clean resources:*",
+    "postbuild": "npx shx mv transpiled/src dist && npx shx rm -rf transpiled",
     "server": "cross-env NODE_ENV=production node -r dotenv-flow/config --expose_gc dist/server/app.js",
     "server": "cross-env NODE_ENV=production node -r dotenv-flow/config --expose_gc dist/server/app.js",
     "server:ci": "yarn server --ci",
     "server:ci": "yarn server --ci",
     "preserver": "yarn migrate",
     "preserver": "yarn migrate",
@@ -54,7 +55,9 @@
   "dependencies": {
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
+    "@growi/plugin-attachment-refs": "^4.3.3-RC",
     "@growi/plugin-pukiwiki-like-linker": "^4.3.3-RC",
     "@growi/plugin-pukiwiki-like-linker": "^4.3.3-RC",
+    "@growi/plugin-lsx": "^4.3.3-RC",
     "@growi/slack": "^4.3.3-RC",
     "@growi/slack": "^4.3.3-RC",
     "@kobalab/socket.io-session": "^1.0.3",
     "@kobalab/socket.io-session": "^1.0.3",
     "@promster/express": "^5.0.1",
     "@promster/express": "^5.0.1",
@@ -76,12 +79,10 @@
     "connect-mongo": "^4.4.1",
     "connect-mongo": "^4.4.1",
     "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",
     "csrf": "^3.1.0",
     "csrf": "^3.1.0",
-    "date-fns": "^2.0.0",
+    "date-fns": "^2.23.0",
     "detect-indent": "^6.0.0",
     "detect-indent": "^6.0.0",
     "diff": "^5.0.0",
     "diff": "^5.0.0",
-    "dotenv-flow": "^3.2.0",
     "elasticsearch": "^16.0.0",
     "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
     "entities": "^2.0.0",
     "esa-nodejs": "^0.0.7",
     "esa-nodejs": "^0.0.7",
@@ -95,9 +96,8 @@
     "express-webpack-assets": "^0.1.0",
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
     "graceful-fs": "^4.1.11",
     "growi-commons": "^5.0.4",
     "growi-commons": "^5.0.4",
-    "growi-plugin-attachment-refs": "^2.0.2",
-    "growi-plugin-lsx": "^4.0.3",
-    "helmet": "^3.13.0",
+    "helmet": "^4.6.0",
+    "nocache": "^3.0.1",
     "http-errors": "~1.6.2",
     "http-errors": "~1.6.2",
     "i18next": "^20.3.2",
     "i18next": "^20.3.2",
     "i18next-express-middleware": "^2.0.0",
     "i18next-express-middleware": "^2.0.0",
@@ -117,7 +117,6 @@
     "multer-autoreap": "^1.0.3",
     "multer-autoreap": "^1.0.3",
     "nodemailer": "^6.6.2",
     "nodemailer": "^6.6.2",
     "nodemailer-ses-transport": "~1.5.0",
     "nodemailer-ses-transport": "~1.5.0",
-    "npm-run-all": "^4.1.2",
     "openid-client": "=2.5.0",
     "openid-client": "=2.5.0",
     "passport": "^0.4.0",
     "passport": "^0.4.0",
     "passport-github": "^1.1.0",
     "passport-github": "^1.1.0",
@@ -133,14 +132,11 @@
     "reconnecting-websocket": "^4.4.0",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
     "rimraf": "^3.0.0",
-    "socket.io": "^2.3.0",
+    "socket.io": "^4.0.0",
     "stream-to-promise": "^3.0.0",
     "stream-to-promise": "^3.0.0",
     "string-width": "=4.2.2",
     "string-width": "=4.2.2",
     "swagger-jsdoc": "^3.4.0",
     "swagger-jsdoc": "^3.4.0",
     "swig-templates": "^2.0.2",
     "swig-templates": "^2.0.2",
-    "ts-node": "^9.1.1",
-    "tsconfig-paths": "^3.9.0",
-    "typescript": "^4.2.3",
     "uglifycss": "^0.0.29",
     "uglifycss": "^0.0.29",
     "universal-bunyan": "^0.9.2",
     "universal-bunyan": "^0.9.2",
     "unzipper": "^0.10.5",
     "unzipper": "^0.10.5",
@@ -158,13 +154,13 @@
     "@alienfast/i18next-loader": "^1.0.16",
     "@alienfast/i18next-loader": "^1.0.16",
     "@atlaskit/drawer": "^5.3.7",
     "@atlaskit/drawer": "^5.3.7",
     "@atlaskit/navigation-next": "^8.0.5",
     "@atlaskit/navigation-next": "^8.0.5",
+    "@growi/ui": "^4.3.3-RC",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@textlint/kernel": "^12.0.2",
     "@textlint/kernel": "^12.0.2",
     "@types/codemirror": "^5.60.2",
     "@types/codemirror": "^5.60.2",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",
     "@types/multer": "^1.4.5",
     "@types/multer": "^1.4.5",
-    "@types/node": "^14.14.35",
     "@types/react-dom": "^17.0.9",
     "@types/react-dom": "^17.0.9",
     "autoprefixer": "^9.0.0",
     "autoprefixer": "^9.0.0",
     "bootstrap": "^4.5.0",
     "bootstrap": "^4.5.0",
@@ -184,8 +180,6 @@
     "hard-source-webpack-plugin": "^0.13.1",
     "hard-source-webpack-plugin": "^0.13.1",
     "i18next-browser-languagedetector": "^4.0.1",
     "i18next-browser-languagedetector": "^4.0.1",
     "imports-loader": "^0.8.0",
     "imports-loader": "^0.8.0",
-    "jest": "^27.0.6",
-    "jest-date-mock": "^1.0.8",
     "jquery-slimscroll": "^1.3.8",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
     "jquery.cookie": "~1.4.1",
@@ -224,15 +218,13 @@
     "react-frame-component": "^4.0.0",
     "react-frame-component": "^4.0.0",
     "react-hotkeys": "^2.0.0",
     "react-hotkeys": "^2.0.0",
     "react-i18next": "^11.1.0",
     "react-i18next": "^11.1.0",
-    "react-images": "1.0.0",
-    "react-motion": "^0.5.2",
     "react-waypoint": "^10.1.0",
     "react-waypoint": "^10.1.0",
     "reactstrap": "^8.9.0",
     "reactstrap": "^8.9.0",
     "replacestream": "^4.0.3",
     "replacestream": "^4.0.3",
     "reveal.js": "^3.5.0",
     "reveal.js": "^3.5.0",
     "sass-loader": "^8.0.0",
     "sass-loader": "^8.0.0",
     "simple-load-script": "^1.0.2",
     "simple-load-script": "^1.0.2",
-    "socket.io-client": "^2.3.0",
+    "socket.io-client": "^4.0.0",
     "sticky-events": "^3.1.3",
     "sticky-events": "^3.1.3",
     "style-loader": "^1.0.0",
     "style-loader": "^1.0.0",
     "styled-components": "^5.0.1",
     "styled-components": "^5.0.1",
@@ -246,10 +238,10 @@
     "textlint-rule-max-comma": "^2.0.2",
     "textlint-rule-max-comma": "^2.0.2",
     "throttle-debounce": "^2.0.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
     "toastr": "^2.1.2",
-    "ts-jest": "^27.0.4",
     "ts-loader": "^8.3.0",
     "ts-loader": "^8.3.0",
     "ts-node-dev": "^1.1.6",
     "ts-node-dev": "^1.1.6",
     "tsc-alias": "^1.2.9",
     "tsc-alias": "^1.2.9",
+    "tsconfig-paths-webpack-plugin": "^3.5.1",
     "unstated": "^2.1.1",
     "unstated": "^2.1.1",
     "webpack": "^4.39.3",
     "webpack": "^4.39.3",
     "webpack-assets-manifest": "^3.1.1",
     "webpack-assets-manifest": "^3.1.1",

+ 3 - 7
packages/app/resource/locales/en_US/admin/admin.json

@@ -137,21 +137,14 @@
       "tab_switch_desc2": "By invalidating, you can make page transition as the only object for forward/back command of the browser.",
       "tab_switch_desc2": "By invalidating, you can make page transition as the only object for forward/back command of the browser.",
       "attach_title_header": "Add h1 section when create new page automatically",
       "attach_title_header": "Add h1 section when create new page automatically",
       "attach_title_header_desc": "Add page path to the first line as h1 section when create new page.",
       "attach_title_header_desc": "Add page path to the first line as h1 section when create new page.",
-
       "list_num_s": "Number of list displayed on modals",
       "list_num_s": "Number of list displayed on modals",
       "list_num_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
       "list_num_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
-
       "list_num_m": "Number of list displayed on article pages included other contents",
       "list_num_m": "Number of list displayed on article pages included other contents",
       "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages.",
       "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages.",
-
       "list_num_l": "Number of list displayed on 'Search' pages",
       "list_num_l": "Number of list displayed on 'Search' pages",
       "list_num_desc_l": "Set number of list per page such as 'Search' pages.",
       "list_num_desc_l": "Set number of list per page such as 'Search' pages.",
-
       "list_num_xl": "Number of list displayed on article pages",
       "list_num_xl": "Number of list displayed on article pages",
       "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
       "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
-
-
-
       "stale_notification": "Display notification on stale pages",
       "stale_notification": "Display notification on stale pages",
       "stale_notification_desc": "Displays the notification to pages more than 1 year since the last update.",
       "stale_notification_desc": "Displays the notification to pages more than 1 year since the last update.",
       "show_all_reply_comments": "Show all reply comments",
       "show_all_reply_comments": "Show all reply comments",
@@ -326,6 +319,9 @@
       "install_complete_if_checked": "Confirm that \"Install your app\" is checked.",
       "install_complete_if_checked": "Confirm that \"Install your app\" is checked.",
       "invite_bot_to_channel": "Invite GROWI bot to channel by calling @example.",
       "invite_bot_to_channel": "Invite GROWI bot to channel by calling @example.",
       "register_secret_and_token": "Set Signing Secret and Bot Token",
       "register_secret_and_token": "Set Signing Secret and Bot Token",
+      "manage_commands": "Manage GROWI commands",
+      "multiple_growi_command": "Commands that could be sent to multiple GROWI instances at once",
+      "single_growi_command": "Commands that could be sent to single GROWI instance at a time",
       "test_connection": "Test Connection",
       "test_connection": "Test Connection",
       "test_connection_by_pressing_button": "Press the button to test the connection",
       "test_connection_by_pressing_button": "Press the button to test the connection",
       "error_check_logs_below": "An error has occurred. Please check the logs below.",
       "error_check_logs_below": "An error has occurred. Please check the logs below.",

+ 3 - 6
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -137,19 +137,14 @@
       "tab_switch_desc2": "無効化することで、ページ遷移のみを戻る/進む操作の対象にすることができます。",
       "tab_switch_desc2": "無効化することで、ページ遷移のみを戻る/進む操作の対象にすることができます。",
       "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
       "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
       "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
       "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
-
       "list_num_s": "モーダルに表示されるリスト数",
       "list_num_s": "モーダルに表示されるリスト数",
       "list_num_desc_s": "モーダルにおける <ページリスト> <タイムライン> <更新履歴> <添付ファイル>での、1ページあたりの表示数を設定します。",
       "list_num_desc_s": "モーダルにおける <ページリスト> <タイムライン> <更新履歴> <添付ファイル>での、1ページあたりの表示数を設定します。",
-
       "list_num_m": "ユーザーページに表示されるリスト数",
       "list_num_m": "ユーザーページに表示されるリスト数",
       "list_num_desc_m": "ユーザーページにおける <Bookmarks> <Recently Created>での、1ページあたりの表示数を設定します。",
       "list_num_desc_m": "ユーザーページにおける <Bookmarks> <Recently Created>での、1ページあたりの表示数を設定します。",
-
       "list_num_l": "検索ページに表示されるリスト数",
       "list_num_l": "検索ページに表示されるリスト数",
       "list_num_desc_l": "<Search>での、1ページあたりの表示数を設定します。",
       "list_num_desc_l": "<Search>での、1ページあたりの表示数を設定します。",
-
       "list_num_xl": "Not FoundページやTrashページに表示されるリスト数",
       "list_num_xl": "Not FoundページやTrashページに表示されるリスト数",
       "list_num_desc_xl": "記事エリアにおける<Not Found> <Trash>での、1ページあたりの表示数を設定します。",
       "list_num_desc_xl": "記事エリアにおける<Not Found> <Trash>での、1ページあたりの表示数を設定します。",
-
       "stale_notification": "古いページに通知を表示する",
       "stale_notification": "古いページに通知を表示する",
       "stale_notification_desc": "最後の更新から1年を超えるページへの通知を表示します。",
       "stale_notification_desc": "最後の更新から1年を超えるページへの通知を表示します。",
       "show_all_reply_comments": "返信コメントを全て表示する",
       "show_all_reply_comments": "返信コメントを全て表示する",
@@ -323,6 +318,9 @@
       "install_complete_if_checked": "Install your app の右側に緑色のチェックがつけばワークスペースへのインストール完了です。",
       "install_complete_if_checked": "Install your app の右側に緑色のチェックがつけばワークスペースへのインストール完了です。",
       "invite_bot_to_channel": "GROWI bot を使いたいチャンネルに @example を使用して招待します。",
       "invite_bot_to_channel": "GROWI bot を使いたいチャンネルに @example を使用して招待します。",
       "register_secret_and_token": "Signing Secret と Bot Token を登録する",
       "register_secret_and_token": "Signing Secret と Bot Token を登録する",
+      "manage_commands": "使用可能なGROWIコマンドを設定する",
+      "multiple_growi_command": "複数のGROWIに対して送信できるコマンド",
+      "single_growi_command": "一つのGROWIに対して送信できるコマンド",
       "test_connection": "連携状況のテストをする",
       "test_connection": "連携状況のテストをする",
       "test_connection_by_pressing_button": "以下のテストボタンを押して、Slack連携が完了しているかの確認をしましょう",
       "test_connection_by_pressing_button": "以下のテストボタンを押して、Slack連携が完了しているかの確認をしましょう",
       "error_check_logs_below": "エラーが発生しました。下記のログを確認してください。",
       "error_check_logs_below": "エラーが発生しました。下記のログを確認してください。",
@@ -334,7 +332,6 @@
       "integration_is_not_complete": "連携は完了していません<br>下の連携手順を進めてください",
       "integration_is_not_complete": "連携は完了していません<br>下の連携手順を進めてください",
       "integration_successful": "連携は完了しています。",
       "integration_successful": "連携は完了しています。",
       "integration_some_ws_is_not_complete": "連携に失敗している ワークスペースがあります。"
       "integration_some_ws_is_not_complete": "連携に失敗している ワークスペースがあります。"
-
     },
     },
     "custom_bot_with_proxy_integration": "Custom bot with proxy 連携",
     "custom_bot_with_proxy_integration": "Custom bot with proxy 連携",
     "official_bot_integration": "Official bot 連携",
     "official_bot_integration": "Official bot 連携",

+ 311 - 313
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -1,84 +1,84 @@
 {
 {
-  "mailer_setup_required":"<a href='/admin/app'>Email settings</a> are required to send.",
-	"admin_top": {
-		"management_wiki": "管理Wiki",
-		"system_information": "系统信息",
-		"wiki_administrator": "只有wiki管理员可以访问此页",
-		"assign_administrator": "您可以使用“授予管理员访问权限”按钮在“用户管理”页上将所选用户指定为wiki管理员",
-		"list_of_installed_plugins": "已安装插件列表",
-		"package_name": "包名称",
-		"specified_version": "指定版本",
-		"installed_version": "已安装版本",
-		"list_of_env_vars": "环境变量列表",
-		"env_var_priority": "对于安全性以外的环境变量,优先获取数据库的值。",
-		"about_security": "检查安全环境变量的<a href='/admin/security'>安全设置</a>。"
-	},
-	"app_setting": {
-		"site_name": "网站名称 ",
-		"sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
-		"header_content": "此处输入的内容将显示在标题等中。",
-		"site_url_desc": "用于网站URL设置。",
-		"site_url_warn": "某些功能不起作用,因为未设置网站URL。",
-		"siteurl_help": "网站完整URL起始于 <code>http://</code> or <code>https://</code>.",
-		"confidential_name": "内部名称",
-		"confidential_example": "ex):仅供内部使用",
-		"default_language": "新用户的默认语言",
-		"default_mail_visibility": "新用户的默认电子邮件可见性",
-		"file_uploading": "文件上传",
-		"enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
-		"attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
-		"update": "更新",
-		"mail_settings": "邮件设置",
+  "mailer_setup_required": "<a href='/admin/app'>Email settings</a> are required to send.",
+  "admin_top": {
+    "management_wiki": "管理Wiki",
+    "system_information": "系统信息",
+    "wiki_administrator": "只有wiki管理员可以访问此页",
+    "assign_administrator": "您可以使用“授予管理员访问权限”按钮在“用户管理”页上将所选用户指定为wiki管理员",
+    "list_of_installed_plugins": "已安装插件列表",
+    "package_name": "包名称",
+    "specified_version": "指定版本",
+    "installed_version": "已安装版本",
+    "list_of_env_vars": "环境变量列表",
+    "env_var_priority": "对于安全性以外的环境变量,优先获取数据库的值。",
+    "about_security": "检查安全环境变量的<a href='/admin/security'>安全设置</a>。"
+  },
+  "app_setting": {
+    "site_name": "网站名称 ",
+    "sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
+    "header_content": "此处输入的内容将显示在标题等中。",
+    "site_url_desc": "用于网站URL设置。",
+    "site_url_warn": "某些功能不起作用,因为未设置网站URL。",
+    "siteurl_help": "网站完整URL起始于 <code>http://</code> or <code>https://</code>.",
+    "confidential_name": "内部名称",
+    "confidential_example": "ex):仅供内部使用",
+    "default_language": "新用户的默认语言",
+    "default_mail_visibility": "新用户的默认电子邮件可见性",
+    "file_uploading": "文件上传",
+    "enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
+    "attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
+    "update": "更新",
+    "mail_settings": "邮件设置",
     "mailer_is_not_set_up": "邮件设置尚未完成。",
     "mailer_is_not_set_up": "邮件设置尚未完成。",
-    "transmission_method":"传送方法",
-    "smtp_label":"SMTP",
-    "ses_label":"SES(AWS)",
-		"from_e-mail_address": "邮件发出地址",
+    "transmission_method": "传送方法",
+    "smtp_label": "SMTP",
+    "ses_label": "SES(AWS)",
+    "from_e-mail_address": "邮件发出地址",
     "send_test_email": "发送测试邮件",
     "send_test_email": "发送测试邮件",
     "success_to_send_test_email": "成功发送了一封测试邮件",
     "success_to_send_test_email": "成功发送了一封测试邮件",
     "smtp_settings": "SMTP 设置",
     "smtp_settings": "SMTP 设置",
-		"host": "服务器",
-		"port": "端口号",
-		"user": "用户名",
+    "host": "服务器",
+    "port": "端口号",
+    "user": "用户名",
     "initialize_mail_settings": "重置邮件设置",
     "initialize_mail_settings": "重置邮件设置",
     "initialize_mail_modal_header": "重置邮件设置",
     "initialize_mail_modal_header": "重置邮件设置",
     "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
     "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
-    "file_upload_settings":"文件上传设置",
-    "file_upload_method":"文件上传方法",
-    "file_delivery_method":"File Delivery Method",
-    "file_delivery_method_redirect":"Redirect",
-    "file_delivery_method_relay":"Internal System Relay",
-    "file_delivery_method_redirect_info":"Redirect: It redirects to a signed URL without GROWI server, it gives excellent performance.",
-    "file_delivery_method_relay_info":"Internal System Relay: The GROWI server delivers to clients, it provides complete security.",
+    "file_upload_settings": "文件上传设置",
+    "file_upload_method": "文件上传方法",
+    "file_delivery_method": "File Delivery Method",
+    "file_delivery_method_redirect": "Redirect",
+    "file_delivery_method_relay": "Internal System Relay",
+    "file_delivery_method_redirect_info": "Redirect: It redirects to a signed URL without GROWI server, it gives excellent performance.",
+    "file_delivery_method_relay_info": "Internal System Relay: The GROWI server delivers to clients, it provides complete security.",
     "gcs_label": "GCP(GCS)",
     "gcs_label": "GCP(GCS)",
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
     "gridfs_label": "MongoDB(GridFS)",
-    "ses_settings":"SES设置",
+    "ses_settings": "SES设置",
     "test_connection": "测试邮件服务器连接",
     "test_connection": "测试邮件服务器连接",
-		"": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
-		"change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
-		"region": "Region",
-		"bucket_name": "Bucket name",
-		"custom_endpoint": "Custom endpoint",
-		"custom_endpoint_change": "输入对象存储服务(如MinIO)端点的URL,MinIO具有与S3兼容的API。如果为空,则使用Amazon S3。",
-		"plugin_settings": "插件设置",
-		"enable_plugin_loading": "启用插件加载",
-		"load_plugins": "加载插件",
-		"enable": "启用",
-		"disable": "停用",
-		"use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <cod>{{variable}}</code> 启用。",
+    "": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
+    "change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
+    "region": "Region",
+    "bucket_name": "Bucket name",
+    "custom_endpoint": "Custom endpoint",
+    "custom_endpoint_change": "输入对象存储服务(如MinIO)端点的URL,MinIO具有与S3兼容的API。如果为空,则使用Amazon S3。",
+    "plugin_settings": "插件设置",
+    "enable_plugin_loading": "启用插件加载",
+    "load_plugins": "加载插件",
+    "enable": "启用",
+    "disable": "停用",
+    "use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <cod>{{variable}}</code> 启用。",
     "note_for_the_only_env_option": "The GCS settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
     "note_for_the_only_env_option": "The GCS settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
   },
   },
-	"markdown_setting": {
-		"lineBreak_header": "换行设置",
-		"lineBreak_desc": "您可以更改换行设置。",
-		"lineBreak_options": {
-			"enable_lineBreak": "启用换行符",
-			"enable_lineBreak_desc": "HTML中将文本页中的换行符转换为<code>&lt;br&gt;</code>",
-			"enable_lineBreak_for_comment": "注释中启用换行符",
-			"enable_lineBreak_for_comment_desc": "HTML中将注释中的换行符转换为<code>&lt;br&gt;</code>"
-		},
+  "markdown_setting": {
+    "lineBreak_header": "换行设置",
+    "lineBreak_desc": "您可以更改换行设置。",
+    "lineBreak_options": {
+      "enable_lineBreak": "启用换行符",
+      "enable_lineBreak_desc": "HTML中将文本页中的换行符转换为<code>&lt;br&gt;</code>",
+      "enable_lineBreak_for_comment": "注释中启用换行符",
+      "enable_lineBreak_for_comment_desc": "HTML中将注释中的换行符转换为<code>&lt;br&gt;</code>"
+    },
     "indent_header": "缩进设置",
     "indent_header": "缩进设置",
     "indent_desc": "您可以更改缩进设置。",
     "indent_desc": "您可以更改缩进设置。",
     "indent_options": {
     "indent_options": {
@@ -88,184 +88,179 @@
       "disallow_indent_change_desc": "您可以不允许用户更改缩进值。"
       "disallow_indent_change_desc": "您可以不允许用户更改缩进值。"
     },
     },
     "presentation_header": "演示文稿设置",
     "presentation_header": "演示文稿设置",
-		"presentation_desc": "您可以更改演示文稿设置。",
-		"presentation_options": {
-			"page_break_setting": "分页设置",
-			"preset_one_separator": "预设 1",
-			"preset_one_separator_desc": "3 空行",
-			"preset_one_separator_value": "\\n\\n\\n",
-			"preset_two_separator": "预设 2",
-			"preset_two_separator_desc": "5 连字符",
-			"preset_two_separator_value": "-----",
-			"custom_separator": "自定义",
-			"custom_separator_desc": "正则表达式"
-		},
-		"xss_header": "阻止XSS(跨站点脚本)设置",
-		"xss_desc": "您可以更改标记文本中HTML标记的处理方式。",
-		"xss_options": {
-			"enable_xss_prevention": "启用XSS预防",
-			"remove_all_tags": "删除所有标记",
-			"remove_all_tags_desc": "Stripe all HTML tags and attributes",
-			"recommended_setting": "推荐设置",
-			"custom_whitelist": "自定义白名单",
-			"tag_names": "标记名",
-			"tag_attributes": "标记属性",
-			"import_recommended": "导入建议 {{target}}"
-		}
-	},
-	"customize_setting": {
+    "presentation_desc": "您可以更改演示文稿设置。",
+    "presentation_options": {
+      "page_break_setting": "分页设置",
+      "preset_one_separator": "预设 1",
+      "preset_one_separator_desc": "3 空行",
+      "preset_one_separator_value": "\\n\\n\\n",
+      "preset_two_separator": "预设 2",
+      "preset_two_separator_desc": "5 连字符",
+      "preset_two_separator_value": "-----",
+      "custom_separator": "自定义",
+      "custom_separator_desc": "正则表达式"
+    },
+    "xss_header": "阻止XSS(跨站点脚本)设置",
+    "xss_desc": "您可以更改标记文本中HTML标记的处理方式。",
+    "xss_options": {
+      "enable_xss_prevention": "启用XSS预防",
+      "remove_all_tags": "删除所有标记",
+      "remove_all_tags_desc": "Stripe all HTML tags and attributes",
+      "recommended_setting": "推荐设置",
+      "custom_whitelist": "自定义白名单",
+      "tag_names": "标记名",
+      "tag_attributes": "标记属性",
+      "import_recommended": "导入建议 {{target}}"
+    }
+  },
+  "customize_setting": {
     "layout": "布局",
     "layout": "布局",
     "layout_options": {
     "layout_options": {
       "default": "默认内容宽度 ",
       "default": "默认内容宽度 ",
       "expanded": "内容宽度100% "
       "expanded": "内容宽度100% "
     },
     },
-		"theme": "主体",
-		"behavior": "行为",
-		"behavior_desc": {
-			"growi_text1": "<code>/page</code> and <code>/page/</code> 都显示同一页。",
-			"growi_text2": "<code>/nonexistent_page</code> 显示编辑表单",
-			"growi_text3": "如果使用GROWI增强布局,则所有页面都显示子页面列表",
-			"crowi_text1": "<code>/page</code> 显示页面",
-			"crowi_text2": "<code>/page/</code> 显示子页列表",
-			"crowi_text3": "如果portal应用于<code>/page/</code>,则会显示portal和子页面列表",
-			"crowi_text4": "<code>/nonexistent_page</code> 显示编辑表单<",
-			"crowi_text5": "<code>/nonexistent_page/</code> 子页列表"
-		},
-		"theme_desc": {
-			"light_and_dark": "明暗模式",
-			"unique": "只有一种模式"
-		},
-		"function": "功能",
-		"function_desc": "您可以选择函数的有效/无效",
-		"function_options": {
-			"timeline": "时间线函数",
-			"timeline_desc1": "您可以显示子页的时间线。",
-			"timeline_desc2": "如果有许多子页,则在加载页时性能会降低。",
-			"timeline_desc3": "通过使列表页无效,可以加快列表页的显示速度。",
-			"tab_switch": "在浏览器中保存选项卡切换",
-			"tab_switch_desc1": "在浏览器中保存编辑选项卡和历史选项卡切换,并使其成为浏览器的前向/后向命令的对象。",
-			"tab_switch_desc2": "通过失效,您可以将页面转换作为浏览器的前向/后向命令的唯一对象。",
-			"attach_title_header": "自动创建新页面时添加h1节",
+    "theme": "主体",
+    "behavior": "行为",
+    "behavior_desc": {
+      "growi_text1": "<code>/page</code> and <code>/page/</code> 都显示同一页。",
+      "growi_text2": "<code>/nonexistent_page</code> 显示编辑表单",
+      "growi_text3": "如果使用GROWI增强布局,则所有页面都显示子页面列表",
+      "crowi_text1": "<code>/page</code> 显示页面",
+      "crowi_text2": "<code>/page/</code> 显示子页列表",
+      "crowi_text3": "如果portal应用于<code>/page/</code>,则会显示portal和子页面列表",
+      "crowi_text4": "<code>/nonexistent_page</code> 显示编辑表单<",
+      "crowi_text5": "<code>/nonexistent_page/</code> 子页列表"
+    },
+    "theme_desc": {
+      "light_and_dark": "明暗模式",
+      "unique": "只有一种模式"
+    },
+    "function": "功能",
+    "function_desc": "您可以选择函数的有效/无效",
+    "function_options": {
+      "timeline": "时间线函数",
+      "timeline_desc1": "您可以显示子页的时间线。",
+      "timeline_desc2": "如果有许多子页,则在加载页时性能会降低。",
+      "timeline_desc3": "通过使列表页无效,可以加快列表页的显示速度。",
+      "tab_switch": "在浏览器中保存选项卡切换",
+      "tab_switch_desc1": "在浏览器中保存编辑选项卡和历史选项卡切换,并使其成为浏览器的前向/后向命令的对象。",
+      "tab_switch_desc2": "通过失效,您可以将页面转换作为浏览器的前向/后向命令的唯一对象。",
+      "attach_title_header": "自动创建新页面时添加h1节",
       "attach_title_header_desc": "创建新页面时,将页面路径作为h1节添加到第一行",
       "attach_title_header_desc": "创建新页面时,将页面路径作为h1节添加到第一行",
-
       "list_num_s": "Number of list displayed on modals",
       "list_num_s": "Number of list displayed on modals",
       "list_num_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
       "list_num_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
-
       "list_num_m": "Number of list displayed on article pages included other contents",
       "list_num_m": "Number of list displayed on article pages included other contents",
       "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages.",
       "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages.",
-
       "list_num_l": "Number of list displayed on 'Search' pages",
       "list_num_l": "Number of list displayed on 'Search' pages",
       "list_num_desc_l": "Set number of list per page such as 'Search' pages.",
       "list_num_desc_l": "Set number of list per page such as 'Search' pages.",
-
       "list_num_xl": "Number of list displayed on article pages",
       "list_num_xl": "Number of list displayed on article pages",
       "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
       "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
-
-			"stale_notification": "在过期页上显示通知",
-			"stale_notification_desc": "显示自上次更新以来超过1年的页面通知。",
-			"show_all_reply_comments": "显示所有回复评论",
-			"show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。"
-		},
-		"code_highlight": "代码突出显示",
-		"nocdn_desc": "当强制应用环境变量<code>NO_CDN=true</code><br>Github样式时,此函数被禁用。",
-		"custom_title": "自定义标题",
-		"custom_title_detail": "您可以自定义<code>&lt;title&gt;</code>标记。<br><code>&123;&123;sitename&&125;&125;</code>将自动替换为应用程序名称,并且<code>&123;&123;page&&125;&125;</code>将替换为页面名称/路径。",
-		"custom_title_detail_placeholder1": "<code>&#123;&#123;站点名称&#125;&#125;</code>-此wiki的站点名称。",
-		"custom_title_detail_placeholder2": "<code>&#123;&#123;页名&#125;&#125;</code>-当前页的页名。",
-		"custom_title_detail_placeholder3": "<code>&#123;&#123;页面路径&#125;&#125;</code>-当前页面的页面路径。",
-		"custom_header": "自定义HTML标题",
-		"custom_header_detail": "您可以自定义应用所有页面的HTML标题。您的自定义脚本将插入<code>&lt;header&gt;</code>中,但位于其他<code>&lt;script&gt;</code>标记之上。<br>重新链接页面以查看更改。",
-		"custom_css": "自定义CSS",
-		"write_css": "您可以编写应用于整个系统的CSS。",
-		"ctrl_space": "Ctrl+Space 自动完成",
-		"custom_script": "定制纸条",
-		"write_java": "您可以编写应用于整个系统的Javascript。",
-		"reflect_change": "您需要重新加载页面以反映更改。"
-	},
-	"importer_management": {
-		"beta_warning": "这个函数是Beta。",
-		"import_from": "Import from {{from}}",
-		"import_growi_archive": "Import GROWI archive",
-		"growi_settings": {
-			"description_of_import_mode": {
-				"about": "When you import data with the same name as an existing one, choose from the following three modes below.",
-				"insert": "Insert: Skip importing the data.",
-				"upsert": "Upsert: Overwrite and update the existing data with imported data.",
-				"flash_and_insert": "Flash and Insert: After deleting the existing data completely, import the data"
-			},
-			"growi_archive_file": "GROWI Archive File",
-			"uploaded_data": "Uploaded Data",
-			"extracted_file": "Extracted File",
-			"collection": "Collection",
-			"upload": "Upload",
-			"discard": "Discard uploaded data",
-			"errors": {
+      "stale_notification": "在过期页上显示通知",
+      "stale_notification_desc": "显示自上次更新以来超过1年的页面通知。",
+      "show_all_reply_comments": "显示所有回复评论",
+      "show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。"
+    },
+    "code_highlight": "代码突出显示",
+    "nocdn_desc": "当强制应用环境变量<code>NO_CDN=true</code><br>Github样式时,此函数被禁用。",
+    "custom_title": "自定义标题",
+    "custom_title_detail": "您可以自定义<code>&lt;title&gt;</code>标记。<br><code>&123;&123;sitename&&125;&125;</code>将自动替换为应用程序名称,并且<code>&123;&123;page&&125;&125;</code>将替换为页面名称/路径。",
+    "custom_title_detail_placeholder1": "<code>&#123;&#123;站点名称&#125;&#125;</code>-此wiki的站点名称。",
+    "custom_title_detail_placeholder2": "<code>&#123;&#123;页名&#125;&#125;</code>-当前页的页名。",
+    "custom_title_detail_placeholder3": "<code>&#123;&#123;页面路径&#125;&#125;</code>-当前页面的页面路径。",
+    "custom_header": "自定义HTML标题",
+    "custom_header_detail": "您可以自定义应用所有页面的HTML标题。您的自定义脚本将插入<code>&lt;header&gt;</code>中,但位于其他<code>&lt;script&gt;</code>标记之上。<br>重新链接页面以查看更改。",
+    "custom_css": "自定义CSS",
+    "write_css": "您可以编写应用于整个系统的CSS。",
+    "ctrl_space": "Ctrl+Space 自动完成",
+    "custom_script": "定制纸条",
+    "write_java": "您可以编写应用于整个系统的Javascript。",
+    "reflect_change": "您需要重新加载页面以反映更改。"
+  },
+  "importer_management": {
+    "beta_warning": "这个函数是Beta。",
+    "import_from": "Import from {{from}}",
+    "import_growi_archive": "Import GROWI archive",
+    "growi_settings": {
+      "description_of_import_mode": {
+        "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
+        "insert": "Insert: Skip importing the data.",
+        "upsert": "Upsert: Overwrite and update the existing data with imported data.",
+        "flash_and_insert": "Flash and Insert: After deleting the existing data completely, import the data"
+      },
+      "growi_archive_file": "GROWI Archive File",
+      "uploaded_data": "Uploaded Data",
+      "extracted_file": "Extracted File",
+      "collection": "Collection",
+      "upload": "Upload",
+      "discard": "Discard uploaded data",
+      "errors": {
         "versions_not_met": "this growi and the uploaded data versions are not met",
         "versions_not_met": "this growi and the uploaded data versions are not met",
-				"at_least_one": "Select one or more collections.",
-				"page_and_revision": "'Pages' and 'Revisions' must be imported both.",
-				"depends": "'{{target}}' must be selected when '{{condition}}' is selected."
-			},
-			"configuration": {
-				"pages": {
-					"overwrite_author": {
-						"label": "Overwrite page's author with the current user",
-						"desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
-					},
-					"set_public_to_page": {
-						"label": "Set 'Public' to the pages that is '{{from}}'",
-						"desc": "Make sure that this configuration makes all <b>'{{from}}'</b> pages readable from <span class=\"text-danger\">ANY users</span>."
-					},
-					"initialize_meta_datas": {
-						"label": "Initialize page's like, read users and comment count",
-						"desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
-					},
-					"initialize_hackmd_related_datas": {
-						"label": "Initialize HackMD related data",
-						"desc": "Recommended to check this unless there is important drafts on HackMD."
-					}
-				},
-				"revisions": {
-					"overwrite_author": {
-						"label": "Overwrite revision's author with the current user",
-						"desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
-					}
-				}
-			}
-		},
-		"esa_settings": {
-			"team_name": "Team name",
-			"access_token": "Access token",
-			"test_connection": "Test connection to esa"
-		},
-		"qiita_settings": {
-			"team_name": "Team name",
-			"access_token": "Access token",
-			"test_connection": "Test connection to qiita:team"
-		},
+        "at_least_one": "Select one or more collections.",
+        "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
+        "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
+      },
+      "configuration": {
+        "pages": {
+          "overwrite_author": {
+            "label": "Overwrite page's author with the current user",
+            "desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
+          },
+          "set_public_to_page": {
+            "label": "Set 'Public' to the pages that is '{{from}}'",
+            "desc": "Make sure that this configuration makes all <b>'{{from}}'</b> pages readable from <span class=\"text-danger\">ANY users</span>."
+          },
+          "initialize_meta_datas": {
+            "label": "Initialize page's like, read users and comment count",
+            "desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
+          },
+          "initialize_hackmd_related_datas": {
+            "label": "Initialize HackMD related data",
+            "desc": "Recommended to check this unless there is important drafts on HackMD."
+          }
+        },
+        "revisions": {
+          "overwrite_author": {
+            "label": "Overwrite revision's author with the current user",
+            "desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
+          }
+        }
+      }
+    },
+    "esa_settings": {
+      "team_name": "Team name",
+      "access_token": "Access token",
+      "test_connection": "Test connection to esa"
+    },
+    "qiita_settings": {
+      "team_name": "Team name",
+      "access_token": "Access token",
+      "test_connection": "Test connection to qiita:team"
+    },
     "import": "Import",
     "import": "Import",
     "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment",
     "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment",
-    "prepare_new_account_for_migration":"Prepare new account for migration",
-    "archive_data_import_detail":"More details? Click here.",
-    "admin_archive_data_import_guide_url":"https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
-		"page_skip": "Pages with a name that already exists on GROWI are not imported",
-		"Directory_hierarchy_tag": "Directory hierarchy tag"
-	},
-	"export_management": {
-		"exporting_collection_list": "正在导出集合列表",
-		"exported_data_list": "导出的存档数据列表",
-		"export_collections": "导出集合",
-		"check_all": "全部检查",
-		"uncheck_all": "全部取消选中",
-		"desc_password_seed": "<p>还原用户数据时,不要忘记将当前的<code>密码种子设置到新的GROWI系统,否则用户将无法使用其密码登录。<br><br><strong>提示:</strong><br>当前的<code>密码种子将存储在<code>meta.json格式</code>在导出的zip压缩包中。</p>",
-		"create_new_archive_data": "创建新的存档数据",
-		"export": "导出",
-		"cancel": "取消",
-		"file": "文件",
-		"growi_version": "Growi Version",
-		"collections": "Collections",
-		"exported_at": "Exported At",
-		"export_menu": "导出菜单",
-		"download": "下载",
-		"delete": "删除"
+    "prepare_new_account_for_migration": "Prepare new account for migration",
+    "archive_data_import_detail": "More details? Click here.",
+    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
+    "page_skip": "Pages with a name that already exists on GROWI are not imported",
+    "Directory_hierarchy_tag": "Directory hierarchy tag"
+  },
+  "export_management": {
+    "exporting_collection_list": "正在导出集合列表",
+    "exported_data_list": "导出的存档数据列表",
+    "export_collections": "导出集合",
+    "check_all": "全部检查",
+    "uncheck_all": "全部取消选中",
+    "desc_password_seed": "<p>还原用户数据时,不要忘记将当前的<code>密码种子设置到新的GROWI系统,否则用户将无法使用其密码登录。<br><br><strong>提示:</strong><br>当前的<code>密码种子将存储在<code>meta.json格式</code>在导出的zip压缩包中。</p>",
+    "create_new_archive_data": "创建新的存档数据",
+    "export": "导出",
+    "cancel": "取消",
+    "file": "文件",
+    "growi_version": "Growi Version",
+    "collections": "Collections",
+    "exported_at": "Exported At",
+    "export_menu": "导出菜单",
+    "download": "下载",
+    "delete": "删除"
   },
   },
   "slack_integration": {
   "slack_integration": {
     "selecting_bot_types": {
     "selecting_bot_types": {
@@ -304,8 +299,8 @@
     "delete": "取消",
     "delete": "取消",
     "integration_procedure": "协作程序",
     "integration_procedure": "协作程序",
     "custom_bot_without_proxy_settings": "Custom Bot without proxy 设置",
     "custom_bot_without_proxy_settings": "Custom Bot without proxy 设置",
-    "integration_failed":"联动失败",
-    "reset":"重置",
+    "integration_failed": "联动失败",
+    "reset": "重置",
     "reset_all_settings": "重置所有设置",
     "reset_all_settings": "重置所有设置",
     "delete_slackbot_settings": "删除 Slack Bot 设置",
     "delete_slackbot_settings": "删除 Slack Bot 设置",
     "slackbot_settings_notice": "Slak 工作区集成过程已被删除。 <br> 你确定吗?",
     "slackbot_settings_notice": "Slak 工作区集成过程已被删除。 <br> 你确定吗?",
@@ -333,6 +328,9 @@
       "install_complete_if_checked": "确认已选中 \"Install your app\"。",
       "install_complete_if_checked": "确认已选中 \"Install your app\"。",
       "invite_bot_to_channel": "通过调用 @example 邀请 GROWI Bot 进行频道。",
       "invite_bot_to_channel": "通过调用 @example 邀请 GROWI Bot 进行频道。",
       "register_secret_and_token": "设置签名秘密和BOT令牌",
       "register_secret_and_token": "设置签名秘密和BOT令牌",
+      "manage_commands": "管理 GROWI 命令",
+      "multiple_growi_command": "可以一次发送到多个 GROWI 实例的命令",
+      "single_growi_command": "可以一次发送到一个 GROWI 实例的命令",
       "test_connection": "测试连接",
       "test_connection": "测试连接",
       "test_connection_by_pressing_button": "按下按钮以测试连接",
       "test_connection_by_pressing_button": "按下按钮以测试连接",
       "error_check_logs_below": "发生了错误。请检查以下日志。",
       "error_check_logs_below": "发生了错误。请检查以下日志。",
@@ -357,93 +355,93 @@
       "custom_bot_with_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
       "custom_bot_with_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
     }
     }
   },
   },
-	"user_management": {
-		"invite_users": "临时发布新用户",
-		"click_twice_same_checkbox": "您应该至少选中一个复选框。",
-		"invite_modal": {
-			"emails": "电子邮件",
-      "description1":"通过电子邮件地址临时发布新用户。",
-      "description2":"将为首次登录生成一个临时密码。",
-      "mail_setting_link":"<i class='icon-settings mr-2'></i><a href='/admin/app'>Email settings</a>",
-			"valid_email": "需要有效的电子邮件地址",
-			"invite_thru_email": "发送邀请电子邮件",
-			"temporary_password": "创建的用户具有临时密码",
-			"send_new_password": "请将新密码发送给用户。",
-			"send_temporary_password": "如果你没有发送电子邮件邀请,请复制此屏幕上的临时密码并联系邀请人。",
+  "user_management": {
+    "invite_users": "临时发布新用户",
+    "click_twice_same_checkbox": "您应该至少选中一个复选框。",
+    "invite_modal": {
+      "emails": "电子邮件",
+      "description1": "通过电子邮件地址临时发布新用户。",
+      "description2": "将为首次登录生成一个临时密码。",
+      "mail_setting_link": "<i class='icon-settings mr-2'></i><a href='/admin/app'>Email settings</a>",
+      "valid_email": "需要有效的电子邮件地址",
+      "invite_thru_email": "发送邀请电子邮件",
+      "temporary_password": "创建的用户具有临时密码",
+      "send_new_password": "请将新密码发送给用户。",
+      "send_temporary_password": "如果你没有发送电子邮件邀请,请复制此屏幕上的临时密码并联系邀请人。",
       "send_email": "你也可以从用户表中的下拉菜单中发送或重新发送邀请邮件。",
       "send_email": "你也可以从用户表中的下拉菜单中发送或重新发送邀请邮件。",
-			"existing_email": "以下电子邮件已存在",
+      "existing_email": "以下电子邮件已存在",
       "issue": "Issue"
       "issue": "Issue"
-		},
-		"user_table": {
-			"administrator": "管理员",
-			"edit_menu": "编辑菜单",
-			"reset_password": "重置密码",
-			"administrator_menu": "管理员菜单",
-			"accept": "接受",
-			"deactivate_account": "停用帐户",
-			"your_own": "您不能停用自己的帐户",
-			"remove_admin_access": "删除管理员访问权限",
-			"cannot_remove": "您不能从管理员中删除自己",
-			"give_admin_access": "授予管理员访问权限",
+    },
+    "user_table": {
+      "administrator": "管理员",
+      "edit_menu": "编辑菜单",
+      "reset_password": "重置密码",
+      "administrator_menu": "管理员菜单",
+      "accept": "接受",
+      "deactivate_account": "停用帐户",
+      "your_own": "您不能停用自己的帐户",
+      "remove_admin_access": "删除管理员访问权限",
+      "cannot_remove": "您不能从管理员中删除自己",
+      "give_admin_access": "授予管理员访问权限",
       "send_invitation_email": "发送邀请邮件",
       "send_invitation_email": "发送邀请邮件",
       "resend_invitation_email": "重发邀请函"
       "resend_invitation_email": "重发邀请函"
-		},
-		"reset_password": "重置密码",
-		"reset_password_modal": {
-			"password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
-			"password_reset_message": "Let the user know the new password below and strongly recommend to change another one immediately.",
-			"send_new_password": "Please send the new password to the user.",
-			"target_user": "Target User",
-			"new_password": "New Password"
-		},
-		"external_account": "外部账户管理",
-		"external_accounts": "外部账户",
-		"create_external_account": "创建外部账户",
+    },
+    "reset_password": "重置密码",
+    "reset_password_modal": {
+      "password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
+      "password_reset_message": "Let the user know the new password below and strongly recommend to change another one immediately.",
+      "send_new_password": "Please send the new password to the user.",
+      "target_user": "Target User",
+      "new_password": "New Password"
+    },
+    "external_account": "外部账户管理",
+    "external_accounts": "外部账户",
+    "create_external_account": "创建外部账户",
     "external_account_list": "外部账户列表",
     "external_account_list": "外部账户列表",
-    "external_account_none":"No External Account",
-		"invite": "邀请",
-		"invited": "已邀请用户",
-		"back_to_user_management": "返回用户管理",
-		"authentication_provider": "身份认证",
-		"manage": "管理",
-		"password_setting": "密码设置",
-		"password_setting_help": "是否设置了密码?",
-		"set": "是",
-		"unset": "否",
-		"related_username": "相关用户的",
-		"cannot_invite_maximum_users": "邀请的用户数不能超过最大值。",
-		"current_users": "当前用户:"
-	},
-	"user_group_management": {
-		"create_group": "创建新组",
-		"deny_create_group": "不能用当前设置创建新组。",
-		"group_name": "组名",
-		"group_example": "e.g.:第1组",
-		"add_modal": {
-			"add_user": "将用户添加到创建的组",
-			"search_option": "搜索选项",
-			"enable_option": "启用{{option}",
-			"forward_match": "Forword匹配",
-			"partial_match": "部分匹配",
-			"backward_match": "向后匹配"
-		},
-		"group_list": "组列表",
-		"back_to_list": "返回组列表",
-		"basic_info": "基本信息",
-		"user_list": "用户列表",
-		"created_group": "已创建组",
-		"is_loading_data": "获取数据。。。",
-		"no_pages": "组没有查看权限的页面。",
-		"remove_from_group": "删除此用户",
-		"delete_modal": {
-			"header": "删除组",
-			"desc": "删除后,将无法检索已删除的组及其私人页。",
-			"dropdown_desc": "为私人页选择操作",
-			"select_group": "选择组",
-			"no_groups": "没有可选择的组",
-			"publish_pages": "全部发布",
-			"delete_pages": "全部删除",
-			"transfer_pages": "转移到另一组"
-		}
-	}
+    "external_account_none": "No External Account",
+    "invite": "邀请",
+    "invited": "已邀请用户",
+    "back_to_user_management": "返回用户管理",
+    "authentication_provider": "身份认证",
+    "manage": "管理",
+    "password_setting": "密码设置",
+    "password_setting_help": "是否设置了密码?",
+    "set": "是",
+    "unset": "否",
+    "related_username": "相关用户的",
+    "cannot_invite_maximum_users": "邀请的用户数不能超过最大值。",
+    "current_users": "当前用户:"
+  },
+  "user_group_management": {
+    "create_group": "创建新组",
+    "deny_create_group": "不能用当前设置创建新组。",
+    "group_name": "组名",
+    "group_example": "e.g.:第1组",
+    "add_modal": {
+      "add_user": "将用户添加到创建的组",
+      "search_option": "搜索选项",
+      "enable_option": "启用{{option}",
+      "forward_match": "Forword匹配",
+      "partial_match": "部分匹配",
+      "backward_match": "向后匹配"
+    },
+    "group_list": "组列表",
+    "back_to_list": "返回组列表",
+    "basic_info": "基本信息",
+    "user_list": "用户列表",
+    "created_group": "已创建组",
+    "is_loading_data": "获取数据。。。",
+    "no_pages": "组没有查看权限的页面。",
+    "remove_from_group": "删除此用户",
+    "delete_modal": {
+      "header": "删除组",
+      "desc": "删除后,将无法检索已删除的组及其私人页。",
+      "dropdown_desc": "为私人页选择操作",
+      "select_group": "选择组",
+      "no_groups": "没有可选择的组",
+      "publish_pages": "全部发布",
+      "delete_pages": "全部删除",
+      "transfer_pages": "转移到另一组"
+    }
+  }
 }
 }

+ 20 - 1
packages/app/src/client/models/Linker.js

@@ -1,3 +1,8 @@
+
+import { pagePathUtils } from '@growi/core';
+
+const { encodeSpaces } = pagePathUtils;
+
 export default class Linker {
 export default class Linker {
 
 
   constructor(
   constructor(
@@ -5,10 +10,15 @@ export default class Linker {
       label = '',
       label = '',
       link = '',
       link = '',
   ) {
   ) {
+
     this.type = type;
     this.type = type;
     this.label = label;
     this.label = label;
     this.link = link;
     this.link = link;
 
 
+    if (type === Linker.types.markdownLink) {
+      this.initWhenMarkdownLink();
+    }
+
     this.generateMarkdownText = this.generateMarkdownText.bind(this);
     this.generateMarkdownText = this.generateMarkdownText.bind(this);
   }
   }
 
 
@@ -25,9 +35,18 @@ export default class Linker {
     markdownLink: /^\[(?<label>.*)\]\((?<link>.*)\)$/, // https://regex101.com/r/DZCKP3/2
     markdownLink: /^\[(?<label>.*)\]\((?<link>.*)\)$/, // https://regex101.com/r/DZCKP3/2
   }
   }
 
 
+  initWhenMarkdownLink() {
+    // fill label with link if empty
+    if (this.label === '') {
+      this.label = this.link;
+    }
+    // encode spaces
+    this.link = encodeSpaces(this.link);
+  }
+
   generateMarkdownText() {
   generateMarkdownText() {
     if (this.type === Linker.types.pukiwikiLink) {
     if (this.type === Linker.types.pukiwikiLink) {
-      if (this.label === this.link) return `[[${this.link}]]`;
+      if (this.label === '') return `[[${this.link}]]`;
       return `[[${this.label}>${this.link}]]`;
       return `[[${this.label}>${this.link}]]`;
     }
     }
     if (this.type === Linker.types.growiLink) {
     if (this.type === Linker.types.growiLink) {

+ 4 - 2
packages/app/src/client/plugin.js

@@ -29,15 +29,17 @@ export default class GrowiPlugin {
       const meta = definition.meta;
       const meta = definition.meta;
 
 
       switch (meta.pluginSchemaVersion) {
       switch (meta.pluginSchemaVersion) {
-        // v1 is deprecated
+        // v1, v2 and v3 is deprecated
         case 1:
         case 1:
           logger.warn('pluginSchemaVersion 1 is deprecated', definition);
           logger.warn('pluginSchemaVersion 1 is deprecated', definition);
           break;
           break;
-        // v2 is deprecated
         case 2:
         case 2:
           logger.warn('pluginSchemaVersion 2 is deprecated', definition);
           logger.warn('pluginSchemaVersion 2 is deprecated', definition);
           break;
           break;
         case 3:
         case 3:
+          logger.warn('pluginSchemaVersion 2 is deprecated', definition);
+          break;
+        case 4:
           definition.entries.forEach((entry) => {
           definition.entries.forEach((entry) => {
             entry(appContainer);
             entry(appContainer);
           });
           });

+ 1 - 1
packages/app/src/client/services/AdminGitHubSecurityContainer.js

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';

+ 1 - 1
packages/app/src/client/services/AdminGoogleSecurityContainer.js

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';

+ 1 - 1
packages/app/src/client/services/AdminOidcSecurityContainer.js

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';

+ 1 - 1
packages/app/src/client/services/AdminSamlSecurityContainer.js

@@ -1,7 +1,7 @@
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';

+ 1 - 1
packages/app/src/client/services/AdminTwitterSecurityContainer.js

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';

+ 3 - 1
packages/app/src/client/services/PageContainer.js

@@ -3,8 +3,8 @@ import { Container } from 'unstated';
 
 
 import * as entities from 'entities';
 import * as entities from 'entities';
 import * as toastr from 'toastr';
 import * as toastr from 'toastr';
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
-import { isTrashPage } from '~/utils/path-utils';
 import { toastError } from '../util/apiNotification';
 import { toastError } from '../util/apiNotification';
 
 
 import {
 import {
@@ -16,6 +16,8 @@ import {
   DrawioInterceptor,
   DrawioInterceptor,
 } from '../util/interceptor/drawio-interceptor';
 } from '../util/interceptor/drawio-interceptor';
 
 
+const { isTrashPage } = pagePathUtils;
+
 const logger = loggerFactory('growi:services:PageContainer');
 const logger = loggerFactory('growi:services:PageContainer');
 
 
 /**
 /**

+ 1 - 1
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';

+ 5 - 1
packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -103,7 +103,9 @@ const CustomBotWithProxySettings = (props) => {
 
 
       <div className="mx-3">
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
         {slackAppIntegrations.map((slackAppIntegration, i) => {
-          const { tokenGtoP, tokenPtoG, _id } = slackAppIntegration;
+          const {
+            tokenGtoP, tokenPtoG, _id, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+          } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
           return (
             <React.Fragment key={slackAppIntegration._id}>
             <React.Fragment key={slackAppIntegration._id}>
@@ -125,6 +127,8 @@ const CustomBotWithProxySettings = (props) => {
                 slackAppIntegrationId={slackAppIntegration._id}
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
                 tokenPtoG={tokenPtoG}
+                supportedCommandsForBroadcastUse={supportedCommandsForBroadcastUse}
+                supportedCommandsForSingleUse={supportedCommandsForSingleUse}
                 onUpdateTokens={onUpdateTokens}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
                 onSubmitForm={onSubmitForm}
               />
               />

+ 144 - 0
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx

@@ -0,0 +1,144 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+import loggerFactory from '~/utils/logger';
+
+import { toastSuccess, toastError } from '../../../client/util/apiNotification';
+
+const logger = loggerFactory('growi:SlackIntegration:ManageCommandsProcess');
+
+const ManageCommandsProcess = ({
+  apiv3Put, slackAppIntegrationId, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+}) => {
+  const { t } = useTranslation();
+  const [selectedCommandsForBroadcastUse, setSelectedCommandsForBroadcastUse] = useState(new Set(supportedCommandsForBroadcastUse));
+  const [selectedCommandsForSingleUse, setSelectedCommandsForSingleUse] = useState(new Set(supportedCommandsForSingleUse));
+
+  const toggleCheckboxForBroadcast = (e) => {
+    const { target } = e;
+    const { name, checked } = target;
+
+    setSelectedCommandsForBroadcastUse((prevState) => {
+      const selectedCommands = new Set(prevState);
+      if (checked) {
+        selectedCommands.add(name);
+      }
+      else {
+        selectedCommands.delete(name);
+      }
+
+      return selectedCommands;
+    });
+  };
+
+  const toggleCheckboxForSingleUse = (e) => {
+    const { target } = e;
+    const { name, checked } = target;
+
+    setSelectedCommandsForSingleUse((prevState) => {
+      const selectedCommands = new Set(prevState);
+      if (checked) {
+        selectedCommands.add(name);
+      }
+      else {
+        selectedCommands.delete(name);
+      }
+
+      return selectedCommands;
+    });
+  };
+
+  const updateCommandsHandler = async() => {
+    try {
+      await apiv3Put(`/slack-integration-settings/${slackAppIntegrationId}/supported-commands`, {
+        supportedCommandsForBroadcastUse: Array.from(selectedCommandsForBroadcastUse),
+        supportedCommandsForSingleUse: Array.from(selectedCommandsForSingleUse),
+      });
+      toastSuccess(t('toaster.update_successed', { target: 'Token' }));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  };
+
+
+  return (
+    <div className="py-4 px-5">
+      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.manage_commands')}</p>
+      <div className="d-flex flex-column align-items-center">
+
+        <div>
+          <p className="font-weight-bold mb-0">Multiple GROWI</p>
+          <p className="text-muted mb-2">{t('admin:slack_integration.accordion.multiple_growi_command')}</p>
+          <div className="custom-control custom-checkbox">
+            <div className="row mb-5">
+              {defaultSupportedCommandsNameForBroadcastUse.map((commandName) => {
+                return (
+                  <div className="col-sm-6 my-1" key={commandName}>
+                    <input
+                      type="checkbox"
+                      className="custom-control-input"
+                      id={commandName}
+                      name={commandName}
+                      value={commandName}
+                      checked={selectedCommandsForBroadcastUse.has(commandName)}
+                      onChange={toggleCheckboxForBroadcast}
+                    />
+                    <label className="text-capitalize custom-control-label ml-3" htmlFor={commandName}>
+                      {commandName}
+                    </label>
+                  </div>
+                );
+              })}
+            </div>
+          </div>
+
+          <p className="font-weight-bold mb-0">Single GROWI</p>
+          <p className="text-muted mb-2">{t('admin:slack_integration.accordion.single_growi_command')}</p>
+          <div className="custom-control custom-checkbox">
+            <div className="row mb-5">
+              {defaultSupportedCommandsNameForSingleUse.map((commandName) => {
+                return (
+                  <div className="col-sm-6 my-1" key={commandName}>
+                    <input
+                      type="checkbox"
+                      className="custom-control-input"
+                      id={commandName}
+                      name={commandName}
+                      value={commandName}
+                      checked={selectedCommandsForSingleUse.has(commandName)}
+                      onChange={toggleCheckboxForSingleUse}
+                    />
+                    <label className="text-capitalize custom-control-label ml-3" htmlFor={commandName}>
+                      {commandName}
+                    </label>
+                  </div>
+                );
+              })}
+            </div>
+          </div>
+        </div>
+      </div>
+      <div className="row">
+        <button
+          type="button"
+          className="btn btn-primary mx-auto"
+          onClick={updateCommandsHandler}
+        >
+          { t('Update') }
+        </button>
+      </div>
+    </div>
+  );
+};
+
+ManageCommandsProcess.propTypes = {
+  apiv3Put: PropTypes.func,
+  slackAppIntegrationId: PropTypes.string.isRequired,
+  supportedCommandsForBroadcastUse: PropTypes.arrayOf(PropTypes.string),
+  supportedCommandsForSingleUse: PropTypes.arrayOf(PropTypes.string),
+};
+
+export default ManageCommandsProcess;

+ 5 - 1
packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx

@@ -69,7 +69,9 @@ const OfficialBotSettings = (props) => {
 
 
       <div className="mx-3">
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
         {slackAppIntegrations.map((slackAppIntegration, i) => {
-          const { tokenGtoP, tokenPtoG, _id } = slackAppIntegration;
+          const {
+            tokenGtoP, tokenPtoG, _id, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+          } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
           return (
             <React.Fragment key={slackAppIntegration._id}>
             <React.Fragment key={slackAppIntegration._id}>
@@ -91,6 +93,8 @@ const OfficialBotSettings = (props) => {
                 slackAppIntegrationId={slackAppIntegration._id}
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
                 tokenPtoG={tokenPtoG}
+                supportedCommandsForBroadcastUse={supportedCommandsForBroadcastUse}
+                supportedCommandsForSingleUse={supportedCommandsForSingleUse}
                 onUpdateTokens={onUpdateTokens}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
                 onSubmitForm={onSubmitForm}
               />
               />

+ 21 - 1
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -11,6 +11,7 @@ import AppContainer from '~/client/services/AppContainer';
 import Accordion from '../Common/Accordion';
 import Accordion from '../Common/Accordion';
 import { addLogs } from './slak-integration-util';
 import { addLogs } from './slak-integration-util';
 import MessageBasedOnConnection from './MessageBasedOnConnection';
 import MessageBasedOnConnection from './MessageBasedOnConnection';
+import ManageCommandsProcess from './ManageCommandsProcess';
 
 
 const logger = loggerFactory('growi:SlackIntegration:WithProxyAccordionsWrapper');
 const logger = loggerFactory('growi:SlackIntegration:WithProxyAccordionsWrapper');
 
 
@@ -287,7 +288,6 @@ const WithProxyAccordions = (props) => {
       props.onSubmitForm();
       props.onSubmitForm();
     }
     }
   };
   };
-
   const submitFormFailed = () => {
   const submitFormFailed = () => {
     setIsLatestConnectionSuccess(false);
     setIsLatestConnectionSuccess(false);
   };
   };
@@ -309,6 +309,15 @@ const WithProxyAccordions = (props) => {
       />,
       />,
     },
     },
     '③': {
     '③': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
+        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
+      />,
+    },
+    '④': {
       title: 'test_connection',
       title: 'test_connection',
       content: <TestProcess
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
         apiv3Post={props.appContainer.apiv3.post}
@@ -344,6 +353,15 @@ const WithProxyAccordions = (props) => {
       content: <RegisteringProxyUrlProcess />,
       content: <RegisteringProxyUrlProcess />,
     },
     },
     '⑤': {
     '⑤': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
+        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
+      />,
+    },
+    '⑥': {
       title: 'test_connection',
       title: 'test_connection',
       content: <TestProcess
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
         apiv3Post={props.appContainer.apiv3.post}
@@ -392,6 +410,8 @@ WithProxyAccordions.propTypes = {
   slackAppIntegrationId: PropTypes.string.isRequired,
   slackAppIntegrationId: PropTypes.string.isRequired,
   tokenPtoG: PropTypes.string,
   tokenPtoG: PropTypes.string,
   tokenGtoP: PropTypes.string,
   tokenGtoP: PropTypes.string,
+  supportedCommandsForBroadcastUse: PropTypes.arrayOf(PropTypes.string),
+  supportedCommandsForSingleUse: PropTypes.arrayOf(PropTypes.string),
 };
 };
 
 
 export default WithProxyAccordionsWrapper;
 export default WithProxyAccordionsWrapper;

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx

@@ -4,11 +4,11 @@ import { withTranslation } from 'react-i18next';
 
 
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
+import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import UserPicture from '../../User/UserPicture';
 
 
 class UserGroupUserFormByInput extends React.Component {
 class UserGroupUserFormByInput extends React.Component {
 
 

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.jsx

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
 
 
-import UserPicture from '../../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';

+ 1 - 1
packages/app/src/components/Admin/Users/UserTable.jsx

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
 
 
-import UserPicture from '../../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import UserMenu from './UserMenu';
 import UserMenu from './UserMenu';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';

+ 3 - 1
packages/app/src/components/ComparePathsTable.jsx

@@ -2,10 +2,12 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
+import { pagePathUtils } from '@growi/core';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
-import { convertToNewAffiliationPath } from '~/utils/path-utils';
+
+const { convertToNewAffiliationPath } = pagePathUtils;
 
 
 function ComparePathsTable(props) {
 function ComparePathsTable(props) {
   const {
   const {

+ 3 - 1
packages/app/src/components/ContentLinkButtons.jsx

@@ -1,7 +1,7 @@
 import React, { useMemo } from 'react';
 import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { isTopPage } from '~/utils/path-utils';
+import { pagePathUtils } from '@growi/core';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
@@ -10,6 +10,8 @@ import { withUnstatedContainers } from './UnstatedUtils';
 
 
 import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
 import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
 
 
+const { isTopPage } = pagePathUtils;
+
 const WIKI_HEADER_LINK = 120;
 const WIKI_HEADER_LINK = 120;
 
 
 /**
 /**

+ 1 - 1
packages/app/src/components/CreateTemplateModal.jsx

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 

+ 3 - 1
packages/app/src/components/DuplicatedPathsTable.jsx

@@ -2,10 +2,12 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
+import { pagePathUtils } from '@growi/core';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
-import { convertToNewAffiliationPath } from '~/utils/path-utils';
+
+const { convertToNewAffiliationPath } = pagePathUtils;
 
 
 function DuplicatedPathsTable(props) {
 function DuplicatedPathsTable(props) {
   const {
   const {

+ 1 - 1
packages/app/src/components/Fab.jsx

@@ -62,7 +62,7 @@ const Fab = (props) => {
   }
   }
 
 
   return (
   return (
-    <div className="grw-fab d-none d-md-block">
+    <div className="grw-fab d-none d-md-block d-edit-none">
       {currentUser != null && renderPageCreateButton()}
       {currentUser != null && renderPageCreateButton()}
       <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
       <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
         <button
         <button

+ 4 - 2
packages/app/src/components/Navbar/AuthorInfo.jsx

@@ -1,9 +1,11 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
-import { userPageRoot } from '~/utils/path-utils';
+import { UserPicture } from '@growi/ui';
+import { pagePathUtils } from '@growi/core';
+
+const { userPageRoot } = pagePathUtils;
 
 
-import UserPicture from '../User/UserPicture';
 
 
 const formatType = 'yyyy/MM/dd HH:mm';
 const formatType = 'yyyy/MM/dd HH:mm';
 const AuthorInfo = (props) => {
 const AuthorInfo = (props) => {

+ 1 - 1
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -3,8 +3,8 @@ import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
+import { DevidedPagePath } from '@growi/core';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import DevidedPagePath from '~/models/devided-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';

+ 1 - 1
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -5,6 +5,7 @@ import { withTranslation } from 'react-i18next';
 
 
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
+import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
@@ -18,7 +19,6 @@ import {
   updateUserPreferenceWithOsSettings,
   updateUserPreferenceWithOsSettings,
 } from '~/client/util/color-scheme';
 } from '~/client/util/color-scheme';
 
 
-import UserPicture from '../User/UserPicture';
 
 
 import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
 import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
 import SidebarDockIcon from '../Icons/SidebarDockIcon';
 import SidebarDockIcon from '../Icons/SidebarDockIcon';

+ 3 - 1
packages/app/src/components/Page/CopyDropdown.jsx

@@ -12,7 +12,9 @@ import {
 
 
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 
 
-import { encodeSpaces } from '~/utils/path-utils';
+import { pagePathUtils } from '@growi/core';
+
+const { encodeSpaces } = pagePathUtils;
 
 
 /* eslint-disable react/prop-types */
 /* eslint-disable react/prop-types */
 const DropdownItemContents = ({ title, contents }) => (
 const DropdownItemContents = ({ title, contents }) => (

+ 3 - 1
packages/app/src/components/Page/PageManagement.jsx

@@ -4,7 +4,7 @@ import { UncontrolledTooltip } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
-import { isTopPage } from '~/utils/path-utils';
+import { pagePathUtils } from '@growi/core';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
@@ -15,6 +15,8 @@ import CreateTemplateModal from '../CreateTemplateModal';
 import PagePresentationModal from '../PagePresentationModal';
 import PagePresentationModal from '../PagePresentationModal';
 import PresentationIcon from '../Icons/PresentationIcon';
 import PresentationIcon from '../Icons/PresentationIcon';
 
 
+const { isTopPage } = pagePathUtils;
+
 
 
 const PageManagement = (props) => {
 const PageManagement = (props) => {
   const {
   const {

+ 1 - 1
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -3,10 +3,10 @@ import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
+import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
-import UserPicture from '../User/UserPicture';
 import PutbackPageModal from '../PutbackPageModal';
 import PutbackPageModal from '../PutbackPageModal';
 import EmptyTrashModal from '../EmptyTrashModal';
 import EmptyTrashModal from '../EmptyTrashModal';
 import PageDeleteModal from '../PageDeleteModal';
 import PageDeleteModal from '../PageDeleteModal';

+ 1 - 1
packages/app/src/components/PageAttachment/DeleteAttachmentModal.jsx

@@ -6,7 +6,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import UserPicture from '../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import Username from '../User/Username';
 import Username from '../User/Username';
 
 
 export default class DeleteAttachmentModal extends React.Component {
 export default class DeleteAttachmentModal extends React.Component {

+ 1 - 1
packages/app/src/components/PageAttachment/PageAttachmentList.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import Attachment from './Attachment';
+import { Attachment } from '@growi/ui';
 
 
 export default class PageAttachmentList extends React.Component {
 export default class PageAttachmentList extends React.Component {
 
 

+ 1 - 1
packages/app/src/components/PageComment/Comment.jsx

@@ -6,6 +6,7 @@ import { format } from 'date-fns';
 
 
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
+import { UserPicture } from '@growi/ui';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
 
 
@@ -13,7 +14,6 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
 import FormattedDistanceDate from '../FormattedDistanceDate';
 import RevisionBody from '../Page/RevisionBody';
 import RevisionBody from '../Page/RevisionBody';
-import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 import Username from '../User/Username';
 import CommentEditor from './CommentEditor';
 import CommentEditor from './CommentEditor';
 import CommentControl from './CommentControl';
 import CommentControl from './CommentControl';

+ 1 - 1
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -8,6 +8,7 @@ import {
 
 
 import * as toastr from 'toastr';
 import * as toastr from 'toastr';
 
 
+import { UserPicture } from '@growi/ui';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
 import CommentContainer from '~/client/services/CommentContainer';
 import CommentContainer from '~/client/services/CommentContainer';
@@ -15,7 +16,6 @@ import EditorContainer from '~/client/services/EditorContainer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
-import UserPicture from '../User/UserPicture';
 import Editor from '../PageEditor/Editor';
 import Editor from '../PageEditor/Editor';
 import SlackNotification from '../SlackNotification';
 import SlackNotification from '../SlackNotification';
 
 

+ 1 - 1
packages/app/src/components/PageComment/DeleteCommentModal.jsx

@@ -7,7 +7,7 @@ import {
 
 
 import { format } from 'date-fns';
 import { format } from 'date-fns';
 
 
-import UserPicture from '../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import Username from '../User/Username';
 import Username from '../User/Username';
 
 
 export default class DeleteCommentModal extends React.Component {
 export default class DeleteCommentModal extends React.Component {

+ 6 - 4
packages/app/src/components/PageCreateModal.jsx

@@ -7,10 +7,8 @@ import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
 
 
-import { pathUtils } from 'growi-commons';
-import {
-  userPageRoot, isCreatablePage, generateEditorPath,
-} from '~/utils/path-utils';
+import { pagePathUtils, pathUtils } from '@growi/core';
+
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
@@ -19,6 +17,10 @@ import { toastError } from '~/client/util/apiNotification';
 
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
 
 
+const {
+  userPageRoot, isCreatablePage, generateEditorPath,
+} = pagePathUtils;
+
 const PageCreateModal = (props) => {
 const PageCreateModal = (props) => {
   const { t, appContainer, navigationContainer } = props;
   const { t, appContainer, navigationContainer } = props;
 
 

+ 6 - 19
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -13,17 +13,18 @@ import {
 import path from 'path';
 import path from 'path';
 import validator from 'validator';
 import validator from 'validator';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
-import PreviewWithSuspense from './PreviewWithSuspense';
-import PagePreviewIcon from '../Icons/PagePreviewIcon';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
+import Linker from '~/client/models/Linker';
 
 
+import PreviewWithSuspense from './PreviewWithSuspense';
+import PagePreviewIcon from '../Icons/PagePreviewIcon';
 import SearchTypeahead from '../SearchTypeahead';
 import SearchTypeahead from '../SearchTypeahead';
-import Linker from '~/client/models/Linker';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
+
 class LinkEditModal extends React.PureComponent {
 class LinkEditModal extends React.PureComponent {
 
 
   constructor(props) {
   constructor(props) {
@@ -175,22 +176,8 @@ class LinkEditModal extends React.PureComponent {
     this.setState({ markdown, previewError, permalink });
     this.setState({ markdown, previewError, permalink });
   }
   }
 
 
-  getLinkForPreview() {
-    const linker = this.generateLink();
-
-    if (this.isUsePermanentLink && this.permalink != null) {
-      linker.link = this.permalink;
-    }
-
-    if (linker.label === '') {
-      linker.label = linker.link;
-    }
-
-    return linker;
-  }
-
   renderLinkPreview() {
   renderLinkPreview() {
-    const linker = this.getLinkForPreview();
+    const linker = this.generateLink();
     return (
     return (
       <div className="d-flex justify-content-between mb-3 flex-column flex-sm-row">
       <div className="d-flex justify-content-between mb-3 flex-column flex-sm-row">
         <div className="card card-disabled w-100 p-1 mb-0">
         <div className="card card-disabled w-100 p-1 mb-0">
@@ -245,7 +232,7 @@ class LinkEditModal extends React.PureComponent {
   }
   }
 
 
   save() {
   save() {
-    const linker = this.getLinkForPreview();
+    const linker = this.generateLink();
 
 
     if (this.props.onSave != null) {
     if (this.props.onSave != null) {
       this.props.onSave(linker.generateMarkdownText());
       this.props.onSave(linker.generateMarkdownText());

+ 1 - 1
packages/app/src/components/PageHistory/Revision.jsx

@@ -1,9 +1,9 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
+import { UserPicture } from '@growi/ui';
 import UserDate from '../User/UserDate';
 import UserDate from '../User/UserDate';
 import Username from '../User/Username';
 import Username from '../User/Username';
-import UserPicture from '../User/UserPicture';
 
 
 export default class Revision extends React.Component {
 export default class Revision extends React.Component {
 
 

+ 2 - 3
packages/app/src/components/PageList/Page.jsx

@@ -1,9 +1,8 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import UserPicture from '../User/UserPicture';
-import PageListMeta from './PageListMeta';
-import PagePathLabel from './PagePathLabel';
+import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
+
 
 
 export default class Page extends React.Component {
 export default class Page extends React.Component {
 
 

+ 0 - 24
packages/app/src/components/PageList/PagePath.jsx

@@ -1,24 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import PagePathLabel from './PagePathLabel';
-
-/**
- * !!DEPRECATED!!
- *
- * maintained for backward compatibility for growi-lsx-plugin(<= 3.1.1)
- */
-const PagePath = props => (
-  <PagePathLabel isLatterOnly={props.isShortPathOnly} {...props} />
-);
-
-PagePath.propTypes = {
-  isShortPathOnly: PropTypes.bool,
-  ...PagePathLabel.propTypes,
-};
-
-PagePath.defaultProps = {
-  ...PagePathLabel.defaultProps,
-};
-
-export default PagePath;

+ 1 - 1
packages/app/src/components/PagePathAutoComplete.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 
 
 import SearchTypeahead from './SearchTypeahead';
 import SearchTypeahead from './SearchTypeahead';
 
 

+ 3 - 1
packages/app/src/components/RevisionComparer/RevisionComparer.jsx

@@ -6,7 +6,7 @@ import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { encodeSpaces } from '~/utils/path-utils';
+import { pagePathUtils } from '@growi/core';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
@@ -14,6 +14,8 @@ import RevisionComparerContainer from '~/client/services/RevisionComparerContain
 
 
 import RevisionDiff from '../PageHistory/RevisionDiff';
 import RevisionDiff from '../PageHistory/RevisionDiff';
 
 
+const { encodeSpaces } = pagePathUtils;
+
 /* eslint-disable react/prop-types */
 /* eslint-disable react/prop-types */
 const DropdownItemContents = ({ title, contents }) => (
 const DropdownItemContents = ({ title, contents }) => (
   <>
   <>

+ 2 - 3
packages/app/src/components/SearchTypeahead.jsx

@@ -4,9 +4,8 @@ import PropTypes from 'prop-types';
 import { noop } from 'lodash/noop';
 import { noop } from 'lodash/noop';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 
-import UserPicture from './User/UserPicture';
-import PageListMeta from './PageList/PageListMeta';
-import PagePathLabel from './PageList/PagePathLabel';
+import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
+
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 

+ 3 - 3
packages/app/src/components/ShareLink/ShareLinkForm.jsx

@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
-import { dateFnsFormat, parse } from 'date-fns';
+import { format, parse } from 'date-fns';
 
 
 import { isInteger } from 'core-js/fn/number';
 import { isInteger } from 'core-js/fn/number';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -20,8 +20,8 @@ class ShareLinkForm extends React.Component {
       expirationType: 'unlimited',
       expirationType: 'unlimited',
       numberOfDays: '7',
       numberOfDays: '7',
       description: '',
       description: '',
-      customExpirationDate: dateFnsFormat(new Date(), 'yyyy-MM-dd'),
-      customExpirationTime: dateFnsFormat(new Date(), 'HH:mm'),
+      customExpirationDate: format(new Date(), 'yyyy-MM-dd'),
+      customExpirationTime: format(new Date(), 'HH:mm'),
     };
     };
 
 
     this.handleChangeExpirationType = this.handleChangeExpirationType.bind(this);
     this.handleChangeExpirationType = this.handleChangeExpirationType.bind(this);

+ 2 - 2
packages/app/src/components/Sidebar/RecentChanges.jsx

@@ -3,10 +3,11 @@ import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
+import { UserPicture } from '@growi/ui';
+import { DevidedPagePath } from '@growi/core';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import DevidedPagePath from '~/models/devided-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -14,7 +15,6 @@ import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
 
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
 import FormattedDistanceDate from '../FormattedDistanceDate';
-import UserPicture from '../User/UserPicture';
 
 
 const logger = loggerFactory('growi:History');
 const logger = loggerFactory('growi:History');
 class RecentChanges extends React.Component {
 class RecentChanges extends React.Component {

+ 1 - 1
packages/app/src/components/User/UserInfo.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import UserPicture from './UserPicture';
+import { UserPicture } from '@growi/ui';
 
 
 const UserInfo = (props) => {
 const UserInfo = (props) => {
   const { pageUser } = props;
   const { pageUser } = props;

+ 1 - 1
packages/app/src/components/User/UserPictureList.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import UserPicture from './UserPicture';
+import { UserPicture } from '@growi/ui';
 
 
 export default class UserPictureList extends React.Component {
 export default class UserPictureList extends React.Component {
 
 

+ 1 - 1
packages/app/src/migrations/20191126173016-adjust-pages-path.js

@@ -1,5 +1,5 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 
 
 import config from '^/config/migrate';
 import config from '^/config/migrate';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';

+ 3 - 6
packages/app/src/models/linked-page-path.js

@@ -1,12 +1,11 @@
-const { pathUtils } = require('growi-commons');
-const { isTrashPage } = require('~/utils/path-utils');
+import { pagePathUtils, DevidedPagePath, pathUtils } from '@growi/core';
 
 
-const DevidedPagePath = require('./devided-page-path');
+const { isTrashPage } = pagePathUtils;
 
 
 /**
 /**
  * Linked Array Structured PagePath Model
  * Linked Array Structured PagePath Model
  */
  */
-class LinkedPagePath {
+export default class LinkedPagePath {
 
 
   constructor(path, skipNormalize = false) {
   constructor(path, skipNormalize = false) {
 
 
@@ -34,5 +33,3 @@ class LinkedPagePath {
   }
   }
 
 
 }
 }
-
-module.exports = LinkedPagePath;

+ 6 - 1
packages/app/src/server/crowi/express-init.js

@@ -53,7 +53,12 @@ module.exports = function(crowi, app) {
       nsSeparator: '::',
       nsSeparator: '::',
     });
     });
 
 
-  app.use(helmet());
+  app.use(helmet({
+    contentSecurityPolicy: false,
+    expectCt: false,
+    referrerPolicy: false,
+    permittedCrossDomainPolicies: false,
+  }));
 
 
   app.use((req, res, next) => {
   app.use((req, res, next) => {
     const now = new Date();
     const now = new Date();

+ 1 - 1
packages/app/src/server/crowi/index.js

@@ -427,7 +427,7 @@ Crowi.prototype.start = async function() {
 
 
   // setup plugins
   // setup plugins
   this.pluginService = new PluginService(this, express);
   this.pluginService = new PluginService(this, express);
-  this.pluginService.autoDetectAndLoadPlugins();
+  await this.pluginService.autoDetectAndLoadPlugins();
 
 
   const server = (this.node_env === 'development') ? this.crowiDev.setupServer(express) : express;
   const server = (this.node_env === 'development') ? this.crowiDev.setupServer(express) : express;
 
 

+ 3 - 4
packages/app/src/server/middlewares/admin-required.js

@@ -7,14 +7,13 @@ module.exports = (crowi, fallback = null) => {
   return async(req, res, next) => {
   return async(req, res, next) => {
     if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
     if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
       if (req.user.admin) {
       if (req.user.admin) {
-        next();
-        return;
+        return next();
       }
       }
 
 
       logger.warn('This user is not admin.');
       logger.warn('This user is not admin.');
 
 
       if (fallback != null) {
       if (fallback != null) {
-        return fallback(req, res);
+        return fallback(req, res, next);
       }
       }
       return res.redirect('/');
       return res.redirect('/');
     }
     }
@@ -22,7 +21,7 @@ module.exports = (crowi, fallback = null) => {
     logger.warn('This user has not logged in.');
     logger.warn('This user has not logged in.');
 
 
     if (fallback != null) {
     if (fallback != null) {
-      return fallback(req, res);
+      return fallback(req, res, next);
     }
     }
     return res.redirect('/login');
     return res.redirect('/login');
   };
   };

+ 5 - 3
packages/app/src/server/models/page.js

@@ -1,3 +1,4 @@
+import { templateChecker, pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 // disable no-return-await for model functions
 // disable no-return-await for model functions
@@ -15,8 +16,9 @@ const differenceInYears = require('date-fns/differenceInYears');
 
 
 const { pathUtils } = require('growi-commons');
 const { pathUtils } = require('growi-commons');
 const escapeStringRegexp = require('escape-string-regexp');
 const escapeStringRegexp = require('escape-string-regexp');
-const templateChecker = require('~/utils/template-checker');
-const { isTopPage, isTrashPage } = require('~/utils/path-utils');
+
+const { isTopPage, isTrashPage } = pagePathUtils;
+const { checkTemplatePath } = templateChecker;
 
 
 const logger = loggerFactory('growi:models:page');
 const logger = loggerFactory('growi:models:page');
 
 
@@ -311,7 +313,7 @@ module.exports = function(crowi) {
   };
   };
 
 
   pageSchema.methods.isTemplate = function() {
   pageSchema.methods.isTemplate = function() {
-    return templateChecker(this.path);
+    return checkTemplatePath(this.path);
   };
   };
 
 
   pageSchema.methods.isLatestRevision = function() {
   pageSchema.methods.isLatestRevision = function() {

+ 2 - 0
packages/app/src/server/models/slack-app-integration.js

@@ -4,6 +4,8 @@ const mongoose = require('mongoose');
 const schema = new mongoose.Schema({
 const schema = new mongoose.Schema({
   tokenGtoP: { type: String, required: true, unique: true },
   tokenGtoP: { type: String, required: true, unique: true },
   tokenPtoG: { type: String, required: true, unique: true },
   tokenPtoG: { type: String, required: true, unique: true },
+  supportedCommandsForBroadcastUse: { type: [String], default: [] },
+  supportedCommandsForSingleUse: { type: [String], default: [] },
 });
 });
 
 
 class SlackAppIntegration {
 class SlackAppIntegration {

+ 22 - 0
packages/app/src/server/models/vo/slackbot-error.js

@@ -0,0 +1,22 @@
+/**
+ * Error class for slackbot service
+ */
+class SlackbotError extends Error {
+
+  constructor({
+    method, to, popupMessage, mainMessage,
+  } = {}) {
+    super();
+    this.method = method;
+    this.to = to;
+    this.popupMessage = popupMessage;
+    this.mainMessage = mainMessage;
+  }
+
+  static isSlackbotError(obj) {
+    return obj instanceof this;
+  }
+
+}
+
+module.exports = SlackbotError;

+ 38 - 0
packages/app/src/server/plugins/plugin-utils-v4.ts

@@ -0,0 +1,38 @@
+import path from 'path';
+
+import { PluginMetaV4, PluginDefinitionV4 } from '@growi/core';
+
+export class PluginUtilsV4 {
+
+  /**
+   * return a definition objects that has following structure:
+   *
+   * {
+   *   name: 'crowi-plugin-X',
+   *   meta: require('crowi-plugin-X'),
+   *   entries: [
+   *     'crowi-plugin-X/lib/client-entry'
+   *   ]
+   * }
+   *
+   *
+   * @param {string} pluginName
+   * @return
+   * @memberOf PluginService
+   */
+  async generatePluginDefinition(name: string, isForClient = false): Promise<PluginDefinitionV4> {
+    const meta: PluginMetaV4 = await import(name);
+    let entries = (isForClient) ? meta.clientEntries : meta.serverEntries;
+
+    entries = entries.map((entryPath) => {
+      return path.join(name, entryPath);
+    });
+
+    return {
+      name,
+      meta,
+      entries,
+    };
+  }
+
+}

+ 15 - 7
packages/app/src/server/plugins/plugin-utils.js

@@ -1,13 +1,13 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
 
-const fs = require('graceful-fs');
+import { PluginUtilsV4 } from './plugin-utils-v4';
 
 
-const PluginUtilsV2 = require('./plugin-utils-v2');
+const fs = require('graceful-fs');
 
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
 
-const pluginUtilsV2 = new PluginUtilsV2();
+const pluginUtilsV4 = new PluginUtilsV4();
 
 
 class PluginUtils {
 class PluginUtils {
 
 
@@ -26,19 +26,27 @@ class PluginUtils {
    * @return
    * @return
    * @memberOf PluginService
    * @memberOf PluginService
    */
    */
-  generatePluginDefinition(name, isForClient = false) {
+  async generatePluginDefinition(name, isForClient = false) {
     const meta = require(name);
     const meta = require(name);
     let definition;
     let definition;
 
 
     switch (meta.pluginSchemaVersion) {
     switch (meta.pluginSchemaVersion) {
-      // v1 is deprecated
+      // v1, v2 and v3 is deprecated
       case 1:
       case 1:
         logger.debug('pluginSchemaVersion 1 is deprecated');
         logger.debug('pluginSchemaVersion 1 is deprecated');
         break;
         break;
-      // v2 or above
       case 2:
       case 2:
+        logger.debug('pluginSchemaVersion 2 is deprecated');
+        break;
+      case 3:
+        logger.debug('pluginSchemaVersion 3 is deprecated');
+        break;
+      // v4 or above
+      case 4:
+        definition = await pluginUtilsV4.generatePluginDefinition(name, isForClient);
+        break;
       default:
       default:
-        definition = pluginUtilsV2.generatePluginDefinition(name, isForClient);
+        logger.warn('Unsupported schema version', meta.pluginSchemaVersion);
     }
     }
 
 
     return definition;
     return definition;

+ 16 - 11
packages/app/src/server/plugins/plugin.service.js

@@ -12,13 +12,13 @@ class PluginService {
     this.pluginUtils = new PluginUtils();
     this.pluginUtils = new PluginUtils();
   }
   }
 
 
-  autoDetectAndLoadPlugins() {
+  async autoDetectAndLoadPlugins() {
     const isEnabledPlugins = this.crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins');
     const isEnabledPlugins = this.crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins');
 
 
     // import plugins
     // import plugins
     if (isEnabledPlugins) {
     if (isEnabledPlugins) {
       logger.debug('Plugins are enabled');
       logger.debug('Plugins are enabled');
-      this.loadPlugins(this.pluginUtils.listPluginNames(this.crowi.rootDir));
+      return this.loadPlugins(this.pluginUtils.listPluginNames(this.crowi.rootDir));
     }
     }
 
 
   }
   }
@@ -28,29 +28,34 @@ class PluginService {
    *
    *
    * @memberOf PluginService
    * @memberOf PluginService
    */
    */
-  loadPlugins(pluginNames) {
-    pluginNames
-      .map((name) => {
-        return this.pluginUtils.generatePluginDefinition(name);
-      })
-      .forEach((definition) => {
+  async loadPlugins(pluginNames) {
+    // get definitions
+    const definitions = [];
+    for (const pluginName of pluginNames) {
+      // eslint-disable-next-line no-await-in-loop
+      const definition = await this.pluginUtils.generatePluginDefinition(pluginName);
+      if (definition != null) {
         this.loadPlugin(definition);
         this.loadPlugin(definition);
-      });
+      }
+    }
   }
   }
 
 
   loadPlugin(definition) {
   loadPlugin(definition) {
     const meta = definition.meta;
     const meta = definition.meta;
 
 
     switch (meta.pluginSchemaVersion) {
     switch (meta.pluginSchemaVersion) {
-      // v1 is deprecated
+      // v1, v2 and v3 is deprecated
       case 1:
       case 1:
         logger.warn('pluginSchemaVersion 1 is deprecated', definition);
         logger.warn('pluginSchemaVersion 1 is deprecated', definition);
         break;
         break;
-      // v2 is deprecated
       case 2:
       case 2:
         logger.warn('pluginSchemaVersion 2 is deprecated', definition);
         logger.warn('pluginSchemaVersion 2 is deprecated', definition);
         break;
         break;
       case 3:
       case 3:
+        logger.warn('pluginSchemaVersion 3 is deprecated', definition);
+        break;
+      // v4 or above
+      case 4:
         logger.info(`load plugin '${definition.name}'`);
         logger.info(`load plugin '${definition.name}'`);
         definition.entries.forEach((entryPath) => {
         definition.entries.forEach((entryPath) => {
           const entry = require(entryPath);
           const entry = require(entryPath);

+ 2 - 1
packages/app/src/server/routes/apiv3/admin-home.js

@@ -1,6 +1,7 @@
+import ConfigLoader from '../../service/config-loader';
+
 const express = require('express');
 const express = require('express');
 const PluginUtils = require('../../plugins/plugin-utils');
 const PluginUtils = require('../../plugins/plugin-utils');
-const ConfigLoader = require('../../service/config-loader');
 
 
 const pluginUtils = new PluginUtils();
 const pluginUtils = new PluginUtils();
 
 

+ 2 - 2
packages/app/src/server/routes/apiv3/healthcheck.js

@@ -6,7 +6,7 @@ const express = require('express');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
-const helmet = require('helmet');
+const noCache = require('nocache');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 /**
 /**
@@ -122,7 +122,7 @@ module.exports = (crowi) => {
    *                  info:
    *                  info:
    *                    $ref: '#/components/schemas/HealthcheckInfo'
    *                    $ref: '#/components/schemas/HealthcheckInfo'
    */
    */
-  router.get('/', helmet.noCache(), async(req, res) => {
+  router.get('/', noCache(), async(req, res) => {
     let checkServices = req.query.checkServices || [];
     let checkServices = req.query.checkServices || [];
     let isStrictly = req.query.strictly != null;
     let isStrictly = req.query.strictly != null;
 
 

+ 3 - 2
packages/app/src/server/routes/apiv3/page.js

@@ -1,13 +1,14 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
 const { body, query } = require('express-validator');
 const { body, query } = require('express-validator');
 
 
 const router = express.Router();
 const router = express.Router();
-
-const { convertToNewAffiliationPath } = require('~/utils/path-utils');
+const { convertToNewAffiliationPath } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 
 

+ 7 - 2
packages/app/src/server/routes/apiv3/pages.js

@@ -1,3 +1,4 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
@@ -6,9 +7,11 @@ const pathUtils = require('growi-commons').pathUtils;
 
 
 const { body } = require('express-validator');
 const { body } = require('express-validator');
 const { query } = require('express-validator');
 const { query } = require('express-validator');
-const { isCreatablePage } = require('~/utils/path-utils');
+
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
+const { isCreatablePage } = pagePathUtils;
+
 const router = express.Router();
 const router = express.Router();
 
 
 const LIMIT_FOR_LIST = 10;
 const LIMIT_FOR_LIST = 10;
@@ -458,7 +461,9 @@ module.exports = (crowi) => {
   ];
   ];
 
 
   router.get('/list', accessTokenParser, loginRequired, validator.displayList, apiV3FormValidator, async(req, res) => {
   router.get('/list', accessTokenParser, loginRequired, validator.displayList, apiV3FormValidator, async(req, res) => {
-    const { isTrashPage } = require('~/utils/path-utils');
+
+
+    const { isTrashPage } = pagePathUtils;
 
 
     const { path } = req.query;
     const { path } = req.query;
     const limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;
     const limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;

+ 2 - 2
packages/app/src/server/routes/apiv3/search.js

@@ -7,7 +7,7 @@ const { body } = require('express-validator');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
-const helmet = require('helmet');
+const noCache = require('nocache');
 
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
@@ -41,7 +41,7 @@ module.exports = (crowi) => {
    *                  info:
    *                  info:
    *                    type: object
    *                    type: object
    */
    */
-  router.get('/indices', helmet.noCache(), accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.get('/indices', noCache(), accessTokenParser, loginRequired, adminRequired, async(req, res) => {
     const { searchService } = crowi;
     const { searchService } = crowi;
 
 
     if (!searchService.isConfigured) {
     if (!searchService.isConfigured) {

+ 75 - 11
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -2,11 +2,13 @@ import loggerFactory from '~/utils/logger';
 
 
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const express = require('express');
 const express = require('express');
-const { body, query } = require('express-validator');
+const { body, query, param } = require('express-validator');
 const axios = require('axios');
 const axios = require('axios');
 const urljoin = require('url-join');
 const urljoin = require('url-join');
 
 
-const { getConnectionStatus, getConnectionStatuses, sendSuccessMessage } = require('@growi/slack');
+const {
+  getConnectionStatus, getConnectionStatuses, sendSuccessMessage, defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse,
+} = require('@growi/slack');
 
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
@@ -60,6 +62,11 @@ module.exports = (crowi) => {
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
         .isURL({ require_tld: false }),
         .isURL({ require_tld: false }),
     ],
     ],
+    updateSupportedCommands: [
+      body('supportedCommandsForSingleUse').toArray(),
+      body('supportedCommandsForBroadcastUse').toArray(),
+      param('id').isMongoId().withMessage('id is required'),
+    ],
     RelationTest: [
     RelationTest: [
       body('slackAppIntegrationId').isMongoId(),
       body('slackAppIntegrationId').isMongoId(),
       body('channel').trim().isString(),
       body('channel').trim().isString(),
@@ -106,17 +113,17 @@ module.exports = (crowi) => {
     return result.data;
     return result.data;
   }
   }
 
 
-  async function postRelationTest(token) {
+  async function requestToProxyServer(token, method, endpoint, body) {
     const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
     const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
     if (proxyUri == null) {
     if (proxyUri == null) {
       throw new Error('Proxy URL is not registered');
       throw new Error('Proxy URL is not registered');
     }
     }
 
 
-    const result = await axios.get(urljoin(proxyUri, '/g2s/relation-test'), {
-      headers: {
-        'x-growi-gtop-tokens': token,
-      },
-    });
+    const headers = {
+      'x-growi-gtop-tokens': token,
+    };
+
+    const result = await axios[method](urljoin(proxyUri, endpoint), body, { headers });
 
 
     return result.data;
     return result.data;
   }
   }
@@ -401,9 +408,13 @@ module.exports = (crowi) => {
     }
     }
 
 
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
-
     try {
     try {
-      const slackAppTokens = await SlackAppIntegration.create({ tokenGtoP, tokenPtoG });
+      const slackAppTokens = await SlackAppIntegration.create({
+        tokenGtoP,
+        tokenPtoG,
+        supportedCommandsForBroadcastUse: defaultSupportedCommandsNameForBroadcastUse,
+        supportedCommandsForSingleUse: defaultSupportedCommandsNameForSingleUse,
+      });
       return res.apiv3(slackAppTokens, 200);
       return res.apiv3(slackAppTokens, 200);
     }
     }
     catch (error) {
     catch (error) {
@@ -488,6 +499,49 @@ module.exports = (crowi) => {
 
 
   });
   });
 
 
+  /**
+   * @swagger
+   *
+   *    /slack-integration-settings/:id/supported-commands:
+   *      put:
+   *        tags: [SlackIntegration]
+   *        operationId: putSupportedCommands
+   *        summary: /slack-integration-settings/:id/supported-commands
+   *        description: update supported commands
+   *        responses:
+   *          200:
+   *            description: Succeeded to update supported commands
+   */
+  router.put('/:id/supported-commands', loginRequiredStrictly, adminRequired, csrf, validator.updateSupportedCommands, apiV3FormValidator, async(req, res) => {
+    const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = req.body;
+    const { id } = req.params;
+
+    try {
+      const slackAppIntegration = await SlackAppIntegration.findByIdAndUpdate(
+        id,
+        { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse },
+        { new: true },
+      );
+
+      await requestToProxyServer(
+        slackAppIntegration.tokenGtoP,
+        'put',
+        '/g2s/supported-commands',
+        {
+          supportedCommandsForBroadcastUse: slackAppIntegration.supportedCommandsForBroadcastUse,
+          supportedCommandsForSingleUse: slackAppIntegration.supportedCommandsForSingleUse,
+        },
+      );
+
+      return res.apiv3({ slackAppIntegration });
+    }
+    catch (error) {
+      const msg = 'Error occured in updating Custom bot setting';
+      logger.error('Error', error);
+      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+    }
+  });
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -523,7 +577,17 @@ module.exports = (crowi) => {
         const msg = 'Could not find SlackAppIntegration by id';
         const msg = 'Could not find SlackAppIntegration by id';
         return res.apiv3Err(new ErrorV3(msg, 'find-slackAppIntegration-failed'), 400);
         return res.apiv3Err(new ErrorV3(msg, 'find-slackAppIntegration-failed'), 400);
       }
       }
-      const result = await postRelationTest(slackAppIntegration.tokenGtoP);
+
+      const result = await requestToProxyServer(
+        slackAppIntegration.tokenGtoP,
+        'post',
+        '/g2s/relation-test',
+        {
+          supportedCommandsForBroadcastUse: slackAppIntegration.supportedCommandsForBroadcastUse,
+          supportedCommandsForSingleUse: slackAppIntegration.supportedCommandsForSingleUse,
+        },
+      );
+
       slackBotToken = result.slackBotToken;
       slackBotToken = result.slackBotToken;
       if (slackBotToken == null) {
       if (slackBotToken == null) {
         const msg = 'Could not find slackBotToken by relation';
         const msg = 'Could not find slackBotToken by relation';

+ 76 - 63
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -4,11 +4,12 @@ const express = require('express');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const urljoin = require('url-join');
 const urljoin = require('url-join');
 
 
-const { verifySlackRequest, generateWebClient } = require('@growi/slack');
+const { verifySlackRequest, generateWebClient, getSupportedGrowiActionsRegExps } = require('@growi/slack');
 
 
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
 const router = express.Router();
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
+const { respondIfSlackbotError } = require('../../service/slack-command-handler/respond-if-slackbot-error');
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   this.app = crowi.express;
   this.app = crowi.express;
@@ -43,6 +44,55 @@ module.exports = (crowi) => {
     next();
     next();
   }
   }
 
 
+  async function checkCommandPermission(req, res, next) {
+    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+
+    const relation = await SlackAppIntegration.findOne({ tokenPtoG });
+    const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = relation;
+    const supportedCommands = supportedCommandsForBroadcastUse.concat(supportedCommandsForSingleUse);
+    const supportedGrowiActionsRegExps = getSupportedGrowiActionsRegExps(supportedCommands);
+
+    // get command name from req.body
+    let command = '';
+    let actionId = '';
+    let callbackId = '';
+    let payload;
+    if (req.body.payload) {
+      payload = JSON.parse(req.body.payload);
+    }
+
+    if (req.body.text == null && !payload) { // when /relation-test
+      return next();
+    }
+
+    if (!payload) { // when request is to /commands
+      command = req.body.text.split(' ')[0];
+    }
+    else if (payload.actions) { // when request is to /interactions && block_actions
+      actionId = payload.actions[0].action_id;
+    }
+    else { // when request is to /interactions && view_submission
+      callbackId = payload.view.callback_id;
+    }
+
+    let isActionSupported = false;
+    supportedGrowiActionsRegExps.forEach((regexp) => {
+      if (regexp.test(actionId) || regexp.test(callbackId)) {
+        isActionSupported = true;
+      }
+    });
+
+    // validate
+    if (command && !supportedCommands.includes(command)) {
+      return res.status(403).send(`It is not allowed to run '${command}' command to this GROWI.`);
+    }
+    if ((actionId || callbackId) && !isActionSupported) {
+      return res.status(403).send(`It is not allowed to run '${command}' command to this GROWI.`);
+    }
+
+    next();
+  }
+
   const addSigningSecretToReq = (req, res, next) => {
   const addSigningSecretToReq = (req, res, next) => {
     req.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:signingSecret');
     req.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:signingSecret');
     return next();
     return next();
@@ -104,18 +154,19 @@ module.exports = (crowi) => {
     const command = args[0];
     const command = args[0];
 
 
     try {
     try {
-      await crowi.slackBotService.handleCommand(command, client, body, args);
+      await crowi.slackBotService.handleCommandRequest(command, client, body, args);
     }
     }
-    catch (error) {
-      logger.error(error);
+    catch (err) {
+      await respondIfSlackbotError(client, body, err);
     }
     }
+
   }
   }
 
 
   router.post('/commands', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
   router.post('/commands', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
     return handleCommands(req, res);
     return handleCommands(req, res);
   });
   });
 
 
-  router.post('/proxied/commands', verifyAccessTokenFromProxy, async(req, res) => {
+  router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
     const { body } = req;
     const { body } = req;
 
 
     // eslint-disable-next-line max-len
     // eslint-disable-next-line max-len
@@ -127,61 +178,6 @@ module.exports = (crowi) => {
     return handleCommands(req, res);
     return handleCommands(req, res);
   });
   });
 
 
-
-  const handleBlockActions = async(client, payload) => {
-    const { action_id: actionId } = payload.actions[0];
-
-    switch (actionId) {
-      case 'shareSingleSearchResult': {
-        await crowi.slackBotService.shareSinglePage(client, payload);
-        break;
-      }
-      case 'dismissSearchResults': {
-        await crowi.slackBotService.dismissSearchResults(client, payload);
-        break;
-      }
-      case 'showNextResults': {
-        const parsedValue = JSON.parse(payload.actions[0].value);
-
-        const { body, args, offset } = parsedValue;
-        const newOffset = offset + 10;
-        await crowi.slackBotService.showEphemeralSearchResults(client, body, args, newOffset);
-        break;
-      }
-      case 'togetterShowMore': {
-        const parsedValue = JSON.parse(payload.actions[0].value);
-        const togetterHandler = require('../../service/slack-command-handler/togetter')(crowi);
-
-        const { body, args, limit } = parsedValue;
-        const newLimit = limit + 1;
-        await togetterHandler.handleCommand(client, body, args, newLimit);
-        break;
-      }
-      case 'togetter:createPage': {
-        await crowi.slackBotService.togetterCreatePageInGrowi(client, payload);
-        break;
-      }
-      case 'togetter:cancel': {
-        await crowi.slackBotService.togetterCancel(client, payload);
-        break;
-      }
-      default:
-        break;
-    }
-  };
-
-  const handleViewSubmission = async(client, payload) => {
-    const { callback_id: callbackId } = payload.view;
-
-    switch (callbackId) {
-      case 'createPage':
-        await crowi.slackBotService.createPageInGrowi(client, payload);
-        break;
-      default:
-        break;
-    }
-  };
-
   async function handleInteractions(req, res) {
   async function handleInteractions(req, res) {
 
 
     // Send response immediately to avoid opelation_timeout error
     // Send response immediately to avoid opelation_timeout error
@@ -206,10 +202,20 @@ module.exports = (crowi) => {
     try {
     try {
       switch (type) {
       switch (type) {
         case 'block_actions':
         case 'block_actions':
-          await handleBlockActions(client, payload);
+          try {
+            await crowi.slackBotService.handleBlockActionsRequest(client, payload);
+          }
+          catch (err) {
+            await respondIfSlackbotError(client, req.body, err);
+          }
           break;
           break;
         case 'view_submission':
         case 'view_submission':
-          await handleViewSubmission(client, payload);
+          try {
+            await crowi.slackBotService.handleViewSubmissionRequest(client, payload);
+          }
+          catch (err) {
+            await respondIfSlackbotError(client, req.body, err);
+          }
           break;
           break;
         default:
         default:
           break;
           break;
@@ -225,9 +231,16 @@ module.exports = (crowi) => {
     return handleInteractions(req, res);
     return handleInteractions(req, res);
   });
   });
 
 
-  router.post('/proxied/interactions', verifyAccessTokenFromProxy, async(req, res) => {
+  router.post('/proxied/interactions', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
     return handleInteractions(req, res);
     return handleInteractions(req, res);
   });
   });
 
 
+  router.get('/supported-commands', verifyAccessTokenFromProxy, async(req, res) => {
+    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+    const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+
+    return res.send(slackAppIntegration);
+  });
+
   return router;
   return router;
 };
 };

+ 2 - 2
packages/app/src/server/routes/apiv3/statistics.js

@@ -6,7 +6,7 @@ const express = require('express');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
-const helmet = require('helmet');
+const noCache = require('nocache');
 
 
 const USER_STATUS_MASTER = {
 const USER_STATUS_MASTER = {
   1: 'registered',
   1: 'registered',
@@ -97,7 +97,7 @@ module.exports = (crowi) => {
    *                    type: object
    *                    type: object
    *                    description: Statistics for all user
    *                    description: Statistics for all user
    */
    */
-  router.get('/user', helmet.noCache(), async(req, res) => {
+  router.get('/user', noCache(), async(req, res) => {
     const data = req.user == null ? await getUserStatisticsForNotLoggedIn() : await getUserStatistics();
     const data = req.user == null ? await getUserStatisticsForNotLoggedIn() : await getUserStatistics();
     res.status(200).send({ data });
     res.status(200).send({ data });
   });
   });

+ 2 - 1
packages/app/src/server/routes/page.js

@@ -1,6 +1,7 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-const { isCreatablePage } = require('~/utils/path-utils');
+const { isCreatablePage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
 const { serializeUserSecurely } = require('../models/serializers/user-serializer');
 const { serializeUserSecurely } = require('../models/serializers/user-serializer');

+ 2 - 2
packages/app/src/server/service/app.ts

@@ -1,4 +1,4 @@
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -119,7 +119,7 @@ class AppService implements S2sMessageHandlable {
   }
   }
 
 
   async setupAfterInstall() {
   async setupAfterInstall() {
-    this.crowi.pluginService.autoDetectAndLoadPlugins();
+    await this.crowi.pluginService.autoDetectAndLoadPlugins();
     this.crowi.setupRoutesAtLast();
     this.crowi.setupRoutesAtLast();
     this.crowi.setupGlobalErrorHandlers();
     this.crowi.setupGlobalErrorHandlers();
 
 

+ 1 - 1
packages/app/src/server/service/customize.ts

@@ -1,8 +1,8 @@
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 import uglifycss from 'uglifycss';
 import uglifycss from 'uglifycss';
 
 
+import { DevidedPagePath } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
-import DevidedPagePath from '~/models/devided-page-path';
 
 
 import S2sMessage from '../models/vo/s2s-message';
 import S2sMessage from '../models/vo/s2s-message';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';

+ 3 - 2
packages/app/src/server/service/export.js

@@ -1,6 +1,8 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { toArrayIfNot } from '~/utils/array-utils';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 
+import ConfigLoader from './config-loader';
+
 const logger = loggerFactory('growi:services:ExportService'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:services:ExportService'); // eslint-disable-line no-unused-vars
 
 
 const fs = require('fs');
 const fs = require('fs');
@@ -9,7 +11,6 @@ const mongoose = require('mongoose');
 const { Transform } = require('stream');
 const { Transform } = require('stream');
 const streamToPromise = require('stream-to-promise');
 const streamToPromise = require('stream-to-promise');
 const archiver = require('archiver');
 const archiver = require('archiver');
-const ConfigLoader = require('../service/config-loader');
 
 
 const CollectionProgressingStatus = require('../models/vo/collection-progressing-status');
 const CollectionProgressingStatus = require('../models/vo/collection-progressing-status');
 
 
@@ -91,7 +92,7 @@ class ExportService {
       url: this.appService.getSiteUrl(),
       url: this.appService.getSiteUrl(),
       passwordSeed,
       passwordSeed,
       exportedAt: new Date(),
       exportedAt: new Date(),
-      envVars: ConfigLoader.getEnvVarsForDisplay(),
+      envVars: await ConfigLoader.getEnvVarsForDisplay(),
     };
     };
 
 
     writeStream.write(JSON.stringify(metaData));
     writeStream.write(JSON.stringify(metaData));

+ 3 - 1
packages/app/src/server/service/global-notification/global-notification-slack.js

@@ -1,9 +1,11 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+
 const logger = loggerFactory('growi:service:GlobalNotificationSlackService'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:service:GlobalNotificationSlackService'); // eslint-disable-line no-unused-vars
 const urljoin = require('url-join');
 const urljoin = require('url-join');
 
 
-const { encodeSpaces } = require('~/utils/path-utils');
+const { encodeSpaces } = pagePathUtils;
 
 
 /**
 /**
  * sub service class of GlobalNotificationSetting
  * sub service class of GlobalNotificationSetting

+ 3 - 1
packages/app/src/server/service/page.js

@@ -1,3 +1,4 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
@@ -7,7 +8,8 @@ const logger = loggerFactory('growi:models:page');
 const debug = require('debug')('growi:models:page');
 const debug = require('debug')('growi:models:page');
 const { Writable } = require('stream');
 const { Writable } = require('stream');
 const { createBatchStream } = require('~/server/util/batch-stream');
 const { createBatchStream } = require('~/server/util/batch-stream');
-const { isTrashPage } = require('~/utils/path-utils');
+
+const { isTrashPage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 
 
 const BULK_REINDEX_SIZE = 100;
 const BULK_REINDEX_SIZE = 100;

+ 48 - 0
packages/app/src/server/service/slack-command-handler/create-page-service.js

@@ -0,0 +1,48 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:CreatePageService');
+const { reshapeContentsBody } = require('@growi/slack');
+const mongoose = require('mongoose');
+const pathUtils = require('growi-commons').pathUtils;
+const SlackbotError = require('../../models/vo/slackbot-error');
+
+class CreatePageService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  async createPageInGrowi(client, payload, path, channelId, contentsBody) {
+    const Page = this.crowi.model('Page');
+    const reshapedContentsBody = reshapeContentsBody(contentsBody);
+    try {
+      // sanitize path
+      const sanitizedPath = this.crowi.xss.process(path);
+      const normalizedPath = pathUtils.normalizePath(sanitizedPath);
+
+      // generate a dummy id because Operation to create a page needs ObjectId
+      const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
+      const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
+
+      // Send a message when page creation is complete
+      const growiUri = this.crowi.appService.getSiteUrl();
+      await client.chat.postEphemeral({
+        channel: channelId,
+        user: payload.user.id,
+        text: `The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`,
+      });
+    }
+    catch (err) {
+      logger.error('Failed to create page in GROWI.', err);
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Cannot create new page to existed path.',
+        mainMessage: `Cannot create new page to existed path\n *Contents* :memo:\n ${reshapedContentsBody}`,
+      });
+    }
+  }
+
+}
+
+module.exports = CreatePageService;

+ 37 - 38
packages/app/src/server/service/slack-command-handler/create.js

@@ -4,51 +4,50 @@ const { markdownSectionBlock, inputSectionBlock } = require('@growi/slack');
 
 
 const logger = loggerFactory('growi:service:SlackCommandHandler:create');
 const logger = loggerFactory('growi:service:SlackCommandHandler:create');
 
 
-module.exports = () => {
+module.exports = (crowi) => {
+  const CreatePageService = require('./create-page-service');
+  const createPageService = new CreatePageService(crowi);
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
   const handler = new BaseSlackCommandHandler();
 
 
   handler.handleCommand = async(client, body) => {
   handler.handleCommand = async(client, body) => {
-    try {
-      await client.views.open({
-        trigger_id: body.trigger_id,
-
-        view: {
-          type: 'modal',
-          callback_id: 'createPage',
-          title: {
-            type: 'plain_text',
-            text: 'Create Page',
-          },
-          submit: {
-            type: 'plain_text',
-            text: 'Submit',
-          },
-          close: {
-            type: 'plain_text',
-            text: 'Cancel',
-          },
-          blocks: [
-            markdownSectionBlock('Create new page.'),
-            inputSectionBlock('path', 'Path', 'path_input', false, '/path'),
-            inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
-          ],
-          private_metadata: JSON.stringify({ channelId: body.channel_id }),
+    await client.views.open({
+      trigger_id: body.trigger_id,
+
+      view: {
+        type: 'modal',
+        callback_id: 'create:createPage',
+        title: {
+          type: 'plain_text',
+          text: 'Create Page',
+        },
+        submit: {
+          type: 'plain_text',
+          text: 'Submit',
+        },
+        close: {
+          type: 'plain_text',
+          text: 'Cancel',
         },
         },
-      });
-    }
-    catch (err) {
-      logger.error('Failed to create a page.');
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed To Create',
         blocks: [
         blocks: [
-          markdownSectionBlock(`*Failed to create new page.*\n ${err}`),
+          markdownSectionBlock('Create new page.'),
+          inputSectionBlock('path', 'Path', 'path_input', false, '/path'),
+          inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
         ],
         ],
-      });
-      throw err;
-    }
+        private_metadata: JSON.stringify({ channelId: body.channel_id }),
+      },
+    });
+  };
+
+  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
+    await this[handlerMethodName](client, payload);
+  };
+
+  handler.createPage = async function(client, payload) {
+    const path = payload.view.state.values.path.path_input.value;
+    const channelId = JSON.parse(payload.view.private_metadata).channelId;
+    const contentsBody = payload.view.state.values.contents.contents_input.value;
+    await createPageService.createPageInGrowi(client, payload, path, channelId, contentsBody);
   };
   };
 
 
   return handler;
   return handler;

+ 66 - 0
packages/app/src/server/service/slack-command-handler/respond-if-slackbot-error.js

@@ -0,0 +1,66 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:SlackCommandHandler:slack-bot-response');
+const { markdownSectionBlock } = require('@growi/slack');
+const SlackbotError = require('../../models/vo/slackbot-error');
+
+async function respondIfSlackbotError(client, body, err) {
+  // check if the request is to /commands OR /interactions
+  const isInteraction = !body.channel_id;
+
+  // throw non-SlackbotError
+  if (!SlackbotError.isSlackbotError(err)) {
+    logger.error(`A non-SlackbotError error occured.\n${err.toString()}`);
+    throw err;
+  }
+
+  // for both postMessage and postEphemeral
+  let toChannel;
+  // for only postEphemeral
+  let toUser;
+  // decide which channel to send to
+  switch (err.to) {
+    case 'dm':
+      toChannel = isInteraction ? JSON.parse(body.payload).user.id : body.user_id;
+      toUser = toChannel;
+      break;
+    case 'channel':
+      toChannel = isInteraction ? JSON.parse(body.payload).channel.id : body.channel_id;
+      toUser = isInteraction ? JSON.parse(body.payload).user.id : body.user_id;
+      break;
+    default:
+      logger.error('The "to" property of SlackbotError must be "dm" or "channel".');
+      break;
+  }
+
+  // argumentObj object to pass to postMessage OR postEphemeral
+  let argumentsObj = {};
+  switch (err.method) {
+    case 'postMessage':
+      argumentsObj = {
+        channel: toChannel,
+        text: err.popupMessage,
+        blocks: [
+          markdownSectionBlock(err.mainMessage),
+        ],
+      };
+      break;
+    case 'postEphemeral':
+      argumentsObj = {
+        channel: toChannel,
+        user: toUser,
+        text: err.popupMessage,
+        blocks: [
+          markdownSectionBlock(err.mainMessage),
+        ],
+      };
+      break;
+    default:
+      logger.error('The "method" property of SlackbotError must be "postMessage" or "postEphemeral".');
+      break;
+  }
+
+  await client.chat[err.method](argumentsObj);
+}
+
+module.exports = { respondIfSlackbotError };

+ 199 - 30
packages/app/src/server/service/slack-command-handler/search.js

@@ -4,6 +4,8 @@ const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
 
 const { markdownSectionBlock, divider } = require('@growi/slack');
 const { markdownSectionBlock, divider } = require('@growi/slack');
 const { formatDistanceStrict } = require('date-fns');
 const { formatDistanceStrict } = require('date-fns');
+const axios = require('axios');
+const SlackbotError = require('../../models/vo/slackbot-error');
 
 
 const PAGINGLIMIT = 10;
 const PAGINGLIMIT = 10;
 
 
@@ -18,19 +20,16 @@ module.exports = (crowi) => {
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Failed to get search results.', err);
       logger.error('Failed to get search results.', err);
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed To Search',
-        blocks: [
-          markdownSectionBlock('*Failed to search.*\n Hint\n `/growi search [keyword]`'),
-        ],
+      throw new SlackbotError({
+        method: 'postEphemeral',
+        to: 'channel',
+        popupMessage: 'Failed To Search',
+        mainMessage: '*Failed to search.*\n Hint\n `/growi search [keyword]`',
       });
       });
-      throw new Error('/growi command:search: Failed to search');
     }
     }
 
 
-    const appUrl = this.crowi.appService.getSiteUrl();
-    const appTitle = this.crowi.appService.getAppTitle();
+    const appUrl = crowi.appService.getSiteUrl();
+    const appTitle = crowi.appService.getAppTitle();
 
 
     const {
     const {
       pages, offset, resultsTotal,
       pages, offset, resultsTotal,
@@ -83,7 +82,7 @@ module.exports = (crowi) => {
           },
           },
           accessory: {
           accessory: {
             type: 'button',
             type: 'button',
-            action_id: 'shareSingleSearchResult',
+            action_id: 'search:shareSinglePageResult',
             text: {
             text: {
               type: 'plain_text',
               type: 'plain_text',
               text: 'Share',
               text: 'Share',
@@ -121,7 +120,7 @@ module.exports = (crowi) => {
             text: 'Dismiss',
             text: 'Dismiss',
           },
           },
           style: 'danger',
           style: 'danger',
-          action_id: 'dismissSearchResults',
+          action_id: 'search:dismissSearchResults',
         },
         },
       ],
       ],
     };
     };
@@ -134,33 +133,203 @@ module.exports = (crowi) => {
             type: 'plain_text',
             type: 'plain_text',
             text: 'Next',
             text: 'Next',
           },
           },
-          action_id: 'showNextResults',
+          action_id: 'search:showNextResults',
           value: JSON.stringify({ offset, body, args }),
           value: JSON.stringify({ offset, body, args }),
         },
         },
       );
       );
     }
     }
     blocks.push(actionBlocks);
     blocks.push(actionBlocks);
 
 
+    await client.chat.postEphemeral({
+      channel: body.channel_id,
+      user: body.user_id,
+      text: 'Successed To Search',
+      blocks,
+    });
+  };
+
+  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
+    await this[handlerMethodName](client, payload);
+  };
+
+  handler.shareSinglePageResult = async function(client, payload) {
+    const { channel, user, actions } = payload;
+
+    const appUrl = crowi.appService.getSiteUrl();
+    const appTitle = crowi.appService.getAppTitle();
+
+    const channelId = channel.id;
+    const action = actions[0]; // shareSinglePage action must have button action
+
+    // restore page data from value
+    const { page, href, pathname } = JSON.parse(action.value);
+    const { updatedAt, commentCount } = page;
+
+    // share
+    const now = new Date();
+    return client.chat.postMessage({
+      channel: channelId,
+      blocks: [
+        { type: 'divider' },
+        markdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
+        {
+          type: 'context',
+          elements: [
+            {
+              type: 'mrkdwn',
+              text: `<${decodeURI(appUrl)}|*${appTitle}*>  |  Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}  |  Shared by *${user.username}*`,
+            },
+          ],
+        },
+      ],
+    });
+  };
+
+  handler.showNextResults = async function(client, payload) {
+    const parsedValue = JSON.parse(payload.actions[0].value);
+
+    const { body, args, offsetNum } = parsedValue;
+    const newOffsetNum = offsetNum + 10;
+    let searchResult;
     try {
     try {
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Successed To Search',
-        blocks,
-      });
+      searchResult = await this.retrieveSearchResults(client, body, args, newOffsetNum);
     }
     }
     catch (err) {
     catch (err) {
-      logger.error('Failed to post ephemeral message.', err);
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed to post ephemeral message.',
-        blocks: [
-          markdownSectionBlock(err.toString()),
-        ],
+      logger.error('Failed to get search results.', err);
+      throw new SlackbotError({
+        method: 'postEphemeral',
+        to: 'channel',
+        popupMessage: 'Failed To Search',
+        mainMessage: '*Failed to search.*\n Hint\n `/growi search [keyword]`',
       });
       });
-      throw new Error(err);
     }
     }
+
+    const appUrl = crowi.appService.getSiteUrl();
+    const appTitle = crowi.appService.getAppTitle();
+
+    const {
+      pages, offset, resultsTotal,
+    } = searchResult;
+
+    const keywords = this.getKeywords(args);
+
+
+    let searchResultsDesc;
+
+    switch (resultsTotal) {
+      case 1:
+        searchResultsDesc = `*${resultsTotal}* page is found.`;
+        break;
+
+      default:
+        searchResultsDesc = `*${resultsTotal}* pages are found.`;
+        break;
+    }
+
+
+    const contextBlock = {
+      type: 'context',
+      elements: [
+        {
+          type: 'mrkdwn',
+          text: `keyword(s) : *"${keywords}"*  |  Current: ${offset + 1} - ${offset + pages.length}  |  Total ${resultsTotal} pages`,
+        },
+      ],
+    };
+
+    const now = new Date();
+    const blocks = [
+      markdownSectionBlock(`:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`),
+      contextBlock,
+      { type: 'divider' },
+      // create an array by map and extract
+      ...pages.map((page) => {
+        const { path, updatedAt, commentCount } = page;
+        // generate URL
+        const url = new URL(path, appUrl);
+        const { href, pathname } = url;
+
+        return {
+          type: 'section',
+          text: {
+            type: 'mrkdwn',
+            text: `${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
+              + `\n    Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}`,
+          },
+          accessory: {
+            type: 'button',
+            action_id: 'shareSingleSearchResult',
+            text: {
+              type: 'plain_text',
+              text: 'Share',
+            },
+            value: JSON.stringify({ page, href, pathname }),
+          },
+        };
+      }),
+      { type: 'divider' },
+      contextBlock,
+    ];
+
+    // DEFAULT show "Share" button
+    // const actionBlocks = {
+    //   type: 'actions',
+    //   elements: [
+    //     {
+    //       type: 'button',
+    //       text: {
+    //         type: 'plain_text',
+    //         text: 'Share',
+    //       },
+    //       style: 'primary',
+    //       action_id: 'shareSearchResults',
+    //     },
+    //   ],
+    // };
+    const actionBlocks = {
+      type: 'actions',
+      elements: [
+        {
+          type: 'button',
+          text: {
+            type: 'plain_text',
+            text: 'Dismiss',
+          },
+          style: 'danger',
+          action_id: 'search:dismissSearchResults',
+        },
+      ],
+    };
+    // show "Next" button if next page exists
+    if (resultsTotal > offset + PAGINGLIMIT) {
+      actionBlocks.elements.unshift(
+        {
+          type: 'button',
+          text: {
+            type: 'plain_text',
+            text: 'Next',
+          },
+          action_id: 'search:showNextResults',
+          value: JSON.stringify({ offset, body, args }),
+        },
+      );
+    }
+    blocks.push(actionBlocks);
+
+    await client.chat.postEphemeral({
+      channel: body.channel_id,
+      user: body.user_id,
+      text: 'Successed To Search',
+      blocks,
+    });
+  };
+
+  handler.dismissSearchResults = async function(client, payload) {
+    const { response_url: responseUrl } = payload;
+
+    return axios.post(responseUrl, {
+      delete_original: true,
+    });
   };
   };
 
 
   handler.retrieveSearchResults = async function(client, body, args, offset = 0) {
   handler.retrieveSearchResults = async function(client, body, args, offset = 0) {
@@ -174,12 +343,12 @@ module.exports = (crowi) => {
           markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
           markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
         ],
         ],
       });
       });
-      return;
+      return { pages: [] };
     }
     }
 
 
     const keywords = this.getKeywords(args);
     const keywords = this.getKeywords(args);
 
 
-    const { searchService } = this.crowi;
+    const { searchService } = crowi;
     const options = { limit: 10, offset };
     const options = { limit: 10, offset };
     const results = await searchService.searchKeyword(keywords, null, {}, options);
     const results = await searchService.searchKeyword(keywords, null, {}, options);
     const resultsTotal = results.meta.total;
     const resultsTotal = results.meta.total;

+ 10 - 0
packages/app/src/server/service/slack-command-handler/slack-command-handler.js

@@ -10,6 +10,16 @@ class BaseSlackCommandHandler {
    */
    */
   handleCommand(client, body, ...opt) { throw new Error('Implement this') }
   handleCommand(client, body, ...opt) { throw new Error('Implement this') }
 
 
+  /**
+   * Handle /interactions endpoint 'block_actions'
+   */
+  handleBlockActions(client, payload, handlerMethodName) { throw new Error('Implement this') }
+
+  /**
+   * Handle /interactions endpoint 'view_submission'
+   */
+  handleViewSubmission(client, payload, handlerMethodName) { throw new Error('Implement this') }
+
 }
 }
 
 
 module.exports = BaseSlackCommandHandler;
 module.exports = BaseSlackCommandHandler;

+ 173 - 2
packages/app/src/server/service/slack-command-handler/togetter.js

@@ -1,9 +1,16 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:SlackBotService:togetter');
 const {
 const {
-  inputBlock, actionsBlock, buttonElement, markdownSectionBlock,
+  inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider,
 } = require('@growi/slack');
 } = require('@growi/slack');
-const { format } = require('date-fns');
+const { parse, format } = require('date-fns');
+const axios = require('axios');
+const SlackbotError = require('../../models/vo/slackbot-error');
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
+  const CreatePageService = require('./create-page-service');
+  const createPageService = new CreatePageService(crowi);
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
   const handler = new BaseSlackCommandHandler();
 
 
@@ -23,6 +30,170 @@ module.exports = (crowi) => {
     return;
     return;
   };
   };
 
 
+  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
+    await this[handlerMethodName](client, payload);
+  };
+
+  handler.cancel = async function(client, payload) {
+    const responseUrl = payload.response_url;
+    axios.post(responseUrl, {
+      delete_original: true,
+    });
+  };
+
+  handler.createPage = async function(client, payload) {
+    let result = [];
+    const channel = payload.channel.id;
+    try {
+      // validate form
+      const { path, oldest, latest } = await this.togetterValidateForm(client, payload);
+      // get messages
+      result = await this.togetterGetMessages(client, payload, channel, path, latest, oldest);
+      // clean messages
+      const cleanedContents = await this.togetterCleanMessages(result.messages);
+
+      const contentsBody = cleanedContents.join('');
+      // create and send url message
+      await this.togetterCreatePageAndSendPreview(client, payload, path, channel, contentsBody);
+    }
+    catch (err) {
+      logger.error('Error occured by togetter.');
+      throw err;
+    }
+  };
+
+  handler.togetterValidateForm = async function(client, payload) {
+    const grwTzoffset = crowi.appService.getTzoffset() * 60;
+    const path = payload.state.values.page_path.page_path.value;
+    let oldest = payload.state.values.oldest.oldest.value;
+    let latest = payload.state.values.latest.latest.value;
+    oldest = oldest.trim();
+    latest = latest.trim();
+    if (!path) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Page path is required.',
+        mainMessage: 'Page path is required.',
+      });
+    }
+    /**
+     * RegExp for datetime yyyy/MM/dd-HH:mm
+     * @see https://regex101.com/r/XbxdNo/1
+     */
+    const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
+
+    if (!regexpDatetime.test(oldest)) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
+        mainMessage: 'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
+      });
+    }
+    if (!regexpDatetime.test(latest)) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Datetime format for latest must be yyyy/MM/dd-HH:mm',
+        mainMessage: 'Datetime format for latest must be yyyy/MM/dd-HH:mm',
+      });
+    }
+    oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
+    // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
+    latest = parse(latest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
+
+    if (oldest > latest) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Oldest datetime must be older than the latest date time.',
+        mainMessage: 'Oldest datetime must be older than the latest date time.',
+      });
+    }
+
+    return { path, oldest, latest };
+  };
+
+  handler.togetterGetMessages = async function(client, payload, channel, path, latest, oldest) {
+    const result = await client.conversations.history({
+      channel,
+      latest,
+      oldest,
+      limit: 100,
+      inclusive: true,
+    });
+
+    // return if no message found
+    if (!result.messages.length) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'No message found from togetter command. Try different datetime.',
+        mainMessage: 'No message found from togetter command. Try different datetime.',
+      });
+    }
+    return result;
+  };
+
+  handler.togetterCleanMessages = async function(messages) {
+    const cleanedContents = [];
+    let lastMessage = {};
+    const grwTzoffset = crowi.appService.getTzoffset() * 60;
+    messages
+      .sort((a, b) => {
+        return a.ts - b.ts;
+      })
+      .forEach((message) => {
+        // increment contentsBody while removing the same headers
+        // exclude header
+        const lastMessageTs = Math.floor(lastMessage.ts / 60);
+        const messageTs = Math.floor(message.ts / 60);
+        if (lastMessage.user === message.user && lastMessageTs === messageTs) {
+          cleanedContents.push(`${message.text}\n`);
+        }
+        // include header
+        else {
+          const ts = (parseInt(message.ts) - grwTzoffset) * 1000;
+          const time = format(new Date(ts), 'h:mm a');
+          cleanedContents.push(`${message.user}  ${time}\n${message.text}\n`);
+          lastMessage = message;
+        }
+      });
+    return cleanedContents;
+  };
+
+  handler.togetterCreatePageAndSendPreview = async function(client, payload, path, channel, contentsBody) {
+    try {
+      await createPageService.createPageInGrowi(client, payload, path, channel, contentsBody);
+      // send preview to dm
+      await client.chat.postMessage({
+        channel: payload.user.id,
+        text: 'Preview from togetter command',
+        blocks: [
+          markdownSectionBlock('*Preview*'),
+          divider(),
+          markdownSectionBlock(contentsBody),
+          divider(),
+        ],
+      });
+      // dismiss message
+      const responseUrl = payload.response_url;
+      axios.post(responseUrl, {
+        delete_original: true,
+      });
+    }
+    catch (err) {
+      logger.error('Error occurred while creating a page.', err);
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Error occurred while creating a page.',
+        mainMessage: 'Error occurred while creating a page.',
+      });
+    }
+  };
+
   handler.togetterMessageBlocks = function(messages, body, args, limit) {
   handler.togetterMessageBlocks = function(messages, body, args, limit) {
     return [
     return [
       markdownSectionBlock('Select the oldest and latest datetime of the messages to use.'),
       markdownSectionBlock('Select the oldest and latest datetime of the messages to use.'),

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor