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

Merge pull request #2587 from weseek/feat/export-single-page-for-merge

Feat/export single page for merge
Yuki Takei 5 лет назад
Родитель
Сommit
e02b5ac198

+ 5 - 0
resource/locales/en_US/translation.json

@@ -730,6 +730,11 @@
     "Registration successful": "Registration successful",
     "Setup": "Setup"
   },
+  "export_bulk": {
+    "failed_to_export": "Failed to export",
+    "export_page_markdown": "Export page as Markdown",
+    "export_page_pdf": "Export page as PDF"
+  },
   "message": {
     "successfully_connected": "Successfully Connected!",
     "fail_to_save_access_token": "Failed to save access_token. Please try again.",

+ 5 - 0
resource/locales/ja_JP/translation.json

@@ -723,6 +723,11 @@
     "Registration successful": "登録完了",
     "Setup": "セットアップ"
   },
+  "export_bulk": {
+    "failed_to_export": "ページのエクスポートに失敗しました",
+    "export_page_markdown": "マークダウン形式でページをエクスポート",
+    "export_page_pdf": "PDF形式でページをエクスポート"
+  },
   "message": {
     "successfully_connected": "接続に成功しました!",
     "fail_to_save_access_token": "アクセストークンの保存に失敗しました、再度お試しください。",

+ 1 - 1
src/client/js/base.jsx

@@ -46,10 +46,10 @@ const componentMappings = {
 
   'grw-sidebar-wrapper': <Sidebar />,
 
-  'share-link-alert': <ShareLinkAlert />,
   'grw-fab-container': <Fab />,
   'grw-hotkeys-manager': <HotkeysManager />,
 
+  'share-link-alert': <ShareLinkAlert />,
 };
 
 export { appContainer, componentMappings };

+ 14 - 0
src/client/js/components/Page/PageShareManagement.jsx

@@ -2,7 +2,9 @@ import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
+import urljoin from 'url-join';
 import { withUnstatedContainers } from '../UnstatedUtils';
+
 import AppContainer from '../../services/AppContainer';
 import PageContainer from '../../services/PageContainer';
 import OutsideShareLinkModal from '../OutsideShareLinkModal';
@@ -23,6 +25,15 @@ const PageShareManagement = (props) => {
     setIsOutsideShareLinkModalShown(false);
   }
 
+  async function exportPageHandler(format) {
+    const { pageId, revisionId } = pageContainer.state;
+    const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
+    url.searchParams.append('_csrf', appContainer.csrfToken);
+    url.searchParams.append('format', format);
+    url.searchParams.append('revisionId', revisionId);
+    window.location.href = url.href;
+  }
+
   function renderModals() {
     if (currentUser == null) {
       return null;
@@ -78,6 +89,9 @@ const PageShareManagement = (props) => {
           <i className="icon-fw icon-link"></i>{t('share_links.Shere this page link to public')}
           <span className="ml-2 badge badge-info badge-pill">{pageContainer.state.shareLinksNumber}</span>
         </button>
+        <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
+          <span>{t('export_bulk.export_page_markdown')}</span>
+        </button>
       </div>
       {renderModals()}
     </>

+ 3 - 1
src/client/js/components/ShareLinkForm.jsx

@@ -259,13 +259,15 @@ class ShareLinkForm extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
 const ShareLinkFormWrapper = withUnstatedContainers(ShareLinkForm, [AppContainer, PageContainer]);
 
 ShareLinkForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
   onCloseForm: PropTypes.func,
 };
 

+ 3 - 0
src/client/js/components/ShareLinkList.jsx

@@ -65,6 +65,9 @@ const ShareLinkList = (props) => {
   );
 };
 
+/**
+ * Wrapper component for using unstated
+ */
 const ShareLinkListWrapper = withUnstatedContainers(ShareLinkList, [AppContainer]);
 
 ShareLinkList.propTypes = {

+ 64 - 2
src/server/routes/apiv3/page.js

@@ -3,11 +3,11 @@ const loggerFactory = require('@alias/logger');
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
-const { body } = require('express-validator');
+const { body, query } = require('express-validator');
 
 const router = express.Router();
 
-// const ErrorV3 = require('../../models/vo/error-apiv3');
+const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
  * @swagger
@@ -118,12 +118,17 @@ module.exports = (crowi) => {
 
   const globalNotificationService = crowi.getGlobalNotificationService();
   const { Page, GlobalNotificationSetting } = crowi.models;
+  const { exportService } = crowi;
 
   const validator = {
     likes: [
       body('pageId').isString(),
       body('bool').isBoolean(),
     ],
+    export: [
+      query('format').isString().isIn(['md', 'pdf']),
+      query('revisionId').isString(),
+    ],
   };
 
   /**
@@ -182,5 +187,62 @@ module.exports = (crowi) => {
     return res.apiv3({ result });
   });
 
+  /**
+  * @swagger
+  *
+  *    /pages/export:
+  *      get:
+  *        tags: [Export]
+  *        description: return page's markdown
+  *        responses:
+  *          200:
+  *            description: Return page's markdown
+  */
+  router.get('/export/:pageId', validator.export, async(req, res) => {
+    const { pageId } = req.params;
+    const { format, revisionId = null } = req.query;
+    let revision;
+
+    try {
+      const Page = crowi.model('Page');
+      const page = await Page.findByIdAndViewer(pageId, req.user);
+
+      if (page == null) {
+        const isPageExist = await Page.count({ _id: pageId }) > 0;
+        if (isPageExist) {
+          // This page exists but req.user has not read permission
+          return res.apiv3Err(new ErrorV3(`Haven't the right to see the page ${pageId}.`), 403);
+        }
+        return res.apiv3Err(new ErrorV3(`Page ${pageId} is not exist.`), 404);
+      }
+
+      const revisionIdForFind = revisionId || page.revision;
+
+      const Revision = crowi.model('Revision');
+      revision = await Revision.findById(revisionIdForFind);
+    }
+    catch (err) {
+      logger.error('Failed to get page data', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    const fileName = revision.id;
+    let stream;
+
+    try {
+      stream = exportService.getReadStreamFromRevision(revision, format);
+    }
+    catch (err) {
+      logger.error('Failed to create readStream', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    res.set({
+      'Content-Disposition': `attachment;filename*=UTF-8''${fileName}.${format}`,
+    });
+
+    return stream.pipe(res);
+  });
+
   return router;
 };

+ 12 - 0
src/server/service/export.js

@@ -350,6 +350,18 @@ class ExportService {
     return zipFile;
   }
 
+  getReadStreamFromRevision(revision, format) {
+    const data = revision.body;
+
+    const Readable = require('stream').Readable;
+    const readable = new Readable();
+    readable._read = () => {};
+    readable.push(data);
+    readable.push(null);
+
+    return readable;
+  }
+
 }
 
 module.exports = ExportService;

+ 1 - 1
src/server/views/widget/page_content.html

@@ -23,9 +23,9 @@
   data-page-creator="{% if page %}{{ page.creator|json }}{% endif %}"
   data-page-updated-at="{% if page %}{{ page.updatedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
+  data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
   data-share-link-id="{% if sharelink %}{{ sharelink._id|json }}{% endif %}"
-  data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   >
 {% else %}
 <div id="content-main" class="content-main"