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

Merge branch 'master' into imprv/reactify-admin

# Conflicts:
#	resource/locales/en-US/translation.json
#	resource/locales/ja/translation.json
#	src/client/js/app.jsx
#	src/server/routes/admin.js
#	src/server/routes/apiv3/index.js
#	src/server/routes/index.js
#	src/server/util/middlewares.js
#	src/server/views/admin/importer.html
#	src/server/views/admin/users.html
#	yarn.lock
Yuki Takei 6 лет назад
Родитель
Сommit
4e188484a6
100 измененных файлов с 3160 добавлено и 1154 удалено
  1. 1 0
      .gitignore
  2. 1 0
      .prettierignore
  3. 90 1
      CHANGES.md
  4. 10 7
      README.md
  5. 11 0
      THIRD-PARTY-NOTICES.md
  6. 1 1
      bin/wercker/trigger-growi-docker.sh
  7. 28 0
      bin/wercker/trigger-growi-docs.sh
  8. 1 0
      config/env.dev.js
  9. 2 0
      config/jest.config.js
  10. 1 1
      config/logger/config.dev.js
  11. 5 1
      config/swagger-definition.js
  12. 34 25
      package.json
  13. 14 14
      resource/cdn-manifests.js
  14. 55 13
      resource/locales/en-US/translation.json
  15. 50 8
      resource/locales/ja/translation.json
  16. 34 0
      src/client/js/app.jsx
  17. 138 0
      src/client/js/components/Admin/Export/ExportPage.jsx
  18. 52 0
      src/client/js/components/Admin/Export/ExportTableMenu.jsx
  19. 162 0
      src/client/js/components/Admin/Export/ExportZipFormModal.jsx
  20. 67 0
      src/client/js/components/Admin/Export/ZipFileTable.jsx
  21. 181 0
      src/client/js/components/Admin/Import/GrowiZipImportForm.jsx
  22. 119 0
      src/client/js/components/Admin/Import/GrowiZipImportSection.jsx
  23. 93 0
      src/client/js/components/Admin/Import/GrowiZipUploadForm.jsx
  24. 1 1
      src/client/js/components/BookmarkButton.jsx
  25. 2 2
      src/client/js/components/InstallerForm.jsx
  26. 1 1
      src/client/js/components/LikeButton.jsx
  27. 2 1
      src/client/js/components/Page.jsx
  28. 5 2
      src/client/js/components/Page/RevisionLoader.jsx
  29. 36 4
      src/client/js/components/Page/RevisionPath.jsx
  30. 1 1
      src/client/js/components/PageAttachment.jsx
  31. 8 4
      src/client/js/components/PageAttachment/Attachment.jsx
  32. 12 1
      src/client/js/components/PageAttachment/PageAttachmentList.jsx
  33. 156 84
      src/client/js/components/PageComment/Comment.jsx
  34. 118 123
      src/client/js/components/PageComment/CommentEditor.jsx
  35. 33 40
      src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx
  36. 2 2
      src/client/js/components/PageComment/DeleteCommentModal.jsx
  37. 64 83
      src/client/js/components/PageComments.jsx
  38. 7 7
      src/client/js/components/PageEditor/Cheatsheet.jsx
  39. 37 38
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  40. 33 2
      src/client/js/components/PageEditor/Editor.jsx
  41. 173 96
      src/client/js/components/PageEditorByHackmd.jsx
  42. 17 6
      src/client/js/components/PageEditorByHackmd/HackmdEditor.jsx
  43. 74 48
      src/client/js/components/PageHistory.jsx
  44. 136 0
      src/client/js/components/PageTimeline.jsx
  45. 1 0
      src/client/js/components/StaffCredit/Contributor.js
  46. 123 0
      src/client/js/components/TableOfContents.jsx
  47. 4 3
      src/client/js/components/User/UserDate.jsx
  48. 0 111
      src/client/js/legacy/crowi.js
  49. 18 18
      src/client/js/services/AppContainer.js
  50. 23 0
      src/client/js/services/CommentContainer.js
  51. 10 3
      src/client/js/services/PageContainer.js
  52. 3 1
      src/client/js/services/TagContainer.js
  53. 2 2
      src/client/js/util/GrowiRenderer.js
  54. 1 1
      src/client/js/util/markdown-it/plantuml.js
  55. 4 4
      src/client/js/util/markdown-it/toc-and-anchor.js
  56. 2 2
      src/client/styles/bootstrap4/_mixins.scss
  57. 3 3
      src/client/styles/bootstrap4/_utilities.scss
  58. 70 70
      src/client/styles/bootstrap4/_variables.scss
  59. 1 1
      src/client/styles/bootstrap4/bootstrap.scss
  60. 2 2
      src/client/styles/hackmd/style.scss
  61. 19 22
      src/client/styles/scss/_comment.scss
  62. 5 0
      src/client/styles/scss/_comment_crowi.scss
  63. 28 11
      src/client/styles/scss/_comment_growi.scss
  64. 63 22
      src/client/styles/scss/_comment_kibela.scss
  65. 1 1
      src/client/styles/scss/_editor-overlay.scss
  66. 1 1
      src/client/styles/scss/_layout.scss
  67. 10 8
      src/client/styles/scss/_login.scss
  68. 1 0
      src/client/styles/scss/_mixins.scss
  69. 12 14
      src/client/styles/scss/_on-edit.scss
  70. 2 0
      src/client/styles/scss/_page.scss
  71. 67 52
      src/client/styles/scss/_page_growi.scss
  72. 1 1
      src/client/styles/scss/_search.scss
  73. 1 0
      src/client/styles/scss/style-app.scss
  74. 3 0
      src/client/styles/scss/theme/_override-agileadmin.scss
  75. 6 6
      src/lib/service/cdn-resources-service.js
  76. 0 15
      src/server/crowi/dev.js
  77. 64 31
      src/server/crowi/index.js
  78. 1 0
      src/server/form/admin/aws.js
  79. 0 4
      src/server/form/admin/securityGeneral.js
  80. 1 2
      src/server/form/admin/securityPassportBasic.js
  81. 11 0
      src/server/form/admin/securityPassportLocal.js
  82. 1 0
      src/server/form/index.js
  83. 27 0
      src/server/middleware/access-token-parser.js
  84. 24 0
      src/server/middleware/admin-required.js
  85. 27 0
      src/server/middleware/csrf.js
  86. 49 0
      src/server/middleware/login-required.js
  87. 20 0
      src/server/models/GlobalNotificationSetting.js
  88. 8 3
      src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js
  89. 8 3
      src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js
  90. 2 2
      src/server/models/GlobalNotificationSetting/index.js
  91. 6 21
      src/server/models/attachment.js
  92. 12 1
      src/server/models/comment.js
  93. 3 0
      src/server/models/config.js
  94. 34 21
      src/server/models/page-tag-relation.js
  95. 19 19
      src/server/models/page.js
  96. 17 1
      src/server/models/tag.js
  97. 27 1
      src/server/plugins/plugin.service.js
  98. 99 52
      src/server/routes/admin.js
  99. 145 0
      src/server/routes/apiv3/export.js
  100. 0 2
      src/server/routes/apiv3/healthcheck.js

+ 1 - 0
.gitignore

@@ -19,6 +19,7 @@ package-lock.json
 # Dist #
 /report/
 /public/*.chunk.js
+/public/*.chunk.js.LICENSE
 /public/*.bundle.js
 /public/manifest.json
 /public/dll

+ 1 - 0
.prettierignore

@@ -1 +1,2 @@
+src/client/styles/bootstrap4/
 src/client/styles/scss/_override-bootstrap-variables.scss

+ 90 - 1
CHANGES.md

@@ -1,8 +1,97 @@
 # CHANGES
 
-## 3.5.5-RC
+## 3.5.16-RC
 
+* Fix: Full Text Search doesn't work after when building indices
+    * Introduced by 3.5.12
+
+## 3.5.15
+
+* Feature: Import/Export Page data
+* Fix: The link to Sandbox on Markdown Help Modal doesn't work
+* Support: Upgrade libs
+    * codemirror
+
+## 3.5.14 (Missing number)
+
+## 3.5.13
+
+* Feature: Re-edit comments
+* Support: [growi-plugin-attachment-refs](https://github.com/weseek/growi-plugin-attachment-refs)
+* Support: Upgrade libs
+    * entities
+    * markdown-it
+
+## 3.5.12
+
+* Improvement: Use Elasticsearch Alias
+* Improvement: Connect to HTTPS PlantUML URL in default
+* Fix: Global Notification doesn't work after updating Webhook URL
+* Fix: User Trigger Notification is not be sent when channel is not specified
+* Support: Upgrade libs
+    * terser-webpack-plugin
+
+## 3.5.11
+
+* Fix: HackMD Editor shows 404 error when HackMD redirect to fqdn URI
+    * Introduced by 3.5.8
+* Fix: Timeline doesn't work
+    * Introduced by 3.5.1
+* Fix: Last Login field does not shown in /admin/user
+* Support: Upgrade libs
+    * env-cmd
+    * sass-loader
+    * webpack
+    * webpack-cli
+    * webpack-merge
+
+## 3.5.10
+
+* Feature: Send Global Notification with Slack
+* Improvement: Show loading spinner when fetching page history data
+* Improvement: Hierarchical page link when the page is in /Trash
+* Fix: Code Highlight Theme does not change
+    * Introduced by 3.5.2
+* Support: Upgrade libs
+    * date-fns
+    * eslint-config-weseek
+
+## 3.5.9
+
+* Fix: Editing table with Spreadsheet like GUI (Handsontable) is failed
+* Fix: Plugins are not initialized when first launching
+    * Introduced by 3.5.0
+* Support: Upgrade libs
+    * entities
+    * growi-commons
+    * openid-client
+    * rimraf
+    * style-loader
+
+## 3.5.8
+
+* Improvement: Controls when HackMD/CodiMD has unsaved draft
+* Improvement: Show hints if HackMD/CodiMD integration is not working
+* Improvement: GROWI server obtains HackMD/CodiMD page id from the 302 response header
+* Improvement: Comment Thread Layout
+* Improvement: Show commented date with date distance format
+
+## 3.5.7 (Missing number)
+
+## 3.5.6
+
+* Fix: Saving new page is failed when empty string tag is set
+* Fix: Link of Create template page button in New Page Modal is broken
+* Fix: Global Notification dows not work when creating/moving/deleting/like/comment
+
+## 3.5.5
+
+* Feature: Support S3-compatible object storage (e.g. MinIO)
+* Feature: Enable/Disable ID/Password Authentication
+* Improvement: Login Mechanism with HTTP Basic Authentication header
+* Improvement: Reactify Table Of Contents
 * Fix: Profile images are broken in User Management
+* Fix: Template page under root page doesn't work
 * Support: Upgrade libs
     * csv-to-markdown-table
     * express-validator

+ 10 - 7
README.md

@@ -185,13 +185,10 @@ Environment Variables
     * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
 * **Option (Overwritable in admin page)**
     * APP_SITE_URL: Site URL. e.g. `https://example.com`, `https://example.com:8080`
-    * OAUTH_GOOGLE_CLIENT_ID: Google API client id for OAuth login.
-    * OAUTH_GOOGLE_CLIENT_SECRET: Google API client secret for OAuth login.
-    * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login.
-    * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login.
-    * OAUTH_TWITTER_CONSUMER_KEY: Twitter consumer key(API key) for OAuth login.
-    * OAUTH_TWITTER_CONSUMER_SECRET: Twitter consumer secret(API secret) for OAuth login.
+    * LOCAL_STRATEGY_ENABLED: Enable or disable ID/Pass login
+    * LOCAL_STRATEGY_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: Prioritize env vars than values in DB for some ID/Pass login options
     * SAML_ENABLED: Enable or disable SAML
+    * SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: Prioritize env vars than values in DB for some SAML options
     * SAML_ENTRY_POINT: IdP entry point
     * SAML_ISSUER: Issuer string to supply to IdP
     * SAML_ATTR_MAPPING_ID: Attribute map for id
@@ -200,6 +197,12 @@ Environment Variables
     * SAML_ATTR_MAPPING_FIRST_NAME: Attribute map for first name
     * SAML_ATTR_MAPPING_LAST_NAME:  Attribute map for last name
     * SAML_CERT: PEM-encoded X.509 signing certificate string to validate the response from IdP
+    * OAUTH_GOOGLE_CLIENT_ID: Google API client id for OAuth login.
+    * OAUTH_GOOGLE_CLIENT_SECRET: Google API client secret for OAuth login.
+    * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login.
+    * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login.
+    * OAUTH_TWITTER_CONSUMER_KEY: Twitter consumer key(API key) for OAuth login.
+    * OAUTH_TWITTER_CONSUMER_SECRET: Twitter consumer secret(API secret) for OAuth login.
 
 
 Documentation
@@ -255,7 +258,7 @@ License
 =======
 
 * The MIT License (MIT)
-* See LICENSE file.
+* See [LICENSE](https://github.com/weseek/growi/blob/master/LICENSE) and [THIRD-PARTY-NOTICES.md](https://github.com/weseek/growi/blob/master/THIRD-PARTY-NOTICES.md).
 
 
 [crowi]: https://github.com/crowi/crowi

+ 11 - 0
THIRD-PARTY-NOTICES.md

@@ -16,6 +16,7 @@ https://github.com/weseek/growi.
 2. crowi/crowi (https://github.com/crowi/crowi)
 3. Microsoft/vscode (https://github.com/Microsoft/vscode)
 4. stephenhutchings/typicons.font (https://github.com/stephenhutchings/typicons.font)
+5. EmojiOne Version 3 (https://github.com/joypixels/emojione/tree/v3.1.1)
 
 
 License Notice for Apache License, Version 2.0 Derivative Works
@@ -97,3 +98,13 @@ https://creativecommons.org/licenses/by-sa/3.0/
 ```
 Copyright (c) 2018 Stephen Hutchings
 ```
+
+
+License Notice for EmojiOne
+------------------------
+
+https://creativecommons.org/licenses/by/4.0/
+
+```
+author: "EmojiOne <ryan@emojione.com> (http://emojione.com)"
+```

+ 1 - 1
bin/wercker/trigger-growi-docker.sh

@@ -9,7 +9,7 @@
 #   - $WERCKER_TOKEN
 #   - $GROWI_DOCKER_PIPELINE_ID
 #   - $RELEASE_VERSION
-#   - $WERCKER_GIT_COMMIT
+#   - $RELEASE_GIT_COMMIT
 #
 RESPONSE=`curl -X POST \
   -H "Content-Type: application/json" \

+ 28 - 0
bin/wercker/trigger-growi-docs.sh

@@ -0,0 +1,28 @@
+#!/bin/sh
+
+# Trigger a new run
+# see: http://devcenter.wercker.com/docs/api/endpoints/runs#trigger-a-run
+
+# exec curl
+#
+# require
+#   - $WERCKER_TOKEN
+#   - $GROWI_DOCS_PIPELINE_ID
+#
+RESPONSE=`curl -X POST \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer $WERCKER_TOKEN" \
+  https://app.wercker.com/api/v3/runs -d '{ \
+    "pipelineId": "'$GROWI_DOCS_PIPELINE_ID'", \
+    "branch": "master"
+  }' \
+`
+
+echo $RESPONSE | jq .
+
+# get wercker run id
+RUN_ID=`echo $RESPONSE | jq .id`
+# exit with failure status
+if [ "$RUN_ID" = "null" ]; then
+  exit 1
+fi

+ 1 - 0
config/env.dev.js

@@ -9,6 +9,7 @@ module.exports = {
   PLUGIN_NAMES_TOBE_LOADED: [
     // 'growi-plugin-lsx',
     // 'growi-plugin-pukiwiki-like-linker',
+    // 'growi-plugin-attachment-refs',
   ],
   // PUBLISH_OPEN_API: true,
   // USER_UPPER_LIMIT: 0,

+ 2 - 0
config/jest.config.js

@@ -6,6 +6,8 @@ module.exports = {
   verbose: true,
 
   rootDir: '../',
+  globalSetup: '<rootDir>/src/test/global-setup.js',
+  globalTeardown: '<rootDir>/src/test/global-teardown.js',
 
   projects: [
     {

+ 1 - 1
config/logger/config.dev.js

@@ -20,7 +20,7 @@ module.exports = {
   // 'growi:service:GlobalNotification': 'debug',
   // 'growi:lib:importer': 'debug',
   // 'growi:routes:page': 'debug',
-  // 'growi-plugin:*': 'debug',
+  'growi-plugin:*': 'debug',
   // 'growi:InterceptorManager': 'debug',
 
   // email

+ 5 - 1
config/swagger-definition.js

@@ -6,5 +6,9 @@ module.exports = {
     title: 'GROWI REST API v3',
     version: pkg.version,
   },
-  basePath: '/api/v3/',
+  servers: [
+    {
+      url: 'https://demo.growi.org/_api/v3/',
+    },
+  ],
 };

+ 34 - 25
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.5.5-RC",
+  "version": "3.5.16-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -20,6 +20,7 @@
     "url": "https://github.com/weseek/growi/issues"
   },
   "scripts": {
+    "build:apiv3:jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js src/server/routes/apiv3/**/*.js",
     "build:dev:app:watch": "npm run build:dev:app -- --watch",
     "build:dev:app": "env-cmd -f config/env.dev.js webpack --config config/webpack.dev.js --progress",
     "build:dev:dll": "webpack --config config/webpack.dev.dll.js",
@@ -36,7 +37,8 @@
     "lint:js": "eslint \"**/*.{js,jsx}\"",
     "lint:styles:fix": "prettier-stylelint --quiet --write src/client/styles/scss/**/*.scss",
     "lint:styles": "stylelint src/client/styles/scss/**/*.scss",
-    "lint": "npm-run-all -p lint:js lint:styles",
+    "lint:swagger2openapi": "node node_modules/swagger2openapi/oas-validate tmp/swagger.json",
+    "lint": "npm-run-all -p lint:js lint:styles lint:swagger2openapi",
     "migrate": "npm run migrate:up",
     "migrate:create": "migrate-mongo create -f config/migrate.js -- ",
     "migrate:status": "migrate-mongo status -f config/migrate.js",
@@ -46,6 +48,7 @@
     "prebuild:dev:watch": "npm run prebuild:dev",
     "prebuild:dev": "npm run clean:app && env-cmd -f config/env.dev.js npm run plugin:def && env-cmd -f config/env.dev.js npm run resource",
     "prebuild:prod": "npm run clean && env-cmd -f config/env.prod.js npm run plugin:def && env-cmd -f config/env.prod.js npm run resource",
+    "prelint:swagger2openapi": "npm run build:apiv3:jsdoc",
     "preserver:prod": "npm run migrate",
     "prestart": "npm run build:prod",
     "resource": "node bin/download-cdn-resources.js",
@@ -62,13 +65,14 @@
   "dependencies": {
     "//": [
       "check-node-version: see https://github.com/parshap/check-node-version/issues/35",
-      "entities: markdown-it@9.0.1 depends on entities@~1.1.1",
-      "mongoose: somehow GlobalNotificationSetting CRUD does not work with mongoose v5.6.0"
+      "mongoose: somehow GlobalNotificationSetting CRUD does not work with mongoose v5.6.0",
+      "openid-client: Node.js 12 or higher is required for openid-client@3 and above."
     ],
+    "JSONStream": "^1.3.5",
+    "archiver": "^3.1.1",
     "async": "^3.0.1",
     "aws-sdk": "^2.88.0",
     "axios": "^0.19.0",
-    "basic-auth-connect": "~1.0.0",
     "body-parser": "^1.18.2",
     "bunyan": "^1.8.12",
     "bunyan-format": "^0.2.1",
@@ -79,10 +83,11 @@
     "cookie-parser": "^1.4.3",
     "cross-env": "^5.0.5",
     "csrf": "^3.1.0",
+    "date-fns": "^2.0.0",
     "diff": "^4.0.1",
     "elasticsearch": "^16.0.0",
-    "entities": "^1.1.1",
-    "env-cmd": "^9.0.1",
+    "entities": "^2.0.0",
+    "env-cmd": "^10.0.1",
     "esa-nodejs": "^0.0.7",
     "escape-string-regexp": "^2.0.0",
     "express": "^4.16.1",
@@ -93,7 +98,7 @@
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
-    "growi-commons": "^4.0.3",
+    "growi-commons": "^4.0.7",
     "helmet": "^3.13.0",
     "i18next": "^17.0.3",
     "i18next-express-middleware": "^1.4.1",
@@ -113,7 +118,7 @@
     "nodemailer": "^6.0.0",
     "nodemailer-ses-transport": "~1.5.0",
     "npm-run-all": "^4.1.2",
-    "openid-client": "^2.5.0",
+    "openid-client": "=2.5.0",
     "passport": "^0.4.0",
     "passport-github": "^1.1.0",
     "passport-google-auth": "^1.0.2",
@@ -122,24 +127,29 @@
     "passport-local": "^1.0.0",
     "passport-saml": "^1.0.0",
     "passport-twitter": "^1.0.4",
-    "rimraf": "^2.6.1",
+    "rimraf": "^3.0.0",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",
     "stream-to-promise": "^2.2.0",
     "string-width": "^4.1.0",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
+    "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "validator": "^11.1.0",
     "xss": "^1.0.6"
   },
   "devDependencies": {
+    "//": [
+      "@handsontable/react: v3 requires handsontable >= 7.0.0.",
+      "handsontable: v7.0.0 or above is no loger MIT lisence."
+    ],
     "@alienfast/i18next-loader": "^1.0.16",
     "@babel/core": "^7.4.5",
     "@babel/polyfill": "^7.4.4",
     "@babel/preset-env": "^7.4.5",
     "@babel/preset-react": "^7.0.0",
-    "@handsontable/react": "^2.0.0",
+    "@handsontable/react": "=2.1.0",
     "autoprefixer": "^9.0.0",
     "babel-eslint": "^10.0.1",
     "babel-loader": "^8.0.6",
@@ -150,23 +160,21 @@
     "browser-sync": "^2.26.3",
     "bunyan-debug": "^2.0.0",
     "cli": "~1.0.1",
-    "codemirror": "^5.42.0",
+    "codemirror": "^5.48.4",
     "colors": "^1.2.5",
-    "commander": "^2.11.0",
     "connect-browser-sync": "^2.1.0",
     "core-js": "=2.6.9",
     "css-loader": "^3.0.0",
     "csv-to-markdown-table": "^1.0.1",
-    "date-fns": "^1.29.0",
     "diff2html": "^2.3.3",
     "eazy-logger": "^3.0.2",
     "eslint": "^6.0.1",
-    "eslint-config-weseek": "^1.0.2",
+    "eslint-config-weseek": "^1.0.3",
     "eslint-plugin-import": "^2.18.0",
     "eslint-plugin-jest": "^22.7.1",
     "eslint-plugin-react": "^7.14.2",
     "file-loader": "^4.0.0",
-    "handsontable": "^6.0.1",
+    "handsontable": "=6.2.2",
     "i18next-browser-languagedetector": "^3.0.1",
     "imports-loader": "^0.8.0",
     "jest": "^24.8.0",
@@ -176,7 +184,7 @@
     "jquery.cookie": "~1.4.1",
     "load-css-file": "^1.0.0",
     "lodash-webpack-plugin": "^0.11.5",
-    "markdown-it": "^9.0.1",
+    "markdown-it": "^10.0.0",
     "markdown-it-blockdiag": "^1.0.2",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-footnote": "^3.0.1",
@@ -190,7 +198,7 @@
     "mini-css-extract-plugin": "^0.8.0",
     "morgan": "^1.9.0",
     "node-dev": "^4.0.0",
-    "node-sass": "^4.11.0",
+    "node-sass": "^4.12.0",
     "normalize-path": "^3.0.0",
     "null-loader": "^3.0.0",
     "on-headers": "^1.0.1",
@@ -212,21 +220,22 @@
     "react-waypoint": "^9.0.0",
     "replacestream": "^4.0.3",
     "reveal.js": "^3.5.0",
-    "sass-loader": "^7.1.0",
+    "sass-loader": "^8.0.0",
     "simple-load-script": "^1.0.2",
     "socket.io-client": "^2.0.3",
-    "style-loader": "^0.23.0",
+    "style-loader": "^1.0.0",
     "stylelint-config-recess-order": "^2.0.1",
-    "swagger-jsdoc": "^3.2.9",
-    "terser-webpack-plugin": "^1.2.2",
+    "swagger-jsdoc": "^3.4.0",
+    "swagger2openapi": "^5.3.1",
+    "terser-webpack-plugin": "^2.0.1",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
     "unstated": "^2.1.1",
-    "webpack": "^4.29.3",
+    "webpack": "^4.39.3",
     "webpack-assets-manifest": "^3.1.1",
     "webpack-bundle-analyzer": "^3.0.2",
-    "webpack-cli": "^3.2.3",
-    "webpack-merge": "^4.2.1"
+    "webpack-cli": "^3.3.7",
+    "webpack-merge": "^4.2.2"
   },
   "_moduleAliases": {
     "@root": ".",

+ 14 - 14
resource/cdn-manifests.js

@@ -2,7 +2,7 @@ module.exports = {
   js: [
     {
       name: 'basis',
-      url: 'https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.3.1,npm/bootstrap@3.4.1/dist/js/bootstrap.min.js',
+      url: 'https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.4.0,npm/bootstrap@3.4.1/dist/js/bootstrap.min.js',
       groups: ['basis'],
       args: {
         integrity: '',
@@ -46,28 +46,28 @@ module.exports = {
     },
     {
       name: 'codemirror-dialog',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/addon/dialog/dialog.min.js',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/addon/dialog/dialog.min.js',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-keymap-vim',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/keymap/vim.min.js',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/keymap/vim.min.js',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-keymap-emacs',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/keymap/emacs.min.js',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/keymap/emacs.min.js',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-keymap-sublime',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/keymap/sublime.min.js',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/keymap/sublime.min.js',
       args: {
         integrity: '',
       },
@@ -145,63 +145,63 @@ module.exports = {
     },
     {
       name: 'codemirror-dialog',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/addon/dialog/dialog.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/addon/dialog/dialog.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-eclipse',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/eclipse.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/eclipse.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-elegant',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/elegant.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/elegant.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-neo',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/neo.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/neo.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-mdn-like',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/mdn-like.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/mdn-like.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-material',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/material.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/material.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-dracula',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/dracula.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/dracula.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-monokai',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/monokai.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/monokai.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-twilight',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/twilight.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/twilight.min.css',
       args: {
         integrity: '',
       },

+ 55 - 13
resource/locales/en-US/translation.json

@@ -8,6 +8,7 @@
   "Click to copy": "Click to copy",
   "Move/Rename": "Move/Rename",
   "Moved": "Moved",
+  "Redirected": "Redirected",
   "Unlinked": "Unlinked",
   "Like!": "Like!",
   "Seen by": "Seen by",
@@ -112,6 +113,7 @@
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
+  "Export Data": "Export Data",
   "Basic Settings": "Basic Settings",
   "Basic authentication": "Basic authentication",
   "Register limitation": "Register limitation",
@@ -243,13 +245,14 @@
   },
 
   "page_page": {
-      "notice": {
-          "version": "This is not the current version.",
-          "moved": "This page was moved from <code>%s</code>",
-          "duplicated": "This page was duplicated from <code>%s</code>",
-          "unlinked": "Redirect pages to this page have been deleted.",
-          "restricted": "Access to this page is restricted"
-      }
+    "notice": {
+      "version": "This is not the current version.",
+      "moved": "This page was moved from <code>%s</code>",
+      "redirected": "You are redirected from <code>%s</code>",
+      "duplicated": "This page was duplicated from <code>%s</code>",
+      "unlinked": "Redirect pages to this page have been deleted.",
+      "restricted": "Access to this page is restricted"
+    }
   },
 
   "page_edit": {
@@ -340,7 +343,7 @@
   "template": {
     "modal_label": {
       "Create/Edit Template Page": "Create/Edit Template Page",
-      "Create template under": "Create template page under: <code>%s</code>"
+      "Create template under": "Create template page under:<br /><code><small>%s</small></code>"
     },
     "option_label": {
       "create/edit": "Create/Edit Template page..",
@@ -428,6 +431,8 @@
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
     "region": "Region",
     "bucket name": "Bucket name",
+    "custom endpoint": "Custom endpoint",
+    "custom_endpoint_change": "Input the URL of the endpoint of an object storage service like MinIO that has a S3-compatible API.  Amazon S3 is used if empty.",
     "Plugin settings": "Plugin settings",
     "Enable plugin loading": "Enable plugin loading",
     "Load plugins": "Load plugins",
@@ -475,7 +480,7 @@
       "readonly": "Accept (Guests can read only)"
     },
     "registration_mode": {
-      "open": "Open (Anyone can registre)",
+      "open": "Open (Anyone can register)",
       "restricted": "Restricted (Requires approval by administrators)",
       "closed": "Closed (Invitation Only)"
     },
@@ -488,6 +493,9 @@
     "Use env var if empty": "Use env var <code>%s</code> if empty",
     "Use default if both are empty": "If both ​​are empty, the default value <code>%s</code> is used.",
     "missing mandatory configs": "The following mandatory items are not set in either database nor environment variables.",
+    "Local": {
+      "name": "ID/Password"
+    },
     "ldap": {
       "server_url_detail": "The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.",
       "bind_mode": "Binding Mode",
@@ -528,6 +536,11 @@
       "Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>%s</code> is used.",
       "note for the only env option": "The setting item that enables or disables the SAML authentication and the highlighted setting items use only the value of environment variables.<br>To change this setting, please change to false or delete the value of the environment variable <code>%s</code> ."
     },
+    "Basic": {
+      "name": "Basic Authentication",
+      "desc_1": "Login with <code>username</code> in Authorization header.",
+      "desc_2": "User will be automatically generated if not exist."
+    },
     "OAuth": {
       "register": "Register for %s",
       "change_redirect_url": "Enter <code>%s</code> <br>(where <code>%s</code> is your host name) for \"Authorized redirect URIs\".",
@@ -536,7 +549,7 @@
         "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
         "register_2": "Create Project if no projects exist",
         "register_3": "Create Credentials &rightarrow; OAuth client ID &rightarrow; Select \"Web application\"",
-        "register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>%s</code> (where <code>%s</code> is your hostname)",
+        "register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>%s</code>",
         "register_5": "Copy and paste your ClientID and Client Secret above"
       },
       "Facebook": {
@@ -547,13 +560,13 @@
         "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
         "register_2": "Sign in Twitter",
         "register_3": "Create Credentials &rightarrow; OAuth client ID &rightarrow; Select \"Web application\"",
-        "register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>%s</code> (where <code>%s</code> is your hostname)",
+        "register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>%s</code>",
         "register_5": "Copy and paste your ClientID and Client Secret above"
       },
       "GitHub": {
         "name": "GitHub OAuth",
         "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
-        "register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>%s</code> (where <code>%s</code> is your hostname)",
+        "register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>%s</code>",
         "register_3": "Copy and paste your ClientID and Client Secret above"
       },
       "OIDC": {
@@ -563,7 +576,7 @@
         "name_detail": "Specification of mappings for <code>name</code> when creating new users",
         "mapping_detail": "Specification of mappings for %s when creating new users",
         "register_1": "Contant to OIDC IdP Administrator",
-        "register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code> (where <code>%s</code> is your hostname)",
+        "register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
         "register_3": "Copy and paste your ClientID and Client Secret above"
       },
       "how_to": {
@@ -730,6 +743,18 @@
   "importer_management": {
     "import_form_esa": "Import from esa.io",
     "import_form_qiita": "import_from Qiita:Team",
+    "beta_warning": "This function is Beta.",
+    "import_from": "Import from %s",
+    "import_form_growi": "Import from GROWI",
+    "growi_settings": {
+      "overwrite_documents": "Imported documents will overwrite existing documents",
+      "zip_file": "Zip File",
+      "uploaded_data": "Uploaded Data",
+      "extracted_file": "Extracted File",
+      "collection": "Collection",
+      "upload": "Upload",
+      "discard": "Discard Uploaded Data"
+    },
     "esa_settings": {
       "team_name": "Team name",
       "access_token": "Access token",
@@ -751,5 +776,22 @@
     "rebuild_description_1":"Force rebuild index.",
     "rebuild_description_2":"Click 'Build Now' to delete and create mapping file and add all pages.",
     "rebuild_description_3":"This may take a while."
+  },
+  "export_management": {
+    "beta_warning": "This function is Beta.",
+    "exported_data_list": "Exported Data List",
+    "export_collections": "Export Collections",
+    "check_all": "Check All",
+    "uncheck_all": "Uncheck All",
+    "create_new_exported_data": "Create New Exported Data",
+    "export": "Export",
+    "cancel": "Cancel",
+    "file": "File",
+    "growi_version": "Growi Version",
+    "collections": "Collections",
+    "exported_at": "Exported At",
+    "export_menu": "Export Menu",
+    "download": "Download",
+    "delete": "Delete"
   }
 }

+ 50 - 8
resource/locales/ja/translation.json

@@ -8,6 +8,7 @@
   "Click to copy": "クリックでコピー",
   "Move/Rename": "移動/名前変更",
   "Moved": "移動しました",
+  "Redirected": "リダイレクトされました",
   "Unlinked": "リダイレクト削除",
   "Like!": "いいね!",
   "Seen by": "見た人",
@@ -112,6 +113,7 @@
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
+  "Export Data": "データエクスポート",
   "Basic Settings": "基本設定",
   "Register limitation": "登録の制限",
   "The contents entered here will be shown in the header etc": "ここに入力した内容は、ヘッダー等に表示されます。",
@@ -242,13 +244,14 @@
   },
 
   "page_page": {
-      "notice": {
-          "version": "これは現在の版ではありません。",
-          "moved": "このページは <code>%s</code> から移動しました。",
-          "duplicated": "このページは <code>%s</code> から複製されました。",
-          "unlinked": "このページへのリダイレクトは削除されました。",
-          "restricted": "このページの閲覧は制限されています"
-      }
+    "notice": {
+      "version": "これは現在の版ではありません。",
+      "moved": "このページは <code>%s</code> から移動しました。",
+      "redirected": "リダイレクト元 >> <code>%s</code>",
+      "duplicated": "このページは <code>%s</code> から複製されました。",
+      "unlinked": "このページへのリダイレクトは削除されました。",
+      "restricted": "このページの閲覧は制限されています"
+    }
   },
 
   "page_edit": {
@@ -339,7 +342,7 @@
   "template": {
     "modal_label": {
       "Create/Edit Template Page": "テンプレートページの作成/編集",
-      "Create template under": "<code>%s</code> にテンプレートページを作成"
+      "Create template under": "<code><small>%s</small></code><br />にテンプレートページを作成"
     },
     "option_label": {
       "select": "テンプレートタイプを選択してください",
@@ -427,6 +430,8 @@
     "change_setting": "この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。",
     "region": "リージョン",
     "bucket name": "バケット名",
+    "custom endpoint": "カスタムエンドポイント",
+    "custom_endpoint_change": "MinIOなど、S3互換APIを持つ他のオブジェクトストレージサービスを使用する場合のみ、そのエンドポイントのURLを入力してください。空欄の場合は、Amazon S3を使用します。",
     "Plugin settings": "プラグイン設定",
     "Enable plugin loading": "プラグインの読み込みを有効にします。",
     "Load plugins": "プラグインを読み込む",
@@ -483,6 +488,9 @@
     "Use env var if empty": "空の場合、環境変数 <code>%s</code> を利用します",
     "Use default if both are empty": "どちらの値も空の場合、デフォルト値 <code>%s</code> を利用します",
     "missing mandatory configs": "以下の必須項目の値がデータベースと環境変数のどちらにも設定されていません",
+    "Local": {
+      "name": "ID/Password"
+    },
     "ldap": {
       "server_url_detail": "LDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code> の形式で入力してください。",
       "bind_mode": "Bind モード",
@@ -523,6 +531,11 @@
       "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>%s</code> の値を利用します",
       "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>%s</code> の値をfalseに変更もしくは削除してください"
     },
+    "Basic": {
+      "name": "Basic 認証",
+      "desc_1": "Authorization ヘッダに格納されている <code>username</code> でログインします。",
+      "desc_2": "ユーザーが存在しなかった場合は自動生成します。"
+    },
     "OAuth": {
       "register": "%sに登録",
       "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力",
@@ -715,6 +728,18 @@
   "importer_management": {
     "import_form_esa": "esa.ioからインポート",
     "import_form_qiita": "Qiita:Teamからインポート",
+    "beta_warning": "この機能はベータ版です",
+    "import_from": "%s からインポート",
+    "import_form_growi": "GROWIからインポート",
+    "growi_settings": {
+      "overwrite_documents": "インポートされたドキュメントは既存のドキュメントを上書きします",
+      "zip_file": "Zip ファイル",
+      "uploaded_data": "アップロードされたデータ",
+      "extracted_file": "展開されたファイル",
+      "collection": "コレクション",
+      "upload": "アップロード",
+      "discard": "アップロードしたデータを破棄する"
+    },
     "esa_settings": {
       "team_name": "チーム名",
       "access_token": "アクセストークン",
@@ -736,5 +761,22 @@
     "rebuild_description_1":"Build Now ボタンを押すと全てのページのインデックスを削除し、作り直します。",
     "rebuild_description_2":"この作業には数秒かかります。",
     "rebuild_description_3":""
+  },
+  "export_management": {
+    "beta_warning": "この機能はベータ版です",
+    "exported_data_list": "エクスポートデータリスト",
+    "export_collections": "コレクションのエクスポート",
+    "check_all": "全てにチェックを付ける",
+    "uncheck_all": "全てからチェックを外す",
+    "create_new_exported_data": "エクスポートデータの新規作成",
+    "export": "エクスポート",
+    "cancel": "キャンセル",
+    "file": "ファイル名",
+    "growi_version": "Growi バージョン",
+    "collections": "コレクション",
+    "exported_at": "エクスポートされた時間",
+    "export_menu": "エクスポートメニュー",
+    "download": "ダウンロード",
+    "delete": "削除"
   }
 }

+ 34 - 0
src/client/js/app.jsx

@@ -19,6 +19,7 @@ import PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page from './components/Page';
 import PageHistory from './components/PageHistory';
 import PageComments from './components/PageComments';
+import PageTimeline from './components/PageTimeline';
 import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
@@ -31,6 +32,7 @@ import RecentCreated from './components/RecentCreated/RecentCreated';
 import StaffCredit from './components/StaffCredit/StaffCredit';
 import MyDraftList from './components/MyDraftList/MyDraftList';
 import UserPictureList from './components/User/UserPictureList';
+import TableOfContents from './components/TableOfContents';
 
 import UserGroupDetailPage from './components/Admin/UserGroupDetail/UserGroupDetailPage';
 import CustomCssEditor from './components/Admin/CustomCssEditor';
@@ -43,6 +45,9 @@ import UserGroupPage from './components/Admin/UserGroup/UserGroupPage';
 import Customize from './components/Admin/Customize/Customize';
 import Importer from './components/Admin/Importer';
 import FullTextSearchManagement from './components/Admin/FullTextSearchManagement/FullTextSearchPage';
+import ExportPage from './components/Admin/Export/ExportPage';
+import GrowiZipImportSection from './components/Admin/Import/GrowiZipImportSection';
+import GroupDeleteModal from './components/GroupDeleteModal/GroupDeleteModal';
 
 import AppContainer from './services/AppContainer';
 import PageContainer from './services/PageContainer';
@@ -118,7 +123,9 @@ if (pageContainer.state.pageId != null) {
     'page-editor-with-hackmd': <PageEditorByHackmd />,
     'page-comments-list': <PageComments />,
     'page-attachment':  <PageAttachment />,
+    'page-timeline':  <PageTimeline />,
     'page-comment-write':  <CommentEditorLazyRenderer />,
+    'revision-toc': <TableOfContents />,
     'like-button': <LikeButton pageId={pageContainer.state.pageId} isLiked={pageContainer.state.isLiked} />,
     'seen-user-list': <UserPictureList userIds={pageContainer.state.seenUserIds} />,
     'liker-list': <UserPictureList userIds={pageContainer.state.likerUserIds} />,
@@ -214,6 +221,33 @@ if (adminUserGroupPageElem != null) {
   );
 }
 
+const adminExportPageElem = document.getElementById('admin-export-page');
+if (adminExportPageElem != null) {
+  ReactDOM.render(
+    <Provider inject={[]}>
+      <I18nextProvider i18n={i18n}>
+        <ExportPage
+          crowi={appContainer}
+        />
+      </I18nextProvider>
+    </Provider>,
+    adminExportPageElem,
+  );
+}
+
+// TODO: move to /imponents/Admin/Importer.jsx
+const growiImportElem = document.getElementById('growi-import');
+if (growiImportElem != null) {
+  ReactDOM.render(
+    <Provider inject={[]}>
+      <I18nextProvider i18n={i18n}>
+        <GrowiZipImportSection />
+      </I18nextProvider>
+    </Provider>,
+    growiImportElem,
+  );
+}
+
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(

+ 138 - 0
src/client/js/components/Admin/Export/ExportPage.jsx

@@ -0,0 +1,138 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import * as toastr from 'toastr';
+
+import ExportZipFormModal from './ExportZipFormModal';
+import ZipFileTable from './ZipFileTable';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class ExportPage extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      collections: [],
+      zipFileStats: [],
+      isExportModalOpen: false,
+    };
+
+    this.onZipFileStatAdd = this.onZipFileStatAdd.bind(this);
+    this.onZipFileStatRemove = this.onZipFileStatRemove.bind(this);
+    this.openExportModal = this.openExportModal.bind(this);
+    this.closeExportModal = this.closeExportModal.bind(this);
+  }
+
+  async componentDidMount() {
+    // TODO:: use apiv3.get
+    // eslint-disable-next-line no-unused-vars
+    const [{ collections }, { zipFileStats }] = await Promise.all([
+      this.props.appContainer.apiGet('/v3/mongo/collections', {}),
+      this.props.appContainer.apiGet('/v3/export/status', {}),
+    ]);
+    // TODO: toastSuccess, toastError
+
+    this.setState({ collections: ['pages', 'revisions'], zipFileStats }); // FIXME: delete this line and uncomment the line below
+    // this.setState({ collections, zipFileStats });
+  }
+
+  onZipFileStatAdd(newStat) {
+    this.setState((prevState) => {
+      return {
+        zipFileStats: [...prevState.zipFileStats, newStat],
+      };
+    });
+  }
+
+  async onZipFileStatRemove(fileName) {
+    try {
+      await this.props.appContainer.apiDelete(`/v3/export/${fileName}`, {});
+
+      this.setState((prevState) => {
+        return {
+          zipFileStats: prevState.zipFileStats.filter(stat => stat.fileName !== fileName),
+        };
+      });
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, `Deleted ${fileName}`, {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }
+
+  openExportModal() {
+    this.setState({ isExportModalOpen: true });
+  }
+
+  closeExportModal() {
+    this.setState({ isExportModalOpen: false });
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <div className="alert alert-warning">
+          <i className="icon-exclamation"></i> { t('export_management.beta_warning') }
+        </div>
+
+        <h2>{t('Export Data')}</h2>
+
+        <button type="button" className="btn btn-default" onClick={this.openExportModal}>{t('export_management.create_new_exported_data')}</button>
+
+        <div className="mt-5">
+          <h3>{t('export_management.exported_data_list')}</h3>
+          <ZipFileTable
+            zipFileStats={this.state.zipFileStats}
+            onZipFileStatRemove={this.onZipFileStatRemove}
+          />
+        </div>
+
+        <ExportZipFormModal
+          isOpen={this.state.isExportModalOpen}
+          onClose={this.closeExportModal}
+          collections={this.state.collections}
+          zipFileStats={this.state.zipFileStats}
+          onZipFileStatAdd={this.onZipFileStatAdd}
+        />
+      </Fragment>
+    );
+  }
+
+}
+
+ExportPage.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ExportPageFormWrapper = (props) => {
+  return createSubscribedElement(ExportPage, props, [AppContainer]);
+};
+
+export default withTranslation()(ExportPageFormWrapper);

+ 52 - 0
src/client/js/components/Admin/Export/ExportTableMenu.jsx

@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class ExportTableMenu extends React.Component {
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <div className="btn-group admin-user-menu">
+        <button type="button" className="btn btn-sm btn-default dropdown-toggle" data-toggle="dropdown">
+          <i className="icon-settings"></i> <span className="caret"></span>
+        </button>
+        <ul className="dropdown-menu" role="menu">
+          <li className="dropdown-header">{t('export_management.export_menu')}</li>
+          <li>
+            <a type="button" href={`/admin/export/${this.props.fileName}`}>
+              <i className="icon-cloud-download" /> {t('export_management.download')}
+            </a>
+          </li>
+          <li>
+            <a type="button" role="button" onClick={() => this.props.onZipFileStatRemove(this.props.fileName)}>
+              <span className="text-danger"><i className="icon-trash" /> {t('export_management.delete')}</span>
+            </a>
+          </li>
+        </ul>
+      </div>
+    );
+  }
+
+}
+
+ExportTableMenu.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  fileName: PropTypes.string.isRequired,
+  onZipFileStatRemove: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ExportTableMenuWrapper = (props) => {
+  return createSubscribedElement(ExportTableMenu, props, [AppContainer]);
+};
+
+export default withTranslation()(ExportTableMenuWrapper);

+ 162 - 0
src/client/js/components/Admin/Export/ExportZipFormModal.jsx

@@ -0,0 +1,162 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import Modal from 'react-bootstrap/es/Modal';
+import * as toastr from 'toastr';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class ExportZipFormModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      collections: new Set(),
+    };
+
+    this.toggleCheckbox = this.toggleCheckbox.bind(this);
+    this.checkAll = this.checkAll.bind(this);
+    this.uncheckAll = this.uncheckAll.bind(this);
+    this.export = this.export.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  toggleCheckbox(e) {
+    const { target } = e;
+    const { name, checked } = target;
+
+    this.setState((prevState) => {
+      const collections = new Set(prevState.collections);
+      if (checked) {
+        collections.add(name);
+      }
+      else {
+        collections.delete(name);
+      }
+
+      return { collections };
+    });
+  }
+
+  checkAll() {
+    this.setState({ collections: new Set(this.props.collections) });
+  }
+
+  uncheckAll() {
+    this.setState({ collections: new Set() });
+  }
+
+  async export(e) {
+    e.preventDefault();
+
+    try {
+      // TODO: use appContainer.apiv3.post
+      const { zipFileStat } = await this.props.appContainer.apiPost('/v3/export', { collections: Array.from(this.state.collections) });
+      // TODO: toastSuccess, toastError
+      this.props.onZipFileStatAdd(zipFileStat);
+      this.props.onClose();
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, `Generated ${zipFileStat.fileName}`, {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }
+
+  validateForm() {
+    return this.state.collections.size > 0;
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Modal show={this.props.isOpen} onHide={this.props.onClose}>
+        <Modal.Header closeButton>
+          <Modal.Title>{t('export_management.export_collections')}</Modal.Title>
+        </Modal.Header>
+
+        <form onSubmit={this.export}>
+          <Modal.Body>
+            <div className="row">
+              <div className="col-sm-12">
+                <button type="button" className="btn btn-sm btn-default mr-2" onClick={this.checkAll}>
+                  <i className="fa fa-check-square-o"></i> {t('export_management.check_all')}
+                </button>
+                <button type="button" className="btn btn-sm btn-default mr-2" onClick={this.uncheckAll}>
+                  <i className="fa fa-square-o"></i> {t('export_management.uncheck_all')}
+                </button>
+              </div>
+            </div>
+            <div className="checkbox checkbox-info">
+              {this.props.collections.map((collectionName) => {
+                return (
+                  <div className="my-1" key={collectionName}>
+                    <input
+                      type="checkbox"
+                      id={collectionName}
+                      name={collectionName}
+                      className="form-check-input"
+                      value={collectionName}
+                      checked={this.state.collections.has(collectionName)}
+                      onChange={this.toggleCheckbox}
+                    />
+                    <label className="text-capitalize form-check-label ml-3" htmlFor={collectionName}>
+                      {collectionName}
+                    </label>
+                  </div>
+                );
+              })}
+            </div>
+          </Modal.Body>
+
+          <Modal.Footer>
+            <button type="button" className="btn btn-sm btn-default" onClick={this.props.onClose}>{t('export_management.cancel')}</button>
+            <button type="submit" className="btn btn-sm btn-primary" disabled={!this.validateForm()}>{t('export_management.export')}</button>
+          </Modal.Footer>
+        </form>
+      </Modal>
+    );
+  }
+
+}
+
+ExportZipFormModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func.isRequired,
+  collections: PropTypes.arrayOf(PropTypes.string).isRequired,
+  zipFileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
+  onZipFileStatAdd: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ExportZipFormModalWrapper = (props) => {
+  return createSubscribedElement(ExportZipFormModal, props, [AppContainer]);
+};
+
+export default withTranslation()(ExportZipFormModalWrapper);

+ 67 - 0
src/client/js/components/Admin/Export/ZipFileTable.jsx

@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import { format } from 'date-fns';
+
+import ExportTableMenu from './ExportTableMenu';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class ZipFileTable extends React.Component {
+
+  render() {
+    // eslint-disable-next-line no-unused-vars
+    const { t } = this.props;
+
+    return (
+      <table className="table table-bordered">
+        <thead>
+          <tr>
+            <th>{t('export_management.file')}</th>
+            <th>{t('export_management.growi_version')}</th>
+            <th>{t('export_management.collections')}</th>
+            <th>{t('export_management.exported_at')}</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          {this.props.zipFileStats.map(({ meta, fileName, fileStats }) => {
+            return (
+              <tr key={fileName}>
+                <th>{fileName}</th>
+                <td>{meta.version}</td>
+                <td className="text-capitalize">{fileStats.map(fileStat => fileStat.collectionName).join(', ')}</td>
+                <td>{meta.exportedAt ? format(new Date(meta.exportedAt), 'yyyy/MM/dd HH:mm:ss') : ''}</td>
+                <td>
+                  <ExportTableMenu
+                    fileName={fileName}
+                    onZipFileStatRemove={this.props.onZipFileStatRemove}
+                  />
+                </td>
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    );
+  }
+
+}
+
+ZipFileTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  zipFileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
+  onZipFileStatRemove: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ZipFileTableWrapper = (props) => {
+  return createSubscribedElement(ZipFileTable, props, [AppContainer]);
+};
+
+export default withTranslation()(ZipFileTableWrapper);

+ 181 - 0
src/client/js/components/Admin/Import/GrowiZipImportForm.jsx

@@ -0,0 +1,181 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import * as toastr from 'toastr';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class GrowiImportForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.initialState = {
+      collections: new Set(),
+      schema: {
+        pages: {},
+        revisions: {},
+        // ...
+      },
+    };
+
+    this.state = this.initialState;
+
+    this.toggleCheckbox = this.toggleCheckbox.bind(this);
+    this.import = this.import.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  toggleCheckbox(e) {
+    const { target } = e;
+    const { name, checked } = target;
+
+    this.setState((prevState) => {
+      const collections = new Set(prevState.collections);
+      if (checked) {
+        collections.add(name);
+      }
+      else {
+        collections.delete(name);
+      }
+      return { collections };
+    });
+  }
+
+  async import(e) {
+    e.preventDefault();
+
+    try {
+      // TODO: use appContainer.apiv3.post
+      const { results } = await this.props.appContainer.apiPost('/v3/import', {
+        fileName: this.props.fileName,
+        collections: Array.from(this.state.collections),
+        schema: this.state.schema,
+      });
+
+      this.setState(this.initialState);
+      this.props.onPostImport();
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, 'Imported documents', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+
+      for (const { collectionName, failedIds } of results) {
+        if (failedIds.length > 0) {
+          toastr.error(`failed to insert ${failedIds.join(', ')}`, collectionName, {
+            closeButton: true,
+            progressBar: true,
+            newestOnTop: false,
+            timeOut: '30000',
+          });
+        }
+      }
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }
+
+  validateForm() {
+    return this.state.collections.size > 0;
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <form className="row" onSubmit={this.import}>
+        <div className="col-xs-12">
+          <table className="table table-bordered table-mapping">
+            <caption>{t('importer_management.growi_settings.uploaded_data')}</caption>
+            <thead>
+              <tr>
+                <th></th>
+                <th>{t('importer_management.growi_settings.extracted_file')}</th>
+                <th>{t('importer_management.growi_settings.collection')}</th>
+              </tr>
+            </thead>
+            <tbody>
+              {this.props.fileStats.map((fileStat) => {
+                  const { fileName, collectionName } = fileStat;
+                  const checked = this.state.collections.has(collectionName);
+                  return (
+                    <Fragment key={collectionName}>
+                      <tr>
+                        <td>
+                          <input
+                            type="checkbox"
+                            id={collectionName}
+                            name={collectionName}
+                            className="form-check-input"
+                            value={collectionName}
+                            checked={checked}
+                            onChange={this.toggleCheckbox}
+                          />
+                        </td>
+                        <td>{fileName}</td>
+                        <td className="text-capitalize">{collectionName}</td>
+                      </tr>
+                      {checked && (
+                        <tr>
+                          <td className="text-muted" colSpan="3">
+                            TBD: define how {collectionName} are imported
+                            {/* TODO: create a component for each collection to modify this.state.schema */}
+                          </td>
+                        </tr>
+                      )}
+                    </Fragment>
+                  );
+                })}
+            </tbody>
+          </table>
+        </div>
+        <div className="col-xs-12 text-center">
+          <button type="submit" className="btn btn-primary mx-1" disabled={!this.validateForm()}>
+            { t('importer_management.import') }
+          </button>
+          <button type="button" className="btn btn-default mx-1" onClick={this.props.onDiscard}>
+            { t('importer_management.growi_settings.discard') }
+          </button>
+        </div>
+      </form>
+    );
+  }
+
+}
+
+GrowiImportForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  fileName: PropTypes.string,
+  fileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
+  onDiscard: PropTypes.func.isRequired,
+  onPostImport: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiImportFormWrapper = (props) => {
+  return createSubscribedElement(GrowiImportForm, props, [AppContainer]);
+};
+
+export default withTranslation()(GrowiImportFormWrapper);

+ 119 - 0
src/client/js/components/Admin/Import/GrowiZipImportSection.jsx

@@ -0,0 +1,119 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import * as toastr from 'toastr';
+
+import GrowiZipUploadForm from './GrowiZipUploadForm';
+import GrowiZipImportForm from './GrowiZipImportForm';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class GrowiZipImportSection extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.initialState = {
+      fileName: '',
+      fileStats: [],
+    };
+
+    this.state = this.initialState;
+
+    this.handleUpload = this.handleUpload.bind(this);
+    this.discardData = this.discardData.bind(this);
+    this.resetState = this.resetState.bind(this);
+  }
+
+  handleUpload({ meta, fileName, fileStats }) {
+    this.setState({
+      fileName,
+      fileStats,
+    });
+  }
+
+  async discardData() {
+    try {
+      const { fileName } = this.state;
+      await this.props.appContainer.apiDelete(`/v3/import/${this.state.fileName}`, {});
+      this.resetState();
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, `Deleted ${fileName}`, {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }
+
+  resetState() {
+    this.setState(this.initialState);
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <legend>{t('importer_management.import_form_growi')}</legend>
+
+        <div className="alert alert-warning">
+          <i className="icon-exclamation"></i> { t('importer_management.beta_warning') }
+        </div>
+
+        <div className="well well-sm small">
+          <ul>
+            <li>{t('importer_management.growi_settings.overwrite_documents')}</li>
+          </ul>
+        </div>
+
+        {this.state.fileName ? (
+          <Fragment>
+            <GrowiZipImportForm
+              fileName={this.state.fileName}
+              fileStats={this.state.fileStats}
+              onDiscard={this.discardData}
+              onPostImport={this.resetState}
+            />
+          </Fragment>
+        ) : (
+          <GrowiZipUploadForm
+            onUpload={this.handleUpload}
+          />
+        )}
+      </Fragment>
+    );
+  }
+
+}
+
+GrowiZipImportSection.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiZipImportSectionWrapper = (props) => {
+  return createSubscribedElement(GrowiZipImportSection, props, [AppContainer]);
+};
+
+export default withTranslation()(GrowiZipImportSectionWrapper);

+ 93 - 0
src/client/js/components/Admin/Import/GrowiZipUploadForm.jsx

@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class GrowiZipUploadForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.inputRef = React.createRef();
+
+    this.changeFileName = this.changeFileName.bind(this);
+    this.uploadZipFile = this.uploadZipFile.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  changeFileName(e) {
+    // to trigger rerender at onChange event
+    // eslint-disable-next-line react/no-unused-state
+    this.setState({ dummy: e.target.files[0].name });
+  }
+
+  async uploadZipFile(e) {
+    e.preventDefault();
+
+    const formData = new FormData();
+    formData.append('_csrf', this.props.appContainer.csrfToken);
+    formData.append('file', this.inputRef.current.files[0]);
+
+    // TODO: use appContainer.apiv3.post
+    const { data } = await this.props.appContainer.apiPost('/v3/import/upload', formData);
+    this.props.onUpload(data);
+    // TODO: toastSuccess, toastError
+  }
+
+  validateForm() {
+    return (
+      this.inputRef.current // null check
+      && this.inputRef.current.files[0] // null check
+      && /\.zip$/.test(this.inputRef.current.files[0].name) // validate extension
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <form className="form-horizontal" onSubmit={this.uploadZipFile}>
+        <fieldset>
+          <div className="form-group d-flex align-items-center">
+            <label htmlFor="file" className="col-xs-3 control-label">{t('importer_management.growi_settings.zip_file')}</label>
+            <div className="col-xs-6">
+              <input
+                type="file"
+                name="file"
+                className="form-control-file"
+                ref={this.inputRef}
+                onChange={this.changeFileName}
+              />
+            </div>
+          </div>
+          <div className="form-group">
+            <div className="col-xs-offset-3 col-xs-6">
+              <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>
+                {t('importer_management.growi_settings.upload')}
+              </button>
+            </div>
+          </div>
+        </fieldset>
+      </form>
+    );
+  }
+
+}
+
+GrowiZipUploadForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  onUpload: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiZipUploadFormWrapper = (props) => {
+  return createSubscribedElement(GrowiZipUploadForm, props, [AppContainer]);
+};
+
+export default withTranslation()(GrowiZipUploadFormWrapper);

+ 1 - 1
src/client/js/components/BookmarkButton.jsx

@@ -56,7 +56,7 @@ export default class BookmarkButton extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.crowi.me !== '';
+    return this.props.crowi.me != null;
   }
 
   render() {

+ 2 - 2
src/client/js/components/InstallerForm.jsx

@@ -62,7 +62,7 @@ class InstallerForm extends React.Component {
               value="en-US"
               checked={checkedBtn === 'en-US'}
               inline
-              onClick={() => { return this.changeLanguage('en-US') }}
+              onChange={(e) => { if (e.target.checked) { this.changeLanguage('en-US') } }}
             >
               English
             </Radio>
@@ -71,7 +71,7 @@ class InstallerForm extends React.Component {
               value="ja"
               checked={checkedBtn === 'ja'}
               inline
-              onClick={() => { return this.changeLanguage('ja') }}
+              onChange={(e) => { if (e.target.checked) { this.changeLanguage('ja') } }}
             >
               日本語
             </Radio>

+ 1 - 1
src/client/js/components/LikeButton.jsx

@@ -37,7 +37,7 @@ class LikeButton extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.appContainer.me !== '';
+    return this.props.appContainer.me != null;
   }
 
   render() {

+ 2 - 1
src/client/js/components/Page.jsx

@@ -47,6 +47,7 @@ class Page extends React.Component {
 
   async saveHandlerForHandsontableModal(markdownTable) {
     const { pageContainer, editorContainer } = this.props;
+    const optionsToSave = editorContainer.getCurrentOptionsToSave();
 
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
       markdownTable,
@@ -60,7 +61,7 @@ class Page extends React.Component {
       editorContainer.disableUnsavedWarning();
 
       // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(newMarkdown);
+      const { page, tags } = await pageContainer.save(newMarkdown, optionsToSave);
       logger.debug('success to save');
 
       pageContainer.showSuccessToastr();

+ 5 - 2
src/client/js/components/Page/RevisionLoader.jsx

@@ -57,6 +57,10 @@ class RevisionLoader extends React.Component {
       markdown: res.revision.body,
       error: null,
     });
+
+    if (this.props.onRevisionLoaded != null) {
+      this.props.onRevisionLoaded(res.revision);
+    }
   }
 
   onWaypointChange(event) {
@@ -95,7 +99,6 @@ class RevisionLoader extends React.Component {
     return (
       <RevisionRenderer
         growiRenderer={this.props.growiRenderer}
-        pagePath={this.props.pagePath}
         markdown={markdown}
         highlightKeywords={this.props.highlightKeywords}
       />
@@ -116,9 +119,9 @@ RevisionLoader.propTypes = {
 
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   pageId: PropTypes.string.isRequired,
-  pagePath: PropTypes.string.isRequired,
   revisionId: PropTypes.string.isRequired,
   lazy: PropTypes.bool,
+  onRevisionLoaded: PropTypes.func,
   highlightKeywords: PropTypes.string,
 };
 

+ 36 - 4
src/client/js/components/Page/RevisionPath.jsx

@@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
+import urljoin from 'url-join';
+
 import CopyDropdown from './CopyDropdown';
 
 class RevisionPath extends React.Component {
@@ -14,6 +16,7 @@ class RevisionPath extends React.Component {
       pages: [],
       isListPage: false,
       isLinkToListPage: true,
+      isInTrash: false,
     };
 
     // retrieve xss library from window
@@ -30,6 +33,23 @@ class RevisionPath extends React.Component {
     const isLinkToListPage = (behaviorType === 'crowi');
     this.setState({ isLinkToListPage });
 
+    this.generateHierarchyData();
+  }
+
+  /**
+   * 1. split `pagePath` with '/'
+   * 2. list hierararchical page paths
+   *
+   * e.g.
+   *  when `pagePath` is '/foo/bar/baz`
+   *  return:
+   *  [
+   *    { pagePath: '/foo',         pageName: 'foo' },
+   *    { pagePath: '/foo/bar',     pageName: 'bar' },
+   *    { pagePath: '/foo/bar/baz', pageName: 'baz' },
+   *  ]
+   */
+  generateHierarchyData() {
     // generate pages obj
     const splitted = this.props.pagePath.split(/\//);
     splitted.shift(); // omit first element with shift()
@@ -38,13 +58,19 @@ class RevisionPath extends React.Component {
     }
 
     const pages = [];
-    let parentPath = '/';
+    const pagePaths = [];
     splitted.forEach((pageName) => {
+      // skip trash
+      if (pageName === 'trash' && splitted.length > 1) {
+        this.setState({ isInTrash: true });
+        return;
+      }
+
+      pagePaths.push(encodeURIComponent(pageName));
       pages.push({
-        pagePath: parentPath + encodeURIComponent(pageName),
+        pagePath: urljoin('/', ...pagePaths),
         pageName: this.xss.process(pageName),
       });
-      parentPath += `${pageName}/`;
     });
 
     this.setState({ pages });
@@ -86,6 +112,7 @@ class RevisionPath extends React.Component {
       padding: '0 2px',
     };
 
+    const { isInTrash } = this.state;
     const pageLength = this.state.pages.length;
 
     const afterElements = [];
@@ -109,7 +136,12 @@ class RevisionPath extends React.Component {
 
     return (
       <span className="d-flex align-items-center">
-        <span className="separator" style={rootStyle}>
+        { isInTrash && (
+          <span className="path-segment">
+            <a href="/trash"><i className="icon-trash"></i></a>
+          </span>
+        ) }
+        <span className="separator" style={isInTrash ? separatorStyle : rootStyle}>
           <a href="/">/</a>
         </span>
         {afterElements}

+ 1 - 1
src/client/js/components/PageAttachment.jsx

@@ -89,7 +89,7 @@ class PageAttachment extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.appContainer.me !== '';
+    return this.props.appContainer.me != null;
   }
 
   render() {

+ 8 - 4
src/client/js/components/PageAttachment/Attachment.jsx

@@ -20,7 +20,9 @@ export default class Attachment extends React.Component {
   }
 
   _onAttachmentDeleteClicked(event) {
-    this.props.onAttachmentDeleteClicked(this.props.attachment);
+    if (this.props.onAttachmentDeleteClicked != null) {
+      this.props.onAttachmentDeleteClicked(this.props.attachment);
+    }
   }
 
   render() {
@@ -73,10 +75,12 @@ export default class Attachment extends React.Component {
 
 Attachment.propTypes = {
   attachment: PropTypes.object.isRequired,
-  inUse: PropTypes.bool.isRequired,
-  onAttachmentDeleteClicked: PropTypes.func.isRequired,
-  isUserLoggedIn: PropTypes.bool.isRequired,
+  inUse: PropTypes.bool,
+  onAttachmentDeleteClicked: PropTypes.func,
+  isUserLoggedIn: PropTypes.bool,
 };
 
 Attachment.defaultProps = {
+  inUse: false,
+  isUserLoggedIn: false,
 };

+ 12 - 1
src/client/js/components/PageAttachment/PageAttachmentList.jsx

@@ -1,5 +1,5 @@
-/* eslint-disable react/prop-types */
 import React from 'react';
+import PropTypes from 'prop-types';
 
 import Attachment from './Attachment';
 
@@ -35,3 +35,14 @@ export default class PageAttachmentList extends React.Component {
   }
 
 }
+
+PageAttachmentList.propTypes = {
+  attachments: PropTypes.arrayOf(PropTypes.object),
+  inUse: PropTypes.objectOf(PropTypes.bool),
+  onAttachmentDeleteClicked: PropTypes.func,
+  isUserLoggedIn: PropTypes.bool,
+};
+PageAttachmentList.defaultProps = {
+  attachments: [],
+  inUse: {},
+};

+ 156 - 84
src/client/js/components/PageComment/Comment.jsx

@@ -1,7 +1,12 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import dateFnsFormat from 'date-fns/format';
+import { format, formatDistanceStrict } from 'date-fns';
+
+import Button from 'react-bootstrap/es/Button';
+import Tooltip from 'react-bootstrap/es/Tooltip';
+import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
+import Collapse from 'react-bootstrap/es/Collapse';
 
 import AppContainer from '../../services/AppContainer';
 import PageContainer from '../../services/PageContainer';
@@ -10,6 +15,7 @@ import { createSubscribedElement } from '../UnstatedUtils';
 import RevisionBody from '../Page/RevisionBody';
 import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
+import CommentEditor from './CommentEditor';
 
 /**
  *
@@ -26,9 +32,12 @@ class Comment extends React.Component {
 
     this.state = {
       html: '',
-      isLayoutTypeGrowi: false,
+      isOlderRepliesShown: false,
+      showReEditorIds: new Set(),
     };
 
+    this.growiRenderer = this.props.appContainer.getRenderer('comment');
+
     this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
     this.isCurrentRevision = this.isCurrentRevision.bind(this);
     this.getRootClassName = this.getRootClassName.bind(this);
@@ -36,16 +45,11 @@ class Comment extends React.Component {
     this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
     this.renderText = this.renderText.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
+    this.commentButtonClickedHandler = this.commentButtonClickedHandler.bind(this);
   }
 
   componentWillMount() {
     this.renderHtml(this.props.comment.comment);
-    this.init();
-  }
-
-  init() {
-    const layoutType = this.props.appContainer.getConfig().layoutType;
-    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
   }
 
   componentWillReceiveProps(nextProps) {
@@ -57,6 +61,10 @@ class Comment extends React.Component {
     this.renderHtml(markdown);
   }
 
+  checkPermissionToControlComment() {
+    return this.props.appContainer.isAdmin || this.isCurrentUserEqualsToAuthor();
+  }
+
   isCurrentUserEqualsToAuthor() {
     return this.props.comment.creator.username === this.props.appContainer.me;
   }
@@ -65,9 +73,25 @@ class Comment extends React.Component {
     return this.props.comment.revision === this.props.pageContainer.state.revisionId;
   }
 
-  getRootClassName() {
-    return `page-comment ${
-      this.isCurrentUserEqualsToAuthor() ? 'page-comment-me ' : ''}`;
+  getRootClassName(comment) {
+    let className = 'page-comment';
+
+    const { revisionId, revisionCreatedAt } = this.props.pageContainer.state;
+    if (comment.revision === revisionId) {
+      className += ' page-comment-current';
+    }
+    else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
+      className += ' page-comment-newer';
+    }
+    else {
+      className += ' page-comment-older';
+    }
+
+    if (this.isCurrentUserEqualsToAuthor()) {
+      className += ' page-comment-me';
+    }
+
+    return className;
   }
 
   getRevisionLabelClassName() {
@@ -75,6 +99,20 @@ class Comment extends React.Component {
       this.isCurrentRevision() ? 'label-primary' : 'label-default'}`;
   }
 
+  editBtnClickedHandler(commentId) {
+    const ids = this.state.showReEditorIds.add(commentId);
+    this.setState({ showReEditorIds: ids });
+  }
+
+  commentButtonClickedHandler(commentId) {
+    this.setState((prevState) => {
+      prevState.showReEditorIds.delete(commentId);
+      return {
+        showReEditorIds: prevState.showReEditorIds,
+      };
+    });
+  }
+
   deleteBtnClickedHandler() {
     this.props.deleteBtnClicked(this.props.comment);
   }
@@ -135,97 +173,127 @@ class Comment extends React.Component {
 
   }
 
+  renderReply(reply) {
+    return (
+      <div key={reply._id} className="page-comment-reply">
+        <CommentWrapper
+          comment={reply}
+          deleteBtnClicked={this.props.deleteBtnClicked}
+          growiRenderer={this.props.growiRenderer}
+        />
+      </div>
+    );
+  }
+
   renderReplies() {
-    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);
+
     let replyList = this.props.replyList;
-    if (!isLayoutTypeGrowi) {
+    if (!isBaloonStyle) {
       replyList = replyList.slice().reverse();
     }
 
     const areThereHiddenReplies = replyList.length > 2;
 
-    const iconForOlder = <i className="icon-options-vertical"></i>;
-    const toggleOlder = areThereHiddenReplies
-      ? (
-        <a className="page-comments-list-toggle-older text-center" data-toggle="collapse" href="#page-comments-list-older">
-          {iconForOlder} Read More
-        </a>
-      )
-      : <div></div>;
+    const { isOlderRepliesShown } = this.state;
+    const toggleButtonIconName = isOlderRepliesShown ? 'icon-arrow-up' : 'icon-options-vertical';
+    const toggleButtonIcon = <i className={`icon-fw ${toggleButtonIconName}`}></i>;
+    const toggleButtonLabel = isOlderRepliesShown ? '' : 'more';
+    const toggleButton = (
+      <Button
+        bsStyle="link"
+        className="page-comments-list-toggle-older"
+        onClick={() => { this.setState({ isOlderRepliesShown: !isOlderRepliesShown }) }}
+      >
+        {toggleButtonIcon} {toggleButtonLabel}
+      </Button>
+    );
 
     const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
     const hiddenReplies = replyList.slice(0, replyList.length - 2);
 
-    const toggleElements = hiddenReplies.map((reply) => {
-      return (
-        <div key={reply._id} className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
-          <CommentWrapper
-            comment={reply}
-            deleteBtnClicked={this.props.deleteBtnClicked}
-            growiRenderer={this.props.growiRenderer}
-            replyList={[]}
-          />
-        </div>
-      );
+    const hiddenElements = hiddenReplies.map((reply) => {
+      return this.renderReply(reply);
     });
 
-    const toggleBlock = (
-      <div className="page-comments-list-older collapse out" id="page-comments-list-older">
-        {toggleElements}
-      </div>
-    );
-
-    const shownBlock = shownReplies.map((reply) => {
-      return (
-        <div key={reply._id} className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
-          <CommentWrapper
-            comment={reply}
-            deleteBtnClicked={this.props.deleteBtnClicked}
-            growiRenderer={this.props.growiRenderer}
-            replyList={[]}
-          />
-        </div>
-      );
+    const shownElements = shownReplies.map((reply) => {
+      return this.renderReply(reply);
     });
 
     return (
-      <div>
-        {toggleBlock}
-        {toggleOlder}
-        {shownBlock}
+      <React.Fragment>
+        { areThereHiddenReplies && (
+          <div className="page-comments-hidden-replies">
+            <Collapse in={this.state.isOlderRepliesShown}>
+              <div>{hiddenElements}</div>
+            </Collapse>
+            <div className="text-center">{toggleButton}</div>
+          </div>
+        ) }
+
+        {shownElements}
+      </React.Fragment>
+    );
+  }
+
+  renderCommentControl(comment) {
+    return (
+      <div className="page-comment-control">
+        <button type="button" className="btn btn-link p-2" onClick={() => { this.editBtnClickedHandler(comment._id) }}>
+          <i className="ti-pencil"></i>
+        </button>
+        <button type="button" className="btn btn-link p-2 mr-2" onClick={this.deleteBtnClickedHandler}>
+          <i className="ti-close"></i>
+        </button>
       </div>
     );
   }
 
   render() {
     const comment = this.props.comment;
+    const commentId = comment._id;
     const creator = comment.creator;
     const isMarkdown = comment.isMarkdown;
+    const createdAt = new Date(comment.createdAt);
+    const updatedAt = new Date(comment.updatedAt);
+    const isEdited = createdAt < updatedAt;
+
+    const showReEditor = this.state.showReEditorIds.has(commentId);
 
-    const rootClassName = this.getRootClassName();
-    const commentDate = dateFnsFormat(comment.createdAt, 'YYYY/MM/DD HH:mm');
+    const rootClassName = this.getRootClassName(comment);
+    const commentDate = formatDistanceStrict(createdAt, new Date());
     const commentBody = isMarkdown ? this.renderRevisionBody() : this.renderText(comment.comment);
     const revHref = `?revision=${comment.revision}`;
     const revFirst8Letters = comment.revision.substr(-8);
     const revisionLavelClassName = this.getRevisionLabelClassName();
 
-    const { revisionId, revisionCreatedAt } = this.props.pageContainer.state;
-
-    let isNewer;
-    if (comment.revision === revisionId) {
-      isNewer = 'page-comments-list-current';
-    }
-    else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
-      isNewer = 'page-comments-list-newer';
-    }
-    else {
-      isNewer = 'page-comments-list-older';
-    }
-
+    const commentDateTooltip = (
+      <Tooltip id={`commentDateTooltip-${comment._id}`}>
+        {format(createdAt, 'yyyy/MM/dd HH:mm')}
+      </Tooltip>
+    );
+    const editedDateTooltip = isEdited
+      ? (
+        <Tooltip id={`editedDateTooltip-${comment._id}`}>
+          {format(updatedAt, 'yyyy/MM/dd HH:mm')}
+        </Tooltip>
+      )
+      : null;
 
     return (
-      <div>
-        <div className={isNewer}>
+      <React.Fragment>
+
+        {showReEditor ? (
+          <CommentEditor
+            growiRenderer={this.growiRenderer}
+            currentCommentId={commentId}
+            commentBody={comment.comment}
+            replyTo={undefined}
+            commentButtonClickedHandler={this.commentButtonClickedHandler}
+            commentCreator={creator.username}
+          />
+        ) : (
           <div className={rootClassName}>
             <UserPicture user={creator} />
             <div className="page-comment-main">
@@ -234,23 +302,24 @@ class Comment extends React.Component {
               </div>
               <div className="page-comment-body">{commentBody}</div>
               <div className="page-comment-meta">
-                {commentDate}&nbsp;
-                <a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a>
-              </div>
-              <div className="page-comment-control">
-                <button type="button" className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
-                  <i className="ti-close"></i>
-                </button>
+                <OverlayTrigger overlay={commentDateTooltip} placement="bottom">
+                  <span>{commentDate}</span>
+                </OverlayTrigger>
+                { isEdited && (
+                  <OverlayTrigger overlay={editedDateTooltip} placement="bottom">
+                    <span>&nbsp;(edited)</span>
+                  </OverlayTrigger>
+                ) }
+                <span className="ml-2"><a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a></span>
               </div>
+              { this.checkPermissionToControlComment() && this.renderCommentControl(comment) }
             </div>
           </div>
-        </div>
-        <div className="container-fluid">
-          <div className="row">
-            {this.renderReplies()}
-          </div>
-        </div>
-      </div>
+        )
+      }
+        {this.renderReplies()}
+
+      </React.Fragment>
     );
   }
 
@@ -272,5 +341,8 @@ Comment.propTypes = {
   deleteBtnClicked: PropTypes.func.isRequired,
   replyList: PropTypes.array,
 };
+Comment.defaultProps = {
+  replyList: [],
+};
 
 export default CommentWrapper;

+ 118 - 123
src/client/js/components/PageComment/CommentEditor.jsx

@@ -36,8 +36,7 @@ class CommentEditor extends React.Component {
     const isUploadableFile = config.upload.file;
 
     this.state = {
-      isLayoutTypeGrowi: false,
-      comment: '',
+      comment: this.props.commentBody || '',
       isMarkdown: true,
       html: '',
       key: 1,
@@ -60,15 +59,6 @@ class CommentEditor extends React.Component {
     this.toggleEditor = this.toggleEditor.bind(this);
   }
 
-  componentWillMount() {
-    this.init();
-  }
-
-  init() {
-    const layoutType = this.props.appContainer.getConfig().layoutType;
-    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
-  }
-
   updateState(value) {
     this.setState({ comment: value });
   }
@@ -94,42 +84,55 @@ class CommentEditor extends React.Component {
   }
 
   toggleEditor() {
-    this.props.commentButtonClickedHandler(this.props.replyTo);
+    const targetId = this.props.replyTo || this.props.currentCommentId;
+    this.props.commentButtonClickedHandler(targetId);
+  }
+
+  initializeEditor() {
+    this.setState({
+      comment: '',
+      isMarkdown: true,
+      html: '',
+      key: 1,
+      errorMessage: undefined,
+    });
+    // reset value
+    this.editor.setValue('');
+    this.toggleEditor();
   }
 
   /**
    * Post comment with CommentContainer and update state
    */
-  postHandler(event) {
+  async postHandler(event) {
     if (event != null) {
       event.preventDefault();
     }
 
-    const { commentContainer } = this.props;
-
-    this.props.commentContainer.postComment(
-      this.state.comment,
-      this.state.isMarkdown,
-      this.props.replyTo,
-      commentContainer.state.isSlackEnabled,
-      commentContainer.state.slackChannels,
-    )
-      .then((res) => {
-        this.setState({
-          comment: '',
-          isMarkdown: true,
-          html: '',
-          key: 1,
-          errorMessage: undefined,
-        });
-        // reset value
-        this.editor.setValue('');
-        this.toggleEditor();
-      })
-      .catch((err) => {
-        const errorMessage = err.message || 'An unknown error occured when posting comment';
-        this.setState({ errorMessage });
-      });
+    try {
+      if (this.props.currentCommentId != null) {
+        await this.props.commentContainer.putComment(
+          this.state.comment,
+          this.state.isMarkdown,
+          this.props.currentCommentId,
+          this.props.commentCreator,
+        );
+      }
+      else {
+        await this.props.commentContainer.postComment(
+          this.state.comment,
+          this.state.isMarkdown,
+          this.props.replyTo,
+          this.props.commentContainer.state.isSlackEnabled,
+          this.props.commentContainer.state.slackChannels,
+        );
+      }
+      this.initializeEditor();
+    }
+    catch (err) {
+      const errorMessage = err.message || 'An unknown error occured when posting comment';
+      this.setState({ errorMessage });
+    }
   }
 
   uploadHandler(file) {
@@ -214,7 +217,8 @@ class CommentEditor extends React.Component {
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
     const emojiStrategy = appContainer.getEmojiStrategy();
 
-    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);
 
     const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
     const submitButton = (
@@ -229,98 +233,86 @@ class CommentEditor extends React.Component {
 
     return (
       <div className="form page-comment-form">
-
-        { username
-          && (
-          <div className="comment-form">
-            { isLayoutTypeGrowi
-              && (
-              <div className="comment-form-user">
-                <UserPicture user={user} />
-              </div>
-              )
-            }
-            <div className="comment-form-main">
-              <div className="comment-write">
-                <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
-                  <Tab eventKey={1} title="Write">
-                    <Editor
-                      ref={(c) => { this.editor = c }}
-                      value={this.state.comment}
-                      isGfmMode={this.state.isMarkdown}
-                      lineNumbers={false}
-                      isMobile={appContainer.isMobile}
-                      isUploadable={this.state.isUploadable && this.state.isLayoutTypeGrowi} // enable only when GROWI layout
-                      isUploadableFile={this.state.isUploadableFile}
-                      emojiStrategy={emojiStrategy}
-                      onChange={this.updateState}
-                      onUpload={this.uploadHandler}
-                      onCtrlEnter={this.postHandler}
-                    />
+        <div className="comment-form">
+          { isBaloonStyle && (
+            <div className="comment-form-user">
+              <UserPicture user={user} />
+            </div>
+          ) }
+          <div className="comment-form-main">
+            <div className="comment-write">
+              <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
+                <Tab eventKey={1} title="Write">
+                  <Editor
+                    ref={(c) => { this.editor = c }}
+                    value={this.state.comment}
+                    isGfmMode={this.state.isMarkdown}
+                    lineNumbers={false}
+                    isMobile={appContainer.isMobile}
+                    isUploadable={this.state.isUploadable && this.state.isLayoutTypeGrowi} // enable only when GROWI layout
+                    isUploadableFile={this.state.isUploadableFile}
+                    emojiStrategy={emojiStrategy}
+                    onChange={this.updateState}
+                    onUpload={this.uploadHandler}
+                    onCtrlEnter={this.postHandler}
+                  />
+                </Tab>
+                { this.state.isMarkdown && (
+                  <Tab eventKey={2} title="Preview">
+                    <div className="comment-form-preview">
+                      {commentPreview}
+                    </div>
                   </Tab>
-                  { this.state.isMarkdown
-                    && (
-                    <Tab eventKey={2} title="Preview">
-                      <div className="comment-form-preview">
-                        {commentPreview}
-                      </div>
-                    </Tab>
-                    )
-                  }
-                </Tabs>
-              </div>
-              <div className="comment-submit">
-                <div className="d-flex">
-                  <label style={{ flex: 1 }}>
-                    { isLayoutTypeGrowi && this.state.key === 1
-                      && (
-                      <span>
-                        <input
-                          type="checkbox"
-                          id="comment-form-is-markdown"
-                          name="isMarkdown"
-                          checked={this.state.isMarkdown}
-                          value="1"
-                          onChange={this.updateStateCheckbox}
-                        />
-                        <span className="ml-2">Markdown</span>
-                      </span>
-                      )
-                  }
-                  </label>
-                  <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
-                  { this.state.hasSlackConfig
-                    && (
-                    <div className="form-inline align-self-center mr-md-2">
-                      <SlackNotification
-                        isSlackEnabled={commentContainer.state.isSlackEnabled}
-                        slackChannels={commentContainer.state.slackChannels}
-                        onEnabledFlagChange={this.onSlackEnabledFlagChange}
-                        onChannelChange={this.onSlackChannelsChange}
+                ) }
+              </Tabs>
+            </div>
+            <div className="comment-submit">
+              <div className="d-flex">
+                <label style={{ flex: 1 }}>
+                  { isBaloonStyle && this.state.key === 1 && (
+                    <span>
+                      <input
+                        type="checkbox"
+                        id="comment-form-is-markdown"
+                        name="isMarkdown"
+                        checked={this.state.isMarkdown}
+                        value="1"
+                        onChange={this.updateStateCheckbox}
                       />
-                    </div>
-                    )
-                  }
-                  <div>
-                    <Button bsStyle="danger" className="fcbtn btn btn-xs btn-danger btn-outline btn-rounded" onClick={this.toggleEditor}>
-                      Cancel
-                    </Button>
+                      <span className="ml-2">Markdown</span>
+                    </span>
+                  ) }
+                </label>
+                <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
+                { this.state.hasSlackConfig
+                  && (
+                  <div className="form-inline align-self-center mr-md-2">
+                    <SlackNotification
+                      isSlackEnabled={commentContainer.state.isSlackEnabled}
+                      slackChannels={commentContainer.state.slackChannels}
+                      onEnabledFlagChange={this.onSlackEnabledFlagChange}
+                      onChannelChange={this.onSlackChannelsChange}
+                    />
                   </div>
-                  &nbsp;&nbsp;&nbsp;&nbsp;
-                  <div className="hidden-xs">{submitButton}</div>
+                  )
+                }
+                <div>
+                  <Button bsStyle="danger" className="fcbtn btn btn-xs btn-danger btn-outline btn-rounded" onClick={this.toggleEditor}>
+                    Cancel
+                  </Button>
                 </div>
-                <div className="visible-xs mt-2">
-                  <div className="d-flex justify-content-end">
-                    { this.state.errorMessage && errorMessage }
-                    <div>{submitButton}</div>
-                  </div>
+                &nbsp;&nbsp;&nbsp;&nbsp;
+                <div className="hidden-xs">{submitButton}</div>
+              </div>
+              <div className="visible-xs mt-2">
+                <div className="d-flex justify-content-end">
+                  { this.state.errorMessage && errorMessage }
+                  <div>{submitButton}</div>
                 </div>
               </div>
             </div>
           </div>
-          )
-        }
-
+        </div>
       </div>
     );
   }
@@ -342,6 +334,9 @@ CommentEditor.propTypes = {
 
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   replyTo: PropTypes.string,
+  currentCommentId: PropTypes.string,
+  commentBody: PropTypes.string,
+  commentCreator: PropTypes.string,
   commentButtonClickedHandler: PropTypes.func.isRequired,
 };
 

+ 33 - 40
src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx

@@ -14,7 +14,6 @@ class CommentEditorLazyRenderer extends React.Component {
 
     this.state = {
       isEditorShown: false,
-      isLayoutTypeGrowi: false,
     };
 
     this.growiRenderer = this.props.appContainer.getRenderer('comment');
@@ -22,15 +21,6 @@ class CommentEditorLazyRenderer extends React.Component {
     this.showCommentFormBtnClickHandler = this.showCommentFormBtnClickHandler.bind(this);
   }
 
-  componentWillMount() {
-    this.init();
-  }
-
-  init() {
-    const layoutType = this.props.appContainer.getConfig().layoutType;
-    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
-  }
-
   showCommentFormBtnClickHandler() {
     this.setState({ isEditorShown: !this.state.isEditorShown });
   }
@@ -38,48 +28,51 @@ class CommentEditorLazyRenderer extends React.Component {
   render() {
     const { appContainer } = this.props;
     const username = appContainer.me;
+    const isLoggedIn = username != null;
     const user = appContainer.findUser(username);
-    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);
+
+    if (!isLoggedIn) {
+      return <React.Fragment></React.Fragment>;
+    }
+
     return (
       <React.Fragment>
-        { !this.state.isEditorShown
-          && (
+
+        { !this.state.isEditorShown && (
           <div className="form page-comment-form">
-            { username
-              && (
-                <div className="comment-form">
-                  { isLayoutTypeGrowi
-                  && (
-                    <div className="comment-form-user">
-                      <UserPicture user={user} />
-                    </div>
-                  )
-                  }
-                  <div className="comment-form-main">
-                    <button
-                      type="button"
-                      className={`btn btn-lg ${this.state.isLayoutTypeGrowi ? 'btn-link' : 'btn-primary'} center-block`}
-                      onClick={this.showCommentFormBtnClickHandler}
-                    >
-                      <i className="icon-bubble"></i> Add Comment
-                    </button>
-                  </div>
+            <div className="comment-form">
+              { isBaloonStyle && (
+                <div className="comment-form-user">
+                  <UserPicture user={user} />
                 </div>
-              )
-            }
+              ) }
+              <div className="comment-form-main">
+                { !this.state.isEditorShown && (
+                  <button
+                    type="button"
+                    className={`btn btn-lg ${isBaloonStyle ? 'btn-link' : 'btn-primary'} center-block`}
+                    onClick={this.showCommentFormBtnClickHandler}
+                  >
+                    <i className="icon-bubble"></i> Add Comment
+                  </button>
+                ) }
+              </div>
+            </div>
           </div>
-          )
-        }
-        { this.state.isEditorShown
-          && (
+        ) }
+
+        { this.state.isEditorShown && (
           <CommentEditor
             growiRenderer={this.growiRenderer}
             replyTo={undefined}
             commentButtonClickedHandler={this.showCommentFormBtnClickHandler}
           >
           </CommentEditor>
-)
-        }
+        ) }
+
       </React.Fragment>
     );
   }

+ 2 - 2
src/client/js/components/PageComment/DeleteCommentModal.jsx

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import Button from 'react-bootstrap/es/Button';
 import Modal from 'react-bootstrap/es/Modal';
 
-import dateFnsFormat from 'date-fns/format';
+import { format } from 'date-fns';
 
 import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
@@ -25,7 +25,7 @@ export default class DeleteCommentModal extends React.Component {
     }
 
     const comment = this.props.comment;
-    const commentDate = dateFnsFormat(comment.createdAt, 'YYYY/MM/DD HH:mm');
+    const commentDate = format(new Date(comment.createdAt), 'yyyy/MM/dd HH:mm');
 
     // generate body
     let commentBody = comment.comment;

+ 64 - 83
src/client/js/components/PageComments.jsx

@@ -7,13 +7,13 @@ import { withTranslation } from 'react-i18next';
 
 import AppContainer from '../services/AppContainer';
 import CommentContainer from '../services/CommentContainer';
+import PageContainer from '../services/PageContainer';
 
 import { createSubscribedElement } from './UnstatedUtils';
-import CommentEditor from './PageComment/CommentEditor';
 
+import CommentEditor from './PageComment/CommentEditor';
 import Comment from './PageComment/Comment';
 import DeleteCommentModal from './PageComment/DeleteCommentModal';
-import PageContainer from '../services/PageContainer';
 
 
 /**
@@ -31,8 +31,6 @@ class PageComments extends React.Component {
     super(props);
 
     this.state = {
-      isLayoutTypeGrowi: false,
-
       // for deleting comment
       commentToDelete: undefined,
       isDeleteConfirmModalShown: false,
@@ -61,9 +59,6 @@ class PageComments extends React.Component {
       return;
     }
 
-    const layoutType = this.props.appContainer.getConfig().layoutType;
-    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
-
     this.props.commentContainer.retrieveComments();
   }
 
@@ -110,10 +105,10 @@ class PageComments extends React.Component {
     });
   }
 
-  // adds replies to specific comment object
-  addRepliesToComments(comment, replies) {
+  // get replies to specific comment object
+  getRepliesFor(comment, allReplies) {
     const replyList = [];
-    replies.forEach((reply) => {
+    allReplies.forEach((reply) => {
       if (reply.replyTo === comment._id) {
         replyList.push(reply);
       }
@@ -122,102 +117,88 @@ class PageComments extends React.Component {
   }
 
   /**
-   * generate Elements of Comment
+   * render Elements of Comment Thread
    *
-   * @param {any} comments Array of Comment Model Obj
+   * @param {any} comment Comment Model Obj
+   * @param {any} replies List of Reply Comment Model Obj
    *
    * @memberOf PageComments
    */
-  generateCommentElements(comments, replies) {
-    return comments.map((comment) => {
-
-      const commentId = comment._id;
-      const showEditor = this.state.showEditorIds.has(commentId);
-      const username = this.props.appContainer.me;
-
-      const replyList = this.addRepliesToComments(comment, replies);
-
-      return (
-        <div key={commentId}>
-          <Comment
-            comment={comment}
-            deleteBtnClicked={this.confirmToDeleteComment}
-            growiRenderer={this.growiRenderer}
-            replyList={replyList}
-          />
-          <div className="container-fluid">
-            <div className="row">
-              <div className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
-                { !showEditor && (
-                  <div>
-                    { username
-                    && (
-                      <div className="col-xs-offset-6 col-sm-offset-6 col-md-offset-6 col-lg-offset-6">
-                        <Button
-                          bsStyle="primary"
-                          className="fcbtn btn btn-outline btn-rounded btn-xxs"
-                          onClick={() => { return this.replyButtonClickedHandler(commentId) }}
-                        >
-                          Reply <i className="fa fa-mail-reply"></i>
-                        </Button>
-                      </div>
-                    )
-                  }
-                  </div>
-                )}
-                { showEditor && (
-                  <CommentEditor
-                    growiRenderer={this.growiRenderer}
-                    replyTo={commentId}
-                    commentButtonClickedHandler={this.commentButtonClickedHandler}
-                  />
-                )}
-              </div>
-            </div>
+  renderThread(comment, replies) {
+    const commentId = comment._id;
+    const showEditor = this.state.showEditorIds.has(commentId);
+    const isLoggedIn = this.props.appContainer.me != null;
+
+    let rootClassNames = 'page-comment-thread';
+    if (replies.length === 0) {
+      rootClassNames += ' page-comment-thread-no-replies';
+    }
+
+    return (
+      <div key={commentId} className={`mb-5 ${rootClassNames}`}>
+        <Comment
+          comment={comment}
+          editBtnClicked={this.confirmToEditComment}
+          deleteBtnClicked={this.confirmToDeleteComment}
+          growiRenderer={this.growiRenderer}
+          replyList={replies}
+        />
+        { !showEditor && isLoggedIn && (
+          <div className="text-right">
+            <Button
+              bsStyle="default"
+              className="btn btn-outline btn-default btn-sm btn-comment-reply"
+              onClick={() => { return this.replyButtonClickedHandler(commentId) }}
+            >
+              <i className="icon-fw icon-action-redo"></i> Reply
+            </Button>
           </div>
-          <br />
-        </div>
-      );
-    });
+        )}
+        { showEditor && isLoggedIn && (
+          <div className="page-comment-reply-form">
+            <CommentEditor
+              growiRenderer={this.growiRenderer}
+              replyTo={commentId}
+              commentButtonClickedHandler={this.commentButtonClickedHandler}
+            />
+          </div>
+        )}
+      </div>
+    );
   }
 
   render() {
-    const currentComments = [];
-    const currentReplies = [];
+    const topLevelComments = [];
+    const allReplies = [];
+
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);
 
     let comments = this.props.commentContainer.state.comments;
-    if (this.state.isLayoutTypeGrowi) {
+    if (isBaloonStyle) {
       // replace with asc order array
       comments = comments.slice().reverse(); // non-destructive reverse
     }
 
     comments.forEach((comment) => {
       if (comment.replyTo === undefined) {
-      // comment is not a reply
-        currentComments.push(comment);
+        // comment is not a reply
+        topLevelComments.push(comment);
       }
       else {
-      // comment is a reply
-        currentReplies.push(comment);
+        // comment is a reply
+        allReplies.push(comment);
       }
     });
 
-    // generate elements
-    const currentElements = this.generateCommentElements(currentComments, currentReplies);
-
-    // generate blocks
-    const currentBlock = (
-      <div className="page-comments-list-current" id="page-comments-list-current">
-        {currentElements}
-      </div>
-    );
-
-    // layout blocks
-    const commentsElements = (<div>{currentBlock}</div>);
-
     return (
       <div>
-        {commentsElements}
+        { topLevelComments.map((topLevelComment) => {
+          // get related replies
+          const replies = this.getRepliesFor(topLevelComment, allReplies);
+
+          return this.renderThread(topLevelComment, replies);
+        }) }
 
         <DeleteCommentModal
           isShown={this.state.isDeleteConfirmModalShown}

+ 7 - 7
src/client/js/components/PageEditor/Cheatsheet.jsx

@@ -28,7 +28,7 @@ class Cheatsheet extends React.Component {
           <h4>{t('sandbox.line_break')}</h4>
           <p className="mb-1"><code>[ ][ ]</code> {t('sandbox.line_break_detail')}</p>
           <ul className="hljs">
-            <li>text</li>
+            <li>text&nbsp;&nbsp;</li>
             <li>text</li>
           </ul>
           <h4>{t('sandbox.typography')}</h4>
@@ -76,12 +76,12 @@ class Cheatsheet extends React.Component {
             <li>&gt;&gt;&gt;&gt; {t('sandbox.quote_nested')}</li>
           </ul>
           <h4>{t('sandbox.table')}</h4>
-          <ul className="hljs text-center">
-            <li>|Left&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;Mid&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Right|</li>
-            <li>|:----------|:---------:|----------:|</li>
-            <li>|col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|</li>
-            <li>|col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|</li>
-          </ul>
+          <pre className="border-0">
+            |Left&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;Mid&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Right|<br />
+            |:----------|:---------:|----------:|<br />
+            |col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|<br />
+            |col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|<br />
+          </pre>
           <h4>{t('sandbox.image')}</h4>
           <p className="mb-1"><code> ![{t('sandbox.alt_text')}](URL)</code> {t('sandbox.insert_image')}</p>
           <ul className="hljs">

+ 37 - 38
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -1,7 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import Modal from 'react-bootstrap/es/Modal';
 import Button from 'react-bootstrap/es/Button';
 import urljoin from 'url-join';
 import * as codemirror from 'codemirror';
@@ -12,7 +11,7 @@ import InterceptorManager from '@commons/service/interceptor-manager';
 
 import AbstractEditor from './AbstractEditor';
 import SimpleCheatsheet from './SimpleCheatsheet';
-import Cheatsheet from './Cheatsheet';
+
 import pasteHelper from './PasteHelper';
 import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
@@ -62,7 +61,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
       isEnabledEmojiAutoComplete: false,
       isLoadingKeymap: false,
       isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value.length === 0,
-      isCheatsheetModalButtonShown: this.props.isGfmMode && this.props.value.length > 0,
       isCheatsheetModalShown: false,
       additionalClassSet: new Set(),
     };
@@ -507,10 +505,15 @@ export default class CodeMirrorEditor extends AbstractEditor {
     const isGfmMode = isGfmModeTmp || this.state.isGfmMode;
     const value = valueTmp || this.getCodeMirror().getDoc().getValue();
 
-    // update isSimpleCheatsheetShown, isCheatsheetModalButtonShown
+    // update isSimpleCheatsheetShown
     const isSimpleCheatsheetShown = isGfmMode && value.length === 0;
-    const isCheatsheetModalButtonShown = isGfmMode && value.length > 0;
-    this.setState({ isSimpleCheatsheetShown, isCheatsheetModalButtonShown });
+    this.setState({ isSimpleCheatsheetShown });
+  }
+
+  markdownHelpButtonClickedHandler() {
+    if (this.props.onMarkdownHelpButtonClicked != null) {
+      this.props.onMarkdownHelpButtonClicked();
+    }
   }
 
   renderLoadingKeymapOverlay() {
@@ -533,38 +536,35 @@ export default class CodeMirrorEditor extends AbstractEditor {
       : '';
   }
 
-  renderSimpleCheatsheet() {
-    return <SimpleCheatsheet />;
-  }
-
-  renderCheatsheetModalBody() {
-    return <Cheatsheet />;
-  }
-
   renderCheatsheetModalButton() {
-    const showCheatsheetModal = () => {
-      this.setState({ isCheatsheetModalShown: true });
-    };
+    return (
+      <button type="button" className="btn-link gfm-cheatsheet-modal-link text-muted small p-0" onClick={() => { this.markdownHelpButtonClickedHandler() }}>
+        <i className="icon-question" /> Markdown
+      </button>
+    );
+  }
 
-    const hideCheatsheetModal = () => {
-      this.setState({ isCheatsheetModalShown: false });
-    };
+  renderCheatsheetOverlay() {
+    const cheatsheetModalButton = this.renderCheatsheetModalButton();
 
     return (
-      <React.Fragment>
-        <Modal className="modal-gfm-cheatsheet" show={this.state.isCheatsheetModalShown} onHide={() => { hideCheatsheetModal() }}>
-          <Modal.Header closeButton>
-            <Modal.Title><i className="icon-fw icon-question" />Markdown Help</Modal.Title>
-          </Modal.Header>
-          <Modal.Body className="pt-1">
-            { this.renderCheatsheetModalBody() }
-          </Modal.Body>
-        </Modal>
-
-        <button type="button" className="btn-link gfm-cheatsheet-modal-link text-muted small mr-3" onClick={() => { showCheatsheetModal() }}>
-          <i className="icon-question" /> Markdown
-        </button>
-      </React.Fragment>
+      <div className="overlay overlay-gfm-cheatsheet mt-1 p-3">
+        { this.state.isSimpleCheatsheetShown
+          ? (
+            <div className="text-right">
+              {cheatsheetModalButton}
+              <div className="mt-2">
+                <SimpleCheatsheet />
+              </div>
+            </div>
+          )
+          : (
+            <div className="mr-4">
+              {cheatsheetModalButton}
+            </div>
+          )
+        }
+      </div>
     );
   }
 
@@ -808,15 +808,13 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
         { this.renderLoadingKeymapOverlay() }
 
-        <div className="overlay overlay-gfm-cheatsheet mt-1 p-3 pt-3">
-          { this.state.isSimpleCheatsheetShown && this.renderSimpleCheatsheet() }
-          { this.state.isCheatsheetModalButtonShown && this.renderCheatsheetModalButton() }
-        </div>
+        { this.renderCheatsheetOverlay() }
 
         <HandsontableModal
           ref={(c) => { this.handsontableModal = c }}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
         />
+
       </React.Fragment>
     );
   }
@@ -827,6 +825,7 @@ CodeMirrorEditor.propTypes = Object.assign({
   editorOptions: PropTypes.object.isRequired,
   emojiStrategy: PropTypes.object,
   lineNumbers: PropTypes.bool,
+  onMarkdownHelpButtonClicked: PropTypes.func,
 }, AbstractEditor.propTypes);
 CodeMirrorEditor.defaultProps = {
   lineNumbers: true,

+ 33 - 2
src/client/js/components/PageEditor/Editor.jsx

@@ -3,14 +3,17 @@ import PropTypes from 'prop-types';
 
 import { Subscribe } from 'unstated';
 
+import Modal from 'react-bootstrap/es/Modal';
 import Dropzone from 'react-dropzone';
+
+import EditorContainer from '../../services/EditorContainer';
+
+import Cheatsheet from './Cheatsheet';
 import AbstractEditor from './AbstractEditor';
 import CodeMirrorEditor from './CodeMirrorEditor';
 import TextAreaEditor from './TextAreaEditor';
 
-
 import pasteHelper from './PasteHelper';
-import EditorContainer from '../../services/EditorContainer';
 
 export default class Editor extends AbstractEditor {
 
@@ -21,6 +24,7 @@ export default class Editor extends AbstractEditor {
       isComponentDidMount: false,
       dropzoneActive: false,
       isUploading: false,
+      isCheatsheetModalShown: false,
     };
 
     this.getEditorSubstance = this.getEditorSubstance.bind(this);
@@ -31,6 +35,8 @@ export default class Editor extends AbstractEditor {
     this.dragLeaveHandler = this.dragLeaveHandler.bind(this);
     this.dropHandler = this.dropHandler.bind(this);
 
+    this.showMarkdownHelp = this.showMarkdownHelp.bind(this);
+
     this.getAcceptableType = this.getAcceptableType.bind(this);
     this.getDropzoneClassName = this.getDropzoneClassName.bind(this);
     this.renderDropzoneOverlay = this.renderDropzoneOverlay.bind(this);
@@ -174,6 +180,10 @@ export default class Editor extends AbstractEditor {
     this.setState({ isUploading: true });
   }
 
+  showMarkdownHelp() {
+    this.setState({ isCheatsheetModalShown: true });
+  }
+
   getDropzoneClassName(isDragAccept, isDragReject) {
     let className = 'dropzone';
     if (!this.props.isUploadable) {
@@ -240,6 +250,23 @@ export default class Editor extends AbstractEditor {
     return navbarItems.concat(this.getEditorSubstance().getNavbarItems());
   }
 
+  renderCheatsheetModal() {
+    const hideCheatsheetModal = () => {
+      this.setState({ isCheatsheetModalShown: false });
+    };
+
+    return (
+      <Modal className="modal-gfm-cheatsheet" show={this.state.isCheatsheetModalShown} onHide={() => { hideCheatsheetModal() }}>
+        <Modal.Header closeButton>
+          <Modal.Title><i className="icon-fw icon-question" />Markdown Help</Modal.Title>
+        </Modal.Header>
+        <Modal.Body className="pt-1">
+          <Cheatsheet />
+        </Modal.Body>
+      </Modal>
+    );
+  }
+
   render() {
     const flexContainer = {
       height: '100%',
@@ -282,6 +309,7 @@ export default class Editor extends AbstractEditor {
                         editorOptions={editorContainer.state.editorOptions}
                         onPasteFiles={this.pasteFilesHandler}
                         onDragEnter={this.dragEnterHandler}
+                        onMarkdownHelpButtonClicked={this.showMarkdownHelp}
                         {...this.props}
                       />
                     )}
@@ -322,6 +350,9 @@ export default class Editor extends AbstractEditor {
           </button>
           )
         }
+
+        { this.renderCheatsheetModal() }
+
       </div>
     );
   }

+ 173 - 96
src/client/js/components/PageEditorByHackmd.jsx

@@ -2,9 +2,6 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
-import SplitButton from 'react-bootstrap/es/SplitButton';
-import MenuItem from 'react-bootstrap/es/MenuItem';
-
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 import EditorContainer from '../services/EditorContainer';
@@ -20,9 +17,12 @@ class PageEditorByHackmd extends React.Component {
     super(props);
 
     this.state = {
-      markdown: this.props.pageContainer.state.markdown,
       isInitialized: false,
       isInitializing: false,
+      // for error
+      hasError: false,
+      errorMessage: '',
+      errorReason: '',
     };
 
     this.getHackmdUri = this.getHackmdUri.bind(this);
@@ -30,6 +30,7 @@ class PageEditorByHackmd extends React.Component {
     this.resumeToEdit = this.resumeToEdit.bind(this);
     this.onSaveWithShortcut = this.onSaveWithShortcut.bind(this);
     this.hackmdEditorChangeHandler = this.hackmdEditorChangeHandler.bind(this);
+    this.penpalErrorOccuredHandler = this.penpalErrorOccuredHandler.bind(this);
   }
 
   componentWillMount() {
@@ -45,11 +46,7 @@ class PageEditorByHackmd extends React.Component {
       return Promise.reject(new Error('HackmdEditor component has not initialized'));
     }
 
-    return this.hackmdEditor.getValue()
-      .then((document) => {
-        this.setState({ markdown: document });
-        return document;
-      });
+    return this.hackmdEditor.getValue();
   }
 
   /**
@@ -67,7 +64,7 @@ class PageEditorByHackmd extends React.Component {
   /**
    * Start integration with HackMD
    */
-  startToEdit() {
+  async startToEdit() {
     const { pageContainer } = this.props;
     const hackmdUri = this.getHackmdUri();
 
@@ -84,26 +81,33 @@ class PageEditorByHackmd extends React.Component {
     const params = {
       pageId: pageContainer.state.pageId,
     };
-    this.props.appContainer.apiPost('/hackmd.integrate', params)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(res.error);
-        }
-
-        this.setState({
-          isInitialized: true,
-        });
-        pageContainer.setState({
-          pageIdOnHackmd: res.pageIdOnHackmd,
-          revisionIdHackmdSynced: res.revisionIdHackmdSynced,
-        });
-      })
-      .catch((err) => {
-        pageContainer.showErrorToastr(err);
-      })
-      .then(() => {
-        this.setState({ isInitializing: false });
+
+    try {
+      const res = await this.props.appContainer.apiPost('/hackmd.integrate', params);
+
+      if (!res.ok) {
+        throw new Error(res.error);
+      }
+
+      await pageContainer.setState({
+        pageIdOnHackmd: res.pageIdOnHackmd,
+        revisionIdHackmdSynced: res.revisionIdHackmdSynced,
+      });
+    }
+    catch (err) {
+      pageContainer.showErrorToastr(err);
+
+      this.setState({
+        hasError: true,
+        errorMessage: 'GROWI server failed to connect to HackMD.',
+        errorReason: err.toString(),
       });
+    }
+
+    this.setState({
+      isInitialized: true,
+      isInitializing: false,
+    });
   }
 
   /**
@@ -116,8 +120,27 @@ class PageEditorByHackmd extends React.Component {
   /**
    * Reset draft
    */
-  discardChanges() {
-    this.props.pageContainer.setState({ hasDraftOnHackmd: false });
+  async discardChanges() {
+    const { pageContainer } = this.props;
+    const { pageId } = pageContainer.state;
+
+    try {
+      const res = await this.props.appContainer.apiPost('/hackmd.discard', { pageId });
+
+      if (!res.ok) {
+        throw new Error(res.error);
+      }
+
+      this.props.pageContainer.setState({
+        hasDraftOnHackmd: false,
+        pageIdOnHackmd: res.pageIdOnHackmd,
+        revisionIdHackmdSynced: res.revisionIdHackmdSynced,
+      });
+    }
+    catch (err) {
+      logger.error(err);
+      pageContainer.showErrorToastr(err);
+    }
   }
 
   /**
@@ -160,7 +183,7 @@ class PageEditorByHackmd extends React.Component {
     }
 
     // do nothing if contents are same
-    if (this.state.markdown === body) {
+    if (pageContainer.state.markdown === body) {
       return;
     }
 
@@ -178,7 +201,19 @@ class PageEditorByHackmd extends React.Component {
     }
   }
 
-  render() {
+  penpalErrorOccuredHandler(error) {
+    const { pageContainer } = this.props;
+
+    pageContainer.showErrorToastr(error);
+
+    this.setState({
+      hasError: true,
+      errorMessage: 'GROWI client failed to connect to GROWI agent for HackMD.',
+      errorReason: error.toString(),
+    });
+  }
+
+  renderPreInitContent() {
     const hackmdUri = this.getHackmdUri();
     const { pageContainer } = this.props;
     const {
@@ -188,26 +223,8 @@ class PageEditorByHackmd extends React.Component {
     const isPageExistsOnHackmd = (pageIdOnHackmd != null);
     const isResume = isPageExistsOnHackmd && hasDraftOnHackmd;
 
-    if (this.state.isInitialized) {
-      return (
-        <HackmdEditor
-          ref={(c) => { this.hackmdEditor = c }}
-          hackmdUri={hackmdUri}
-          pageIdOnHackmd={pageIdOnHackmd}
-          initializationMarkdown={isResume ? null : this.state.markdown}
-          onChange={this.hackmdEditorChangeHandler}
-          onSaveWithShortcut={(document) => {
-            this.onSaveWithShortcut(document);
-          }}
-        >
-        </HackmdEditor>
-      );
-    }
-
-    const isRevisionOutdated = revisionId !== remoteRevisionId;
-    const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
-
     let content;
+
     /*
      * HackMD is not setup
      */
@@ -222,58 +239,59 @@ class PageEditorByHackmd extends React.Component {
      * Resume to edit or discard changes
      */
     else if (isResume) {
-      const title = (
-        <React.Fragment>
-          <span className="btn-label"><i className="icon-control-end"></i></span>
-          Resume to edit with HackMD
-        </React.Fragment>
-      );
+      const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
+
       content = (
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
-          <div className="text-center hackmd-resume-button-container mb-3">
-            <SplitButton
-              id="split-button-resume-hackmd"
-              title={title}
-              bsStyle="success"
-              bsSize="large"
-              className="btn-resume waves-effect waves-light"
-              onClick={() => { return this.resumeToEdit() }}
-            >
-              <MenuItem className="text-center" onClick={() => { return this.discardChanges() }}>
-                <i className="icon-control-rewind"></i> Discard changes
-              </MenuItem>
-            </SplitButton>
-          </div>
-          <p className="text-center">
-            Click to edit from the previous continuation<br />
-            or
-            <button
-              type="button"
-              className="btn btn-link text-danger p-0 hackmd-discard-button"
-              onClick={() => { return this.discardChanges() }}
-            >
-              Discard changes
-            </button>.
-          </p>
-          { isHackmdDocumentOutdated
-            && (
-            <div className="panel panel-warning mt-5">
+          <p className="text-center"><strong>HackMD has unsaved draft.</strong></p>
+
+          { isHackmdDocumentOutdated && (
+            <div className="panel panel-warning">
               <div className="panel-heading"><i className="icon-fw icon-info"></i> DRAFT MAY BE OUTDATED</div>
               <div className="panel-body text-center">
                 The current draft on HackMD is based on&nbsp;
-                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="label label-default">{revisionIdHackmdSynced.substr(-8)}</span></a>.<br />
-                <button
-                  type="button"
-                  className="btn btn-link text-danger p-0 hackmd-discard-button"
-                  onClick={() => { return this.discardChanges() }}
-                >
-                  Discard it
-                </button> to start to edit with current revision.
+                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="label label-default">{revisionIdHackmdSynced.substr(-8)}</span></a>.
+
+                <div className="text-center mt-3">
+                  <button
+                    className="btn btn-link btn-view-outdated-draft p-0"
+                    type="button"
+                    disabled={this.state.isInitializing}
+                    onClick={() => { return this.resumeToEdit() }}
+                  >
+                    View the outdated draft on HackMD
+                  </button>
+                </div>
               </div>
             </div>
-            )
-          }
+          ) }
+
+          { !isHackmdDocumentOutdated && (
+            <div className="text-center hackmd-resume-button-container mb-3">
+              <button
+                className="btn btn-success btn-lg waves-effect waves-light"
+                type="button"
+                disabled={this.state.isInitializing}
+                onClick={() => { return this.resumeToEdit() }}
+              >
+                <span className="btn-label"><i className="icon-control-end"></i></span>
+                <span className="btn-text">Resume to edit with HackMD</span>
+              </button>
+            </div>
+          ) }
+
+          <div className="text-center hackmd-discard-button-container mb-3">
+            <button
+              className="btn btn-default btn-lg waves-effect waves-light"
+              type="button"
+              onClick={() => { return this.discardChanges() }}
+            >
+              <span className="btn-label"><i className="icon-control-start"></i></span>
+              <span className="btn-text">Discard changes of HackMD</span>
+            </button>
+          </div>
+
         </div>
       );
     }
@@ -281,6 +299,8 @@ class PageEditorByHackmd extends React.Component {
      * Start to edit
      */
     else {
+      const isRevisionOutdated = revisionId !== remoteRevisionId;
+
       content = (
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
@@ -307,6 +327,63 @@ class PageEditorByHackmd extends React.Component {
     );
   }
 
+  render() {
+    const hackmdUri = this.getHackmdUri();
+    const { pageContainer } = this.props;
+    const {
+      markdown, pageIdOnHackmd, hasDraftOnHackmd,
+    } = pageContainer.state;
+
+    const isPageExistsOnHackmd = (pageIdOnHackmd != null);
+    const isResume = isPageExistsOnHackmd && hasDraftOnHackmd;
+
+    let content;
+
+    if (this.state.isInitialized) {
+      content = (
+        <HackmdEditor
+          ref={(c) => { this.hackmdEditor = c }}
+          hackmdUri={hackmdUri}
+          pageIdOnHackmd={pageIdOnHackmd}
+          initializationMarkdown={isResume ? null : markdown}
+          onChange={this.hackmdEditorChangeHandler}
+          onSaveWithShortcut={(document) => {
+            this.onSaveWithShortcut(document);
+          }}
+          onPenpalErrorOccured={this.penpalErrorOccuredHandler}
+        >
+        </HackmdEditor>
+      );
+    }
+    else {
+      content = this.renderPreInitContent();
+    }
+
+
+    return (
+      <div className="position-relative">
+
+        {content}
+
+        { this.state.hasError && (
+          <div className="hackmd-error position-absolute d-flex flex-column justify-content-center align-items-center">
+            <div className="white-box text-center">
+              <h2 className="text-warning"><i className="icon-fw icon-exclamation"></i> HackMD Integration failed</h2>
+              <h4>{this.state.errorMessage}</h4>
+              <p className="well well-sm text-danger">
+                {this.state.errorReason}
+              </p>
+              <p>
+                Check your configuration following <a href="https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html">the manual</a>.
+              </p>
+            </div>
+          </div>
+        ) }
+
+      </div>
+    );
+  }
+
 }
 
 /**

+ 17 - 6
src/client/js/components/PageEditorByHackmd/HackmdEditor.jsx

@@ -1,18 +1,18 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 
 import connectToChild from 'penpal/lib/connectToChild';
 
 const DEBUG_PENPAL = false;
 
+const logger = loggerFactory('growi:HackmdEditor');
+
 export default class HackmdEditor extends React.PureComponent {
 
   constructor(props) {
     super(props);
 
-    this.state = {
-    };
-
     this.hackmd = null;
 
     this.initHackmdWithPenpal = this.initHackmdWithPenpal.bind(this);
@@ -26,7 +26,7 @@ export default class HackmdEditor extends React.PureComponent {
     this.initHackmdWithPenpal();
   }
 
-  initHackmdWithPenpal() {
+  async initHackmdWithPenpal() {
     const _this = this; // for in methods scope
 
     const iframe = document.createElement('iframe');
@@ -43,14 +43,24 @@ export default class HackmdEditor extends React.PureComponent {
           _this.saveWithShortcutHandler(document);
         },
       },
+      timeout: 15000,
       debug: DEBUG_PENPAL,
     });
-    connection.promise.then((child) => {
+
+    try {
+      const child = await connection.promise;
       this.hackmd = child;
       if (this.props.initializationMarkdown != null) {
         child.setValueOnInit(this.props.initializationMarkdown);
       }
-    });
+    }
+    catch (err) {
+      logger.error(err);
+
+      if (this.props.onPenpalErrorOccured != null) {
+        this.props.onPenpalErrorOccured(err);
+      }
+    }
   }
 
   /**
@@ -93,4 +103,5 @@ HackmdEditor.propTypes = {
   initializationMarkdown: PropTypes.string,
   onChange: PropTypes.func,
   onSaveWithShortcut: PropTypes.func,
+  onPenpalErrorOccured: PropTypes.func,
 };

+ 74 - 48
src/client/js/components/PageHistory.jsx

@@ -1,15 +1,21 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
+
 import { withTranslation } from 'react-i18next';
 
 import PageRevisionList from './PageHistory/PageRevisionList';
 
+const logger = loggerFactory('growi:PageHistory');
 class PageHistory extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.state = {
+      isLoaded: false,
+      isLoading: false,
+      errorMessage: null,
       revisions: [],
       diffOpened: {},
     };
@@ -18,52 +24,60 @@ class PageHistory extends React.Component {
     this.onDiffOpenClicked = this.onDiffOpenClicked.bind(this);
   }
 
-  componentDidMount() {
+  async componentWillMount() {
     const pageId = this.props.pageId;
 
     if (!pageId) {
       return;
     }
 
-    this.props.crowi.apiGet('/revisions.ids', { page_id: pageId })
-      .then((res) => {
+    let res;
+    try {
+      this.setState({ isLoading: true });
+      res = await this.props.crowi.apiGet('/revisions.ids', { page_id: pageId });
+    }
+    catch (err) {
+      logger.error(err);
+      this.setState({ errorMessage: err });
+      return;
+    }
+    finally {
+      this.setState({ isLoading: false });
+    }
 
-        const rev = res.revisions;
-        const diffOpened = {};
-        const lastId = rev.length - 1;
-        res.revisions.forEach((revision, i) => {
-          const user = this.props.crowi.findUserById(revision.author);
-          if (user) {
-            rev[i].author = user;
-          }
-
-          if (i === 0 || i === lastId) {
-            diffOpened[revision._id] = true;
-          }
-          else {
-            diffOpened[revision._id] = false;
-          }
-        });
-
-        this.setState({
-          revisions: rev,
-          diffOpened,
-        });
-
-        // load 0, and last default
-        if (rev[0]) {
-          this.fetchPageRevisionBody(rev[0]);
-        }
-        if (rev[1]) {
-          this.fetchPageRevisionBody(rev[1]);
-        }
-        if (lastId !== 0 && lastId !== 1 && rev[lastId]) {
-          this.fetchPageRevisionBody(rev[lastId]);
-        }
-      })
-      .catch((err) => {
-      // do nothing
-      });
+    const rev = res.revisions;
+    const diffOpened = {};
+    const lastId = rev.length - 1;
+    res.revisions.forEach((revision, i) => {
+      const user = this.props.crowi.findUserById(revision.author);
+      if (user) {
+        rev[i].author = user;
+      }
+
+      if (i === 0 || i === lastId) {
+        diffOpened[revision._id] = true;
+      }
+      else {
+        diffOpened[revision._id] = false;
+      }
+    });
+
+    this.setState({
+      isLoaded: true,
+      revisions: rev,
+      diffOpened,
+    });
+
+    // load 0, and last default
+    if (rev[0]) {
+      this.fetchPageRevisionBody(rev[0]);
+    }
+    if (rev[1]) {
+      this.fetchPageRevisionBody(rev[1]);
+    }
+    if (lastId !== 0 && lastId !== 1 && rev[lastId]) {
+      this.fetchPageRevisionBody(rev[lastId]);
+    }
   }
 
   getPreviousRevision(currentRevision) {
@@ -124,15 +138,27 @@ class PageHistory extends React.Component {
 
   render() {
     return (
-      <div>
-        <PageRevisionList
-          t={this.props.t}
-          revisions={this.state.revisions}
-          diffOpened={this.state.diffOpened}
-          getPreviousRevision={this.getPreviousRevision}
-          onDiffOpenClicked={this.onDiffOpenClicked}
-        />
-      </div>
+      <React.Fragment>
+        { this.state.isLoading && (
+          <div className="my-5 text-center">
+            <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
+          </div>
+        ) }
+        { this.state.errorMessage && (
+          <div className="my-5">
+            <div className="text-danger">{this.state.errorMessage}</div>
+          </div>
+        ) }
+        { this.state.isLoaded && (
+          <PageRevisionList
+            t={this.props.t}
+            revisions={this.state.revisions}
+            diffOpened={this.state.diffOpened}
+            getPreviousRevision={this.getPreviousRevision}
+            onDiffOpenClicked={this.onDiffOpenClicked}
+          />
+        ) }
+      </React.Fragment>
     );
   }
 

+ 136 - 0
src/client/js/components/PageTimeline.jsx

@@ -0,0 +1,136 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import * as entities from 'entities';
+
+import AppContainer from '../services/AppContainer';
+import { createSubscribedElement } from './UnstatedUtils';
+
+import RevisionLoader from './Page/RevisionLoader';
+
+
+class PageTimeline extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    const { appContainer } = this.props;
+
+    this.state = {
+      isEnabled: appContainer.getConfig().isEnabledTimeline,
+      isInitialized: false,
+
+      // TODO: remove after when timeline is implemented with React and inject data with props
+      pages: this.props.pages,
+    };
+
+  }
+
+  componentWillMount() {
+    if (!this.state.isEnabled) {
+      return;
+    }
+
+    const { appContainer } = this.props;
+
+    // initialize GrowiRenderer
+    this.growiRenderer = appContainer.getRenderer('timeline');
+
+    this.initBsTab();
+  }
+
+  /**
+   * initialize Bootstrap Tab event for 'shown.bs.tab'
+   * TODO: remove this method after implement with React
+   */
+  initBsTab() {
+    $('a[data-toggle="tab"][href="#view-timeline"]').on('shown.bs.tab', () => {
+      if (this.state.isInitialized) {
+        return;
+      }
+
+      const pageIdsElm = document.getElementById('page-timeline-data');
+
+      if (pageIdsElm == null || pageIdsElm.text.length === 0) {
+        return;
+      }
+
+      const pages = this.extractDataFromDom();
+
+      this.setState({
+        isInitialized: true,
+        pages,
+      });
+    });
+  }
+
+  /**
+   * extract page data from DOM
+   * TODO: remove this method after implement with React
+   */
+  extractDataFromDom() {
+    const pageIdsElm = document.getElementById('page-timeline-data');
+
+    if (pageIdsElm == null || pageIdsElm.text.length === 0) {
+      return null;
+    }
+
+    let pages = JSON.parse(pageIdsElm.text);
+    // decode path
+    pages = pages.map((page) => {
+      page.path = decodeURIComponent(entities.decodeHTML(page.path));
+      return page;
+    });
+
+    return pages;
+  }
+
+  render() {
+    if (!this.state.isEnabled) {
+      return <React.Fragment></React.Fragment>;
+    }
+
+    const { pages } = this.state;
+
+    if (pages == null) {
+      return <React.Fragment></React.Fragment>;
+    }
+
+    return pages.map((page) => {
+      return (
+        <div className="timeline-body" key={`key-${page.id}`}>
+          <div className="panel panel-timeline">
+            <div className="panel-heading"><a href={page.path}>{page.path}</a></div>
+            <div className="panel-body">
+              <RevisionLoader
+                lazy
+                growiRenderer={this.growiRenderer}
+                pageId={page.id}
+                revisionId={page.revision}
+              />
+            </div>
+          </div>
+        </div>
+      );
+    });
+
+  }
+
+}
+
+PageTimeline.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pages: PropTypes.arrayOf(PropTypes.object),
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageTimelineWrapper = (props) => {
+  return createSubscribedElement(PageTimeline, props, [AppContainer]);
+};
+
+export default withTranslation()(PageTimelineWrapper);

+ 1 - 0
src/client/js/components/StaffCredit/Contributor.js

@@ -70,6 +70,7 @@ const contributors = [
           { name: 'kmyk' },
           { name: 'aximov' },
           { name: 'tats-u' },
+          { name: 'yamatomo717' },
         ],
       },
     ],

+ 123 - 0
src/client/js/components/TableOfContents.jsx

@@ -0,0 +1,123 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import { debounce } from 'throttle-debounce';
+
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+import { createSubscribedElement } from './UnstatedUtils';
+
+// get these value with
+//   document.querySelector('.revision-toc').getBoundingClientRect().top
+const DEFAULT_REVISION_TOC_TOP_FOR_GROWI_LAYOUT = 190;
+const DEFAULT_REVISION_TOC_TOP_FOR_KIBELA_LAYOUT = 105;
+
+/**
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class TableOfContents
+ * @extends {React.Component}
+ */
+class TableOfContents extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.resetScrollbarDebounced = debounce(100, this.resetScrollbar);
+  }
+
+  componentDidUpdate() {
+    const { layoutType } = this.props.appContainer.config;
+    if (layoutType === 'crowi') {
+      return;
+    }
+
+    let defaultRevisionTocTop = DEFAULT_REVISION_TOC_TOP_FOR_GROWI_LAYOUT;
+    if (layoutType === 'kibela') {
+      defaultRevisionTocTop = DEFAULT_REVISION_TOC_TOP_FOR_KIBELA_LAYOUT;
+    }
+
+    // initialize
+    this.resetScrollbar(defaultRevisionTocTop);
+
+    /*
+     * set event listener
+     */
+    // resize
+    window.addEventListener('resize', (event) => {
+      this.resetScrollbarDebounced(defaultRevisionTocTop);
+    });
+    // affix on
+    $('#revision-toc').on('affixed.bs.affix', () => {
+      this.resetScrollbar(this.getCurrentRevisionTocTop());
+    });
+    // affix off
+    $('#revision-toc').on('affixed-top.bs.affix', () => {
+      this.resetScrollbar(defaultRevisionTocTop);
+    });
+  }
+
+  getCurrentRevisionTocTop() {
+    // calculate absolute top of '#revision-toc' element
+    const revisionTocElem = document.querySelector('.revision-toc');
+    return revisionTocElem.getBoundingClientRect().top;
+  }
+
+  resetScrollbar(revisionTocTop) {
+    const tocContentElem = document.querySelector('.revision-toc .markdownIt-TOC');
+
+    if (tocContentElem == null) {
+      return;
+    }
+
+    // window height - revisionTocTop - .system-version height
+    const viewHeight = window.innerHeight - revisionTocTop - 20;
+
+    const tocContentHeight = tocContentElem.getBoundingClientRect().height + 15; // add margin
+
+    if (viewHeight < tocContentHeight) {
+      $('#revision-toc-content').slimScroll({
+        railVisible: true,
+        position: 'right',
+        height: viewHeight,
+      });
+    }
+    else {
+      $('#revision-toc-content').slimScroll({ destroy: true });
+    }
+  }
+
+  render() {
+    const { tocHtml } = this.props.pageContainer.state;
+
+    return (
+      <div
+        id="revision-toc-content"
+        className="revision-toc-content"
+        // eslint-disable-next-line react/no-danger
+        dangerouslySetInnerHTML={{
+          __html: tocHtml,
+        }}
+      />
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const TableOfContentsWrapper = (props) => {
+  return createSubscribedElement(TableOfContents, props, [AppContainer, PageContainer]);
+};
+
+TableOfContents.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+};
+
+export default withTranslation()(TableOfContentsWrapper);

+ 4 - 3
src/client/js/components/User/UserDate.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import dateFnsFormat from 'date-fns/format';
+import { format } from 'date-fns';
 
 /**
  * UserDate
@@ -11,7 +11,8 @@ import dateFnsFormat from 'date-fns/format';
 export default class UserDate extends React.Component {
 
   render() {
-    const dt = dateFnsFormat(this.props.dateTime, this.props.format);
+    const date = new Date(this.props.dateTime);
+    const dt = format(date, this.props.format);
 
     return (
       <span className={this.props.className}>
@@ -29,6 +30,6 @@ UserDate.propTypes = {
 };
 
 UserDate.defaultProps = {
-  format: 'YYYY/MM/DD HH:mm:ss',
+  format: 'yyyy/MM/dd HH:mm:ss',
   className: '',
 };

+ 0 - 111
src/client/js/legacy/crowi.js

@@ -1,17 +1,7 @@
 /* eslint-disable react/jsx-filename-extension */
 
-import React from 'react';
-import ReactDOM from 'react-dom';
-
-import { Provider } from 'unstated';
-
-import { debounce } from 'throttle-debounce';
-
 import { pathUtils } from 'growi-commons';
 
-import GrowiRenderer from '../util/GrowiRenderer';
-import RevisionLoader from '../components/Page/RevisionLoader';
-
 const entities = require('entities');
 const escapeStringRegexp = require('escape-string-regexp');
 require('jquery.cookie');
@@ -26,14 +16,6 @@ if (!window) {
 }
 window.Crowi = Crowi;
 
-/**
- * render Table Of Contents
- * @param {string} tocHtml
- */
-Crowi.renderTocContent = (tocHtml) => {
-  $('#revision-toc-content').html(tocHtml);
-};
-
 /**
  * set 'data-caret-line' attribute that will be processed when 'shown.bs.tab' event fired
  * @param {number} line
@@ -157,60 +139,6 @@ Crowi.initAffix = () => {
   }
 };
 
-Crowi.initSlimScrollForRevisionToc = () => {
-  const revisionTocElem = document.querySelector('.growi .revision-toc');
-  const tocContentElem = document.querySelector('.growi .revision-toc .markdownIt-TOC');
-
-  // growi layout only
-  if (revisionTocElem == null || tocContentElem == null) {
-    return;
-  }
-
-  function getCurrentRevisionTocTop() {
-    // calculate absolute top of '#revision-toc' element
-    return revisionTocElem.getBoundingClientRect().top;
-  }
-
-  function resetScrollbar(revisionTocTop) {
-    // window height - revisionTocTop - .system-version height
-    let h = window.innerHeight - revisionTocTop - 20;
-
-    const tocContentHeight = tocContentElem.getBoundingClientRect().height + 15; // add margin
-
-    h = Math.min(h, tocContentHeight);
-
-    $('#revision-toc-content').slimScroll({
-      railVisible: true,
-      position: 'right',
-      height: h,
-    });
-  }
-
-  const resetScrollbarDebounced = debounce(100, resetScrollbar);
-
-  // initialize
-  const revisionTocTop = getCurrentRevisionTocTop();
-  resetScrollbar(revisionTocTop);
-
-  /*
-   * set event listener
-   */
-  // resize
-  window.addEventListener('resize', (event) => {
-    resetScrollbarDebounced(getCurrentRevisionTocTop());
-  });
-  // affix on
-  $('#revision-toc').on('affixed.bs.affix', () => {
-    resetScrollbar(getCurrentRevisionTocTop());
-  });
-  // affix off
-  $('#revision-toc').on('affixed-top.bs.affix', () => {
-    // calculate sum of height (.navbar-header + .bg-title) + margin-top of .main
-    const sum = 138;
-    resetScrollbar(sum);
-  });
-};
-
 Crowi.initClassesByOS = function() {
   // add classes to cmd-key by OS
   const platform = navigator.platform.toLowerCase();
@@ -551,44 +479,6 @@ $(() => {
     $link.html(path.replace(new RegExp(pattern), `<strong>${shortPath}$1</strong>`));
   });
 
-  // for list page
-  let growiRendererForTimeline = null;
-  $('a[data-toggle="tab"][href="#view-timeline"]').on('shown.bs.tab', () => {
-    const isShown = $('#view-timeline').data('shown');
-
-    if (growiRendererForTimeline == null) {
-      growiRendererForTimeline = GrowiRenderer.generate('timeline');
-    }
-
-    if (isShown === 0) {
-      $('#view-timeline .timeline-body').each(function() {
-        const id = $(this).attr('id');
-        const revisionBody = `#${id} .revision-body`;
-        const revisionBodyElem = document.querySelector(revisionBody);
-        const revisionPath = `#${id} .revision-path`; // eslint-disable-line no-unused-vars
-        const timelineElm = document.getElementById(id);
-        const pageId = timelineElm.getAttribute('data-page-id');
-        const pagePath = timelineElm.getAttribute('data-page-path');
-        const revisionId = timelineElm.getAttribute('data-revision');
-
-        ReactDOM.render(
-          <Provider inject={[appContainer]}>
-            <RevisionLoader
-              lazy
-              growiRenderer={growiRendererForTimeline}
-              pageId={pageId}
-              pagePath={pagePath}
-              revisionId={revisionId}
-            />
-          </Provider>,
-          revisionBodyElem,
-        );
-      });
-
-      $('#view-timeline').data('shown', 1);
-    }
-  });
-
   if (pageId) {
     // for Crowi Template LangProcessor
     $('.template-create-button', $('#revision-body')).on('click', function() {
@@ -769,7 +659,6 @@ window.addEventListener('load', (e) => {
 
   Crowi.highlightSelectedSection(window.location.hash);
   Crowi.modifyScrollTop();
-  Crowi.initSlimScrollForRevisionToc();
   Crowi.initAffix();
   Crowi.initClassesByOS();
 });

+ 18 - 18
src/client/js/services/AppContainer.js

@@ -31,7 +31,7 @@ export default class AppContainer extends Container {
 
     const body = document.querySelector('body');
 
-    this.me = body.dataset.currentUsername;
+    this.me = body.dataset.currentUsername || null; // will be initialized with null when data is empty string
     this.isAdmin = body.dataset.isAdmin === 'true';
     this.csrfToken = body.dataset.csrftoken;
     this.isPluginEnabled = body.dataset.pluginEnabled === 'true';
@@ -69,6 +69,7 @@ export default class AppContainer extends Container {
     this.fetchUsers = this.fetchUsers.bind(this);
     this.apiGet = this.apiGet.bind(this);
     this.apiPost = this.apiPost.bind(this);
+    this.apiDelete = this.apiDelete.bind(this);
     this.apiRequest = this.apiRequest.bind(this);
 
     this.apiv3Root = '/_api/v3';
@@ -288,11 +289,11 @@ export default class AppContainer extends Container {
     targetComponent.launchHandsontableModal(beginLineNumber, endLineNumber);
   }
 
-  apiGet(path, params) {
+  async apiGet(path, params) {
     return this.apiRequest('get', path, { params });
   }
 
-  apiPost(path, params) {
+  async apiPost(path, params) {
     if (!params._csrf) {
       params._csrf = this.csrfToken;
     }
@@ -300,21 +301,20 @@ export default class AppContainer extends Container {
     return this.apiRequest('post', path, params);
   }
 
-  apiRequest(method, path, params) {
-    return new Promise((resolve, reject) => {
-      axios[method](`/_api${path}`, params)
-        .then((res) => {
-          if (res.data.ok) {
-            resolve(res.data);
-          }
-          else {
-            reject(new Error(res.data.error));
-          }
-        })
-        .catch((res) => {
-          reject(res);
-        });
-    });
+  async apiDelete(path, params) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
+    return this.apiRequest('delete', path, { data: params });
+  }
+
+  async apiRequest(method, path, params) {
+    const res = await axios[method](`/_api${path}`, params);
+    if (res.data.ok) {
+      return res.data;
+    }
+    throw new Error(res.data.error);
   }
 
   async apiv3Request(method, path, params) {

+ 23 - 0
src/client/js/services/CommentContainer.js

@@ -100,6 +100,29 @@ export default class CommentContainer extends Container {
       });
   }
 
+  /**
+   * Load data of comments and rerender <PageComments />
+   */
+  putComment(comment, isMarkdown, commentId, author) {
+    const { pageId, revisionId } = this.getPageContainer().state;
+
+    return this.appContainer.apiPost('/comments.update', {
+      commentForm: {
+        comment,
+        page_id: pageId,
+        revision_id: revisionId,
+        is_markdown: isMarkdown,
+        comment_id: commentId,
+        author,
+      },
+    })
+      .then((res) => {
+        if (res.ok) {
+          return this.retrieveComments();
+        }
+      });
+  }
+
   deleteComment(comment) {
     return this.appContainer.apiPost('/comments.remove', { comment_id: comment._id })
       .then((res) => {

+ 10 - 3
src/client/js/services/PageContainer.js

@@ -36,18 +36,19 @@ export default class PageContainer extends Container {
       revisionId,
       revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
       path: mainContent.getAttribute('data-path'),
+      tocHtml: '',
       isLiked: false,
       seenUserIds: [],
       likerUserIds: [],
 
       tags: [],
-      templateTagData: mainContent.getAttribute('data-template-tags'),
+      templateTagData: mainContent.getAttribute('data-template-tags') || null,
 
       // latest(on remote) information
       remoteRevisionId: revisionId,
-      revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced'),
+      revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
       lastUpdateUsername: undefined,
-      pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd'),
+      pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       isHackmdDraftUpdatingInRealtime: false,
     };
@@ -55,6 +56,7 @@ export default class PageContainer extends Container {
     this.initStateMarkdown();
     this.initStateOthers();
 
+    this.setTocHtml = this.setTocHtml.bind(this);
     this.save = this.save.bind(this);
     this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
     this.addWebSocketEventHandlers();
@@ -110,6 +112,11 @@ export default class PageContainer extends Container {
     });
   }
 
+  setTocHtml(tocHtml) {
+    if (this.state.tocHtml !== tocHtml) {
+      this.setState({ tocHtml });
+    }
+  }
 
   /**
    * save success handler

+ 3 - 1
src/client/js/services/TagContainer.js

@@ -49,7 +49,9 @@ export default class TagContainer extends Container {
     }
     // when the page not exist
     else if (templateTagData != null) {
-      tags = templateTagData.split(',');
+      tags = templateTagData.split(',').filter((str) => {
+        return str !== ''; // filter empty values
+      });
     }
 
     logger.debug('tags data has been initialized');

+ 2 - 2
src/client/js/util/GrowiRenderer.js

@@ -74,11 +74,11 @@ export default class GrowiRenderer {
     // add configurers according to mode
     switch (mode) {
       case 'page': {
-        const renderToc = appContainer.getCrowiForJquery().renderTocContent;
+        const pageContainer = appContainer.getContainer('PageContainer');
 
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
           new FooternoteConfigurer(appContainer),
-          new TocAndAnchorConfigurer(appContainer, renderToc),
+          new TocAndAnchorConfigurer(appContainer, pageContainer.setTocHtml),
           new HeaderLineNumberConfigurer(appContainer),
           new HeaderWithEditLinkConfigurer(appContainer),
           new TableWithHandsontableButtonConfigurer(appContainer),

+ 1 - 1
src/client/js/util/markdown-it/plantuml.js

@@ -7,7 +7,7 @@ export default class PlantUMLConfigurer {
     this.crowi = crowi;
     const config = crowi.getConfig();
 
-    this.serverUrl = config.env.PLANTUML_URI || 'http://plantuml.com/plantuml';
+    this.serverUrl = config.env.PLANTUML_URI || 'https://plantuml.com/plantuml';
 
     this.generateSource = this.generateSource.bind(this);
   }

+ 4 - 4
src/client/js/util/markdown-it/toc-and-anchor.js

@@ -1,8 +1,8 @@
 export default class TocAndAnchorConfigurer {
 
-  constructor(crowi, renderToc) {
+  constructor(crowi, setHtml) {
     this.crowi = crowi;
-    this.renderToc = renderToc;
+    this.setHtml = setHtml;
   }
 
   configure(md) {
@@ -15,10 +15,10 @@ export default class TocAndAnchorConfigurer {
     });
 
     // set toc render function
-    if (this.renderToc != null) {
+    if (this.setHtml != null) {
       md.set({
         tocCallback: (tocMarkdown, tocArray, tocHtml) => {
-          this.renderToc(tocHtml);
+          this.setHtml(tocHtml);
         },
       });
     }

+ 2 - 2
src/client/styles/bootstrap4/_mixins.scss

@@ -5,7 +5,7 @@
 // Utilities
 @import 'mixins/breakpoints';
 // @import "mixins/hover";
-// @import "mixins/image";
+@import 'mixins/image';
 // @import "mixins/badge";
 // @import "mixins/resize";
 // @import "mixins/screen-reader";
@@ -29,7 +29,7 @@
 
 // // Skins
 // @import "mixins/background-variant";
-// @import "mixins/border-radius";
+@import 'mixins/border-radius';
 // @import "mixins/box-shadow";
 // @import "mixins/gradients";
 // @import "mixins/transition";

+ 3 - 3
src/client/styles/bootstrap4/_utilities.scss

@@ -1,15 +1,15 @@
 // @import "utilities/align";
 // @import "utilities/background";
-// @import "utilities/borders";
+@import "utilities/borders";
 // @import "utilities/clearfix";
 @import 'utilities/display';
 // @import "utilities/embed";
 @import 'utilities/flex';
 // @import "utilities/float";
-// @import "utilities/position";
+@import 'utilities/position';
 // @import "utilities/screenreaders";
 // @import "utilities/shadows";
-// @import "utilities/sizing";
+@import 'utilities/sizing';
 @import 'utilities/spacing';
 @import 'utilities/text';
 // @import "utilities/visibility";

+ 70 - 70
src/client/styles/bootstrap4/_variables.scss

@@ -7,17 +7,17 @@
 // // Color system
 // //
 
-// $white:    #fff !default;
-// $gray-100: #f8f9fa !default;
-// $gray-200: #e9ecef !default;
-// $gray-300: #dee2e6 !default;
-// $gray-400: #ced4da !default;
-// $gray-500: #adb5bd !default;
-// $gray-600: #6c757d !default;
-// $gray-700: #495057 !default;
-// $gray-800: #343a40 !default;
-// $gray-900: #212529 !default;
-// $black:    #000 !default;
+$white:    #fff !default;
+$gray-100: #f8f9fa !default;
+$gray-200: #e9ecef !default;
+$gray-300: #dee2e6 !default;
+$gray-400: #ced4da !default;
+$gray-500: #adb5bd !default;
+$gray-600: #6c757d !default;
+$gray-700: #495057 !default;
+$gray-800: #343a40 !default;
+$gray-900: #212529 !default;
+$black:    #000 !default;
 
 // $grays: () !default;
 // // stylelint-disable-next-line scss/dollar-variable-default
@@ -74,27 +74,27 @@
 // $info:          $cyan !default;
 // $warning:       $yellow !default;
 // $danger:        $red !default;
-// $light:         $gray-100 !default;
-// $dark:          $gray-800 !default;
+$light:         $gray-100 !default;
+$dark:          $gray-800 !default;
 
-// $theme-colors: () !default;
-// // stylelint-disable-next-line scss/dollar-variable-default
-// $theme-colors: map-merge(
-//   (
-//     "primary":    $primary,
-//     "secondary":  $secondary,
-//     "success":    $success,
-//     "info":       $info,
-//     "warning":    $warning,
-//     "danger":     $danger,
-//     "light":      $light,
-//     "dark":       $dark
-//   ),
-//   $theme-colors
-// );
+$theme-colors: () !default;
+// stylelint-disable-next-line scss/dollar-variable-default
+$theme-colors: map-merge(
+  (
+    'primary':    $primary,
+    // 'secondary':  $secondary,
+    'success':    $success,
+    'info':       $info,
+    'warning':    $warning,
+    'danger':     $danger,
+    'light':      $light,
+    'dark':       $dark
+  ),
+  $theme-colors
+);
 
-// // Set a specific jump point for requesting color jumps
-// $theme-color-interval:      8% !default;
+// Set a specific jump point for requesting color jumps
+$theme-color-interval:      8% !default;
 
 // // The yiq lightness value that determines when the lightness of color changes from "dark" to "light". Acceptable values are between 0 and 255.
 // $yiq-contrasted-threshold:  150 !default;
@@ -103,18 +103,18 @@
 // $yiq-text-dark:             $gray-900 !default;
 // $yiq-text-light:            $white !default;
 
-// // Options
-// //
-// // Quickly modify global styling by enabling or disabling optional features.
+// Options
+//
+// Quickly modify global styling by enabling or disabling optional features.
 
-// $enable-caret:              true !default;
-// $enable-rounded:            true !default;
-// $enable-shadows:            false !default;
-// $enable-gradients:          false !default;
-// $enable-transitions:        true !default;
-// $enable-hover-media-query:  false !default; // Deprecated, no longer affects any compiled CSS
-// $enable-grid-classes:       true !default;
-// $enable-print-styles:       true !default;
+$enable-caret:              true !default;
+$enable-rounded:            true !default;
+$enable-shadows:            false !default;
+$enable-gradients:          false !default;
+$enable-transitions:        true !default;
+$enable-hover-media-query:  false !default; // Deprecated, no longer affects any compiled CSS
+$enable-grid-classes:       true !default;
+$enable-print-styles:       true !default;
 
 // Spacing
 //
@@ -145,19 +145,19 @@ $spacers: map-merge(
   $spacers
 );
 
-// // This variable affects the `.h-*` and `.w-*` classes.
-// $sizes: () !default;
-// // stylelint-disable-next-line scss/dollar-variable-default
-// $sizes: map-merge(
-//   (
-//     25: 25%,
-//     50: 50%,
-//     75: 75%,
-//     100: 100%,
-//     auto: auto
-//   ),
-//   $sizes
-// );
+// This variable affects the `.h-*` and `.w-*` classes.
+$sizes: () !default;
+// stylelint-disable-next-line scss/dollar-variable-default
+$sizes: map-merge(
+  (
+    25: 25%,
+    50: 50%,
+    75: 75%,
+    100: 100%,
+    auto: auto
+  ),
+  $sizes
+);
 
 // // Body
 // //
@@ -224,12 +224,12 @@ $grid-breakpoints: (
 // $line-height-lg:              1.5 !default;
 // $line-height-sm:              1.5 !default;
 
-// $border-width:                1px !default;
-// $border-color:                $gray-300 !default;
+$border-width:                1px !default;
+$border-color:                $gray-300 !default;
 
-// $border-radius:               .25rem !default;
-// $border-radius-lg:            .3rem !default;
-// $border-radius-sm:            .2rem !default;
+$border-radius:               .25rem !default;
+$border-radius-lg:            .3rem !default;
+$border-radius-sm:            .2rem !default;
 
 // $box-shadow-sm:               0 .125rem .25rem rgba($black, .075) !default;
 // $box-shadow:                  0 .5rem 1rem rgba($black, .15) !default;
@@ -606,8 +606,8 @@ $font-weight-bold: 700 !default;
 // // of components dependent on the z-axis and are designed to all work together.
 
 // $zindex-dropdown:                   1000 !default;
-// $zindex-sticky:                     1020 !default;
-// $zindex-fixed:                      1030 !default;
+$zindex-sticky:                     1020 !default;
+$zindex-fixed:                      1030 !default;
 // $zindex-modal-backdrop:             1040 !default;
 // $zindex-modal:                      1050 !default;
 // $zindex-popover:                    1060 !default;
@@ -859,17 +859,17 @@ $font-weight-bold: 700 !default;
 
 // // Image thumbnails
 
-// $thumbnail-padding:                 .25rem !default;
-// $thumbnail-bg:                      $body-bg !default;
-// $thumbnail-border-width:            $border-width !default;
-// $thumbnail-border-color:            $gray-300 !default;
-// $thumbnail-border-radius:           $border-radius !default;
-// $thumbnail-box-shadow:              0 1px 2px rgba($black, .075) !default;
+$thumbnail-padding:                 .25rem !default;
+$thumbnail-bg:                      $body-bg !default;
+$thumbnail-border-width:            $border-width !default;
+$thumbnail-border-color:            $gray-300 !default;
+$thumbnail-border-radius:           $border-radius !default;
+$thumbnail-box-shadow:              0 1px 2px rgba($black, .075) !default;
 
-// // Figures
+// Figures
 
-// $figure-caption-font-size:          90% !default;
-// $figure-caption-color:              $gray-600 !default;
+$figure-caption-font-size:          90% !default;
+$figure-caption-color:              $gray-600 !default;
 
 // // Breadcrumbs
 

+ 1 - 1
src/client/styles/bootstrap4/bootstrap.scss

@@ -11,7 +11,7 @@
 // @import "root";
 // @import "reboot";
 // @import "type";
-// @import "images";
+@import 'images';
 // @import "code";
 // @import "grid";
 // @import "tables";

+ 2 - 2
src/client/styles/hackmd/style.scss

@@ -14,8 +14,8 @@
   }
 }
 
-.CodeMirror pre {
-  font-family: Osaka-Mono, "MS Gothic", Monaco, Menlo, Consolas, "Courier New", monospace;
+.CodeMirror pre.CodeMirror-line {
+  font-family: Osaka-Mono, 'MS Gothic', Monaco, Menlo, Consolas, 'Courier New', monospace;
   font-size: 14px;
   line-height: 20px;
 }

+ 19 - 22
src/client/styles/scss/_comment.scss

@@ -23,34 +23,31 @@
 
 .main-container {
   .page-comments {
-    .page-comments-list-toggle-newer,
     .page-comments-list-toggle-older {
-      display: block;
-      margin: 8px;
+      display: inline-block;
       font-size: 0.9em;
-      text-align: center;
     }
 
-    // older comments
-    .page-comments-list-older .page-comment {
-    }
-    // newer comments
-    .page-comments-list-newer .page-comment {
-      opacity: 0.7;
+    .page-comment {
+      // older comments
+      &.page-comment-older {
+      }
+      // newer comments
+      &.page-comment-newer {
+        opacity: 0.7;
 
-      &:hover {
-        opacity: 1;
+        &:hover {
+          opacity: 1;
+        }
+      }
+
+      .page-comment-meta {
+        display: flex;
+        justify-content: flex-end;
+
+        font-size: 0.9em;
+        color: #999;
       }
     }
   }
 }
-
-.btn-xxs {
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  width: 50px;
-  height: 10px;
-  font-size: 11px;
-  border-radius: 1px;
-}

+ 5 - 0
src/client/styles/scss/_comment_crowi.scss

@@ -0,0 +1,5 @@
+.crowi.main-container {
+  .page-comment-main {
+    margin-bottom: 0.5em;
+  }
+}

+ 28 - 11
src/client/styles/scss/_comment_growi.scss

@@ -2,8 +2,8 @@
   %comment-section {
     position: relative;
     padding: 1em;
-    margin-bottom: 1em;
     margin-left: 4.5em;
+
     // screen-xs
     @media (max-width: $screen-xs) {
       margin-left: 3.5em;
@@ -75,16 +75,32 @@
       margin-bottom: 0.5em;
       word-wrap: break-word;
     }
+  }
 
-    .page-comment-meta {
-      font-size: 0.9em;
-      color: #999;
-      text-align: right;
-
-      * {
-        vertical-align: 25%;
-      }
-    }
+  /*
+   * reply
+   */
+  .page-comment-reply {
+    margin-top: 1em;
+  }
+  // remove margin after hidden replies
+  .page-comments-hidden-replies + .page-comment-reply {
+    margin-top: 0;
+  }
+  .page-comment-reply,
+  .page-comment-reply-form {
+    margin-right: 15px;
+    margin-left: 6em;
+  }
+  // reply button
+  .btn.btn-comment-reply {
+    width: 120px;
+    margin-top: 0.5em;
+    margin-right: 15px;
+
+    border-top: none;
+    border-right: none;
+    border-left: none;
   }
 
   // show when hover
@@ -99,7 +115,8 @@
     }
 
     position: relative;
-    margin-top: 2em;
+    margin-top: 1em;
+
     // user icon
     .picture {
       @extend %picture;

+ 63 - 22
src/client/styles/scss/_comment_kibela.scss

@@ -3,18 +3,19 @@
   %comment-section {
     position: relative;
     padding: 1em;
-    margin-bottom: 1em; // screen-xs
     margin-left: 4.5em;
+
     @media (max-width: $screen-xs) {
       margin-left: 3.5em;
-    } // speech balloon
+    }
+
+    // speech balloon
     &:before {
       position: absolute;
       top: 1.5em;
       left: -1em;
       display: block;
       width: 0;
-      width: 0; // screen-xs
       height: 0;
       content: '';
       border-top: 20px solid transparent;
@@ -22,11 +23,13 @@
       border-bottom: 20px solid transparent;
       border-left: 20px solid transparent;
       border-left-width: 0;
+
       @media (max-width: $screen-xs) {
         top: 1em;
       }
     }
   }
+
   %picture {
     float: left;
     width: 3em;
@@ -37,55 +40,91 @@
       height: 2em;
     }
   }
+
   .page-comments-row {
     margin: 10px 0px;
   }
+
   .page-comments {
     h4 {
       margin-bottom: 1em;
     }
   }
   .page-comment {
-    position: relative; // ユーザー名
+    position: relative;
+
+    // ユーザー名
     .page-comment-creator {
       margin-top: -0.5em;
       margin-bottom: 0.5em;
       font-weight: bold;
-    } // ユーザーアイコン
+    }
+
+    // ユーザーアイコン
     .picture {
       @extend %picture;
-    } // コメントセクション
+    }
+
+    // コメントセクション
     .page-comment-main {
       @extend %comment-section;
       background: #e6e9ec;
       border-radius: 0.35em;
-    } // コメント本文
+    }
+
+    // コメント本文
     .page-comment-body {
       margin-bottom: 0.5em;
       word-wrap: break-word;
     }
-    .page-comment-meta {
-      font-size: 0.9em;
-      color: #e5ecf1;
-      text-align: right;
-      * {
-        vertical-align: 25%;
-      }
-    }
-  } // show when hover
+  }
+
+  /*
+   * reply
+   */
+  .page-comment-reply {
+    margin-top: 1em;
+  }
+  // remove margin after hidden replies
+  .page-comments-hidden-replies + .page-comment-reply {
+    margin-top: 0;
+  }
+  .page-comment-reply,
+  .page-comment-reply-form {
+    margin-right: 15px;
+    margin-left: 6em;
+  }
+  // reply button
+  .btn.btn-comment-reply {
+    width: 120px;
+    margin-top: 0.5em;
+    margin-right: 15px;
+
+    border-top: none;
+    border-right: none;
+    border-left: none;
+  }
+
+  // show when hover
   .page-comment-main:hover > .page-comment-control {
     display: block;
-  } // display cheatsheet for comment form only
+  }
+
+  // display cheatsheet for comment form only
   .comment-form {
-    position: relative;
-    margin-top: 2em; // user icon
-    border: none;
     .editor-cheatsheet {
       display: none;
     }
+
+    position: relative;
+    margin-top: 1em;
+
+    // user icon
     .picture {
       @extend %picture;
-    } // seciton
+    }
+
+    // seciton
     .comment-form-main {
       @extend %comment-section;
       background: #e6e9ec;
@@ -93,7 +132,9 @@
       .CodeMirror {
         border: 0px;
       }
-    } // textarea
+    }
+
+    // textarea
     .comment-write {
       margin-bottom: 0.5em;
     }

+ 1 - 1
src/client/styles/scss/_editor-overlay.scss

@@ -69,6 +69,6 @@
 
 .modal-gfm-cheatsheet .modal-body {
   .hljs {
-    font-family: monospace;
+    font-family: $font-family-monospace;
   }
 }

+ 1 - 1
src/client/styles/scss/_layout.scss

@@ -64,7 +64,7 @@
     position: fixed;
     right: 25%;
     bottom: 25px;
-    z-index: 1039;
+    z-index: 1;
     display: block;
     padding: 5px 8px;
     font-size: 0.8em;

+ 10 - 8
src/client/styles/scss/_login.scss

@@ -94,13 +94,7 @@
     }
   }
 
-  .collapse-oauth {
-    overflow: hidden;
-    &:not(.in) {
-      height: 0;
-      padding: 0 !important;
-    }
-
+  .external-auth {
     form {
       flex: 1;
       @media (min-width: 350px) {
@@ -112,11 +106,19 @@
     }
   }
 
+  .collapse-external-auth {
+    overflow: hidden;
+    &:not(.in) {
+      height: 0;
+      padding: 0 !important;
+    }
+  }
+
   // button style
   .btn-login.fcbtn,
   .btn-register.fcbtn,
   .btn-login-oauth.fcbtn,
-  .btn-collapse-oauth {
+  .btn-collapse-external-auth {
     color: white;
     background-color: rgba(lighten(black, 20%), 0.4);
     border: none;

+ 1 - 0
src/client/styles/scss/_mixins.scss

@@ -73,6 +73,7 @@
       #page-editor-with-hackmd {
         &,
         .hackmd-preinit,
+        .hackmd-error,
         #iframe-hackmd-container > iframe {
           width: 100vw;
           height: calc(100vh - #{$header-plus-footer});

+ 12 - 14
src/client/styles/scss/_on-edit.scss

@@ -267,27 +267,25 @@ body.on-edit {
       border: none;
     }
 
+    .hackmd-error {
+      top: 0;
+      background-color: rgba($gray-dark, 0.8);
+    }
+
     .hackmd-status-label {
       font-size: 3em;
       color: $muted;
     }
 
-    .hackmd-start-button-container,
-    .hackmd-resume-button-container {
-      .btn-lg .btn-label {
-        padding-top: 6px; // for SplitButton
-        padding-bottom: 6px; // for SplitButton
-      }
-    }
-
-    .hackmd-resume-button-container {
-      .dropdown-menu {
-        right: 0;
-        left: unset;
+    .hackmd-resume-button-container,
+    .hackmd-discard-button-container {
+      .btn-text {
+        display: inline-block;
+        min-width: 230px;
       }
     }
 
-    .hackmd-discard-button {
+    .btn-view-outdated-draft {
       text-decoration: underline;
       vertical-align: unset;
     }
@@ -306,7 +304,7 @@ body.on-edit {
 }
 
 // overwrite .CodeMirror pre
-.CodeMirror pre {
+.CodeMirror pre.CodeMirror-line {
   font-family: $font-family-monospace;
 }
 

+ 2 - 0
src/client/styles/scss/_page.scss

@@ -10,6 +10,8 @@
   header {
     // the container of h1
     div.title-container {
+      padding-right: 5px;
+      padding-left: 5px;
       margin-right: auto;
     }
 

+ 67 - 52
src/client/styles/scss/_page_growi.scss

@@ -1,52 +1,67 @@
-.growi.main-container {
-  header {
-    div.title-logo-container {
-      display: none; // hide in default
-
-      a {
-        // centering
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        width: 32px;
-        height: 32px;
-
-        img {
-          width: 16px;
-          height: 16px;
-        }
-      }
-    }
-
-    ul.authors {
-      margin: 0;
-
-      li {
-        font-size: 12px;
-        list-style: none;
-      }
-
-      .picture {
-        width: 22px;
-        height: 22px;
-        border: 1px solid #ccc;
-      }
-    }
-  }
-
-  /*
-   * affix header
-   */
-  header.affix {
-    // show logo link
-    div.title-logo-container {
-      display: unset;
-      margin-right: 6px;
-      margin-left: -12px;
-    }
-    // hide authors in affix
-    .authors {
-      display: none !important;
-    }
-  }
-}
+.growi.main-container {
+  header {
+    div.title-logo-container {
+      display: none; // hide in default
+
+      a {
+        // centering
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 32px;
+        height: 32px;
+
+        img {
+          width: 16px;
+          height: 16px;
+        }
+      }
+    }
+
+    ul.authors {
+      padding-left: 1.5em;
+      margin: 0;
+
+      li {
+        font-size: 12px;
+        list-style: none;
+      }
+
+      .picture {
+        width: 22px;
+        height: 22px;
+        border: 1px solid #ccc;
+
+        &.picture-xs {
+          width: 14px;
+          height: 14px;
+        }
+      }
+    }
+  }
+
+  /*
+   * affix header
+   */
+  header:not(.affix) {
+    .only-affix {
+      display: none !important;
+    }
+  }
+  header.affix {
+    .not-affix {
+      display: none !important;
+    }
+
+    // show logo link
+    div.title-logo-container {
+      display: unset;
+      margin-right: 6px;
+      margin-left: -12px;
+    }
+    // hide authors in affix
+    .authors {
+      padding-left: 0.5em;
+    }
+  }
+}

+ 1 - 1
src/client/styles/scss/_search.scss

@@ -210,7 +210,7 @@
 .search-page-input {
   position: sticky;
   top: 0;
-  z-index: 99;
+  z-index: 1;
 
   // for sticky layout
   padding-top: 15px;

+ 1 - 0
src/client/styles/scss/style-app.scss

@@ -15,6 +15,7 @@
 @import 'admin';
 @import 'attachments';
 @import 'comment';
+@import 'comment_crowi';
 @import 'comment_growi';
 @import 'comment_kibela';
 @import 'navbar_kibela';

+ 3 - 0
src/client/styles/scss/theme/_override-agileadmin.scss

@@ -1,4 +1,7 @@
 .bg-title {
+  padding: 6px 0;
+  margin-right: -15px;
+  margin-left: -15px;
   overflow: unset;
 }
 

+ 6 - 6
src/lib/service/cdn-resources-service.js

@@ -19,15 +19,15 @@ class CdnResourcesService {
     this.loadManifests();
   }
 
+  get noCdn() {
+    return envUtils.toBoolean(process.env.NO_CDN);
+  }
+
   loadManifests() {
     this.cdnManifests = require('@root/resource/cdn-manifests');
     this.logger.debug('manifest data loaded : ', this.cdnManifests);
   }
 
-  noCdn() {
-    return envUtils.toBoolean(process.env.NO_CDN);
-  }
-
   getScriptManifestByName(name) {
     const manifests = this.cdnManifests.js
       .filter((manifest) => { return manifest.name === name });
@@ -91,7 +91,7 @@ class CdnResourcesService {
 
     // TODO process integrity
 
-    const url = this.noCdn()
+    const url = this.noCdn
       ? `${urljoin(cdnLocalScriptWebRoot, manifest.name)}.js`
       : manifest.url;
     return `<script src="${url}" ${attrs.join(' ')}></script>`;
@@ -130,7 +130,7 @@ class CdnResourcesService {
 
     // TODO process integrity
 
-    const url = this.noCdn()
+    const url = this.noCdn
       ? `${urljoin(cdnLocalStyleWebRoot, manifest.name)}.css`
       : manifest.url;
 

+ 0 - 15
src/server/crowi/dev.js

@@ -123,21 +123,6 @@ class CrowiDev {
     app.use(require('connect-browser-sync')(bs));
   }
 
-  loadPlugins(app) {
-    if (process.env.PLUGIN_NAMES_TOBE_LOADED !== undefined
-        && process.env.PLUGIN_NAMES_TOBE_LOADED.length > 0) {
-      const pluginNames = process.env.PLUGIN_NAMES_TOBE_LOADED.split(',');
-      logger.debug('[development] loading Plugins', pluginNames);
-
-      // merge and remove duplicates
-      if (pluginNames.length > 0) {
-        const PluginService = require('../plugins/plugin.service');
-        const pluginService = new PluginService(this.crowi, app);
-        pluginService.loadPlugins(pluginNames);
-      }
-    }
-  }
-
 }
 
 module.exports = CrowiDev;

+ 64 - 31
src/server/crowi/index.js

@@ -16,6 +16,8 @@ const mongoose = require('mongoose');
 const models = require('../models');
 const initMiddlewares = require('../middlewares');
 
+const PluginService = require('../plugins/plugin.service');
+
 function Crowi(rootdir) {
   const self = this;
 
@@ -45,6 +47,9 @@ function Crowi(rootdir) {
   this.appService = null;
   this.fileUploadService = null;
   this.restQiitaAPIService = null;
+  this.growiBridgeService = null;
+  this.exportService = null;
+  this.importService = null;
   this.cdnResourcesService = new CdnResourcesService();
   this.interceptorManager = new InterceptorManager();
   this.xss = new Xss();
@@ -86,10 +91,12 @@ Crowi.prototype.init = async function() {
   // customizeService depends on AppService and XssService
   // passportService depends on appService
   // slack depends on setUpSlacklNotification
+  // export and import depends on setUpGrowiBridge
   await Promise.all([
     this.setUpApp(),
     this.setUpXss(),
     this.setUpSlacklNotification(),
+    this.setUpGrowiBridge(),
   ]);
 
   await Promise.all([
@@ -99,12 +106,18 @@ Crowi.prototype.init = async function() {
     this.setupMailer(),
     this.setupSlack(),
     this.setupCsrf(),
-    this.setUpGlobalNotification(),
     this.setUpFileUpload(),
     this.setUpAcl(),
     this.setUpCustomize(),
     this.setUpRestQiitaAPI(),
     this.setupUserGroup(),
+    this.setupExport(),
+    this.setupImport(),
+  ]);
+
+  // globalNotification depends on slack and mailer
+  await Promise.all([
+    this.setUpGlobalNotification(),
   ]);
 };
 
@@ -119,6 +132,7 @@ Crowi.prototype.initForTest = async function() {
     this.setUpApp(),
     // this.setUpXss(),
     // this.setUpSlacklNotification(),
+    // this.setUpGrowiBridge(),
   ]);
 
   await Promise.all([
@@ -128,12 +142,19 @@ Crowi.prototype.initForTest = async function() {
   //   this.setupMailer(),
   //   this.setupSlack(),
   //   this.setupCsrf(),
-  //   this.setUpGlobalNotification(),
   //   this.setUpFileUpload(),
     this.setUpAcl(),
   //   this.setUpCustomize(),
   //   this.setUpRestQiitaAPI(),
+  //   this.setupUserGroup(),
+  //   this.setupExport(),
+  //   this.setupImport(),
   ]);
+
+  // globalNotification depends on slack and mailer
+  // await Promise.all([
+  //   this.setUpGlobalNotification(),
+  // ]);
 };
 
 Crowi.prototype.isPageId = function(pageId) {
@@ -272,6 +293,10 @@ Crowi.prototype.getMailer = function() {
   return this.mailer;
 };
 
+Crowi.prototype.getSlack = function() {
+  return this.slack;
+};
+
 Crowi.prototype.getInterceptorManager = function() {
   return this.interceptorManager;
 };
@@ -294,8 +319,8 @@ Crowi.prototype.setupPassport = async function() {
   }
   this.passportService.setupSerializer();
   // setup strategies
-  this.passportService.setupLocalStrategy();
   try {
+    this.passportService.setupLocalStrategy();
     this.passportService.setupLdapStrategy();
     this.passportService.setupGoogleStrategy();
     this.passportService.setupGitHubStrategy();
@@ -315,18 +340,17 @@ Crowi.prototype.setupSearcher = async function() {
   const searcherUri = this.env.ELASTICSEARCH_URI
     || this.env.BONSAI_URL
     || null;
-  return new Promise(((resolve, reject) => {
-    if (searcherUri) {
-      try {
-        self.searcher = new (require(path.join(self.libDir, 'util', 'search')))(self, searcherUri);
-      }
-      catch (e) {
-        logger.error('Error on setup searcher', e);
-        self.searcher = null;
-      }
+
+  if (searcherUri) {
+    try {
+      self.searcher = new (require(path.join(self.libDir, 'util', 'search')))(self, searcherUri);
+      self.searcher.initIndices();
     }
-    resolve();
-  }));
+    catch (e) {
+      logger.error('Error on setup searcher', e);
+      self.searcher = null;
+    }
+  }
 };
 
 Crowi.prototype.setupMailer = async function() {
@@ -341,10 +365,7 @@ Crowi.prototype.setupSlack = async function() {
   const self = this;
 
   return new Promise(((resolve, reject) => {
-    if (this.slackNotificationService.hasSlackConfig()) {
-      self.slack = require('../util/slack')(self);
-    }
-
+    self.slack = require('../util/slack')(self);
     resolve();
   }));
 };
@@ -371,6 +392,10 @@ Crowi.prototype.start = async function() {
   await this.init();
   const express = await this.buildServer();
 
+  // setup plugins
+  this.pluginService = new PluginService(this, express);
+  this.pluginService.autoDetectAndLoadPlugins();
+
   const server = (this.node_env === 'development') ? this.crowiDev.setupServer(express) : express;
 
   // listen
@@ -399,19 +424,6 @@ Crowi.prototype.buildServer = function() {
 
   require('./express-init')(this, express);
 
-  // import plugins
-  const isEnabledPlugins = this.configManager.getConfig('crowi', 'plugin:isEnabledPlugins');
-  if (isEnabledPlugins) {
-    debug('Plugins are enabled');
-    const PluginService = require('../plugins/plugin.service');
-    const pluginService = new PluginService(this, express);
-    pluginService.autoDetectAndLoadPlugins();
-
-    if (env === 'development') {
-      this.crowiDev.loadPlugins(express);
-    }
-  }
-
   // use bunyan
   if (env === 'production') {
     const expressBunyanLogger = require('express-bunyan-logger');
@@ -539,4 +551,25 @@ Crowi.prototype.setupUserGroup = async function() {
   }
 };
 
+Crowi.prototype.setUpGrowiBridge = async function() {
+  const GrowiBridgeService = require('../service/growi-bridge');
+  if (this.growiBridgeService == null) {
+    this.growiBridgeService = new GrowiBridgeService(this);
+  }
+};
+
+Crowi.prototype.setupExport = async function() {
+  const ExportService = require('../service/export');
+  if (this.exportService == null) {
+    this.exportService = new ExportService(this);
+  }
+};
+
+Crowi.prototype.setupImport = async function() {
+  const ImportService = require('../service/import');
+  if (this.importService == null) {
+    this.importService = new ImportService(this);
+  }
+};
+
 module.exports = Crowi;

+ 1 - 0
src/server/form/admin/aws.js

@@ -4,6 +4,7 @@ const field = form.field;
 
 module.exports = form(
   field('settingForm[aws:region]', 'リージョン').trim().is(/^[a-z]+-[a-z]+-\d+$/, 'リージョンには、AWSリージョン名を入力してください。 例: ap-northeast-1'),
+  field('settingForm[aws:customEndpoint]', 'カスタムエンドポイント').trim().is(/^(https?:\/\/[^/]+|)$/, 'カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。'),
   field('settingForm[aws:bucket]', 'バケット名').trim(),
   field('settingForm[aws:accessKeyId]', 'Access Key Id').trim().is(/^[\da-zA-Z]+$/),
   field('settingForm[aws:secretAccessKey]', 'Secret Access Key').trim(),

+ 0 - 4
src/server/form/admin/securityGeneral.js

@@ -1,13 +1,9 @@
 const form = require('express-form');
 
 const field = form.field;
-const stringToArray = require('../../util/formUtil').stringToArrayFilter;
-const normalizeCRLF = require('../../util/formUtil').normalizeCRLFFilter;
 
 module.exports = form(
   field('settingForm[security:restrictGuestMode]'),
-  field('settingForm[security:registrationMode]').required(),
-  field('settingForm[security:registrationWhiteList]').custom(normalizeCRLF).custom(stringToArray),
   field('settingForm[security:list-policy:hideRestrictedByOwner]').trim().toBooleanStrict(),
   field('settingForm[security:list-policy:hideRestrictedByGroup]').trim().toBooleanStrict(),
   field('settingForm[security:pageCompleteDeletionAuthority]'),

+ 1 - 2
src/server/form/admin/securityPassportBasic.js

@@ -4,6 +4,5 @@ const field = form.field;
 
 module.exports = form(
   field('settingForm[security:passport-basic:isEnabled]').trim().toBooleanStrict().required(),
-  field('settingForm[security:passport-basic:id]').trim(),
-  field('settingForm[security:passport-basic:password]').trim(),
+  field('settingForm[security:passport-basic:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
 );

+ 11 - 0
src/server/form/admin/securityPassportLocal.js

@@ -0,0 +1,11 @@
+const form = require('express-form');
+
+const field = form.field;
+const stringToArray = require('../../util/formUtil').stringToArrayFilter;
+const normalizeCRLF = require('../../util/formUtil').normalizeCRLFFilter;
+
+module.exports = form(
+  field('settingForm[security:passport-local:isEnabled]').trim().toBooleanStrict().required(),
+  field('settingForm[security:registrationMode]').required(),
+  field('settingForm[security:registrationWhiteList]').custom(normalizeCRLF).custom(stringToArray),
+);

+ 1 - 0
src/server/form/index.js

@@ -17,6 +17,7 @@ module.exports = {
     aws: require('./admin/aws'),
     plugin: require('./admin/plugin'),
     securityGeneral: require('./admin/securityGeneral'),
+    securityPassportLocal: require('./admin/securityPassportLocal'),
     securityPassportLdap: require('./admin/securityPassportLdap'),
     securityPassportSaml: require('./admin/securityPassportSaml'),
     securityPassportBasic: require('./admin/securityPassportBasic'),

+ 27 - 0
src/server/middleware/access-token-parser.js

@@ -0,0 +1,27 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:access-token-parser');
+
+module.exports = (crowi) => {
+
+  return async(req, res, next) => {
+    // TODO: comply HTTP header of RFC6750 / Authorization: Bearer
+    const accessToken = req.query.access_token || req.body.access_token || null;
+    if (!accessToken) {
+      return next();
+    }
+
+    const User = crowi.model('User');
+
+    logger.debug('accessToken is', accessToken);
+
+    const user = await User.findUserByApiToken(accessToken);
+    req.user = user;
+    req.skipCsrfVerify = true;
+
+    logger.debug('Access token parsed: skipCsrfVerify');
+
+    next();
+  };
+
+};

+ 24 - 0
src/server/middleware/admin-required.js

@@ -0,0 +1,24 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:admin-required');
+
+module.exports = (crowi) => {
+
+  return async(req, res, next) => {
+    if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
+      if (req.user.admin) {
+        next();
+        return;
+      }
+
+      logger.warn('This user is not admin.');
+
+      return res.redirect('/');
+    }
+
+    logger.warn('This user has not logged in.');
+
+    return res.redirect('/login');
+  };
+
+};

+ 27 - 0
src/server/middleware/csrf.js

@@ -0,0 +1,27 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:csrf');
+
+module.exports = (crowi) => {
+
+  return async(req, res, next) => {
+    const token = req.body._csrf || req.query._csrf || null;
+    const csrfKey = (req.session && req.session.id) || 'anon';
+
+    logger.debug('req.skipCsrfVerify', req.skipCsrfVerify);
+
+    if (req.skipCsrfVerify) {
+      logger.debug('csrf verify skipped');
+      return next();
+    }
+
+    if (crowi.getTokens().verify(csrfKey, token)) {
+      logger.debug('csrf successfully verified');
+      return next();
+    }
+
+    logger.warn('csrf verification failed. return 403', csrfKey, token);
+    return res.sendStatus(403);
+  };
+
+};

+ 49 - 0
src/server/middleware/login-required.js

@@ -0,0 +1,49 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:login-required');
+
+/**
+ * require login handler
+ *
+ * @param {boolean} isGuestAllowed whethere guest user is allowed (default false)
+ */
+module.exports = (crowi, isGuestAllowed = false) => {
+
+  return function(req, res, next) {
+
+    // check the route config and ACL
+    if (isGuestAllowed && crowi.aclService.isGuestAllowedToRead()) {
+      logger.debug('Allowed to read: ', req.path);
+      return next();
+    }
+
+    const User = crowi.model('User');
+
+    // check the user logged in
+    if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
+      if (req.user.status === User.STATUS_ACTIVE) {
+        // Active の人だけ先に進める
+        return next();
+      }
+      if (req.user.status === User.STATUS_REGISTERED) {
+        return res.redirect('/login/error/registered');
+      }
+      if (req.user.status === User.STATUS_SUSPENDED) {
+        return res.redirect('/login/error/suspended');
+      }
+      if (req.user.status === User.STATUS_INVITED) {
+        return res.redirect('/login/invited');
+      }
+    }
+
+    // is api path
+    const path = req.path || '';
+    if (path.match(/^\/_api\/.+$/)) {
+      return res.sendStatus(403);
+    }
+
+    req.session.jumpTo = req.originalUrl;
+    return res.redirect('/login');
+  };
+
+};

+ 20 - 0
src/server/models/GlobalNotificationSetting.js

@@ -7,6 +7,26 @@ const GlobalNotificationSetting = require('./GlobalNotificationSetting/index');
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
 const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
 
+/**
+ * global notifcation event master
+ */
+GlobalNotificationSettingSchema.statics.EVENT = {
+  PAGE_CREATE: 'pageCreate',
+  PAGE_EDIT: 'pageEdit',
+  PAGE_DELETE: 'pageDelete',
+  PAGE_MOVE: 'pageMove',
+  PAGE_LIKE: 'pageLike',
+  COMMENT: 'comment',
+};
+
+/**
+ * global notifcation type master
+ */
+GlobalNotificationSettingSchema.statics.TYPE = {
+  MAIL: 'mail',
+  SLACK: 'slack',
+};
+
 module.exports = function(crowi) {
   GlobalNotificationSettingClass.crowi = crowi;
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);

+ 8 - 3
src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js

@@ -9,9 +9,14 @@ module.exports = function(crowi) {
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
 
   const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
-  const GlobalNotificationMailSettingModel = GlobalNotificationSettingModel.discriminator('mail', new mongoose.Schema({
-    toEmail: String,
-  }, { discriminatorKey: 'type' }));
+  const GlobalNotificationMailSettingModel = GlobalNotificationSettingModel.discriminator(
+    GlobalNotificationSetting.schema.statics.TYPE.MAIL,
+    new mongoose.Schema({
+      toEmail: String,
+    }, {
+      discriminatorKey: 'type',
+    }),
+  );
 
   return GlobalNotificationMailSettingModel;
 };

+ 8 - 3
src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js

@@ -9,9 +9,14 @@ module.exports = function(crowi) {
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
 
   const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
-  const GlobalNotificationSlackSettingModel = GlobalNotificationSettingModel.discriminator('slack', new mongoose.Schema({
-    slackChannels: String,
-  }, { discriminatorKey: 'type' }));
+  const GlobalNotificationSlackSettingModel = GlobalNotificationSettingModel.discriminator(
+    GlobalNotificationSetting.schema.statics.TYPE.SLACK,
+    new mongoose.Schema({
+      slackChannels: String,
+    }, {
+      discriminatorKey: 'type',
+    }),
+  );
 
   return GlobalNotificationSlackSettingModel;
 };

+ 2 - 2
src/server/models/GlobalNotificationSetting/index.js

@@ -92,12 +92,13 @@ class GlobalNotificationSetting {
    * @param {string} path
    * @param {string} event
    */
-  static async findSettingByPathAndEvent(path, event) {
+  static async findSettingByPathAndEvent(event, path, type) {
     const pathsToMatch = generatePathsToMatch(path);
 
     const settings = await this.find({
       triggerPath: { $in: pathsToMatch },
       triggerEvents: event,
+      __t: type,
       isEnabled: true,
     })
       .sort({ triggerPath: 1 });
@@ -107,7 +108,6 @@ class GlobalNotificationSetting {
 
 }
 
-
 module.exports = {
   class: GlobalNotificationSetting,
   schema: globalNotificationSettingSchema,

+ 6 - 21
src/server/models/attachment.js

@@ -1,7 +1,6 @@
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 
-const debug = require('debug')('growi:models:attachment');
 // eslint-disable-next-line no-unused-vars
 const logger = require('@alias/logger')('growi:models:attachment');
 const path = require('path');
@@ -68,28 +67,14 @@ module.exports = function(crowi) {
     return attachment;
   };
 
-  attachmentSchema.statics.removeAttachmentsByPageId = function(pageId) {
-    const Attachment = this;
+  attachmentSchema.statics.removeAttachmentsByPageId = async function(pageId) {
+    const attachments = await this.find({ page: pageId });
 
-    return new Promise((resolve, reject) => {
-      Attachment.find({ page: pageId })
-        .then((attachments) => {
-          for (const attachment of attachments) {
-            Attachment.removeWithSubstanceById(attachment._id)
-              .then((res) => {
-                // do nothing
-              })
-              .catch((err) => {
-                debug('Attachment remove error', err);
-              });
-          }
-
-          resolve(attachments);
-        })
-        .catch((err) => {
-          reject(err);
-        });
+    const promises = attachments.map(async(attachment) => {
+      return this.removeWithSubstanceById(attachment._id);
     });
+
+    return Promise.all(promises);
   };
 
   attachmentSchema.statics.removeWithSubstanceById = async function(id) {

+ 12 - 1
src/server/models/comment.js

@@ -12,9 +12,10 @@ module.exports = function(crowi) {
     revision: { type: ObjectId, ref: 'Revision', index: true },
     comment: { type: String, required: true },
     commentPosition: { type: Number, default: -1 },
-    createdAt: { type: Date, default: Date.now },
     isMarkdown: { type: Boolean, default: false },
     replyTo: { type: ObjectId },
+  }, {
+    timestamps: true,
   });
 
   commentSchema.statics.create = function(pageId, creatorId, revisionId, comment, position, isMarkdown, replyTo) {
@@ -64,6 +65,16 @@ module.exports = function(crowi) {
     }));
   };
 
+  commentSchema.statics.updateCommentsByPageId = function(comment, isMarkdown, commentId) {
+    const Comment = this;
+
+    return Comment.findOneAndUpdate(
+      { _id: commentId },
+      { $set: { comment, isMarkdown } },
+    );
+
+  };
+
   commentSchema.statics.removeCommentsByPageId = function(pageId) {
     const Comment = this;
 

+ 3 - 0
src/server/models/config.js

@@ -49,6 +49,7 @@ module.exports = function(crowi) {
       'security:list-policy:hideRestrictedByGroup' : false,
       'security:pageCompleteDeletionAuthority' : undefined,
 
+      'security:passport-local:isEnabled' : true,
       'security:passport-ldap:isEnabled' : false,
       'security:passport-ldap:serverUrl' : undefined,
       'security:passport-ldap:isUserBind' : undefined,
@@ -74,6 +75,7 @@ module.exports = function(crowi) {
       'aws:region'          : 'ap-northeast-1',
       'aws:accessKeyId'     : undefined,
       'aws:secretAccessKey' : undefined,
+      'aws:customEndpoint'  : undefined,
 
       'mail:from'         : undefined,
       'mail:smtpHost'     : undefined,
@@ -174,6 +176,7 @@ module.exports = function(crowi) {
       isEnabledLinebreaks: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
       isEnabledLinebreaksInComments: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
       isEnabledXssPrevention: crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+      isEnabledTimeline: crowi.configManager.getConfig('crowi', 'customize:isEnabledTimeline'),
       xssOption: crowi.configManager.getConfig('markdown', 'markdown:xss:option'),
       tagWhiteList: crowi.xssService.getTagWhiteList(),
       attrWhiteList: crowi.xssService.getAttrWhiteList(),

+ 34 - 21
src/server/models/page-tag-relation.js

@@ -31,12 +31,6 @@ schema.plugin(mongoosePaginate);
  */
 class PageTagRelation {
 
-  static async createIfNotExist(pageId, tagId) {
-    if (!await this.findOne({ relatedPage: pageId, relatedTag: tagId })) {
-      await this.create({ relatedPage: pageId, relatedTag: tagId });
-    }
-  }
-
   static async createTagListWithCount(option) {
     const Tag = mongoose.model('Tag');
     const opt = option || {};
@@ -56,35 +50,54 @@ class PageTagRelation {
     return { list, totalCount };
   }
 
-  static async listTagsByPage(pageId) {
-    const tags = await this.find({ relatedPage: pageId }).populate('relatedTag').select('-_id relatedTag');
-    return tags.filter((tag) => { return tag.relatedTag !== null });
+  static async findByPageId(pageId) {
+    const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('-_id relatedTag');
+    return relations.filter((relation) => { return relation.relatedTag !== null });
   }
 
   static async listTagNamesByPage(pageId) {
-    const tags = await this.listTagsByPage(pageId);
-    return tags.map((tag) => { return tag.relatedTag.name });
+    const relations = await this.findByPageId(pageId);
+    return relations.map((relation) => { return relation.relatedTag.name });
   }
 
   static async updatePageTags(pageId, tags) {
+    if (pageId == null || tags == null) {
+      throw new Error('args \'pageId\' and \'tags\' are required.');
+    }
+
+    // filter empty string
+    // eslint-disable-next-line no-param-reassign
+    tags = tags.filter((tag) => { return tag !== '' });
+
     const Tag = mongoose.model('Tag');
 
-    // get tags relate this page
-    const relatedTags = await this.listTagsByPage(pageId);
+    // get relations for this page
+    const relations = await this.findByPageId(pageId);
 
     // unlink relations
-    const unlinkTagRelations = relatedTags.filter((tag) => { return !tags.includes(tag.relatedTag.name) });
-    await this.deleteMany({
+    const unlinkTagRelations = relations.filter((relation) => { return !tags.includes(relation.relatedTag.name) });
+    const bulkDeletePromise = this.deleteMany({
       relatedPage: pageId,
       relatedTag: { $in: unlinkTagRelations.map((relation) => { return relation.relatedTag._id }) },
     });
 
-    // create tag and relations
-    /* eslint-disable no-await-in-loop */
-    for (const tag of tags) {
-      const setTag = await Tag.findOrCreate(tag);
-      await this.createIfNotExist(pageId, setTag._id);
-    }
+    // filter tags to create
+    const relatedTagNames = relations.map((relation) => { return relation.relatedTag.name });
+    // find or create tags
+    const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
+    const tagEntities = await Tag.findOrCreateMany(tagsToCreate);
+
+    // create relations
+    const bulkCreatePromise = this.insertMany(
+      tagEntities.map((relatedTag) => {
+        return {
+          relatedPage: pageId,
+          relatedTag,
+        };
+      }),
+    );
+
+    return Promise.all([bulkDeletePromise, bulkCreatePromise]);
   }
 
 }

+ 19 - 19
src/server/models/page.js

@@ -5,13 +5,16 @@
 
 const debug = require('debug')('growi:models:page');
 const nodePath = require('path');
+const urljoin = require('url-join');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate');
 const uniqueValidator = require('mongoose-unique-validator');
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
-const escapeStringRegexp = require('escape-string-regexp');
+const { pathUtils } = require('growi-commons');
 const templateChecker = require('@commons/util/template-checker');
+const escapeStringRegexp = require('escape-string-regexp');
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
 
 /*
  * define schema
@@ -844,17 +847,19 @@ module.exports = function(crowi) {
   /**
    * find all templates applicable to the new page
    */
-  pageSchema.statics.findTemplate = function(path) {
+  pageSchema.statics.findTemplate = async function(path) {
     const templatePath = nodePath.posix.dirname(path);
     const pathList = generatePathsOnTree(path, []);
-    const regexpList = pathList.map((path) => { return new RegExp(`^${escapeStringRegexp(path)}/_{1,2}template$`) });
+    const regexpList = pathList.map((path) => {
+      const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
+      return new RegExp(`^${escapeStringRegexp(pathWithTrailingSlash)}_{1,2}template$`);
+    });
 
-    return this
-      .find({ path: { $in: regexpList } })
+    const templatePages = await this.find({ path: { $in: regexpList } })
       .populate({ path: 'revision', model: 'Revision' })
-      .then((templates) => {
-        return fetchTemplate(templates, templatePath);
-      });
+      .exec();
+
+    return fetchTemplate(templatePages, templatePath);
   };
 
   const generatePathsOnTree = (path, pathList) => {
@@ -870,11 +875,11 @@ module.exports = function(crowi) {
   };
 
   const assignTemplateByType = (templates, path, type) => {
-    for (let i = 0; i < templates.length; i++) {
-      if (templates[i].path === `${path}/${type}template`) {
-        return templates[i];
-      }
-    }
+    const targetTemplatePath = urljoin(path, `${type}template`);
+
+    return templates.find((template) => {
+      return (template.path === targetTemplatePath);
+    });
   };
 
   const assignDecendantsTemplate = (decendantsTemplates, path) => {
@@ -1330,12 +1335,7 @@ module.exports = function(crowi) {
    * @param {string} pageIdOnHackmd
    */
   pageSchema.statics.registerHackmdPage = function(pageData, pageIdOnHackmd) {
-    if (pageData.pageIdOnHackmd != null) {
-      throw new Error(`'pageIdOnHackmd' of the page '${pageData.path}' is not empty`);
-    }
-
     pageData.pageIdOnHackmd = pageIdOnHackmd;
-
     return this.syncRevisionToHackmd(pageData);
   };
 

+ 17 - 1
src/server/models/tag.js

@@ -11,6 +11,7 @@ const schema = new mongoose.Schema({
   name: {
     type: String,
     required: true,
+    unique: true,
   },
 });
 schema.plugin(mongoosePaginate);
@@ -25,11 +26,26 @@ class Tag {
   static async findOrCreate(tagName) {
     const tag = await this.findOne({ name: tagName });
     if (!tag) {
-      return await this.create({ name: tagName });
+      return this.create({ name: tagName });
     }
     return tag;
   }
 
+  static async findOrCreateMany(tagNames) {
+    const existTags = await this.find({ name: { $in: tagNames } });
+    const existTagNames = existTags.map((tag) => { return tag.name });
+
+    // bulk insert
+    const tagsToCreate = tagNames.filter((tagName) => { return !existTagNames.includes(tagName) });
+    await this.insertMany(
+      tagsToCreate.map((tag) => {
+        return { name: tag };
+      }),
+    );
+
+    return this.find({ name: { $in: tagNames } });
+  }
+
 }
 
 module.exports = function(crowi) {

+ 27 - 1
src/server/plugins/plugin.service.js

@@ -10,7 +10,33 @@ class PluginService {
   }
 
   autoDetectAndLoadPlugins() {
-    this.loadPlugins(this.pluginUtils.listPluginNames(this.crowi.rootDir));
+    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));
+
+      // when dev
+      if (this.crowi.node_env === 'development') {
+        this.autoDetectAndLoadPluginsForDev();
+      }
+    }
+
+  }
+
+  autoDetectAndLoadPluginsForDev() {
+    if (process.env.PLUGIN_NAMES_TOBE_LOADED !== undefined
+      && process.env.PLUGIN_NAMES_TOBE_LOADED.length > 0) {
+
+      const pluginNames = process.env.PLUGIN_NAMES_TOBE_LOADED.split(',');
+      logger.debug('[development] loading Plugins', pluginNames);
+
+      // merge and remove duplicates
+      if (pluginNames.length > 0) {
+        this.crowi.pluginService.loadPlugins(pluginNames);
+      }
+    }
   }
 
   /**

+ 99 - 52
src/server/routes/admin.js

@@ -18,6 +18,7 @@ module.exports = function(crowi, app) {
     aclService,
     slackNotificationService,
     customizeService,
+    exportService,
   } = crowi;
 
   const recommendedWhitelist = require('@commons/service/xss/recommended-whitelist');
@@ -335,14 +336,14 @@ module.exports = function(crowi, app) {
     let setting;
 
     switch (form.notifyToType) {
-      case 'mail':
+      case GlobalNotificationSetting.TYPE.MAIL:
         setting = new GlobalNotificationMailSetting(crowi);
         setting.toEmail = form.toEmail;
         break;
-      // case 'slack':
-      //   setting = new GlobalNotificationSlackSetting(crowi);
-      //   setting.slackChannels = form.slackChannels;
-      //   break;
+      case GlobalNotificationSetting.TYPE.SLACK:
+        setting = new GlobalNotificationSlackSetting(crowi);
+        setting.slackChannels = form.slackChannels;
+        break;
       default:
         logger.error('GlobalNotificationSetting Type Error: undefined type');
         req.flash('errorMessage', 'Error occurred in creating a new global notification setting: undefined notification type');
@@ -358,24 +359,44 @@ module.exports = function(crowi, app) {
 
   actions.globalNotification.update = async(req, res) => {
     const form = req.form.notificationGlobal;
-    const setting = await GlobalNotificationSetting.findOne({ _id: form.id });
+
+    const models = {
+      [GlobalNotificationSetting.TYPE.MAIL]: GlobalNotificationMailSetting,
+      [GlobalNotificationSetting.TYPE.SLACK]: GlobalNotificationSlackSetting,
+    };
+
+    let setting = await GlobalNotificationSetting.findOne({ _id: form.id });
+    setting = setting.toObject();
+
+    // when switching from one type to another,
+    // remove toEmail from slack setting and slackChannels from mail setting
+    if (setting.__t !== form.notifyToType) {
+      setting = models[setting.__t].hydrate(setting);
+      setting.toEmail = undefined;
+      setting.slackChannels = undefined;
+      await setting.save();
+      setting = setting.toObject();
+    }
 
     switch (form.notifyToType) {
-      case 'mail':
+      case GlobalNotificationSetting.TYPE.MAIL:
+        setting = GlobalNotificationMailSetting.hydrate(setting);
         setting.toEmail = form.toEmail;
         break;
-      // case 'slack':
-      //   setting.slackChannels = form.slackChannels;
-      //   break;
+      case GlobalNotificationSetting.TYPE.SLACK:
+        setting = GlobalNotificationSlackSetting.hydrate(setting);
+        setting.slackChannels = form.slackChannels;
+        break;
       default:
         logger.error('GlobalNotificationSetting Type Error: undefined type');
         req.flash('errorMessage', 'Error occurred in updating the global notification setting: undefined notification type');
         return res.redirect('/admin/notification#global-notification');
     }
 
+    setting.__t = form.notifyToType;
     setting.triggerPath = form.triggerPath;
     setting.triggerEvents = getNotificationEvents(form);
-    setting.save();
+    await setting.save();
 
     return res.redirect('/admin/notification#global-notification');
   };
@@ -424,7 +445,7 @@ module.exports = function(crowi, app) {
 
     const result = await User.findUsersWithPagination({
       page,
-      select: User.USER_PUBLIC_FIELDS,
+      select: `${User.USER_PUBLIC_FIELDS} lastLoginAt`,
       populate: User.IMAGE_POPULATION,
     });
 
@@ -709,6 +730,27 @@ module.exports = function(crowi, app) {
   };
 
 
+  // Export management
+  actions.export = {};
+  actions.export.index = (req, res) => {
+    return res.render('admin/export');
+  };
+
+  actions.export.download = (req, res) => {
+    // TODO: add express validator
+    const { fileName } = req.params;
+
+    try {
+      const zipFile = exportService.getFile(fileName);
+      return res.download(zipFile);
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.json(ApiResponse.error());
+    }
+  };
+
   actions.api = {};
   actions.api.appSetting = async function(req, res) {
     const form = req.form.settingForm;
@@ -779,7 +821,7 @@ module.exports = function(crowi, app) {
     }
   };
 
-  actions.api.securityPassportLdapSetting = function(req, res) {
+  actions.api.securityPassportLocalSetting = async function(req, res) {
     const form = req.form.settingForm;
 
     if (!req.form.isValid) {
@@ -787,19 +829,48 @@ module.exports = function(crowi, app) {
     }
 
     debug('form content', form);
-    return configManager.updateConfigsInTheSameNamespace('crowi', form)
-      .then(() => {
-        // reset strategy
-        crowi.passportService.resetLdapStrategy();
-        // setup strategy
-        if (configManager.getConfig('crowi', 'security:passport-ldap:isEnabled')) {
-          crowi.passportService.setupLdapStrategy(true);
-        }
-        return;
-      })
-      .then(() => {
-        res.json({ status: true });
-      });
+
+    try {
+      await configManager.updateConfigsInTheSameNamespace('crowi', form);
+      // reset strategy
+      crowi.passportService.resetLocalStrategy();
+      // setup strategy
+      if (configManager.getConfig('crowi', 'security:passport-local:isEnabled')) {
+        crowi.passportService.setupLocalStrategy(true);
+      }
+    }
+    catch (err) {
+      logger.error(err);
+      return res.json({ status: false, message: err.message });
+    }
+
+    return res.json({ status: true });
+  };
+
+  actions.api.securityPassportLdapSetting = async function(req, res) {
+    const form = req.form.settingForm;
+
+    if (!req.form.isValid) {
+      return res.json({ status: false, message: req.form.errors.join('\n') });
+    }
+
+    debug('form content', form);
+
+    try {
+      await configManager.updateConfigsInTheSameNamespace('crowi', form);
+      // reset strategy
+      crowi.passportService.resetLdapStrategy();
+      // setup strategy
+      if (configManager.getConfig('crowi', 'security:passport-ldap:isEnabled')) {
+        crowi.passportService.setupLdapStrategy(true);
+      }
+    }
+    catch (err) {
+      logger.error(err);
+      return res.json({ status: false, message: err.message });
+    }
+
+    return res.json({ status: true });
   };
 
   actions.api.securityPassportSamlSetting = async(req, res) => {
@@ -1182,38 +1253,14 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('ElasticSearch Integration is not set up.'));
     }
 
-    // first, delete index
-    try {
-      await search.deleteIndex();
-    }
-    catch (err) {
-      logger.warn('Delete index Error, but if it is initialize, its ok.', err);
-    }
-
-    // second, create index
-    try {
-      await search.buildIndex();
-    }
-    catch (err) {
-      logger.error('Error', err);
-      return res.json(ApiResponse.error(err));
-    }
-
     searchEvent.on('addPageProgress', (total, current, skip) => {
       crowi.getIo().sockets.emit('admin:addPageProgress', { total, current, skip });
     });
     searchEvent.on('finishAddPage', (total, current, skip) => {
       crowi.getIo().sockets.emit('admin:finishAddPage', { total, current, skip });
     });
-    // add all page
-    search
-      .addAllPages()
-      .then(() => {
-        debug('Data is successfully indexed. ------------------ ✧✧');
-      })
-      .catch((err) => {
-        logger.error('Error', err);
-      });
+
+    await search.buildIndex();
 
     return res.json(ApiResponse.success());
   };

+ 145 - 0
src/server/routes/apiv3/export.js

@@ -0,0 +1,145 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:export');
+const path = require('path');
+const fs = require('fs');
+
+const express = require('express');
+
+const router = express.Router();
+
+/**
+ * @swagger
+ *  tags:
+ *    name: Export
+ */
+
+module.exports = (crowi) => {
+  const accessTokenParser = require('../../middleware/access-token-parser')(crowi);
+  const loginRequired = require('../../middleware/login-required')(crowi);
+  const adminRequired = require('../../middleware/admin-required')(crowi);
+  const csrf = require('../../middleware/csrf')(crowi);
+
+  const { growiBridgeService, exportService } = crowi;
+
+  /**
+   * @swagger
+   *
+   *  /export/status:
+   *    get:
+   *      tags: [Export]
+   *      description: get properties of stored zip files for export
+   *      responses:
+   *        200:
+   *          description: the zip file statuses
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  zipFileStats:
+   *                    type: array
+   *                    items:
+   *                      type: object
+   *                      description: the property of each file
+   */
+  router.get('/status', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+    const zipFileStats = await exportService.getStatus();
+
+    // TODO: use res.apiv3
+    return res.json({ ok: true, zipFileStats });
+  });
+
+  /**
+   * @swagger
+   *
+   *  /export:
+   *    post:
+   *      tags: [Export]
+   *      description: generate zipped jsons for collections
+   *      responses:
+   *        200:
+   *          description: a zip file is generated
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  zipFileStat:
+   *                    type: object
+   *                    description: the property of the zip file
+   */
+  router.post('/', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
+    // TODO: add express validator
+    try {
+      const { collections } = req.body;
+      // get model for collection
+      const models = collections.map(collectionName => growiBridgeService.getModelFromCollectionName(collectionName));
+
+      const [metaJson, jsonFiles] = await Promise.all([
+        exportService.createMetaJson(),
+        exportService.exportMultipleCollectionsToJsons(models),
+      ]);
+
+      // zip json
+      const configs = jsonFiles.map((jsonFile) => { return { from: jsonFile, as: path.basename(jsonFile) } });
+      // add meta.json in zip
+      configs.push({ from: metaJson, as: path.basename(metaJson) });
+      // exec zip
+      const zipFile = await exportService.zipFiles(configs);
+      // get stats for the zip file
+      const zipFileStat = await growiBridgeService.parseZipFile(zipFile);
+
+      // TODO: use res.apiv3
+      return res.status(200).json({
+        ok: true,
+        zipFileStat,
+      });
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.status(500).send({ status: 'ERROR' });
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *  /export/{fileName}:
+   *    delete:
+   *      tags: [Export]
+   *      description: delete the file
+   *      parameters:
+   *        - name: fileName
+   *          in: path
+   *          description: the file name of zip file
+   *          required: true
+   *          schema:
+   *            type: string
+   *      responses:
+   *        200:
+   *          description: the file is deleted
+   *          content:
+   *            application/json:
+   *              schema:
+   *                type: object
+   */
+  router.delete('/:fileName', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
+    // TODO: add express validator
+    const { fileName } = req.params;
+
+    try {
+      const zipFile = exportService.getFile(fileName);
+      fs.unlinkSync(zipFile);
+
+      // TODO: use res.apiv3
+      return res.status(200).send({ ok: true });
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.status(500).send({ ok: false });
+    }
+  });
+
+  return router;
+};

+ 0 - 2
src/server/routes/apiv3/healthcheck.js

@@ -22,8 +22,6 @@ module.exports = (crowi) => {
    *    get:
    *      tags: [Healthcheck]
    *      description: Check whether the server is healthy or not
-   *      produces:
-   *        - application/json
    *      parameters:
    *        - name: connectToMiddlewares
    *          in: query

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