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

Merge branch 'master' into imprv/gw5979-update-socket.io-4.0

Yuki Takei 4 лет назад
Родитель
Сommit
204188fe7b
100 измененных файлов с 1949 добавлено и 1100 удалено
  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. 4 0
      .markdownlint.yml
  7. 20 0
      .vscode/launch.json
  8. 21 3
      package.json
  9. 2 2
      packages/app/.env.development
  10. 1 1
      packages/app/.env.production
  11. 1 0
      packages/app/.eslintignore
  12. 1 1
      packages/app/.gitignore
  13. 22 14
      packages/app/bin/generate-plugin-definitions-source.ts
  14. 2 0
      packages/app/config/ci/.env.local.for-ci
  15. 1 0
      packages/app/jest.config.js
  16. 3 14
      packages/app/package.json
  17. 3 7
      packages/app/resource/locales/en_US/admin/admin.json
  18. 3 6
      packages/app/resource/locales/ja_JP/admin/admin.json
  19. 311 313
      packages/app/resource/locales/zh_CN/admin/admin.json
  20. 4 2
      packages/app/src/client/plugin.js
  21. 1 1
      packages/app/src/client/services/AdminGitHubSecurityContainer.js
  22. 1 1
      packages/app/src/client/services/AdminGoogleSecurityContainer.js
  23. 1 1
      packages/app/src/client/services/AdminOidcSecurityContainer.js
  24. 1 1
      packages/app/src/client/services/AdminSamlSecurityContainer.js
  25. 1 1
      packages/app/src/client/services/AdminTwitterSecurityContainer.js
  26. 3 1
      packages/app/src/client/services/PageContainer.js
  27. 1 1
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  28. 5 1
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  29. 144 0
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  30. 5 1
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  31. 21 1
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  32. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  33. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.jsx
  34. 1 1
      packages/app/src/components/Admin/Users/UserTable.jsx
  35. 3 1
      packages/app/src/components/ComparePathsTable.jsx
  36. 3 1
      packages/app/src/components/ContentLinkButtons.jsx
  37. 1 1
      packages/app/src/components/CreateTemplateModal.jsx
  38. 3 1
      packages/app/src/components/DuplicatedPathsTable.jsx
  39. 4 2
      packages/app/src/components/Navbar/AuthorInfo.jsx
  40. 1 1
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  41. 1 1
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  42. 3 1
      packages/app/src/components/Page/CopyDropdown.jsx
  43. 3 1
      packages/app/src/components/Page/PageManagement.jsx
  44. 1 1
      packages/app/src/components/Page/TrashPageAlert.jsx
  45. 1 1
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.jsx
  46. 1 1
      packages/app/src/components/PageAttachment/PageAttachmentList.jsx
  47. 1 1
      packages/app/src/components/PageComment/Comment.jsx
  48. 1 1
      packages/app/src/components/PageComment/CommentEditor.jsx
  49. 1 1
      packages/app/src/components/PageComment/DeleteCommentModal.jsx
  50. 6 4
      packages/app/src/components/PageCreateModal.jsx
  51. 1 1
      packages/app/src/components/PageHistory/Revision.jsx
  52. 2 3
      packages/app/src/components/PageList/Page.jsx
  53. 0 24
      packages/app/src/components/PageList/PagePath.jsx
  54. 1 1
      packages/app/src/components/PagePathAutoComplete.jsx
  55. 3 1
      packages/app/src/components/RevisionComparer/RevisionComparer.jsx
  56. 2 3
      packages/app/src/components/SearchTypeahead.jsx
  57. 2 2
      packages/app/src/components/Sidebar/RecentChanges.jsx
  58. 1 1
      packages/app/src/components/User/UserInfo.jsx
  59. 1 1
      packages/app/src/components/User/UserPictureList.jsx
  60. 1 1
      packages/app/src/migrations/20191126173016-adjust-pages-path.js
  61. 3 6
      packages/app/src/models/linked-page-path.js
  62. 1 1
      packages/app/src/server/crowi/index.js
  63. 5 3
      packages/app/src/server/models/page.js
  64. 2 0
      packages/app/src/server/models/slack-app-integration.js
  65. 22 0
      packages/app/src/server/models/vo/slackbot-error.js
  66. 38 0
      packages/app/src/server/plugins/plugin-utils-v4.ts
  67. 15 7
      packages/app/src/server/plugins/plugin-utils.js
  68. 16 11
      packages/app/src/server/plugins/plugin.service.js
  69. 3 2
      packages/app/src/server/routes/apiv3/page.js
  70. 7 2
      packages/app/src/server/routes/apiv3/pages.js
  71. 75 11
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  72. 76 63
      packages/app/src/server/routes/apiv3/slack-integration.js
  73. 2 1
      packages/app/src/server/routes/page.js
  74. 2 2
      packages/app/src/server/service/app.ts
  75. 1 1
      packages/app/src/server/service/customize.ts
  76. 3 1
      packages/app/src/server/service/global-notification/global-notification-slack.js
  77. 3 1
      packages/app/src/server/service/page.js
  78. 48 0
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  79. 37 38
      packages/app/src/server/service/slack-command-handler/create.js
  80. 66 0
      packages/app/src/server/service/slack-command-handler/respond-if-slackbot-error.js
  81. 199 30
      packages/app/src/server/service/slack-command-handler/search.js
  82. 10 0
      packages/app/src/server/service/slack-command-handler/slack-command-handler.js
  83. 173 2
      packages/app/src/server/service/slack-command-handler/togetter.js
  84. 35 461
      packages/app/src/server/service/slackbot.ts
  85. 3 1
      packages/app/src/server/util/createGrowiPagesFromImports.js
  86. 2 5
      packages/app/tsconfig.base.json
  87. 0 1
      packages/app/tsconfig.build.client.json
  88. 1 0
      packages/core/.eslintignore
  89. 1 0
      packages/core/.gitignore
  90. 27 0
      packages/core/README.md
  91. 60 0
      packages/core/jest.config.js
  92. 24 0
      packages/core/package.json
  93. 21 0
      packages/core/src/index.js
  94. 2 4
      packages/core/src/models/devided-page-path.js
  95. 11 0
      packages/core/src/plugin/interfaces/plugin-definition-v4.ts
  96. 14 0
      packages/core/src/plugin/model/tag-context.js
  97. 71 0
      packages/core/src/plugin/service/tag-cache-manager.js
  98. 59 0
      packages/core/src/plugin/util/args-parser.js
  99. 88 0
      packages/core/src/plugin/util/custom-tag-utils.js
  100. 70 0
      packages/core/src/plugin/util/option-parser.js

+ 1 - 0
.devcontainer/devcontainer.json

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

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

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

+ 4 - 0
.eslintrc.js

@@ -26,5 +26,9 @@ module.exports = {
         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
       working-directory: ./packages/slackbot-proxy
       run: |
-        cp config/ci/.env.local.for-ci .env.local
+        cp config/ci/.env.local.for-ci .env.development.local
         yarn dev:ci
       env:
         TYPEORM_CONNECTION: mysql
@@ -211,7 +211,7 @@ jobs:
     - name: yarn start:prod:ci
       working-directory: ./packages/slackbot-proxy
       run: |
-        cp config/ci/.env.local.for-ci .env.local
+        cp config/ci/.env.local.for-ci .env.production.local
         yarn start:prod:ci
       env:
         TYPEORM_CONNECTION: mysql

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

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

+ 4 - 0
.markdownlint.yml

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

+ 20 - 0
.vscode/launch.json

@@ -44,6 +44,26 @@
         "url": "http://localhost:3000",
         "webRoot": "${workspaceFolder}/packages/app/public",
         "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",
             "path": "${workspaceFolder}/packages/app/src"

+ 21 - 3
package.json

@@ -21,8 +21,12 @@
   },
   "private": true,
   "workspaces": {
-    "packages": ["packages/*"],
-    "nohoist": ["**/slackbot-proxy/bootstrap"]
+    "packages": [
+      "packages/*"
+    ],
+    "nohoist": [
+      "**/slackbot-proxy/bootstrap"
+    ]
   },
   "scripts": {
     "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"
   },
   "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": {
+    "@types/jest": "^26.0.22",
+    "@types/node": "^14.14.35",
+    "@types/rewire": "^2.5.28",
     "@typescript-eslint/eslint-plugin": "^4.28.5",
     "@typescript-eslint/parser": "^4.28.5",
     "eslint": "^7.31.0",
@@ -48,7 +61,12 @@
     "eslint-plugin-jest": "^24.3.2",
     "eslint-plugin-react": "^7.24.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": {
     "node": "^12 || ^14",

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

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

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

@@ -2,4 +2,4 @@
 ## Handled by Next.js with dotenv or dotenv-flow
 ## 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/**
 /src/client/legacy/thirdparty-js/**
 /src/client/util/reveal/plugins/markdown.js

+ 1 - 1
packages/app/.gitignore

@@ -13,7 +13,7 @@
 
 # dist (for GROWI v4.x and below)
 /public/*.chunk.js
-/public/*.chunk.js.LICENSE
+/public/*.chunk.js.LICENSE.txt
 /public/*.bundle.js
 /public/manifest.json
 /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 swig from 'swig-templates';
 
+import { PluginDefinitionV4 } from '@growi/core';
+
 import PluginUtils from '../src/server/plugins/plugin-utils';
 import loggerFactory from '../src/utils/logger';
 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();
 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
     definition.entries = definition.entries.map((entryPath) => {
       return normalize(entryPath);
     });
     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
 ELASTICSEARCH_URI=

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

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

+ 3 - 14
packages/app/package.json

@@ -55,7 +55,9 @@
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@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-lsx": "^4.3.3-RC",
     "@growi/slack": "^4.3.3-RC",
     "@kobalab/socket.io-session": "^1.0.3",
     "@promster/express": "^5.0.1",
@@ -77,12 +79,10 @@
     "connect-mongo": "^4.4.1",
     "connect-redis": "^4.0.4",
     "cookie-parser": "^1.4.5",
-    "cross-env": "^7.0.0",
     "csrf": "^3.1.0",
     "date-fns": "^2.0.0",
     "detect-indent": "^6.0.0",
     "diff": "^5.0.0",
-    "dotenv-flow": "^3.2.0",
     "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
     "esa-nodejs": "^0.0.7",
@@ -96,8 +96,6 @@
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
     "growi-commons": "^5.0.4",
-    "growi-plugin-attachment-refs": "^2.0.2",
-    "growi-plugin-lsx": "^4.0.3",
     "helmet": "^3.13.0",
     "http-errors": "~1.6.2",
     "i18next": "^20.3.2",
@@ -118,7 +116,6 @@
     "multer-autoreap": "^1.0.3",
     "nodemailer": "^6.6.2",
     "nodemailer-ses-transport": "~1.5.0",
-    "npm-run-all": "^4.1.2",
     "openid-client": "=2.5.0",
     "passport": "^0.4.0",
     "passport-github": "^1.1.0",
@@ -139,9 +136,6 @@
     "string-width": "=4.2.2",
     "swagger-jsdoc": "^3.4.0",
     "swig-templates": "^2.0.2",
-    "ts-node": "^9.1.1",
-    "tsconfig-paths": "^3.9.0",
-    "typescript": "^4.2.3",
     "uglifycss": "^0.0.29",
     "universal-bunyan": "^0.9.2",
     "unzipper": "^0.10.5",
@@ -159,11 +153,11 @@
     "@alienfast/i18next-loader": "^1.0.16",
     "@atlaskit/drawer": "^5.3.7",
     "@atlaskit/navigation-next": "^8.0.5",
+    "@growi/ui": "^4.3.3-RC",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/multer": "^1.4.5",
-    "@types/node": "^14.14.35",
     "@types/react-dom": "^17.0.9",
     "autoprefixer": "^9.0.0",
     "bootstrap": "^4.5.0",
@@ -183,8 +177,6 @@
     "hard-source-webpack-plugin": "^0.13.1",
     "i18next-browser-languagedetector": "^4.0.1",
     "imports-loader": "^0.8.0",
-    "jest": "^27.0.6",
-    "jest-date-mock": "^1.0.8",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
@@ -222,8 +214,6 @@
     "react-frame-component": "^4.0.0",
     "react-hotkeys": "^2.0.0",
     "react-i18next": "^11.1.0",
-    "react-images": "1.0.0",
-    "react-motion": "^0.5.2",
     "react-waypoint": "^10.1.0",
     "reactstrap": "^8.9.0",
     "replacestream": "^4.0.3",
@@ -240,7 +230,6 @@
     "terser-webpack-plugin": "^4.1.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
-    "ts-jest": "^27.0.4",
     "ts-loader": "^8.3.0",
     "ts-node-dev": "^1.1.6",
     "tsc-alias": "^1.2.9",

+ 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.",
       "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.",
-
       "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_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_l": "Number of list displayed on '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_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
-
-
-
       "stale_notification": "Display notification on stale pages",
       "stale_notification_desc": "Displays the notification to pages more than 1 year since the last update.",
       "show_all_reply_comments": "Show all reply comments",
@@ -326,6 +319,9 @@
       "install_complete_if_checked": "Confirm that \"Install your app\" is checked.",
       "invite_bot_to_channel": "Invite GROWI bot to channel by calling @example.",
       "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_by_pressing_button": "Press the button to test the connection",
       "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": "無効化することで、ページ遷移のみを戻る/進む操作の対象にすることができます。",
       "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
       "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
-
       "list_num_s": "モーダルに表示されるリスト数",
       "list_num_desc_s": "モーダルにおける <ページリスト> <タイムライン> <更新履歴> <添付ファイル>での、1ページあたりの表示数を設定します。",
-
       "list_num_m": "ユーザーページに表示されるリスト数",
       "list_num_desc_m": "ユーザーページにおける <Bookmarks> <Recently Created>での、1ページあたりの表示数を設定します。",
-
       "list_num_l": "検索ページに表示されるリスト数",
       "list_num_desc_l": "<Search>での、1ページあたりの表示数を設定します。",
-
       "list_num_xl": "Not FoundページやTrashページに表示されるリスト数",
       "list_num_desc_xl": "記事エリアにおける<Not Found> <Trash>での、1ページあたりの表示数を設定します。",
-
       "stale_notification": "古いページに通知を表示する",
       "stale_notification_desc": "最後の更新から1年を超えるページへの通知を表示します。",
       "show_all_reply_comments": "返信コメントを全て表示する",
@@ -323,6 +318,9 @@
       "install_complete_if_checked": "Install your app の右側に緑色のチェックがつけばワークスペースへのインストール完了です。",
       "invite_bot_to_channel": "GROWI bot を使いたいチャンネルに @example を使用して招待します。",
       "register_secret_and_token": "Signing Secret と Bot Token を登録する",
+      "manage_commands": "使用可能なGROWIコマンドを設定する",
+      "multiple_growi_command": "複数のGROWIに対して送信できるコマンド",
+      "single_growi_command": "一つのGROWIに対して送信できるコマンド",
       "test_connection": "連携状況のテストをする",
       "test_connection_by_pressing_button": "以下のテストボタンを押して、Slack連携が完了しているかの確認をしましょう",
       "error_check_logs_below": "エラーが発生しました。下記のログを確認してください。",
@@ -334,7 +332,6 @@
       "integration_is_not_complete": "連携は完了していません<br>下の連携手順を進めてください",
       "integration_successful": "連携は完了しています。",
       "integration_some_ws_is_not_complete": "連携に失敗している ワークスペースがあります。"
-
     },
     "custom_bot_with_proxy_integration": "Custom bot with proxy 連携",
     "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": "邮件设置尚未完成。",
-    "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": "发送测试邮件",
     "success_to_send_test_email": "成功发送了一封测试邮件",
     "smtp_settings": "SMTP 设置",
-		"host": "服务器",
-		"port": "端口号",
-		"user": "用户名",
+    "host": "服务器",
+    "port": "端口号",
+    "user": "用户名",
     "initialize_mail_settings": "重置邮件设置",
     "initialize_mail_modal_header": "重置邮件设置",
     "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)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
-    "ses_settings":"SES设置",
+    "ses_settings": "SES设置",
     "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> ."
   },
-	"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_desc": "您可以更改缩进设置。",
     "indent_options": {
@@ -88,184 +88,179 @@
       "disallow_indent_change_desc": "您可以不允许用户更改缩进值。"
     },
     "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_options": {
       "default": "默认内容宽度 ",
       "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节添加到第一行",
-
       "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_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_l": "Number of list displayed on '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_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",
-				"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",
     "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": {
     "selecting_bot_types": {
@@ -304,8 +299,8 @@
     "delete": "取消",
     "integration_procedure": "协作程序",
     "custom_bot_without_proxy_settings": "Custom Bot without proxy 设置",
-    "integration_failed":"联动失败",
-    "reset":"重置",
+    "integration_failed": "联动失败",
+    "reset": "重置",
     "reset_all_settings": "重置所有设置",
     "delete_slackbot_settings": "删除 Slack Bot 设置",
     "slackbot_settings_notice": "Slak 工作区集成过程已被删除。 <br> 你确定吗?",
@@ -333,6 +328,9 @@
       "install_complete_if_checked": "确认已选中 \"Install your app\"。",
       "invite_bot_to_channel": "通过调用 @example 邀请 GROWI Bot 进行频道。",
       "register_secret_and_token": "设置签名秘密和BOT令牌",
+      "manage_commands": "管理 GROWI 命令",
+      "multiple_growi_command": "可以一次发送到多个 GROWI 实例的命令",
+      "single_growi_command": "可以一次发送到一个 GROWI 实例的命令",
       "test_connection": "测试连接",
       "test_connection_by_pressing_button": "按下按钮以测试连接",
       "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"
     }
   },
-	"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": "你也可以从用户表中的下拉菜单中发送或重新发送邀请邮件。",
-			"existing_email": "以下电子邮件已存在",
+      "existing_email": "以下电子邮件已存在",
       "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": "发送邀请邮件",
       "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_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": "转移到另一组"
+    }
+  }
 }

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

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

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

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

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

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

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

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

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

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

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

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 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 toastr from 'toastr';
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
-import { isTrashPage } from '~/utils/path-utils';
 import { toastError } from '../util/apiNotification';
 
 import {
@@ -16,6 +16,8 @@ import {
   DrawioInterceptor,
 } from '../util/interceptor/drawio-interceptor';
 
+const { isTrashPage } = pagePathUtils;
+
 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 urljoin from 'url-join';
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 
 import AppContainer from '~/client/services/AppContainer';
 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">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
-          const { tokenGtoP, tokenPtoG, _id } = slackAppIntegration;
+          const {
+            tokenGtoP, tokenPtoG, _id, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+          } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
             <React.Fragment key={slackAppIntegration._id}>
@@ -125,6 +127,8 @@ const CustomBotWithProxySettings = (props) => {
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
+                supportedCommandsForBroadcastUse={supportedCommandsForBroadcastUse}
+                supportedCommandsForSingleUse={supportedCommandsForSingleUse}
                 onUpdateTokens={onUpdateTokens}
                 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">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
-          const { tokenGtoP, tokenPtoG, _id } = slackAppIntegration;
+          const {
+            tokenGtoP, tokenPtoG, _id, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+          } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
             <React.Fragment key={slackAppIntegration._id}>
@@ -91,6 +93,8 @@ const OfficialBotSettings = (props) => {
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
+                supportedCommandsForBroadcastUse={supportedCommandsForBroadcastUse}
+                supportedCommandsForSingleUse={supportedCommandsForSingleUse}
                 onUpdateTokens={onUpdateTokens}
                 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 { addLogs } from './slak-integration-util';
 import MessageBasedOnConnection from './MessageBasedOnConnection';
+import ManageCommandsProcess from './ManageCommandsProcess';
 
 const logger = loggerFactory('growi:SlackIntegration:WithProxyAccordionsWrapper');
 
@@ -287,7 +288,6 @@ const WithProxyAccordions = (props) => {
       props.onSubmitForm();
     }
   };
-
   const submitFormFailed = () => {
     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',
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
@@ -344,6 +353,15 @@ const WithProxyAccordions = (props) => {
       content: <RegisteringProxyUrlProcess />,
     },
     '⑤': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
+        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
+      />,
+    },
+    '⑥': {
       title: 'test_connection',
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
@@ -392,6 +410,8 @@ WithProxyAccordions.propTypes = {
   slackAppIntegrationId: PropTypes.string.isRequired,
   tokenPtoG: PropTypes.string,
   tokenGtoP: PropTypes.string,
+  supportedCommandsForBroadcastUse: PropTypes.arrayOf(PropTypes.string),
+  supportedCommandsForSingleUse: PropTypes.arrayOf(PropTypes.string),
 };
 
 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 { debounce } from 'throttle-debounce';
+import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import UserPicture from '../../User/UserPicture';
 
 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 dateFnsFormat from 'date-fns/format';
 
-import UserPicture from '../../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 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 dateFnsFormat from 'date-fns/format';
 
-import UserPicture from '../../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import UserMenu from './UserMenu';
 
 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 { withTranslation } from 'react-i18next';
+import { pagePathUtils } from '@growi/core';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import PageContainer from '~/client/services/PageContainer';
-import { convertToNewAffiliationPath } from '~/utils/path-utils';
+
+const { convertToNewAffiliationPath } = pagePathUtils;
 
 function ComparePathsTable(props) {
   const {

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

@@ -1,7 +1,7 @@
 import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 
-import { isTopPage } from '~/utils/path-utils';
+import { pagePathUtils } from '@growi/core';
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
@@ -10,6 +10,8 @@ import { withUnstatedContainers } from './UnstatedUtils';
 
 import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
 
+const { isTopPage } = pagePathUtils;
+
 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 { withTranslation } from 'react-i18next';
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 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 { withTranslation } from 'react-i18next';
+import { pagePathUtils } from '@growi/core';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import PageContainer from '~/client/services/PageContainer';
-import { convertToNewAffiliationPath } from '~/utils/path-utils';
+
+const { convertToNewAffiliationPath } = pagePathUtils;
 
 function DuplicatedPathsTable(props) {
   const {

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

@@ -1,9 +1,11 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 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 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 { DevidedPagePath } from '@growi/core';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import DevidedPagePath from '~/models/devided-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
 
 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 { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
@@ -18,7 +19,6 @@ import {
   updateUserPreferenceWithOsSettings,
 } from '~/client/util/color-scheme';
 
-import UserPicture from '../User/UserPicture';
 
 import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
 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 { encodeSpaces } from '~/utils/path-utils';
+import { pagePathUtils } from '@growi/core';
+
+const { encodeSpaces } = pagePathUtils;
 
 /* eslint-disable react/prop-types */
 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 urljoin from 'url-join';
 
-import { isTopPage } from '~/utils/path-utils';
+import { pagePathUtils } from '@growi/core';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
@@ -15,6 +15,8 @@ import CreateTemplateModal from '../CreateTemplateModal';
 import PagePresentationModal from '../PagePresentationModal';
 import PresentationIcon from '../Icons/PresentationIcon';
 
+const { isTopPage } = pagePathUtils;
+
 
 const PageManagement = (props) => {
   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 { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
-import UserPicture from '../User/UserPicture';
 import PutbackPageModal from '../PutbackPageModal';
 import EmptyTrashModal from '../EmptyTrashModal';
 import PageDeleteModal from '../PageDeleteModal';

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

@@ -6,7 +6,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import UserPicture from '../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import Username from '../User/Username';
 
 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 PropTypes from 'prop-types';
 
-import Attachment from './Attachment';
+import { Attachment } from '@growi/ui';
 
 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 { UserPicture } from '@growi/ui';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 
@@ -13,7 +14,6 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
 import RevisionBody from '../Page/RevisionBody';
-import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 import CommentEditor from './CommentEditor';
 import CommentControl from './CommentControl';

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

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

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

@@ -7,7 +7,7 @@ import {
 
 import { format } from 'date-fns';
 
-import UserPicture from '../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import Username from '../User/Username';
 
 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 { 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 NavigationContainer from '~/client/services/NavigationContainer';
@@ -19,6 +17,10 @@ import { toastError } from '~/client/util/apiNotification';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 
+const {
+  userPageRoot, isCreatablePage, generateEditorPath,
+} = pagePathUtils;
+
 const PageCreateModal = (props) => {
   const { t, appContainer, navigationContainer } = props;
 

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

@@ -1,9 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { UserPicture } from '@growi/ui';
 import UserDate from '../User/UserDate';
 import Username from '../User/Username';
-import UserPicture from '../User/UserPicture';
 
 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 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 {
 

+ 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 PropTypes from 'prop-types';
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 
 import SearchTypeahead from './SearchTypeahead';
 

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

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

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

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

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

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

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

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import UserPicture from './UserPicture';
+import { UserPicture } from '@growi/ui';
 
 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 { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 
 import config from '^/config/migrate';
 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
  */
-class LinkedPagePath {
+export default class LinkedPagePath {
 
   constructor(path, skipNormalize = false) {
 
@@ -34,5 +33,3 @@ class LinkedPagePath {
   }
 
 }
-
-module.exports = LinkedPagePath;

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

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

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

@@ -1,3 +1,4 @@
+import { templateChecker, pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 // disable no-return-await for model functions
@@ -15,8 +16,9 @@ const differenceInYears = require('date-fns/differenceInYears');
 
 const { pathUtils } = require('growi-commons');
 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');
 
@@ -311,7 +313,7 @@ module.exports = function(crowi) {
   };
 
   pageSchema.methods.isTemplate = function() {
-    return templateChecker(this.path);
+    return checkTemplatePath(this.path);
   };
 
   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({
   tokenGtoP: { type: String, required: true, unique: true },
   tokenPtoG: { type: String, required: true, unique: true },
+  supportedCommandsForBroadcastUse: { type: [String], default: [] },
+  supportedCommandsForSingleUse: { type: [String], default: [] },
 });
 
 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 { 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 pluginUtilsV2 = new PluginUtilsV2();
+const pluginUtilsV4 = new PluginUtilsV4();
 
 class PluginUtils {
 
@@ -26,19 +26,27 @@ class PluginUtils {
    * @return
    * @memberOf PluginService
    */
-  generatePluginDefinition(name, isForClient = false) {
+  async generatePluginDefinition(name, isForClient = false) {
     const meta = require(name);
     let definition;
 
     switch (meta.pluginSchemaVersion) {
-      // v1 is deprecated
+      // v1, v2 and v3 is deprecated
       case 1:
         logger.debug('pluginSchemaVersion 1 is deprecated');
         break;
-      // v2 or above
       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:
-        definition = pluginUtilsV2.generatePluginDefinition(name, isForClient);
+        logger.warn('Unsupported schema version', meta.pluginSchemaVersion);
     }
 
     return definition;

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

@@ -12,13 +12,13 @@ class PluginService {
     this.pluginUtils = new PluginUtils();
   }
 
-  autoDetectAndLoadPlugins() {
+  async autoDetectAndLoadPlugins() {
     const isEnabledPlugins = this.crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins');
 
     // import plugins
     if (isEnabledPlugins) {
       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
    */
-  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);
-      });
+      }
+    }
   }
 
   loadPlugin(definition) {
     const meta = definition.meta;
 
     switch (meta.pluginSchemaVersion) {
-      // v1 is deprecated
+      // v1, v2 and v3 is deprecated
       case 1:
         logger.warn('pluginSchemaVersion 1 is deprecated', definition);
         break;
-      // v2 is deprecated
       case 2:
         logger.warn('pluginSchemaVersion 2 is deprecated', definition);
         break;
       case 3:
+        logger.warn('pluginSchemaVersion 3 is deprecated', definition);
+        break;
+      // v4 or above
+      case 4:
         logger.info(`load plugin '${definition.name}'`);
         definition.entries.forEach((entryPath) => {
           const entry = require(entryPath);

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

@@ -1,13 +1,14 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
+
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
 const { body, query } = require('express-validator');
 
 const router = express.Router();
-
-const { convertToNewAffiliationPath } = require('~/utils/path-utils');
+const { convertToNewAffiliationPath } = pagePathUtils;
 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';
 
 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 { query } = require('express-validator');
-const { isCreatablePage } = require('~/utils/path-utils');
+
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
+const { isCreatablePage } = pagePathUtils;
+
 const router = express.Router();
 
 const LIMIT_FOR_LIST = 10;
@@ -458,7 +461,9 @@ module.exports = (crowi) => {
   ];
 
   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 limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;

+ 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 express = require('express');
-const { body, query } = require('express-validator');
+const { body, query, param } = require('express-validator');
 const axios = require('axios');
 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');
 
@@ -60,6 +62,11 @@ module.exports = (crowi) => {
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
         .isURL({ require_tld: false }),
     ],
+    updateSupportedCommands: [
+      body('supportedCommandsForSingleUse').toArray(),
+      body('supportedCommandsForBroadcastUse').toArray(),
+      param('id').isMongoId().withMessage('id is required'),
+    ],
     RelationTest: [
       body('slackAppIntegrationId').isMongoId(),
       body('channel').trim().isString(),
@@ -106,17 +113,17 @@ module.exports = (crowi) => {
     return result.data;
   }
 
-  async function postRelationTest(token) {
+  async function requestToProxyServer(token, method, endpoint, body) {
     const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
     if (proxyUri == null) {
       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;
   }
@@ -401,9 +408,13 @@ module.exports = (crowi) => {
     }
 
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
-
     try {
-      const slackAppTokens = await SlackAppIntegration.create({ tokenGtoP, tokenPtoG });
+      const slackAppTokens = await SlackAppIntegration.create({
+        tokenGtoP,
+        tokenPtoG,
+        supportedCommandsForBroadcastUse: defaultSupportedCommandsNameForBroadcastUse,
+        supportedCommandsForSingleUse: defaultSupportedCommandsNameForSingleUse,
+      });
       return res.apiv3(slackAppTokens, 200);
     }
     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
    *
@@ -523,7 +577,17 @@ module.exports = (crowi) => {
         const msg = 'Could not find SlackAppIntegration by id';
         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;
       if (slackBotToken == null) {
         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 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 router = express.Router();
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
+const { respondIfSlackbotError } = require('../../service/slack-command-handler/respond-if-slackbot-error');
 
 module.exports = (crowi) => {
   this.app = crowi.express;
@@ -43,6 +44,55 @@ module.exports = (crowi) => {
     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) => {
     req.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:signingSecret');
     return next();
@@ -104,18 +154,19 @@ module.exports = (crowi) => {
     const command = args[0];
 
     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) => {
     return handleCommands(req, res);
   });
 
-  router.post('/proxied/commands', verifyAccessTokenFromProxy, async(req, res) => {
+  router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
     const { body } = req;
 
     // eslint-disable-next-line max-len
@@ -127,61 +178,6 @@ module.exports = (crowi) => {
     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) {
 
     // Send response immediately to avoid opelation_timeout error
@@ -206,10 +202,20 @@ module.exports = (crowi) => {
     try {
       switch (type) {
         case 'block_actions':
-          await handleBlockActions(client, payload);
+          try {
+            await crowi.slackBotService.handleBlockActionsRequest(client, payload);
+          }
+          catch (err) {
+            await respondIfSlackbotError(client, req.body, err);
+          }
           break;
         case 'view_submission':
-          await handleViewSubmission(client, payload);
+          try {
+            await crowi.slackBotService.handleViewSubmissionRequest(client, payload);
+          }
+          catch (err) {
+            await respondIfSlackbotError(client, req.body, err);
+          }
           break;
         default:
           break;
@@ -225,9 +231,16 @@ module.exports = (crowi) => {
     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);
   });
 
+  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;
 };

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

@@ -1,6 +1,7 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
-const { isCreatablePage } = require('~/utils/path-utils');
+const { isCreatablePage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-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';
 
@@ -119,7 +119,7 @@ class AppService implements S2sMessageHandlable {
   }
 
   async setupAfterInstall() {
-    this.crowi.pluginService.autoDetectAndLoadPlugins();
+    await this.crowi.pluginService.autoDetectAndLoadPlugins();
     this.crowi.setupRoutesAtLast();
     this.crowi.setupGlobalErrorHandlers();
 

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

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

+ 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';
 
+
 const logger = loggerFactory('growi:service:GlobalNotificationSlackService'); // eslint-disable-line no-unused-vars
 const urljoin = require('url-join');
 
-const { encodeSpaces } = require('~/utils/path-utils');
+const { encodeSpaces } = pagePathUtils;
 
 /**
  * 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';
 
 const mongoose = require('mongoose');
@@ -7,7 +8,8 @@ const logger = loggerFactory('growi:models:page');
 const debug = require('debug')('growi:models:page');
 const { Writable } = require('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 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');
 
-module.exports = () => {
+module.exports = (crowi) => {
+  const CreatePageService = require('./create-page-service');
+  const createPageService = new CreatePageService(crowi);
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
 
   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: [
-          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;

+ 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 { formatDistanceStrict } = require('date-fns');
+const axios = require('axios');
+const SlackbotError = require('../../models/vo/slackbot-error');
 
 const PAGINGLIMIT = 10;
 
@@ -18,19 +20,16 @@ module.exports = (crowi) => {
     }
     catch (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 {
       pages, offset, resultsTotal,
@@ -83,7 +82,7 @@ module.exports = (crowi) => {
           },
           accessory: {
             type: 'button',
-            action_id: 'shareSingleSearchResult',
+            action_id: 'search:shareSinglePageResult',
             text: {
               type: 'plain_text',
               text: 'Share',
@@ -121,7 +120,7 @@ module.exports = (crowi) => {
             text: 'Dismiss',
           },
           style: 'danger',
-          action_id: 'dismissSearchResults',
+          action_id: 'search:dismissSearchResults',
         },
       ],
     };
@@ -134,33 +133,203 @@ module.exports = (crowi) => {
             type: 'plain_text',
             text: 'Next',
           },
-          action_id: 'showNextResults',
+          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.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 {
-      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) {
-      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) {
@@ -174,12 +343,12 @@ module.exports = (crowi) => {
           markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
         ],
       });
-      return;
+      return { pages: [] };
     }
 
     const keywords = this.getKeywords(args);
 
-    const { searchService } = this.crowi;
+    const { searchService } = crowi;
     const options = { limit: 10, offset };
     const results = await searchService.searchKeyword(keywords, null, {}, options);
     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') }
 
+  /**
+   * 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;

+ 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 {
-  inputBlock, actionsBlock, buttonElement, markdownSectionBlock,
+  inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider,
 } = 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) => {
+  const CreatePageService = require('./create-page-service');
+  const createPageService = new CreatePageService(crowi);
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
 
@@ -23,6 +30,170 @@ module.exports = (crowi) => {
     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) {
     return [
       markdownSectionBlock('Select the oldest and latest datetime of the messages to use.'),

+ 35 - 461
packages/app/src/server/service/slackbot.ts

@@ -1,19 +1,13 @@
-
 import loggerFactory from '~/utils/logger';
 import { S2sMessagingService } from './s2s-messaging/base';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 const logger = loggerFactory('growi:service:SlackBotService');
-const mongoose = require('mongoose');
-const axios = require('axios');
 
-const { markdownSectionBlock, divider } = require('@growi/slack');
-const { reshapeContentsBody } = require('@growi/slack');
-const { formatDistanceStrict, parse, format } = require('date-fns');
+const { markdownSectionBlock } = require('@growi/slack');
 
 const S2sMessage = require('../models/vo/s2s-message');
 
-const PAGINGLIMIT = 10;
 
 class SlackBotService implements S2sMessageHandlable {
 
@@ -76,485 +70,65 @@ class SlackBotService implements S2sMessageHandlable {
   /**
    * Handle /commands endpoint
    */
-  async handleCommand(command, client, body, ...opt) {
-    const module = `./slack-command-handler/${command}`;
-    try {
-      const handler = require(module)(this.crowi);
-      await handler.handleCommand(client, body, ...opt);
-    }
-    catch (err) {
-      this.notCommand(client, body);
-    }
-  }
-
-  async notCommand(client, body) {
-    logger.error('Invalid first argument');
-    client.chat.postEphemeral({
-      channel: body.channel_id,
-      user: body.user_id,
-      text: 'No command',
-      blocks: [
-        markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
-      ],
-    });
-    return;
-  }
-
-  generatePageLinkMrkdwn(pathname, href) {
-    return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
-  }
-
-  appendSpeechBaloon(mrkdwn, commentCount) {
-    return (commentCount != null && commentCount > 0)
-      ? `${mrkdwn}   :speech_balloon: ${commentCount}`
-      : mrkdwn;
-  }
-
-  generateLastUpdateMrkdwn(updatedAt, baseDate) {
-    if (updatedAt != null) {
-      // cast to date
-      const date = new Date(updatedAt);
-      return formatDistanceStrict(date, baseDate);
-    }
-    return '';
-  }
-
-
-  async shareSinglePage(client, payload) {
-    const { channel, user, actions } = payload;
-
-    const appUrl = this.crowi.appService.getSiteUrl();
-    const appTitle = this.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}*`,
-            },
-          ],
-        },
-      ],
-    });
-  }
-
-  async dismissSearchResults(client, payload) {
-    const { response_url: responseUrl } = payload;
-
-    return axios.post(responseUrl, {
-      delete_original: true,
-    });
-  }
-
-  async showEphemeralSearchResults(client, body, args, offsetNum) {
-    let searchResult;
-    try {
-      searchResult = await this.retrieveSearchResults(client, body, args, offsetNum);
-    }
-    catch (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 Error('/growi command:search: Failed to search');
-    }
-
-    const appUrl = this.crowi.appService.getSiteUrl();
-    const appTitle = this.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',
-    //     },
-    //   ],
-    // };
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const actionBlocks: any = {
-      type: 'actions',
-      elements: [
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: 'Dismiss',
-          },
-          style: 'danger',
-          action_id: '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: 'showNextResults',
-          value: JSON.stringify({ offset, body, args }),
-        },
-      );
-    }
-    blocks.push(actionBlocks);
-
+  async handleCommandRequest(command, client, body, ...opt) {
+    let module;
     try {
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Successed To Search',
-        blocks,
-      });
+      module = `./slack-command-handler/${command}`;
     }
     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()),
-        ],
-      });
-      throw new Error(err);
+      await this.notCommand(client, body);
     }
-  }
-
-  async retrieveSearchResults(client, body, args, offset = 0) {
-    const firstKeyword = args[1];
-    if (firstKeyword == null) {
-      client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Input keywords',
-        blocks: [
-          markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
-        ],
-      });
-      return;
-    }
-
-    const keywords = this.getKeywords(args);
-
-    const { searchService } = this.crowi;
-    const options = { limit: 10, offset };
-    const results = await searchService.searchKeyword(keywords, null, {}, options);
-    const resultsTotal = results.meta.total;
-
-    // no search results
-    if (results.data.length === 0) {
-      logger.info(`No page found with "${keywords}"`);
-      client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: `No page found with "${keywords}"`,
-        blocks: [
-          markdownSectionBlock(`*No page that matches your keyword(s) "${keywords}".*`),
-          markdownSectionBlock(':mag: *Help: Searching*'),
-          divider(),
-          markdownSectionBlock('`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body'),
-          divider(),
-          markdownSectionBlock('`"This is GROWI"` (surround with double quotes) \n Search pages that include the phrase "This is GROWI"'),
-          divider(),
-          markdownSectionBlock('`-keyword` \n Exclude pages that include keyword in the title or body'),
-          divider(),
-          markdownSectionBlock('`prefix:/user/` \n Search only the pages that the title start with /user/'),
-          divider(),
-          markdownSectionBlock('`-prefix:/user/` \n Exclude the pages that the title start with /user/'),
-          divider(),
-          markdownSectionBlock('`tag:wiki` \n Search for pages with wiki tag'),
-          divider(),
-          markdownSectionBlock('`-tag:wiki` \n Exclude pages with wiki tag'),
-        ],
-      });
-      return { pages: [] };
-    }
-
-    const pages = results.data.map((data) => {
-      const { path, updated_at: updatedAt, comment_count: commentCount } = data._source;
-      return { path, updatedAt, commentCount };
-    });
 
-    return {
-      pages, offset, resultsTotal,
-    };
-  }
-
-  getKeywords(args) {
-    const keywordsArr = args.slice(1);
-    const keywords = keywordsArr.join(' ');
-    return keywords;
-  }
-
-  // Submit action in create Modal
-  async createPage(client, payload, path, channelId, contentsBody) {
-    const Page = this.crowi.model('Page');
-    const pathUtils = require('growi-commons').pathUtils;
-    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.`,
-      });
+      const handler = require(module)(this.crowi);
+      await handler.handleCommand(client, body, ...opt);
     }
     catch (err) {
-      client.chat.postMessage({
-        channel: payload.user.id,
-        blocks: [
-          markdownSectionBlock(`Cannot create new page to existed path\n *Contents* :memo:\n ${reshapedContentsBody}`)],
-      });
-      logger.error('Failed to create page in GROWI.');
       throw err;
     }
   }
 
-  async createPageInGrowi(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 this.createPage(client, payload, path, channelId, contentsBody);
-  }
-
-  async togetterCreatePageInGrowi(client, payload) {
-    let result = [];
-    const channel = payload.channel.id;
+  async handleBlockActionsRequest(client, payload) {
+    const { action_id: actionId } = payload.actions[0];
+    const commandName = actionId.split(':')[0];
+    const handlerMethodName = actionId.split(':')[1];
+    const module = `./slack-command-handler/${commandName}`;
     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
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      const cleanedContents = await this.togetterCleanMessages((result as any).messages);
-
-      const contentsBody = cleanedContents.join('');
-      // create and send url message
-      await this.togetterCreatePageAndSendPreview(client, payload, path, channel, contentsBody);
+      const handler = require(module)(this.crowi);
+      await handler.handleBlockActions(client, payload, handlerMethodName);
     }
     catch (err) {
-      await client.chat.postMessage({
-        channel: payload.user.id,
-        text: err.message,
-        blocks: [
-          markdownSectionBlock(err.message),
-        ],
-      });
-      return;
-    }
-  }
-
-  async togetterGetMessages(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 Error('No message found from togetter command. Try again.');
-    }
-    return result;
-  }
-
-  async togetterValidateForm(client, payload) {
-    const grwTzoffset = this.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 Error('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 Error('Datetime format for oldest must be yyyy/MM/dd-HH:mm');
-    }
-    if (!regexpDatetime.test(latest)) {
-      throw new Error('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 Error('Oldest datetime must be older than the latest date time.');
+      throw err;
     }
-
-    return { path, oldest, latest };
-  }
-
-  async togetterCleanMessages(messages) {
-    const cleanedContents: string[] = [];
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    let lastMessage: any = {};
-    const grwTzoffset = this.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;
+    return;
   }
 
-  async togetterCreatePageAndSendPreview(client, payload, path, channel, contentsBody) {
+  async handleViewSubmissionRequest(client, payload) {
+    const { callback_id: callbackId } = payload.view;
+    const commandName = callbackId.split(':')[0];
+    const handlerMethodName = callbackId.split(':')[1];
+    const module = `./slack-command-handler/${commandName}`;
     try {
-      await this.createPage(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,
-      });
+      const handler = require(module)(this.crowi);
+      await handler.handleBlockActions(client, payload, handlerMethodName);
     }
     catch (err) {
-      throw new Error('Error occurred while creating a page.');
+      throw err;
     }
+    return;
   }
 
-  async togetterCancel(client, payload) {
-    const responseUrl = payload.response_url;
-    axios.post(responseUrl, {
-      delete_original: true,
+  async notCommand(client, body) {
+    logger.error('Invalid first argument');
+    client.chat.postEphemeral({
+      channel: body.channel_id,
+      user: body.user_id,
+      text: 'No command',
+      blocks: [
+        markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
+      ],
     });
+    return;
   }
 
 }

+ 3 - 1
packages/app/src/server/util/createGrowiPagesFromImports.js

@@ -1,4 +1,6 @@
-const { isCreatablePage } = require('~/utils/path-utils');
+import { pagePathUtils } from '@growi/core';
+
+const { isCreatablePage } = pagePathUtils;
 
 module.exports = (crowi) => {
   const Page = crowi.model('Page');

+ 2 - 5
packages/app/tsconfig.base.json

@@ -1,9 +1,6 @@
 {
   "extends": "../../tsconfig.base.json",
   "compilerOptions": {
-  },
-  "exclude": [
-    "node_modules",
-    "**/*.test.ts"
-  ]
+    "jsx": "react"
+  }
 }

+ 0 - 1
packages/app/tsconfig.build.client.json

@@ -2,7 +2,6 @@
   "extends": "./tsconfig.base.json",
   "compilerOptions": {
     "module": "esnext",
-    "jsx": "react",
     "noFallthroughCasesInSwitch": true,
     "noUnusedLocals": true,
     "noUnusedParameters": true,

+ 1 - 0
packages/core/.eslintignore

@@ -0,0 +1 @@
+/dist/**

+ 1 - 0
packages/core/.gitignore

@@ -0,0 +1 @@
+/dist

+ 27 - 0
packages/core/README.md

@@ -0,0 +1,27 @@
+# growi-commons
+
+[![dependencies status](https://david-dm.org/weseek/growi-commons.svg)](https://david-dm.org/weseek/growi-commons)
+[![devDependencies Status](https://david-dm.org/weseek/growi-commons/dev-status.svg)](https://david-dm.org/weseek/growi-commons?type=dev)
+[![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
+
+[GROWI](https://growi.org) Commons Libraries to develop GROWI and plugins
+
+
+Overview
+--------
+
+growi-commons package is includes some functions, classes and modules to develop GROWI substance and GROWI plugins.
+
+Install
+--------
+
+1. install plugin
+
+    ```
+    $ npm install --save growi-commons
+    ```
+
+Documentation
+------------
+
+See https://docs.growi.org/api/commons/

+ 60 - 0
packages/core/jest.config.js

@@ -0,0 +1,60 @@
+// For a detailed explanation regarding each configuration property, visit:
+// https://jestjs.io/docs/en/configuration.html
+
+const MODULE_NAME_MAPPING = {
+  '^~/(.+)$': '<rootDir>/src/$1',
+};
+
+module.exports = {
+
+  preset: 'ts-jest/presets/js-with-ts',
+
+  moduleNameMapper: MODULE_NAME_MAPPING,
+
+  // Automatically clear mock calls and instances between every test
+  clearMocks: true,
+
+  // Indicates whether the coverage information should be collected while executing the test
+  collectCoverage: true,
+
+  // An array of glob patterns indicating a set of files for which coverage information should be collected
+  // collectCoverageFrom: undefined,
+
+  // The directory where Jest should output its coverage files
+  coverageDirectory: 'coverage',
+
+  // An array of regexp pattern strings used to skip coverage collection
+  coveragePathIgnorePatterns: [
+    '/node_modules/',
+  ],
+
+  // An object that configures minimum threshold enforcement for coverage results
+  // TODO: activate -- 2020.03.24 Yuki Takei
+  // coverageThreshold: {
+  //   global: {
+  //     branches: 70,
+  //     functions: 70,
+  //     lines: 70,
+  //     statements: 70,
+  //   },
+  // },
+
+  // An array of file extensions your modules use
+  moduleFileExtensions: [
+    'js',
+    'json',
+    'jsx',
+    'ts',
+    'tsx',
+    'node',
+  ],
+
+  // The test environment that will be used for testing
+  testEnvironment: 'node',
+
+  // The glob patterns Jest uses to detect test files
+  testMatch: [
+    '**/src/**/__tests__/**/*.[jt]s?(x)',
+    '**/src/**/?(*.)+(spec|test).[tj]s?(x)',
+  ],
+};

+ 24 - 0
packages/core/package.json

@@ -0,0 +1,24 @@
+{
+  "name": "@growi/core",
+  "version": "4.3.3-RC",
+  "description": "GROWI Core Libraries",
+  "license": "MIT",
+  "keywords": [
+    "growi"
+  ],
+  "main": "dist/cjs/index.js",
+  "module": "dist/esm/index.js",
+  "files": ["dist"],
+  "scripts": {
+    "build": "run-p build:*",
+    "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json",
+    "build:esm": "tsc -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.esm.json",
+    "lint:js": "eslint **/*.{js,ts}",
+    "lint:styles": "stylelint src/styles/scss/**/*.scss",
+    "lint": "npm-run-all -p lint:*",
+    "test": "jest --verbose"
+  },
+  "dependencies": {},
+  "devDependencies": {
+  }
+}

+ 21 - 0
packages/core/src/index.js

@@ -0,0 +1,21 @@
+import * as _pathUtils from './utils/path-utils';
+import * as _pagePathUtils from './utils/page-path-utils';
+import * as _templateChecker from './utils/template-checker';
+
+// module.exports = {
+//   BasicInterceptor: require('./utils/basic-interceptor'),
+//   envUtils: require('./utils/env-utils'),
+//   // plugin
+//   customTagUtils: require('./plugin/util/custom-tag-utils'),
+//   TagCacheManager: require('./plugin/service/tag-cache-manager'),
+//   // service
+//   LocalStorageManager: require('./service/localstorage-manager'),
+// };
+
+export * from './plugin/interfaces/plugin-definition-v4';
+export * from './models/devided-page-path';
+
+// export utils
+export const pathUtils = _pathUtils;
+export const pagePathUtils = _pagePathUtils;
+export const templateChecker = _templateChecker;

+ 2 - 4
packages/app/src/models/devided-page-path.js → packages/core/src/models/devided-page-path.js

@@ -1,11 +1,11 @@
-const { pathUtils } = require('growi-commons');
+import * as pathUtils from '../utils/path-utils';
 
 // https://regex101.com/r/BahpKX/2
 const PATTERN_INCLUDE_DATE = /^(.+\/[^/]+)\/(\d{4}|\d{4}\/\d{2}|\d{4}\/\d{2}\/\d{2})$/;
 // https://regex101.com/r/WVpPpY/1
 const PATTERN_DEFAULT = /^((.*)\/)?([^/]+)$/;
 
-class DevidedPagePath {
+export class DevidedPagePath {
 
   constructor(path, skipNormalize = false, evalDatePath = false) {
 
@@ -43,5 +43,3 @@ class DevidedPagePath {
   }
 
 }
-
-module.exports = DevidedPagePath;

+ 11 - 0
packages/core/src/plugin/interfaces/plugin-definition-v4.ts

@@ -0,0 +1,11 @@
+export type PluginMetaV4 = {
+  pluginSchemaVersion: number,
+  serverEntries: string[],
+  clientEntries: string[],
+};
+
+export type PluginDefinitionV4 = {
+  name: string,
+  meta: PluginMetaV4,
+  entries: string[],
+};

+ 14 - 0
packages/core/src/plugin/model/tag-context.js

@@ -0,0 +1,14 @@
+/**
+ * Context class for custom-tag-utils#findTagAndReplace
+ */
+class TagContext {
+
+  constructor(initArgs = {}) {
+    this.tagExpression = initArgs.tagExpression || null;
+    this.method = initArgs.method || null;
+    this.args = initArgs.args || null;
+  }
+
+}
+
+module.exports = TagContext;

+ 71 - 0
packages/core/src/plugin/service/tag-cache-manager.js

@@ -0,0 +1,71 @@
+const LocalStorageManager = require('../../service/localstorage-manager');
+
+/**
+ * Service Class for caching React state and TagContext
+ */
+class TagCacheManager {
+
+  /**
+   * @callback generateCacheKey
+   * @param {TagContext} tagContext - TagContext instance
+   * @returns {string} Cache key from TagContext
+   *
+   */
+
+  /**
+   * Constructor
+   * @param {string} cacheNs Used as LocalStorageManager namespace
+   * @param {generateCacheKey} generateCacheKey
+   */
+  constructor(cacheNs, generateCacheKey) {
+    if (cacheNs == null) {
+      throw new Error('args \'cacheNs\' is required.');
+    }
+    if (generateCacheKey == null) {
+      throw new Error('args \'generateCacheKey\' is required.');
+    }
+    if (typeof generateCacheKey !== 'function') {
+      throw new Error('args \'generateCacheKey\' should be function.');
+    }
+
+    this.cacheNs = cacheNs;
+    this.generateCacheKey = generateCacheKey;
+  }
+
+  /**
+   * Retrieve state cache object from local storage
+   * @param {TagContext} tagContext
+   * @returns {object} a cache object that correspont to the specified `tagContext`
+   */
+  getStateCache(tagContext) {
+    const localStorageManager = LocalStorageManager.getInstance();
+
+    const key = this.generateCacheKey(tagContext);
+    const stateCache = localStorageManager.retrieveFromSessionStorage(this.cacheNs, key);
+
+    return stateCache;
+  }
+
+  /**
+   * store state object of React Component with specified key
+   *
+   * @param {TagContext} tagContext
+   * @param {object} state state object of React Component
+   */
+  cacheState(tagContext, state) {
+    const localStorageManager = LocalStorageManager.getInstance();
+    const key = this.generateCacheKey(tagContext);
+    localStorageManager.saveToSessionStorage(this.cacheNs, key, state);
+  }
+
+  /**
+   * clear all state caches
+   */
+  clearAllStateCaches() {
+    const localStorageManager = LocalStorageManager.getInstance();
+    localStorageManager.clearAllStateCaches(this.cacheNs);
+  }
+
+}
+
+module.exports = TagCacheManager;

+ 59 - 0
packages/core/src/plugin/util/args-parser.js

@@ -0,0 +1,59 @@
+/**
+ * Arguments parser for custom tag
+ */
+class ArgsParser {
+
+  /**
+   * @typedef ParseArgsResult
+   * @property {string} firstArgsKey - key of the first argument
+   * @property {string} firstArgsValue - value of the first argument
+   * @property {object} options - key of the first argument
+   */
+
+  /**
+   * parse plugin argument strings
+   *
+   * @static
+   * @param {string} str
+   * @returns {ParseArgsResult}
+   */
+  static parse(str) {
+    let firstArgsKey = null;
+    let firstArgsValue = null;
+    const options = {};
+
+    if (str != null && str.length > 0) {
+      const splittedArgs = str.split(',');
+
+      splittedArgs.forEach((rawArg, index) => {
+        const arg = rawArg.trim();
+
+        // parse string like 'key1=value1, key2=value2, ...'
+        // see https://regex101.com/r/pYHcOM/1
+        const match = arg.match(/([^=]+)=?(.+)?/);
+
+        if (match == null) {
+          return;
+        }
+
+        const key = match[1];
+        const value = match[2] || true;
+        options[key] = value;
+
+        if (index === 0) {
+          firstArgsKey = key;
+          firstArgsValue = value;
+        }
+      });
+    }
+
+    return {
+      firstArgsKey,
+      firstArgsValue,
+      options,
+    };
+  }
+
+}
+
+module.exports = ArgsParser;

+ 88 - 0
packages/core/src/plugin/util/custom-tag-utils.js

@@ -0,0 +1,88 @@
+const TagContext = require('../model/tag-context');
+
+/**
+ * @private
+ *
+ * create random strings
+ * @see http://qiita.com/ryounagaoka/items/4736c225bdd86a74d59c
+ *
+ * @param {number} length
+ * @return {string} random strings
+ */
+function createRandomStr(length) {
+  const bag = 'abcdefghijklmnopqrstuvwxyz0123456789';
+  let generated = '';
+  for (let i = 0; i < length; i++) {
+    generated += bag[Math.floor(Math.random() * bag.length)];
+  }
+  return generated;
+}
+
+/**
+ * @typedef FindTagAndReplaceResult
+ * @property {string} html - HTML string
+ * @property {Object} tagContextMap - Object.<string, [TagContext]{@link ../model/tag-context.html#TagContext}>
+ *
+ * @memberof customTagUtils
+ */
+/**
+ * @param {RegExp} tagPattern
+ * @param {string} html
+ * @param {function} replace replace function
+ * @return {FindTagAndReplaceResult}
+ *
+ * @memberof customTagUtils
+ */
+function findTagAndReplace(tagPattern, html, replace) {
+  let replacedHtml = html;
+  const tagContextMap = {};
+
+  if (tagPattern == null || html == null) {
+    return { html: replacedHtml, tagContextMap };
+  }
+
+  // see: https://regex101.com/r/NQq3s9/9
+  const pattern = new RegExp(`\\$(${tagPattern.source})\\((.*?)\\)(?=[<\\[\\s\\$])|\\$(${tagPattern.source})\\((.*)\\)(?![<\\[\\s\\$])`, 'g');
+
+  replacedHtml = html.replace(pattern, (all, group1, group2, group3, group4) => {
+    const tagExpression = all;
+    const method = (group1 || group3).trim();
+    const args = (group2 || group4 || '').trim();
+
+    // create contexts
+    const tagContext = new TagContext({ tagExpression, method, args });
+
+    if (replace != null) {
+      return replace(tagContext);
+    }
+
+    // replace with empty dom
+    const domId = `${method}-${createRandomStr(8)}`;
+    tagContextMap[domId] = tagContext;
+    return `<div id="${domId}"></div>`;
+  });
+
+  return { html: replacedHtml, tagContextMap };
+}
+
+/**
+ * @namespace customTagUtils
+ */
+module.exports = {
+  findTagAndReplace,
+  /**
+   * Context class used by findTagAndReplace
+   * @memberof customTagUtils
+   */
+  TagContext,
+  /**
+   * [ArgsParser]{@link ./args-parser#ArgsParser}
+   * @memberof customTagUtils
+   */
+  ArgsParser: require('./args-parser'),
+  /**
+   * [OptionParser]{@link ./option-parser#OptionParser}
+   * @memberof customTagUtils
+   */
+  OptionParser: require('./option-parser'),
+};

+ 70 - 0
packages/core/src/plugin/util/option-parser.js

@@ -0,0 +1,70 @@
+/**
+ * Options parser for custom tag
+ */
+class OptionParser {
+
+  /**
+   * @typedef ParseRangeResult
+   * @property {number} start - start index
+   * @property {number} end - end index
+   */
+
+  /**
+   * Parse range expression
+   *
+   * <ul>
+   *  <li>ex:</li>
+   *  <ul>
+   *    <li>1:2 -> { start: 1, end: 2 }</li>
+   *    <li>1:  -> { start: 1, end: -1 }</li>
+   *    <li>2+3 -> { start: 1, end: 5 }</li>
+   *  </ul>
+   * </ul>
+   *
+   * @see https://regex101.com/r/w4KCwC/4
+   *
+   * @static
+   * @param {string} str
+   * @returns {ParseRangeResult}
+   */
+  static parseRange(str) {
+    if (str == null) {
+      return null;
+    }
+
+    // see: https://regex101.com/r/w4KCwC/4
+    const match = str.match(/^(-?[0-9]+)(([:+]{1})(-?[0-9]+)?)?$/);
+    if (!match) {
+      return null;
+    }
+
+    // determine start
+    let start;
+    let end;
+
+    // has operator
+    if (match[3] != null) {
+      start = +match[1];
+      const operator = match[3];
+
+      // determine end
+      if (operator === ':') {
+        end = +match[4] || -1; // set last(-1) if undefined
+      }
+      else if (operator === '+') {
+        end = +match[4] || 0; // plus zero if undefined
+        end += start;
+      }
+    }
+    // don't have operator
+    else {
+      start = 1;
+      end = +match[1];
+    }
+
+    return { start, end };
+  }
+
+}
+
+module.exports = OptionParser;

Некоторые файлы не были показаны из-за большого количества измененных файлов