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

Merge remote-tracking branch 'origin/master' into imprv/take-over-tags-from-template-page

# Conflicts:
#	src/client/js/app.js
#	src/client/js/components/Page/TagLabel.jsx
yusuketk 7 лет назад
Родитель
Сommit
1d5eeee8f2
58 измененных файлов с 1031 добавлено и 93 удалено
  1. 1 0
      CHANGES.md
  2. 1 0
      README.md
  3. 1 0
      config/env.dev.js
  4. 10 0
      config/swagger-definition.js
  5. 1 0
      package.json
  6. 1 0
      public/images/icons/editor/bold.svg
  7. 1 0
      public/images/icons/editor/check.svg
  8. 1 0
      public/images/icons/editor/code.svg
  9. 1 0
      public/images/icons/editor/header.svg
  10. 1 0
      public/images/icons/editor/italic.svg
  11. 1 0
      public/images/icons/editor/link.svg
  12. 1 0
      public/images/icons/editor/list-ol.svg
  13. 1 0
      public/images/icons/editor/list-ul.svg
  14. 1 0
      public/images/icons/editor/picture.svg
  15. 1 0
      public/images/icons/editor/quote.svg
  16. 1 0
      public/images/icons/editor/strikethrough.svg
  17. 1 1
      public/images/icons/editor/table.svg
  18. 7 0
      resource/cdn-manifests.js
  19. 6 3
      resource/locales/en-US/translation.json
  20. 7 4
      resource/locales/ja/translation.json
  21. 21 2
      src/client/js/app.js
  22. 0 0
      src/client/js/components/CopyButton.jsx
  23. 240 0
      src/client/js/components/MyDraftList/MyDraftList.jsx
  24. 0 0
      src/client/js/components/Page/RevisionPath.jsx
  25. 108 0
      src/client/js/components/Page/TagEditor.jsx
  26. 0 0
      src/client/js/components/Page/TagLabels.jsx
  27. 15 11
      src/client/js/components/Page/TagsInput.jsx
  28. 23 3
      src/client/js/components/PageEditor.js
  29. 13 24
      src/client/js/components/PageEditor/CodeMirrorEditor.js
  30. 165 0
      src/client/js/components/PageList/Draft.jsx
  31. 1 0
      src/client/js/components/SavePageControls.jsx
  32. 4 1
      src/client/js/components/SearchTypeahead.js
  33. 13 0
      src/client/js/util/Crowi.js
  34. 11 8
      src/client/styles/scss/_page.scss
  35. 1 1
      src/client/styles/scss/_tag.scss
  36. 32 4
      src/client/styles/scss/_user.scss
  37. 0 1
      src/server/crowi/dev.js
  38. 2 0
      src/server/crowi/index.js
  39. 13 0
      src/server/events/tag.js
  40. 43 0
      src/server/routes/apiv3/docs.js
  41. 35 0
      src/server/routes/apiv3/healthcheck.js
  42. 2 0
      src/server/routes/index.js
  43. 23 0
      src/server/routes/page.js
  44. 9 3
      src/server/routes/tag.js
  45. 6 0
      src/server/service/config-loader.js
  46. 11 0
      src/server/util/search.js
  47. 2 2
      src/server/views/admin/customize.html
  48. 3 1
      src/server/views/layout-crowi/forbidden.html
  49. 3 1
      src/server/views/layout-crowi/not_creatable.html
  50. 3 1
      src/server/views/layout-crowi/not_found.html
  51. 3 1
      src/server/views/layout-crowi/page.html
  52. 4 4
      src/server/views/layout-crowi/page_list.html
  53. 2 6
      src/server/views/layout-growi/widget/header.html
  54. 3 1
      src/server/views/layout-kibela/widget/header.html
  55. 4 1
      src/server/views/layout/layout.html
  56. 24 0
      src/server/views/redoc.html
  57. 24 1
      src/server/views/widget/user_page_content.html
  58. 119 8
      yarn.lock

+ 1 - 0
CHANGES.md

@@ -5,6 +5,7 @@
 * Improvement: Show display name when mouse hover to user image
 * Fix: URL in slack message is broken on Safari
 * Fix: Registration does not work when basic auth is enabled
+* Support: Publish API docs with swagger-jsdoc and ReDoc
 * Support: Upgrade libs
     * cmd-env
     * elasticsearch

+ 1 - 0
README.md

@@ -171,6 +171,7 @@ Environment Variables
     * MAX_FILE_SIZE: The maximum file size limit for uploads (bytes). default: `Infinity`
     * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
     * SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: If `true`, the system uses only the value of the environment variable as the value of the SAML option that can be set via the environment variable.
+    * PUBLISH_OPEN_API: Publish GROWI OpenAPI resources with [ReDoc](https://github.com/Rebilly/ReDoc). Visit `/api-docs`.
 * **Option to integrate with external systems**
     * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
         * **This server must load the GROWI agent. [Here's how to prepare it](https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html).**

+ 1 - 0
config/env.dev.js

@@ -10,6 +10,7 @@ module.exports = {
     // 'growi-plugin-lsx',
     // 'growi-plugin-pukiwiki-like-linker',
   ],
+  // PUBLISH_OPEN_API: true,
   // USER_UPPER_LIMIT: 0,
   // DEV_HTTPS: true,
   // PUBLIC_WIKI_ONLY: true,

+ 10 - 0
config/swagger-definition.js

@@ -0,0 +1,10 @@
+const pkg = require('../package.json');
+
+module.exports = {
+  openapi: '3.0.1',
+  info: {
+    title: 'GROWI REST API v3',
+    version: pkg.version,
+  },
+  basePath: '/api/v3/',
+};

+ 1 - 0
package.json

@@ -211,6 +211,7 @@
     "socket.io-client": "^2.0.3",
     "style-loader": "^0.23.0",
     "stylelint-config-recess-order": "^2.0.1",
+    "swagger-jsdoc": "^3.2.9",
     "terser-webpack-plugin": "^1.2.2",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",

+ 1 - 0
public/images/icons/editor/bold.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 10.9 14"><path d="M0 0h5.6c3 0 4.7 1.1 4.7 3.4a3.1 3.1 0 0 1-2.5 3.1 3.7 3.7 0 0 1 3.1 3.5c0 2.9-1.4 4-4.2 4H0zm5.2 6.5c2.7 0 2.6-1.4 2.6-3.1S7.9.7 5.6.7H2.3v5.8zm-2.9 6.6h3.4c2.1 0 2.7-1.1 2.7-3.1s0-2.8-3.2-2.8H2.3z"/></svg>

+ 1 - 0
public/images/icons/editor/check.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 14.4 16"><path d="M13.9 5.5a.5.5 0 0 1 .5.5v9a1.1 1.1 0 0 1-1.1 1H1a1.1 1.1 0 0 1-1-1V2.6a1.1 1.1 0 0 1 1-1h7.1a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5H1V15h12.3V6a.6.6 0 0 1 .6-.5zM3.6 8.3a.5.5 0 0 0 0 .7l2.5 2.5a.8.8 0 0 0 1.1 0h.1l7-10.7c.1-.2.1-.6-.2-.7a.5.5 0 0 0-.7.1L6.6 10.6 4.3 8.3a.5.5 0 0 0-.7 0z"/></svg>

+ 1 - 0
public/images/icons/editor/code.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 18.1 14"><path d="M17.8 7.9l-4 3.8a.5.5 0 0 1-.8 0 .5.5 0 0 1 0-.8L16.8 7 13 3.2a.6.6 0 0 1 0-.9.5.5 0 0 1 .8 0l4 3.8a1.3 1.3 0 0 1 0 1.8zM5.2 2.3a.7.7 0 0 1 0 .9L1.3 7l3.9 3.9a.6.6 0 0 1 0 .8.6.6 0 0 1-.9 0L.4 7.9a1.3 1.3 0 0 1 0-1.8l3.9-3.8a.6.6 0 0 1 .9 0zM11.5.8L7.8 13.6a.6.6 0 0 1-.7.4.6.6 0 0 1-.5-.8L10.3.4a.7.7 0 0 1 .8-.4.6.6 0 0 1 .4.8z"/></svg>

+ 1 - 0
public/images/icons/editor/header.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 13.7 14"><path d="M10.2 0h2.9a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-.8v11.6h.8a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-2.9a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6h.8V7.2H2.7v5.6h.8a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6H.6a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6h.7V1.2H.6A.6.6 0 0 1 0 .6.6.6 0 0 1 .6 0h2.9a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-.8v4.9H11V1.2h-.8a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6z"/></svg>

+ 1 - 0
public/images/icons/editor/italic.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 8.6 13.9"><path d="M8.1 0a.6.6 0 0 1 .5.6c0 .3-.2.6-.7.6H6.2L3.8 12.8h1.8c.2 0 .4.3.4.5a.7.7 0 0 1-.7.6H.5c-.3 0-.5-.4-.5-.6s.4-.6.7-.6h1.7L4.9 1.2H3.1a.5.5 0 0 1-.5-.5c0-.3.1-.7.8-.7z"/></svg>

+ 1 - 0
public/images/icons/editor/link.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 16 16"><path d="M4.6 11.4l.4.2h.2v-.2l6.1-6.1a.4.4 0 0 0 .1-.3.5.5 0 0 0-.5-.5h-.3l-6 6.2a.6.6 0 0 0-.1.4c0 .1 0 .3.1.3zm2.8-1a2 2 0 0 1 0 1.1 4.1 4.1 0 0 1-.5.9l-2.1 1.9a1.9 1.9 0 0 1-1.5.7 2 2 0 0 1-1.6-.7 1.9 1.9 0 0 1-.7-1.5 2 2 0 0 1 .7-1.6l1.9-2.1a2 2 0 0 1 2.2-.5l.8-.8a3.2 3.2 0 0 0-1.4-.3 3.3 3.3 0 0 0-2.3.9L1 10.5A3.2 3.2 0 0 0 .9 15H1a2.9 2.9 0 0 0 2.3 1 3.2 3.2 0 0 0 2.3-1l2-1.9a4.6 4.6 0 0 0 .9-1.7 2.9 2.9 0 0 0-.3-1.8zM15 1a2.5 2.5 0 0 0-1-.8 3.1 3.1 0 0 0-3.5.8L8.4 2.9a3.1 3.1 0 0 0-.9 1.8 3.2 3.2 0 0 0 .3 1.9l.8-.8a2 2 0 0 1 0-1.1 2.2 2.2 0 0 1 .5-1.1l2.1-1.9.3-.3.4-.2.4-.2h.5a1.9 1.9 0 0 1 1.5.7 2 2 0 0 1 .7 1.6 1.9 1.9 0 0 1-.7 1.5l-2 2.1-.7.4a1.5 1.5 0 0 1-.9.2h-.4l-.8.8H12l1-.7 2-2.1A3 3 0 0 0 15 1z"/></svg>

+ 1 - 0
public/images/icons/editor/list-ol.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 23.7 16"><path d="M23.7 2a.8.8 0 0 1-.8.8H6.6a.8.8 0 0 1-.7-.8.7.7 0 0 1 .7-.7h16.3a.7.7 0 0 1 .8.7zM6.6 8.7h16.3a.7.7 0 0 0 .8-.7.8.8 0 0 0-.8-.8H6.6a.8.8 0 0 0-.7.8.7.7 0 0 0 .7.7zm0 5.9h16.3a.7.7 0 0 0 .8-.7.7.7 0 0 0-.8-.7H6.6a.7.7 0 0 0-.7.7.7.7 0 0 0 .7.7zM1.5.5V4h.6V0h-.5L.7.5v.4l.8-.4zM.9 9.6l.3-.3c.9-.9 1.4-1.5 1.4-2.2a1.2 1.2 0 0 0-1.3-1.2h-.1a1.4 1.4 0 0 0-1.2.6l.3.4a1.2 1.2 0 0 1 .9-.5.6.6 0 0 1 .8.6v.2c0 .6-.4 1.1-1.5 2.1l-.4.4v.3h2.6v-.4zm.9 4.1a1 1 0 0 0 .7-.9 1 1 0 0 0-1.1-1 2 2 0 0 0-1.1.3v.4l.8-.2c.5 0 .8.2.8.6s-.5.7-.9.7H.7v.4H1c.6 0 1.1.2 1.1.8a.8.8 0 0 1-.9.8l-.9-.3-.2.4a2 2 0 0 0 1.1.3c1 0 1.5-.6 1.5-1.2a1.2 1.2 0 0 0-.9-1.1z"/></svg>

+ 1 - 0
public/images/icons/editor/list-ul.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 21.6 13.5"><path d="M6.4 1.5h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zM6.4 7.5h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zM6.4 13.5h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zM.9 1.5h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.8h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.8zM.9 7.5h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.8h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.8zM.9 13.5h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.7h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.7z"/></svg>

+ 1 - 0
public/images/icons/editor/picture.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 19 16"><path d="M17.8 0H1.2A1.2 1.2 0 0 0 0 1.2v13.6A1.2 1.2 0 0 0 1.2 16h16.6a1.2 1.2 0 0 0 1.2-1.2V1.2a1.4 1.4 0 0 0-.2-.6.8.8 0 0 0-.4-.4zm0 14.8H1.2v-3.5l4.7-4.6 5 4.9.3.2.5-.2 2.1-1.9 3.9 4h.1v1.1zm0-2.8l-3.5-3.5-.4-.2h-.4l-2.2 2-4.9-4.8-.4-.2c-.2 0-.4 0-.5.2L1.2 9.7V1.2h16.6V12zm-4.2-6.1h.6a1.1 1.1 0 0 0 .6-1.1 1.2 1.2 0 0 0-1.2-1.1 1.3 1.3 0 0 0-1.2 1.2 1.2 1.2 0 0 0 .4.8 1.1 1.1 0 0 0 .8.3z"/></svg>

+ 1 - 0
public/images/icons/editor/quote.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 17 12"><path d="M5 0H2a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a1.7 1.7 0 0 0 1-.3V10a.9.9 0 0 1-1 1H3v1h2a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 6H2a.9.9 0 0 1-1-1V2a.9.9 0 0 1 1-1h3a.9.9 0 0 1 1 1v3a.9.9 0 0 1-1 1zM15 0h-3a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a1.7 1.7 0 0 0 1-.3V10a.9.9 0 0 1-1 1h-2v1h2a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 6h-3a.9.9 0 0 1-1-1V2a.9.9 0 0 1 1-1h3a.9.9 0 0 1 1 1v3a.9.9 0 0 1-1 1z"/></svg>

+ 1 - 0
public/images/icons/editor/strikethrough.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 19.5 14"><path d="M5.8 6.2H9C7.2 5.7 6.3 5 6.3 3.8a2.2 2.2 0 0 1 .9-1.9 4.3 4.3 0 0 1 2.5-.7 4.3 4.3 0 0 1 2.5.7 3.1 3.1 0 0 1 1.1 1.6.7.7 0 0 0 .6.4h.3a.7.7 0 0 0 .4-.8A3.6 3.6 0 0 0 13.1 1a6.7 6.7 0 0 0-6-.5 3.1 3.1 0 0 0-1.7 1.3 3.6 3.6 0 0 0-.6 2 2.9 2.9 0 0 0 1 2.3zM12.8 8.7a2 2 0 0 1 .6 1.4 2.4 2.4 0 0 1-1 1.9 3.7 3.7 0 0 1-2.5.7 4.6 4.6 0 0 1-3-.8 3.7 3.7 0 0 1-1.2-2 .6.6 0 0 0-.6-.5h-.2a.7.7 0 0 0-.5.8 4.1 4.1 0 0 0 1.5 2.5A6 6 0 0 0 9.8 14a7.5 7.5 0 0 0 2.6-.5 4.9 4.9 0 0 0 1.8-1.4 4.3 4.3 0 0 0 .6-2.2 5 5 0 0 0-.2-1.2zM.4 7.9a.7.7 0 0 1-.4-.5.4.4 0 0 1 .4-.4h18.8a.4.4 0 0 1 .3.6c0 .1-.1.2-.2.3z"/></svg>

+ 1 - 1
public/images/icons/editor/table.svg

@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M0 19.7v216.6h256V19.7H0zm78.8 196.9H19.7v-39.4h59.1v39.4zm0-59.1H19.7v-39.3h59.1v39.3zm0-59H19.7V59.1h59.1v39.4zm78.7 118.1h-59v-39.4h59v39.4zm0-59.1h-59v-39.3h59v39.3zm0-59h-59V59.1h59v39.4zm78.8 118.1h-59.1v-39.4h59.1v39.4zm0-59.1h-59.1v-39.3h59.1v39.3zm0-59h-59.1V59.1h59.1v39.4z"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 20.3 16"><path d="M19.1 16H1.2A1.2 1.2 0 0 1 0 14.8V1.2A1.2 1.2 0 0 1 1.2 0h17.9a1.2 1.2 0 0 1 1.2 1.2v13.6a1.2 1.2 0 0 1-1.2 1.2zm-5.2-4.3v3.2h5.3v-3.2zm-6.4 0v3.2h5.3v-3.2zm-6.4 0v3.2h5.3v-3.2zm12.8-4.2v3.2h5.3V7.5zm-6.4 0v3.2h5.3V7.5zm-6.4 0v3.2h5.3V7.5zm12.8-4.3v3.2h5.3V3.2zm-6.4 0v3.2h5.3V3.2zm-6.4 0v3.2h5.3V3.2z"/></svg>

+ 7 - 0
resource/cdn-manifests.js

@@ -70,6 +70,13 @@ module.exports = {
         integrity: '',
       },
     },
+    {
+      name: 'redoc-standalone',
+      url: 'https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js',
+      args: {
+        integrity: '',
+      },
+    },
   ],
   style: [
     {

+ 6 - 3
resource/locales/en-US/translation.json

@@ -3,6 +3,8 @@
   "Edit": "Edit",
   "Delete": "Delete",
   "Duplicate": "Duplicate",
+  "Copy": "Copy",
+  "Click to copy": "Click to copy",
   "Move": "Move",
   "Moved": "Moved",
   "Unlinked": "Unlinked",
@@ -78,7 +80,7 @@
   "Hide": "Hide",
   "Disclose E-mail": "Disclose E-mail",
 
-
+  "page exists": "this page already exists",
 
   "Create today's": "Create today's ...",
   "Memo": "memo",
@@ -101,6 +103,7 @@
   "Load latest": "Load latest",
   "edited this page": "edited this page.",
 
+  "Resume Drafts": "Your Drafts",
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
 
@@ -623,8 +626,8 @@
     "write_java": "You can write Javascript that is applied to whole system.",
     "attach_title_header": "Add h1 section when create new page automatically",
     "attach_title_header_desc": "Add page path to the first line as h1 section when create new page",
-    "recent_created_page_num": "Recent Created Paging num",
-    "recent_created_page_num_desc": "The number of pages to show in Recent Created Page List on the user's home"
+    "recent_created__n_draft_num_desc": "Number of Recently Created Pages & Drafts Displayed",
+    "recently_created_n_draft_num_desc": "Number of recently created pages and drafts displayed on user page"
   },
 
   "user_management": {

+ 7 - 4
resource/locales/ja/translation.json

@@ -3,6 +3,8 @@
   "Edit": "編集",
   "Delete": "削除",
   "Duplicate": "複製",
+  "Copy": "コピー",
+  "Click to copy": "クリックでコピー",
   "Move": "移動",
   "Moved": "移動しました",
   "Unlinked": "リダイレクト削除",
@@ -76,8 +78,7 @@
   "Hide": "非公開",
   "Disclose E-mail": "メールアドレスの公開",
 
-
-
+  "page exists": "このページはすでに存在しています",
 
   "Create today's": "今日の◯◯を作成",
   "Memo": "メモ",
@@ -113,6 +114,7 @@
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Selecting authentication mechanism": "認証機構選択",
   "Add tags for this page": "タグを付ける",
+  "Edit tags for this page": "タグを編集する",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
 
 
@@ -120,6 +122,7 @@
   "Load latest": "最新版を読み込む",
   "edited this page": "さんがこのページを編集しました。",
 
+  "Resume Drafts": "下書きを続ける",
   "Deleted Pages": "削除済みページ",
   "Sign out": "ログアウト",
 
@@ -644,8 +647,8 @@
     "write_java": "システム全体に適用されるJavaScriptを記述できます。",
     "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
     "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
-    "recent_created_page_num": "Recent Created ページングサイズ",
-    "recent_created_page_num_desc": "ホーム画面の Recent Created で、1ページに表示する件数を設定します。"
+    "recent_created__n_draft_num_desc": "最近作成したページと下書きの表示数",
+    "recently_created_n_draft_num_desc": "ホーム画面の Recently Created での、1ページの表示数を設定します。"
   },
 
   "user_management": {

+ 21 - 2
src/client/js/app.js

@@ -32,12 +32,12 @@ import CommentForm from './components/PageComment/CommentForm';
 import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
 import RevisionPath from './components/Page/RevisionPath';
-import TagLabel from './components/Page/TagLabel';
-import RevisionUrl from './components/Page/RevisionUrl';
+import TagLabels from './components/Page/TagLabels';
 import BookmarkButton from './components/BookmarkButton';
 import LikeButton from './components/LikeButton';
 import PagePathAutoComplete from './components/PagePathAutoComplete';
 import RecentCreated from './components/RecentCreated/RecentCreated';
+import MyDraftList from './components/MyDraftList/MyDraftList';
 import UserPictureList from './components/User/UserPictureList';
 
 import CustomCssEditor from './components/Admin/CustomCssEditor';
@@ -405,6 +405,25 @@ if (recentCreatedControlsElem) {
   );
 }
 
+const myDraftControlsElem = document.getElementById('user-draft-list');
+if (myDraftControlsElem) {
+  let limit = crowi.getConfig().recentCreatedLimit;
+  if (limit == null) {
+    limit = 10;
+  }
+
+  ReactDOM.render(
+    <I18nextProvider i18n={i18n}>
+      <MyDraftList
+        limit={limit}
+        crowi={crowi}
+        crowiOriginRenderer={crowiRenderer}
+      />
+    </I18nextProvider>,
+    myDraftControlsElem,
+  );
+}
+
 /*
  * HackMD Editor
  */

+ 0 - 0
src/client/js/components/CopyButton.js → src/client/js/components/CopyButton.jsx


+ 240 - 0
src/client/js/components/MyDraftList/MyDraftList.jsx

@@ -0,0 +1,240 @@
+import React from 'react';
+
+import PropTypes from 'prop-types';
+import Pagination from 'react-bootstrap/lib/Pagination';
+import Draft from '../PageList/Draft';
+
+export default class MyDraftList extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      drafts: [],
+      currentDrafts: [],
+      activePage: 1,
+      paginationNumbers: {},
+    };
+
+    this.getDraftsFromLocalStorage = this.getDraftsFromLocalStorage.bind(this);
+    this.getCurrentDrafts = this.getCurrentDrafts.bind(this);
+    this.clearDraft = this.clearDraft.bind(this);
+    this.clearAllDrafts = this.clearAllDrafts.bind(this);
+    this.calculatePagination = this.calculatePagination.bind(this);
+  }
+
+  async componentWillMount() {
+    await this.getDraftsFromLocalStorage();
+    this.getCurrentDrafts(1);
+  }
+
+  async getDraftsFromLocalStorage() {
+    const draftsAsObj = JSON.parse(this.props.crowi.localStorage.getItem('draft') || '{}');
+
+    const res = await this.props.crowi.apiGet('/pages.exist', {
+      pages: draftsAsObj,
+    });
+
+    // {'/a': '#a', '/b': '#b'} => [{path: '/a', markdown: '#a'}, {path: '/b', markdown: '#b'}]
+    const drafts = Object.entries(draftsAsObj).map((d) => {
+      const path = d[0];
+      return {
+        path,
+        markdown: d[1],
+        isExist: res.pages[path],
+      };
+    });
+
+    this.setState({ drafts });
+  }
+
+  getCurrentDrafts(selectPageNumber) {
+    const limit = this.props.limit;
+    const totalCount = this.state.drafts.length;
+    const activePage = selectPageNumber;
+    const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
+
+    const currentDrafts = this.state.drafts.slice((activePage - 1) * limit, activePage * limit);
+
+    this.setState({
+      currentDrafts,
+      activePage,
+      paginationNumbers,
+    });
+  }
+
+  /**
+   * generate Elements of Draft
+   *
+   * @param {any} drafts Array of pages Model Obj
+   *
+   */
+  generateDraftList(drafts) {
+    return drafts.map((draft) => {
+      return (
+        <Draft
+          key={draft.path}
+          crowi={this.props.crowi}
+          crowiOriginRenderer={this.props.crowiOriginRenderer}
+          path={draft.path}
+          markdown={draft.markdown}
+          isExist={draft.isExist}
+          clearDraft={this.clearDraft}
+        />
+      );
+    });
+  }
+
+  clearDraft(path) {
+    this.props.crowi.clearDraft(path);
+
+    this.setState((prevState) => {
+      return {
+        drafts: prevState.drafts.filter((draft) => { return draft.path !== path }),
+        currentDrafts: prevState.drafts.filter((draft) => { return draft.path !== path }),
+      };
+    });
+  }
+
+  clearAllDrafts() {
+    this.props.crowi.clearAllDrafts();
+
+    this.setState({
+      drafts: [],
+      currentDrafts: [],
+      activePage: 1,
+      paginationNumbers: {},
+    });
+  }
+
+  calculatePagination(limit, totalCount, activePage) {
+    // calc totalPageNumber
+    const totalPage = Math.floor(totalCount / limit) + (totalCount % limit === 0 ? 0 : 1);
+
+    let paginationStart = activePage - 2;
+    let maxViewPageNum = activePage + 2;
+    // pagiNation Number area size = 5 , pageNuber calculate in here
+    // activePage Position calculate ex. 4 5 [6] 7 8 (Page8 over is Max), 3 4 5 [6] 7 (Page7 is Max)
+    if (paginationStart < 1) {
+      const diff = 1 - paginationStart;
+      paginationStart += diff;
+      maxViewPageNum = Math.min(totalPage, maxViewPageNum + diff);
+    }
+    if (maxViewPageNum > totalPage) {
+      const diff = maxViewPageNum - totalPage;
+      maxViewPageNum -= diff;
+      paginationStart = Math.max(1, paginationStart - diff);
+    }
+
+    return {
+      totalPage,
+      paginationStart,
+      maxViewPageNum,
+    };
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   * ex.  <<   <   1  2  3  >  >>
+   * this function set << & <
+   */
+  generateFirstPrev(activePage) {
+    const paginationItems = [];
+    if (activePage !== 1) {
+      paginationItems.push(
+        <Pagination.First key="first" onClick={() => { return this.getCurrentDrafts(1) }} />,
+      );
+      paginationItems.push(
+        <Pagination.Prev key="prev" onClick={() => { return this.getCurrentDrafts(this.state.activePage - 1) }} />,
+      );
+    }
+    else {
+      paginationItems.push(
+        <Pagination.First key="first" disabled />,
+      );
+      paginationItems.push(
+        <Pagination.Prev key="prev" disabled />,
+      );
+
+    }
+    return paginationItems;
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   *  ex. << < 4 5 6 7 8 > >>, << < 1 2 3 4 > >>
+   * this function set  numbers
+   */
+  generatePaginations(activePage, paginationStart, maxViewPageNum) {
+    const paginationItems = [];
+    for (let number = paginationStart; number <= maxViewPageNum; number++) {
+      paginationItems.push(
+        <Pagination.Item key={number} active={number === activePage} onClick={() => { return this.getCurrentDrafts(number) }}>{number}</Pagination.Item>,
+      );
+    }
+    return paginationItems;
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   * ex.  <<   <   1  2  3  >  >>
+   * this function set > & >>
+   */
+  generateNextLast(activePage, totalPage) {
+    const paginationItems = [];
+    if (totalPage !== activePage) {
+      paginationItems.push(
+        <Pagination.Next key="next" onClick={() => { return this.getCurrentDrafts(this.state.activePage + 1) }} />,
+      );
+      paginationItems.push(
+        <Pagination.Last key="last" onClick={() => { return this.getCurrentDrafts(totalPage) }} />,
+      );
+    }
+    else {
+      paginationItems.push(
+        <Pagination.Next key="next" disabled />,
+      );
+      paginationItems.push(
+        <Pagination.Last key="last" disabled />,
+      );
+
+    }
+    return paginationItems;
+
+  }
+
+  render() {
+    const draftList = this.generateDraftList(this.state.currentDrafts);
+
+    const paginationItems = [];
+
+    const activePage = this.state.activePage;
+    const totalPage = this.state.paginationNumbers.totalPage;
+    const paginationStart = this.state.paginationNumbers.paginationStart;
+    const maxViewPageNum = this.state.paginationNumbers.maxViewPageNum;
+    const firstPrevItems = this.generateFirstPrev(activePage);
+    paginationItems.push(firstPrevItems);
+    const paginations = this.generatePaginations(activePage, paginationStart, maxViewPageNum);
+    paginationItems.push(paginations);
+    const nextLastItems = this.generateNextLast(activePage, totalPage);
+    paginationItems.push(nextLastItems);
+
+    return (
+      <div className="page-list-container-create">
+        <button type="button" className="btn-danger mb-3" onClick={this.clearAllDrafts}>Delete All</button>
+        <div className="tab-pane m-t-30 accordion" id="draft-list">
+          {draftList}
+        </div>
+        <Pagination bsSize="small">{paginationItems}</Pagination>
+      </div>
+    );
+  }
+
+}
+
+
+MyDraftList.propTypes = {
+  limit: PropTypes.number,
+  crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
+};

+ 0 - 0
src/client/js/components/Page/RevisionPath.js → src/client/js/components/Page/RevisionPath.jsx


+ 108 - 0
src/client/js/components/Page/TagEditor.jsx

@@ -0,0 +1,108 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import * as toastr from 'toastr';
+import Button from 'react-bootstrap/es/Button';
+import Modal from 'react-bootstrap/es/Modal';
+import TagsInput from './TagsInput';
+
+class TagEditor extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      tags: [],
+      isOpenModal: false,
+      isEditorMode: null,
+    };
+
+    this.show = this.show.bind(this);
+    this.onTagsUpdatedByFormHandler = this.onTagsUpdatedByFormHandler.bind(this);
+    this.closeModalHandler = this.closeModalHandler.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.apiSuccessHandler = this.apiSuccessHandler.bind(this);
+    this.apiErrorHandler = this.apiErrorHandler.bind(this);
+  }
+
+  show(tags) {
+    const isEditorMode = this.props.crowi.getCrowiForJquery().getCurrentEditorMode();
+    this.setState({ isOpenModal: true, isEditorMode, tags });
+  }
+
+  onTagsUpdatedByFormHandler(tags) {
+    this.setState({ tags });
+  }
+
+  closeModalHandler() {
+    this.setState({ isOpenModal: false });
+  }
+
+  async handleSubmit() {
+
+    if (!this.state.isEditorMode) {
+      try {
+        await this.props.crowi.apiPost('/tags.update', { pageId: this.props.pageId, tags: this.state.tags });
+        this.apiSuccessHandler();
+      }
+      catch (err) {
+        this.apiErrorHandler(err);
+        return;
+      }
+    }
+
+    this.props.onTagsUpdated(this.state.tags);
+
+    // close modal
+    this.setState({ isOpenModal: false });
+  }
+
+  apiSuccessHandler() {
+    toastr.success(undefined, 'updated tags successfully', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '1200',
+      extendedTimeOut: '150',
+    });
+  }
+
+  apiErrorHandler(err) {
+    toastr.error(err.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
+
+  render() {
+    return (
+      <Modal show={this.state.isOpenModal} onHide={this.closeModalHandler} id="editTagModal">
+        <Modal.Header closeButton className="bg-primary">
+          <Modal.Title className="text-white">Edit Tags</Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          <TagsInput crowi={this.props.crowi} tags={this.state.tags} onTagsUpdated={this.onTagsUpdatedByFormHandler} />
+        </Modal.Body>
+        <Modal.Footer>
+          <Button variant="primary" onClick={this.handleSubmit}>
+            Done
+          </Button>
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+}
+
+TagEditor.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  pageId: PropTypes.string,
+  onTagsUpdated: PropTypes.func.isRequired,
+};
+
+export default TagEditor;

+ 0 - 0
src/client/js/components/Page/TagLabel.jsx → src/client/js/components/Page/TagLabels.jsx


+ 15 - 11
src/client/js/components/PageTagForm.jsx → src/client/js/components/Page/TagsInput.jsx

@@ -7,11 +7,11 @@ import { AsyncTypeahead } from 'react-bootstrap-typeahead';
  * @author Yuki Takei <yuki@weseek.co.jp>
  *
  * @export
- * @class PageTagForm
+ * @class TagsInput
  * @extends {React.Component}
  */
 
-export default class PageTagForm extends React.Component {
+export default class TagsInput extends React.Component {
 
   constructor(props) {
     super(props);
@@ -19,8 +19,8 @@ export default class PageTagForm extends React.Component {
     this.state = {
       resultTags: [],
       isLoading: false,
-      selected: this.props.currentPageTags,
-      defaultPageTags: this.props.currentPageTags,
+      selected: this.props.tags,
+      defaultPageTags: this.props.tags,
     };
     this.crowi = this.props.crowi;
 
@@ -29,10 +29,14 @@ export default class PageTagForm extends React.Component {
     this.handleSelect = this.handleSelect.bind(this);
   }
 
+  componentDidMount() {
+    this.typeahead.getInstance().focus();
+  }
+
   handleChange(selected) {
-    // list is a list of object about value. an element have customOption, id and label properties
+    // send tags to TagLabel Component when user add tag to form everytime
     this.setState({ selected }, () => {
-      this.props.addNewTag(this.state.selected);
+      this.props.onTagsUpdated(this.state.selected);
     });
   }
 
@@ -62,7 +66,7 @@ export default class PageTagForm extends React.Component {
     return (
       <div className="tag-typeahead">
         <AsyncTypeahead
-          id="async-typeahead"
+          id="tag-typeahead-asynctypeahead"
           ref={(typeahead) => { this.typeahead = typeahead }}
           caseSensitive={false}
           defaultSelected={this.state.defaultPageTags}
@@ -83,11 +87,11 @@ export default class PageTagForm extends React.Component {
 
 }
 
-PageTagForm.propTypes = {
+TagsInput.propTypes = {
   crowi: PropTypes.object.isRequired,
-  currentPageTags: PropTypes.array.isRequired,
-  addNewTag: PropTypes.func.isRequired,
+  tags: PropTypes.array.isRequired,
+  onTagsUpdated: PropTypes.func.isRequired,
 };
 
-PageTagForm.defaultProps = {
+TagsInput.defaultProps = {
 };

+ 23 - 3
src/client/js/components/PageEditor.js

@@ -38,6 +38,7 @@ export default class PageEditor extends React.Component {
     this.setCaretLine = this.setCaretLine.bind(this);
     this.focusToEditor = this.focusToEditor.bind(this);
     this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
+    this.onSave = this.onSave.bind(this);
     this.onUpload = this.onUpload.bind(this);
     this.onEditorScroll = this.onEditorScroll.bind(this);
     this.onEditorScrollCursorIntoView = this.onEditorScrollCursorIntoView.bind(this);
@@ -45,6 +46,7 @@ export default class PageEditor extends React.Component {
     this.saveDraft = this.saveDraft.bind(this);
     this.clearDraft = this.clearDraft.bind(this);
     this.apiErrorHandler = this.apiErrorHandler.bind(this);
+    this.showUnsavedWarning = this.showUnsavedWarning.bind(this);
 
     // for scrolling
     this.lastScrolledDateWithCursor = null;
@@ -62,6 +64,20 @@ export default class PageEditor extends React.Component {
   componentWillMount() {
     // initial rendering
     this.renderPreview(this.state.markdown);
+
+    this.props.crowi.window.addEventListener('beforeunload', this.showUnsavedWarning);
+  }
+
+  componentWillUnmount() {
+    this.props.crowi.window.removeEventListener('beforeunload', this.showUnsavedWarning);
+  }
+
+  showUnsavedWarning(e) {
+    if (!this.props.crowi.getIsDocSaved()) {
+      // display browser default message
+      e.returnValue = '';
+      return '';
+    }
   }
 
   getMarkdown() {
@@ -111,6 +127,12 @@ export default class PageEditor extends React.Component {
   onMarkdownChanged(value) {
     this.renderPreviewWithDebounce(value);
     this.saveDraftWithDebounce();
+    this.props.crowi.setIsDocSaved(false);
+  }
+
+  onSave() {
+    this.props.onSaveWithShortcut(this.state.markdown);
+    this.props.crowi.setIsDocSaved(true);
   }
 
   /**
@@ -330,9 +352,7 @@ export default class PageEditor extends React.Component {
             onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
             onChange={this.onMarkdownChanged}
             onUpload={this.onUpload}
-            onSave={() => {
-              this.props.onSaveWithShortcut(this.state.markdown);
-            }}
+            onSave={this.onSave}
           />
         </div>
         <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">

+ 13 - 24
src/client/js/components/PageEditor/CodeMirrorEditor.js

@@ -615,11 +615,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
 
   getNavbarItems() {
-    // The following styles will be removed after creating icons for the editor navigation bar.
-    const paddingTopBottom54 = { paddingTop: '6px', paddingBottom: '5px' };
-    const paddingBottom6 = { paddingBottom: '7px' };
-    const fontSize18 = { fontSize: '18px' };
-
     return [
       <Button
         key="nav-item-bold"
@@ -627,7 +622,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Bold"
         onClick={this.createReplaceSelectionHandler('**', '**')}
       >
-        <i className="fa fa-bold"></i>
+        <img src="/images/icons/editor/bold.svg" alt="icon-bold" />
       </Button>,
       <Button
         key="nav-item-italic"
@@ -635,15 +630,15 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Italic"
         onClick={this.createReplaceSelectionHandler('*', '*')}
       >
-        <i className="fa fa-italic"></i>
+        <img src="/images/icons/editor/italic.svg" alt="icon-italic" />
       </Button>,
       <Button
-        key="nav-item-strikethough"
+        key="nav-item-strikethrough"
         bsSize="small"
         title="Strikethrough"
         onClick={this.createReplaceSelectionHandler('~~', '~~')}
       >
-        <i className="fa fa-strikethrough"></i>
+        <img src="/images/icons/editor/strikethrough.svg" alt="icon-strikethrough" />
       </Button>,
       <Button
         key="nav-item-header"
@@ -651,7 +646,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Heading"
         onClick={this.makeHeaderHandler}
       >
-        <i className="fa fa-header"></i>
+        <img src="/images/icons/editor/header.svg" alt="icon-header" />
       </Button>,
       <Button
         key="nav-item-code"
@@ -659,61 +654,55 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Inline Code"
         onClick={this.createReplaceSelectionHandler('`', '`')}
       >
-        <i className="fa fa-code"></i>
+        <img src="/images/icons/editor/code.svg" alt="icon-code" />
       </Button>,
       <Button
         key="nav-item-quote"
         bsSize="small"
         title="Quote"
         onClick={this.createAddPrefixToEachLinesHandler('> ')}
-        style={paddingBottom6}
       >
-        <i className="ti-quote-right"></i>
+        <img src="/images/icons/editor/quote.svg" alt="icon-quote" />
       </Button>,
       <Button
         key="nav-item-ul"
         bsSize="small"
         title="List"
         onClick={this.createAddPrefixToEachLinesHandler('- ')}
-        style={paddingTopBottom54}
       >
-        <i className="ti-list" style={fontSize18}></i>
+        <img src="/images/icons/editor/list-ul.svg" alt="icon-list-ul" />
       </Button>,
       <Button
         key="nav-item-ol"
         bsSize="small"
         title="Numbered List"
         onClick={this.createAddPrefixToEachLinesHandler('1. ')}
-        style={paddingTopBottom54}
       >
-        <i className="ti-list-ol" style={fontSize18}></i>
+        <img src="/images/icons/editor/list-ol.svg" alt="icon-list-ol" />
       </Button>,
       <Button
         key="nav-item-checkbox"
         bsSize="small"
         title="Check List"
         onClick={this.createAddPrefixToEachLinesHandler('- [ ] ')}
-        style={paddingBottom6}
       >
-        <i className="ti-check-box"></i>
+        <img src="/images/icons/editor/check.svg" alt="icon-check" />
       </Button>,
       <Button
         key="nav-item-link"
         bsSize="small"
         title="Link"
         onClick={this.createReplaceSelectionHandler('[', ']()')}
-        style={paddingBottom6}
       >
-        <i className="icon-link"></i>
+        <img src="/images/icons/editor/link.svg" alt="icon-link" />
       </Button>,
       <Button
         key="nav-item-image"
         bsSize="small"
         title="Image"
         onClick={this.createReplaceSelectionHandler('![', ']()')}
-        style={paddingBottom6}
       >
-        <i className="icon-picture"></i>
+        <img src="/images/icons/editor/picture.svg" alt="icon-picture" />
       </Button>,
       <Button
         key="nav-item-table"
@@ -721,7 +710,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Table"
         onClick={this.showHandsonTableHandler}
       >
-        <img src="/images/icons/editor/table.svg" alt="icon-table" width="14" height="14" />
+        <img src="/images/icons/editor/table.svg" alt="icon-table" />
       </Button>,
     ];
   }

+ 165 - 0
src/client/js/components/PageList/Draft.jsx

@@ -0,0 +1,165 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import GrowiRenderer from '../../util/GrowiRenderer';
+
+import RevisionBody from '../Page/RevisionBody';
+
+class Draft extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      html: '',
+      isOpen: false,
+    };
+
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'draft' });
+
+    this.renderHtml = this.renderHtml.bind(this);
+    this.toggleContent = this.toggleContent.bind(this);
+    this.copyMarkdownToClipboard = this.copyMarkdownToClipboard.bind(this);
+    this.renderAccordionTitle = this.renderAccordionTitle.bind(this);
+  }
+
+  copyMarkdownToClipboard() {
+    navigator.clipboard.writeText(this.props.markdown);
+  }
+
+  async toggleContent(e) {
+    const target = e.currentTarget.getAttribute('data-target');
+
+    if (!this.state.html) {
+      await this.renderHtml();
+    }
+
+    if (this.state.isOpen) {
+      $(target).collapse('hide');
+      this.setState({ isOpen: false });
+    }
+    else {
+      $(target).collapse('show');
+      this.setState({ isOpen: true });
+    }
+  }
+
+  async renderHtml() {
+    const context = {
+      markdown: this.props.markdown,
+    };
+
+    const growiRenderer = this.growiRenderer;
+    const interceptorManager = this.props.crowi.interceptorManager;
+    await interceptorManager.process('prePreProcess', context)
+      .then(() => {
+        context.markdown = growiRenderer.preProcess(context.markdown);
+      })
+      .then(() => { return interceptorManager.process('postPreProcess', context) })
+      .then(() => {
+        const parsedHTML = growiRenderer.process(context.markdown);
+        context.parsedHTML = parsedHTML;
+      })
+      .then(() => { return interceptorManager.process('prePostProcess', context) })
+      .then(() => {
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+      })
+      .then(() => { return interceptorManager.process('postPostProcess', context) })
+      .then(() => {
+        this.setState({ html: context.parsedHTML });
+      });
+  }
+
+  renderAccordionTitle(isExist) {
+    if (isExist) {
+      return (
+        <Fragment>
+          <span>{this.props.path}</span>
+          <span className="mx-2">({this.props.t('page exists')})</span>
+        </Fragment>
+      );
+    }
+
+    return (
+      <Fragment>
+        <a href={`${this.props.path}#edit`} target="_blank" rel="noopener noreferrer">{this.props.path}</a>
+        <span className="mx-2">
+          <span className="label-draft label label-default">draft</span>
+        </span>
+      </Fragment>
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+    const id = this.props.path.replace('/', '-');
+
+    return (
+      <div className="timeline-body">
+        <div className="panel panel-timeline">
+          <div className="panel-heading d-flex justify-content-between">
+            <div className="panel-title" onClick={this.toggleContent} data-target={`#${id}`}>
+              {this.renderAccordionTitle(this.props.isExist)}
+            </div>
+            <div>
+              {this.props.isExist
+                ? null
+                : (
+                  <a
+                    href={`${this.props.path}#edit`}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="draft-edit"
+                    data-toggle="tooltip"
+                    data-placement="bottom"
+                    title={this.props.t('Edit')}
+                  >
+                    <i className="icon-note" />
+                  </a>
+                )
+              }
+              <a
+                className="draft-copy"
+                data-toggle="tooltip"
+                data-placement="bottom"
+                title={this.props.t('Copy')}
+                onClick={this.copyMarkdownToClipboard}
+              >
+                <i className="icon-doc" />
+              </a>
+              <a
+                className="text-danger draft-delete"
+                data-toggle="tooltip"
+                data-placement="top"
+                title={t('Delete')}
+                onClick={() => { return this.props.clearDraft(this.props.path) }}
+              >
+                <i className="icon-trash" />
+              </a>
+            </div>
+          </div>
+          <div className="panel-body collapse" id={id} aria-labelledby={id} data-parent="#draft-list">
+            <div className="revision-body wiki">
+              <RevisionBody html={this.state.html} />
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+Draft.propTypes = {
+  t: PropTypes.func.isRequired,
+  crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
+  path: PropTypes.string.isRequired,
+  markdown: PropTypes.string.isRequired,
+  isExist: PropTypes.bool.isRequired,
+  clearDraft: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(Draft);

+ 1 - 0
src/client/js/components/SavePageControls.jsx

@@ -47,6 +47,7 @@ class SavePageControls extends React.PureComponent {
   }
 
   submit() {
+    this.props.crowi.setIsDocSaved(true);
     this.props.onSubmit();
   }
 

+ 4 - 1
src/client/js/components/SearchTypeahead.js

@@ -41,6 +41,9 @@ export default class SearchTypeahead extends React.Component {
   }
 
   componentDidMount() {
+    // **MEMO** This doesn't work at this time -- 2019.05.13 Yuki Takei
+    // It is needed to use Modal component of react-bootstrap when showing Move/Duplicate/CreateNewPage modals
+    // this.typeahead.getInstance().focus();
   }
 
   componentWillUnmount() {
@@ -173,7 +176,7 @@ export default class SearchTypeahead extends React.Component {
       <div className="search-typeahead">
         <AsyncTypeahead
           {...this.props}
-          id="async-typeahead"
+          id="search-typeahead-asynctypeahead"
           ref={(c) => { this.typeahead = c }}
           inputProps={inputProps}
           isLoading={this.state.isLoading}

+ 13 - 0
src/client/js/util/Crowi.js

@@ -30,6 +30,7 @@ export default class Crowi {
     this.socketClientId = Math.floor(Math.random() * 100000);
     this.page = undefined;
     this.pageEditor = undefined;
+    this.isDocSaved = true;
 
     this.fetchUsers = this.fetchUsers.bind(this);
     this.apiGet = this.apiGet.bind(this);
@@ -79,6 +80,14 @@ export default class Crowi {
     this.pageEditor = pageEditor;
   }
 
+  setIsDocSaved(isSaved) {
+    this.isDocSaved = isSaved;
+  }
+
+  getIsDocSaved() {
+    return this.isDocSaved;
+  }
+
   getWebSocket() {
     return this.socket;
   }
@@ -164,6 +173,10 @@ export default class Crowi {
     this.localStorage.setItem('draft', JSON.stringify(this.draft));
   }
 
+  clearAllDrafts() {
+    this.localStorage.removeItem('draft');
+  }
+
   saveDraft(path, body) {
     this.draft[path] = body;
     this.localStorage.setItem('draft', JSON.stringify(this.draft));

+ 11 - 8
src/client/styles/scss/_page.scss

@@ -13,14 +13,8 @@
       margin-right: auto;
     }
 
-    .btn-tag {
-      display: block;
-      line-height: 20px;
-    }
-
     .btn-copy,
     .btn-copy-link,
-    .btn-tag,
     .btn-edit {
       @extend .text-muted;
       border: none;
@@ -31,12 +25,21 @@
       }
     }
 
+    .btn-edit-tags {
+      @extend .text-muted;
+      opacity: 0.5;
+
+      &.no-tags {
+        opacity: 0.7;
+      }
+    }
+
     // change button opacity
     &:hover {
       .btn.btn-copy,
       .btn-copy-link,
-      .btn-tag,
-      .btn.btn-edit {
+      .btn.btn-edit,
+      .btn.btn-edit-tags {
         opacity: unset;
       }
     }

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

@@ -8,7 +8,7 @@
     margin-left: 5px;
   }
 
-  .display-of-notag,
+  .btn-edit-tags,
   .tag-icon {
     font-size: 10px;
   }

+ 32 - 4
src/client/styles/scss/_user.scss

@@ -24,17 +24,19 @@
 
       ul {
         padding-left: 0;
+
         li {
           list-style: none;
         }
       }
+
       .user-page-username {
         font-weight: bold;
       }
-      .user-page-email {
-      }
-      .user-page-introduction {
-      }
+
+      .user-page-email {}
+
+      .user-page-introduction {}
     }
 
     .btn-like,
@@ -62,4 +64,30 @@
       height: 48px;
     }
   }
+
+  .user-page-content {
+    #user-draft-list {
+      #draft-list {
+        .panel-title {
+          width: 100%;
+
+          .label-draft {
+            padding: 1px 5px;
+            margin: 0 0 0 4px;
+            font-weight: normal;
+          }
+        }
+
+        a {
+
+          .icon-copy,
+          .draft-delete,
+          .icon-edit {
+            margin: 0 0 0 4px;
+            cursor: pointer;
+          }
+        }
+      }
+    }
+  }
 }

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

@@ -5,7 +5,6 @@ const path = require('path');
 const swig = require('swig-templates');
 const onHeaders = require('on-headers');
 
-
 class CrowiDev {
 
   /**

+ 2 - 0
src/server/crowi/index.js

@@ -6,6 +6,7 @@ const pkg = require('@root/package.json');
 const InterceptorManager = require('@commons/service/interceptor-manager');
 const CdnResourcesService = require('@commons/service/cdn-resources-service');
 const Xss = require('@commons/service/xss');
+
 const path = require('path');
 
 const sep = path.sep;
@@ -56,6 +57,7 @@ function Crowi(rootdir) {
     page: new (require(`${self.eventsDir}page`))(this),
     search: new (require(`${self.eventsDir}search`))(this),
     bookmark: new (require(`${self.eventsDir}bookmark`))(this),
+    tag: new (require(`${self.eventsDir}tag`))(this),
   };
 }
 

+ 13 - 0
src/server/events/tag.js

@@ -0,0 +1,13 @@
+const util = require('util');
+const events = require('events');
+
+function TagEvent(crowi) {
+  this.crowi = crowi;
+
+  events.EventEmitter.call(this);
+}
+util.inherits(TagEvent, events.EventEmitter);
+
+TagEvent.prototype.onUpdate = function(tag) { };
+
+module.exports = TagEvent;

+ 43 - 0
src/server/routes/apiv3/docs.js

@@ -0,0 +1,43 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:docs'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+
+const router = express.Router();
+
+// paths to scan
+const APIS = [
+  'src/server/routes/apiv3/**/*.js',
+];
+
+module.exports = (crowi) => {
+
+  // skip if disabled
+  if (!crowi.configManager.getConfig('crowi', 'app:publishOpenAPI')) {
+    return router;
+  }
+
+  const swaggerJSDoc = require('swagger-jsdoc');
+  const swaggerDefinition = require('@root/config/swagger-definition');
+
+  // generate swagger spec
+  const options = {
+    swaggerDefinition,
+    apis: APIS,
+  };
+  const swaggerSpec = swaggerJSDoc(options);
+
+  // publish swagger spec
+  router.get('/swagger-spec.json', (req, res) => {
+    res.setHeader('Content-Type', 'application/json');
+    res.send(swaggerSpec);
+  });
+
+  // publish redoc
+  router.get('/', (req, res) => {
+    res.render('redoc');
+  });
+
+  return router;
+};

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

@@ -8,7 +8,42 @@ const router = express.Router();
 
 const helmet = require('helmet');
 
+/**
+ * @swagger
+ *  tags:
+ *    name: Healthcheck
+ */
+
 module.exports = (crowi) => {
+  /**
+   * @swagger
+   *
+   *  /healthcheck:
+   *    get:
+   *      tags: [Healthcheck]
+   *      description: Check whether the server is healthy or not
+   *      produces:
+   *        - application/json
+   *      parameters:
+   *        - name: connectToMiddlewares
+   *          in: query
+   *          description: Check also MongoDB and Elasticsearch
+   *          schema:
+   *            type: boolean
+   *      responses:
+   *        200:
+   *          description: Resources are available
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  mongo:
+   *                    type: string
+   *                    description: 'OK'
+   *                  esInfo:
+   *                    type: object
+   *                    description: A result of `client.info()` of Elasticsearch Info API
+   */
   router.get('/', helmet.noCache(), async(req, res) => {
     const connectToMiddlewares = req.query.connectToMiddlewares;
 

+ 2 - 0
src/server/routes/index.js

@@ -193,6 +193,7 @@ module.exports = function(crowi, app) {
   app.post('/_api/pages.create'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.create);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.update);
   app.get('/_api/pages.get'           , accessTokenParser , loginRequired(crowi, app, false) , page.api.get);
+  app.get('/_api/pages.exist'         , accessTokenParser , loginRequired(crowi, app, false) , page.api.exist);
   app.get('/_api/pages.updatePost', accessTokenParser, loginRequired(crowi, app, false), page.api.getUpdatePost);
   app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired(crowi, app, false) , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
@@ -234,6 +235,7 @@ module.exports = function(crowi, app) {
   app.post('/_api/hackmd.saveOnHackmd' , accessTokenParser , loginRequired(crowi, app) , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
   // API v3
+  app.use('/api-docs', require('./apiv3/docs')(crowi));
   app.use('/_api/v3', require('./apiv3')(crowi));
 
   app.get('/*/$'                   , loginRequired(crowi, app, false) , page.showPageWithEndOfSlash, page.notFound);

+ 23 - 0
src/server/routes/page.js

@@ -725,6 +725,29 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
   };
 
+  /**
+   * @api {get} /pages.exist Get if page exists
+   * @apiName GetPage
+   * @apiGroup Page
+   *
+   * @apiParam {String} pages (stringified JSON)
+   */
+  api.exist = async function(req, res) {
+    const pagesAsObj = JSON.parse(req.query.pages || '{}');
+    const pagePaths = Object.keys(pagesAsObj);
+
+    await Promise.all(pagePaths.map(async(path) => {
+      // check page existence
+      const isExist = await Page.count({ path }) > 0;
+      pagesAsObj[path] = isExist;
+      return;
+    }));
+
+    const result = { pages: pagesAsObj };
+
+    return res.json(ApiResponse.success(result));
+  };
+
   /**
    * @api {get} /pages.getPageTag get page tags
    * @apiName GetPageTag

+ 9 - 3
src/server/routes/tag.js

@@ -26,7 +26,7 @@ module.exports = function(crowi, app) {
   };
 
   /**
-   * @api {post} /tags.update update tags
+   * @api {post} /tags.update update tags on view-mode (not edit-mode)
    * @apiName UpdateTag
    * @apiGroup Tag
    *
@@ -35,9 +35,15 @@ module.exports = function(crowi, app) {
    */
   api.update = async function(req, res) {
     const Page = crowi.model('Page');
+    const tagEvent = crowi.event('tag');
+    const pageId = req.body.pageId;
+    const tags = req.body.tags;
+
     try {
-      const page = await Page.findById(req.body.pageId);
-      await page.updateTags(req.body.tags);
+      const page = await Page.findById(pageId);
+      await page.updateTags(tags);
+
+      tagEvent.emit('update', page, tags);
     }
     catch (err) {
       return res.json(ApiResponse.error(err));

+ 6 - 0
src/server/service/config-loader.js

@@ -116,6 +116,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: null,
   },
+  PUBLISH_OPEN_API: {
+    ns:      'crowi',
+    key:     'app:publishOpenAPI',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
   MAX_FILE_SIZE: {
     ns:      'crowi',
     key:     'app:maxFileSize',

+ 11 - 0
src/server/util/search.js

@@ -84,11 +84,15 @@ SearchClient.prototype.registerUpdateEvent = function() {
   const pageEvent = this.crowi.event('page');
   pageEvent.on('create', this.syncPageCreated.bind(this));
   pageEvent.on('update', this.syncPageUpdated.bind(this));
+  pageEvent.on('updateTag', this.syncPageUpdated.bind(this));
   pageEvent.on('delete', this.syncPageDeleted.bind(this));
 
   const bookmarkEvent = this.crowi.event('bookmark');
   bookmarkEvent.on('create', this.syncBookmarkChanged.bind(this));
   bookmarkEvent.on('delete', this.syncBookmarkChanged.bind(this));
+
+  const tagEvent = this.crowi.event('tag');
+  tagEvent.on('update', this.syncTagChanged.bind(this));
 };
 
 SearchClient.prototype.shouldIndexed = function(page) {
@@ -849,4 +853,11 @@ SearchClient.prototype.syncBookmarkChanged = async function(pageId) {
     .catch((err) => { return logger.error('ES Error', err) });
 };
 
+SearchClient.prototype.syncTagChanged = async function(page) {
+  this.updatePages([page])
+    .then((res) => { return debug('ES Response', res) })
+    .catch((err) => { return logger.error('ES Error', err) });
+};
+
+
 module.exports = SearchClient;

+ 2 - 2
src/server/views/admin/customize.html

@@ -282,7 +282,7 @@
           </div>
 
           <div class="form-group">
-            <label for="settingForm[customize:showRecentCreatedNumber]" class="col-xs-3 control-label">{{ t("customize_page.recent_created_page_num") }}</label>
+            <label for="settingForm[customize:showRecentCreatedNumber]" class="col-xs-3 control-label">{{ t("customize_page.recent_created__n_draft_num_desc") }}</label>
             <div class="col-xs-5">
               <select class="form-control selectpicker" name="settingForm[customize:showRecentCreatedNumber]" value="{{ settingForm['customize:showRecentCreatedNumber'] }}">
                 <option value="10" {% if 10 == settingForm['customize:showRecentCreatedNumber'] %}selected{% endif %}>10</option>
@@ -291,7 +291,7 @@
               </select>
 
               <p class="help-block">
-                {{ t("customize_page.recent_created_page_num_desc") }}
+                {{ t("customize_page.recently_created_n_draft_num_desc") }}
               </p>
             </div>
           </div>

+ 3 - 1
src/server/views/layout-crowi/forbidden.html

@@ -10,7 +10,9 @@
       <div>
         <div>
           <h1 class="title" id="revision-path"></h1>
-          <div id="revision-url" class="url-line"></div>
+          {% if page and not forbidden and not isTrashPage() %}
+            <div id="tag-label"></div>
+          {% endif %}
         </div>
       </div>
 

+ 3 - 1
src/server/views/layout-crowi/not_creatable.html

@@ -10,7 +10,9 @@
       <div>
         <div>
           <h1 class="title" id="revision-path"></h1>
-          <div id="revision-url" class="url-line"></div>
+          {% if page and not forbidden and not isTrashPage() %}
+            <div id="tag-label"></div>
+          {% endif %}
         </div>
       </div>
 

+ 3 - 1
src/server/views/layout-crowi/not_found.html

@@ -10,7 +10,9 @@
       <div>
         <div>
           <h1 class="title" id="revision-path"></h1>
-          <div id="revision-url" class="url-line"></div>
+          {% if page and not forbidden and not isTrashPage() %}
+            <div id="tag-label"></div>
+          {% endif %}
         </div>
       </div>
 

+ 3 - 1
src/server/views/layout-crowi/page.html

@@ -11,7 +11,9 @@
       <div class="d-flex align-items-center">
         <div class="title-container">
           <h1 class="title" id="revision-path"></h1>
-          <div id="revision-url" class="url-line"></div>
+          {% if page and not forbidden and not isTrashPage() %}
+            <div id="tag-label"></div>
+          {% endif %}
         </div>
         {% include '../widget/header-buttons.html' %}
       </div>

+ 4 - 4
src/server/views/layout-crowi/page_list.html

@@ -16,10 +16,10 @@
 
     <div class="d-flex align-items-center">
       <div class="title-container">
-        <div class="d-flex">
-          <h1 class="title" id="revision-path"></h1>
-        </div>
-        <div id="revision-url" class="url-line"></div>
+        <h1 class="title" id="revision-path"></h1>
+        {% if page and not forbidden and not isTrashPage() %}
+          <div id="tag-label"></div>
+        {% endif %}
       </div>
       {% include '../widget/header-buttons.html' %}
     </div>

+ 2 - 6
src/server/views/layout-growi/widget/header.html

@@ -8,13 +8,9 @@
       </div>
       <div class="title-container">
         <h1 class="title" id="revision-path"></h1>
-
-        <!-- [TODO] commentout Until the destination is decided -->
-        <!-- <div id="revision-url" class="url-line"></div> -->
-        {% if not forbidden %}
-          <div class="title" id="tag-label"></div>
+        {% if page and not forbidden and not isTrashPage() %}
+          <div id="tag-label"></div>
         {% endif %}
-
       </div>
       {% if page %}
       {% include '../../widget/header-buttons.html' %}

+ 3 - 1
src/server/views/layout-kibela/widget/header.html

@@ -7,7 +7,9 @@
     </div>
     <div class="title-container">
       <h1 class="title" id="revision-path"></h1>
-      <div id="revision-url" class="url-line"></div>
+      {% if page and not forbidden and not isTrashPage() %}
+        <div id="tag-label"></div>
+      {% endif %}
     </div>
     {% if page %} {% include '../../widget/header-buttons.html' %}
 

+ 4 - 1
src/server/views/layout/layout.html

@@ -152,6 +152,7 @@
             <i class="ti-menu"></i>
           </a>
         </li>
+        {#  disabled temporary -- 2019.05.13 Yuki Takei
         <li>
           <li class="nav-item-admin">
             <a href="/tags">
@@ -159,6 +160,7 @@
             </a>
           </li>
         </li>
+        #}
         <li>
           {% if searchConfigured() %}
           <div class="navbar-form navbar-left search-top" role="search" id="search-top"></div>
@@ -189,7 +191,8 @@
             <li><a href="/user/{{ user.username }}"><i class="icon-fw icon-home"></i>{{ t('Home') }}</a></li>
             <li><a href="/me"><i class="icon-fw icon-wrench"></i>{{ t('User Settings') }}</a></li>
             <li role="separator" class="divider"></li>
-            <li><a href="/trash"><i class="icon-fw icon-trash"></i>{{ t('Deleted Pages') }}</a></li>
+            <li><a href="/user/{{ user.username }}#user-draft-list"><i class="icon-fw icon-docs"></i>{{ t('Resume Drafts') }}</a></li>
+            <li><a href="/user"><i class="icon-fw icon-trash"></i>{{ t('Deleted Pages') }}</a></li>
             <li role="separator" class="divider"></li>
             <li><a href="/logout"><i class="icon-fw icon-power"></i>{{ t('Sign out') }}</a></li>
           </ul>

+ 24 - 0
src/server/views/redoc.html

@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>GROWI API v3</title>
+    <!-- needed for adaptive design -->
+    <meta charset="utf-8"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
+
+    <!--
+    ReDoc doesn't change outer page styles
+    -->
+    <style>
+      body {
+        margin: 0;
+        padding: 0;
+      }
+    </style>
+  </head>
+  <body>
+    <redoc spec-url='/api-docs/swagger-spec.json'></redoc>
+    {{ cdnScriptTag('redoc-standalone') }}
+  </body>
+</html>

+ 24 - 1
src/server/views/widget/user_page_content.html

@@ -4,7 +4,10 @@
       <a href="#user-bookmark-list" data-toggle="tab"><i class="icon-star"></i> Bookmarks</a>
     </li>
     <li>
-      <a href="#user-created-list" data-toggle="tab"><i class="icon-clock"></i> Recent Created</a>
+      <a href="#user-created-list" data-toggle="tab"><i class="icon-clock"></i> Recently Created</a>
+    </li>
+    <li>
+      <a href="#user-draft-list" data-toggle="tab"><i class="icon-docs"></i> My Drafts</a>
     </li>
     {% if user._id.toString() == pageUser._id.toString() %}
     <li>
@@ -29,5 +32,25 @@
       <div class="page-list-container">
       </div>
     </div>
+
+    <div class="tab-pane user-draft-list page-list" id="user-draft-list">
+      <div class="page-list-container">
+      </div>
+    </div>
   </div>
 </div>
+
+<script>
+  function activateTab(tab){
+    $('.nav-tabs a[href="#' + tab + '"]').tab('show');
+  };
+
+  window.addEventListener('load', function(e) {
+    // hash on page
+    if (location.hash) {
+      if (location.hash == '#user-draft-list') {
+        activateTab('user-draft-list');
+      }
+    }
+  });
+</script>

+ 119 - 8
yarn.lock

@@ -1914,6 +1914,11 @@ cache-base@^1.0.1:
     union-value "^1.0.0"
     unset-value "^1.0.0"
 
+call-me-maybe@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b"
+  integrity sha1-JtII6onje1y95gJQoV8DHBak1ms=
+
 callsite@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20"
@@ -2348,6 +2353,10 @@ combined-stream@^1.0.6, combined-stream@~1.0.6:
   dependencies:
     delayed-stream "~1.0.0"
 
+commander@2.17.1, commander@~2.17.1:
+  version "2.17.1"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
+
 commander@2.19.0:
   version "2.19.0"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
@@ -2364,15 +2373,11 @@ commander@^2.2.0:
   version "2.15.1"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f"
 
-commander@^2.20.0:
+commander@^2.20.0, commander@^2.7.1:
   version "2.20.0"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422"
   integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==
 
-commander@~2.17.1:
-  version "2.17.1"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
-
 commondir@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
@@ -2557,6 +2562,11 @@ core-js@^2.4.0, core-js@^2.5.0:
   version "2.5.3"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.3.tgz#8acc38345824f16d8365b7c9b4259168e8ed603e"
 
+core-js@^2.5.7:
+  version "2.6.5"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.5.tgz#44bc8d249e7fb2ff5d00e0341a7ffb94fbf67895"
+  integrity sha512-klh/kDpwX8hryYL14M9w/xei6vrv6sE8gTHDG7/T/+SEovB/G4ejwcfE/CBzO6Edsu+OETZMZ3wcX/EjUkrl5A==
+
 core-util-is@1.0.2, core-util-is@~1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@@ -3118,7 +3128,7 @@ doctrine@1.5.0:
     esutils "^2.0.2"
     isarray "^1.0.0"
 
-doctrine@^2.1.0:
+doctrine@2.1.0, doctrine@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
   dependencies:
@@ -4306,6 +4316,11 @@ form-data@~2.3.2:
     combined-stream "^1.0.6"
     mime-types "^2.1.12"
 
+format-util@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/format-util/-/format-util-1.0.3.tgz#032dca4a116262a12c43f4c3ec8566416c5b2d95"
+  integrity sha1-Ay3KShFiYqEsQ/TD7IVmQWxbLZU=
+
 formidable@~1.0.14:
   version "1.0.17"
   resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.0.17.tgz#ef5491490f9433b705faa77249c99029ae348559"
@@ -5713,6 +5728,14 @@ js-yaml@3.12.0, js-yaml@^3.9.0:
     argparse "^1.0.7"
     esprima "^4.0.0"
 
+js-yaml@3.13.1:
+  version "3.13.1"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
+  integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==
+  dependencies:
+    argparse "^1.0.7"
+    esprima "^4.0.0"
+
 js-yaml@^3.11.0:
   version "3.11.0"
   resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.11.0.tgz#597c1a8bd57152f26d622ce4117851a51f5ebaef"
@@ -5764,6 +5787,16 @@ json-parse-better-errors@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
 
+json-schema-ref-parser@^5.1.3:
+  version "5.1.3"
+  resolved "https://registry.yarnpkg.com/json-schema-ref-parser/-/json-schema-ref-parser-5.1.3.tgz#f86c5868f40898e69169e1bbc854725a4fd0e1ad"
+  integrity sha512-CpDFlBwz/6la78hZxyB9FECVKGYjIIl3Ms3KLqFj99W7IIb7D00/RDgc++IGB4BBALl0QRhh5m4q5WNSopvLtQ==
+  dependencies:
+    call-me-maybe "^1.0.1"
+    debug "^3.1.0"
+    js-yaml "^3.12.0"
+    ono "^4.0.6"
+
 json-schema-traverse@^0.3.0:
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340"
@@ -5817,6 +5850,16 @@ jsonify@~0.0.0:
   version "0.0.0"
   resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
 
+jsonschema-draft4@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/jsonschema-draft4/-/jsonschema-draft4-1.0.0.tgz#f0af2005054f0f0ade7ea2118614b69dc512d865"
+  integrity sha1-8K8gBQVPDwrefqIRhhS2ncUS2GU=
+
+jsonschema@1.2.4:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.2.4.tgz#a46bac5d3506a254465bc548876e267c6d0d6464"
+  integrity sha512-lz1nOH69GbsVHeVgEdvyavc/33oymY1AZwtePMiMj4HZPMbP5OIKK3zT9INMWjwua/V4Z4yq7wSlBbSG+g4AEw==
+
 jsprim@^1.2.2:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
@@ -6080,7 +6123,7 @@ lodash.foreach@^4.1.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53"
 
-lodash.get@^4.0, lodash.get@^4.0.2, lodash.get@^4.4.2:
+lodash.get@^4.0, lodash.get@^4.0.0, lodash.get@^4.0.2, lodash.get@^4.4.2:
   version "4.4.2"
   resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
 
@@ -6088,6 +6131,11 @@ lodash.has@^4.0, lodash.has@^4.5.2:
   version "4.5.2"
   resolved "https://registry.yarnpkg.com/lodash.has/-/lodash.has-4.5.2.tgz#d19f4dc1095058cccbe2b0cdf4ee0fe4aa37c862"
 
+lodash.isequal@^4.0.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
+  integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
+
 lodash.isfinite@^3.3.2:
   version "3.3.2"
   resolved "https://registry.yarnpkg.com/lodash.isfinite/-/lodash.isfinite-3.3.2.tgz#fb89b65a9a80281833f0b7478b3a5104f898ebb3"
@@ -7340,6 +7388,22 @@ onetime@^2.0.0:
   dependencies:
     mimic-fn "^1.0.0"
 
+ono@^4.0.6:
+  version "4.0.11"
+  resolved "https://registry.yarnpkg.com/ono/-/ono-4.0.11.tgz#c7f4209b3e396e8a44ef43b9cedc7f5d791d221d"
+  integrity sha512-jQ31cORBFE6td25deYeD80wxKBMj+zBmHTrVxnc6CKhx8gho6ipmWM5zj/oeoqioZ99yqBls9Z/9Nss7J26G2g==
+  dependencies:
+    format-util "^1.0.3"
+
+openapi-schema-validation@^0.4.2:
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/openapi-schema-validation/-/openapi-schema-validation-0.4.2.tgz#895c29021be02e000f71c51f859da52118eb1e21"
+  integrity sha512-K8LqLpkUf2S04p2Nphq9L+3bGFh/kJypxIG2NVGKX0ffzT4NQI9HirhiY6Iurfej9lCu7y4Ndm4tv+lm86Ck7w==
+  dependencies:
+    jsonschema "1.2.4"
+    jsonschema-draft4 "^1.0.0"
+    swagger-schema-official "2.0.0-bab6bed"
+
 opener@^1.5.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.1.tgz#6d2f0e77f1a0af0032aca716c2c1fbb8e7e8abed"
@@ -10228,6 +10292,41 @@ svgo@^1.0.0:
     unquote "~1.1.1"
     util.promisify "~1.0.0"
 
+swagger-jsdoc@^3.2.9:
+  version "3.2.9"
+  resolved "https://registry.yarnpkg.com/swagger-jsdoc/-/swagger-jsdoc-3.2.9.tgz#9fe2f10c480e06f62d207c00396b0a759fa569c5"
+  integrity sha512-yEGoqnIA5Owb15266x8JyQQM054kyhqkqz/zGxCEsrcx/fq/gf14alEp3qqLpknGOxTr3cqxQr3LrgOxXVm5vg==
+  dependencies:
+    commander "2.17.1"
+    doctrine "2.1.0"
+    glob "7.1.3"
+    js-yaml "3.13.1"
+    swagger-parser "5.0.5"
+
+swagger-methods@^1.0.4:
+  version "1.0.8"
+  resolved "https://registry.yarnpkg.com/swagger-methods/-/swagger-methods-1.0.8.tgz#8baf37ee861d3c72ff7b2faad6d74c60b336e2ed"
+  integrity sha512-G6baCwuHA+C5jf4FNOrosE4XlmGsdjbOjdBK4yuiDDj/ro9uR4Srj3OR84oQMT8F3qKp00tYNv0YN730oTHPZA==
+
+swagger-parser@5.0.5:
+  version "5.0.5"
+  resolved "https://registry.yarnpkg.com/swagger-parser/-/swagger-parser-5.0.5.tgz#52cf173dcb1f3189fa30da4d18f293887e79707d"
+  integrity sha512-6UiaUT9nH5nEzvxDvwZpTfhCs2VOwxrP9neZ83QpsTA3mMGHdun4x8vSXiqjaGQzLh2LG8ND5TRhmVNG1hRUqA==
+  dependencies:
+    call-me-maybe "^1.0.1"
+    debug "^3.1.0"
+    json-schema-ref-parser "^5.1.3"
+    ono "^4.0.6"
+    openapi-schema-validation "^0.4.2"
+    swagger-methods "^1.0.4"
+    swagger-schema-official "2.0.0-bab6bed"
+    z-schema "^3.23.0"
+
+swagger-schema-official@2.0.0-bab6bed:
+  version "2.0.0-bab6bed"
+  resolved "https://registry.yarnpkg.com/swagger-schema-official/-/swagger-schema-official-2.0.0-bab6bed.tgz#70070468d6d2977ca5237b2e519ca7d06a2ea3fd"
+  integrity sha1-cAcEaNbSl3ylI3suUZyn0Gouo/0=
+
 swig-templates@^2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/swig-templates/-/swig-templates-2.0.2.tgz#d2502a7303019356f4ea76ea9065d4f58af6ab75"
@@ -10841,7 +10940,7 @@ validate-npm-package-license@^3.0.1:
     spdx-correct "~1.0.0"
     spdx-expression-parse "~1.0.0"
 
-validator@>=10.11.0:
+validator@>=10.11.0, validator@^10.0.0:
   version "10.11.0"
   resolved "https://registry.yarnpkg.com/validator/-/validator-10.11.0.tgz#003108ea6e9a9874d31ccc9e5006856ccd76b228"
   integrity sha512-X/p3UZerAIsbBfN/IwahhYaBbY68EN/UQBWHtsbXGT5bfrH/p4NQzUCG1kF/rtKaNpnJ7jAu6NGTdSNtyNIXMw==
@@ -11370,3 +11469,15 @@ yargs@~3.10.0:
 yeast@0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
+
+z-schema@^3.23.0:
+  version "3.25.1"
+  resolved "https://registry.yarnpkg.com/z-schema/-/z-schema-3.25.1.tgz#7e14663be2b96003d938a56f644fb8561643fb7e"
+  integrity sha512-7tDlwhrBG+oYFdXNOjILSurpfQyuVgkRe3hB2q8TEssamDHB7BbLWYkYO98nTn0FibfdFroFKDjndbgufAgS/Q==
+  dependencies:
+    core-js "^2.5.7"
+    lodash.get "^4.0.0"
+    lodash.isequal "^4.0.0"
+    validator "^10.0.0"
+  optionalDependencies:
+    commander "^2.7.1"