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

Merge branch 'imprv/duplicate-Page-with-child' into GW-3225

# Conflicts:
#	src/client/js/components/PageDuplicateModal.jsx
takeru0001 5 лет назад
Родитель
Сommit
a87ca6b1bd

+ 35 - 0
.github/workflows/prerelease.yml

@@ -0,0 +1,35 @@
+name: Pre Release
+
+on:
+  push:
+    branches:
+      - master
+
+jobs:
+  build-image-for-cache:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - name: Set up Docker Buildx
+      uses: crazy-max/ghaction-docker-buildx@v3
+
+    - name: Cache Docker layers
+      uses: actions/cache@v2
+      id: cache
+      with:
+        path: /tmp/.buildx-cache
+        key: ${{ runner.OS }}-buildx-${{ hashFiles('**/yarn.lock') }}-default
+        restore-keys: |
+          ${{ runner.os }}-buildx-
+
+    - name: Build Docker Image
+      run: |
+        docker buildx build \
+          --cache-from "type=local,src=/tmp/.buildx-cache" \
+          --cache-to "type=local,dest=/tmp/.buildx-cache" \
+          --platform linux/amd64 \
+          --load \
+          --file ./docker/Dockerfile .

+ 12 - 3
.github/workflows/release.yml

@@ -78,16 +78,25 @@ jobs:
       run: |
         echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
 
+    - name: Cache Docker layers
+      uses: actions/cache@v2
+      id: cache
+      with:
+        path: /tmp/.buildx-cache
+        key: ${{ runner.OS }}-buildx-${{ hashFiles('**/yarn.lock') }}-${{ matrix.flavor }}
+        restore-keys: |
+          ${{ runner.OS }}-buildx-${{ hashFiles('**/yarn.lock') }}-
+          ${{ runner.OS }}-buildx-
+
     - name: Build Docker Image
       run: |
-        CACHE_REF=weseek/growi-cache:4${{ env.SUFFIX }}
         docker buildx build \
+          --cache-from "type=local,src=/tmp/.buildx-cache" \
+          --cache-to "type=local,dest=/tmp/.buildx-cache" \
           --tag growi${{ env.SUFFIX }} \
           --build-arg flavor=${{ matrix.flavor }} \
           --platform linux/amd64 \
           --load \
-          --cache-from=type=registry,ref=$CACHE_REF \
-          --cache-to=type=registry,ref=$CACHE_REF,mode=max \
           --file ./docker/Dockerfile .
 
     - name: Docker Tags by SemVer

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.1.2-RC",
+  "version": "4.1.3-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

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

@@ -443,6 +443,10 @@
     "someone_editing": "Someone editing this page on HackMD",
     "this_page_has_draft": "This page has a draft on HackMD"
   },
+  "slack_notification": {
+    "popover_title": "Slack Notification",
+    "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
+  },
   "search_result": {
     "result_meta": "Found \"{{keyword}}\" in {{total}}.",
     "deletion_mode_btn_lavel": "Select and delete page",
@@ -733,6 +737,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.",

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

@@ -445,6 +445,10 @@
     "someone_editing": "このページは、HackMD で編集されています。",
     "this_page_has_draft": "このページは、HackMD のドラフトがあります。"
   },
+  "slack_notification": {
+    "popover_title": "Slack 通知",
+    "popover_desc": "チャンネル名を入れてください。カンマ区切りのリストを入力することで複数のチャンネルに通知することができます。"
+  },
   "search_result": {
     "result_meta": "{{total}}件のページが見つかりました。検索ワード: \"{{keyword}}\"",
     "deletion_mode_btn_lavel": "ページを指定して削除",
@@ -726,6 +730,11 @@
     "Registration successful": "登録完了",
     "Setup": "セットアップ"
   },
+  "export_bulk": {
+    "failed_to_export": "ページのエクスポートに失敗しました",
+    "export_page_markdown": "マークダウン形式でページをエクスポート",
+    "export_page_pdf": "PDF形式でページをエクスポート"
+  },
   "message": {
     "successfully_connected": "接続に成功しました!",
     "fail_to_save_access_token": "アクセストークンの保存に失敗しました、再度お試しください。",

+ 3 - 3
resource/locales/zh_CN/admin/admin.json

@@ -35,9 +35,9 @@
 		"host": "服务器",
 		"port": "端口号",
 		"user": "用户名",
-    "initialize_mail_settings": "初始化邮件设置",
-    "initialize_mail_modal_header": "初始化邮件设置",
-    "confirm_to_initialize_mail_settings": "You can't restore to the current settings. Are you sure you want to initialize e-mail settings?",
+    "initialize_mail_settings": "重置邮件设置",
+    "initialize_mail_modal_header": "重置邮件设置",
+    "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
 		"aws_settings": "AWS设置",
 		"aws_access": "这是用于AWS设置的。如果您完成了AWS设置,文件上传功能,个人资料图片功能等将被启用。",
 		"no_smtp_setting": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",

+ 5 - 1
resource/locales/zh_CN/translation.json

@@ -413,7 +413,11 @@
 		"not_initialized": "HackmdEditor component has not initialized",
 		"someone_editing": "Someone editing this page on HackMD",
 		"this_page_has_draft": "This page has a draft on HackMD"
-	},
+  },
+  "slack_notification": {
+    "popover_title": "Slack Notification",
+    "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
+  },
   "security_settings": "安全设置",
   "share_links": {
     "Shere this page link to public": "Shere this page link to public",

+ 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 };

+ 1 - 1
src/client/js/components/Admin/App/MailSetting.jsx

@@ -153,7 +153,7 @@ class MailSetting extends React.Component {
                 {t('Cancel')}
               </button>
               <button type="button" className="btn btn-danger" onClick={this.initialize}>
-                {t('Initialize')}
+                {t('Reset')}
               </button>
             </div>
           </ModalBody>

+ 13 - 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,14 @@ 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('format', format);
+    url.searchParams.append('revisionId', revisionId);
+    window.location.href = url.href;
+  }
+
   function renderModals() {
     if (currentUser == null) {
       return null;
@@ -78,6 +88,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()}
     </>

+ 11 - 6
src/client/js/components/PageDuplicateModal.jsx

@@ -6,7 +6,6 @@ import {
 } from 'reactstrap';
 
 import { withTranslation } from 'react-i18next';
-
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import AppContainer from '../services/AppContainer';
@@ -28,7 +27,6 @@ const PageDuplicateModal = (props) => {
 
   const [subordinatedPaths, setSubordinatedPaths] = useState([]);
   const [getSubordinatedError, setGetSuborinatedError] = useState(null);
-
   const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(true);
   const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
 
@@ -95,8 +93,8 @@ const PageDuplicateModal = (props) => {
     setErrs(null);
 
     try {
-      const res = await appContainer.apiPost('/pages.duplicate', { page_id: pageId, new_path: pageNameInput });
-      const { page } = res;
+      const res = await appContainer.apiv3Post('/pages/duplicate', { pageId, pageNameInput });
+      const { page } = res.data;
       window.location.href = encodeURI(`${page.path}?duplicated=${path}`);
     }
     catch (err) {
@@ -172,8 +170,15 @@ const PageDuplicateModal = (props) => {
             </label>
           </ul>
           <div>
-            <ul>
-              {isDuplicateRecursively && checkExistPath()}
+            <ul className="duplicate-name">
+              {isDuplicateRecursively && subordinatedPaths.map((duplicatedNewPath) => {
+                  // ToDo: The "true" statement below will be modified by task GW3503
+                  if (true) {
+                    return <li className="duplicate-exist" key={duplicatedNewPath}>{duplicatedNewPath}: Same page already exists</li>;
+                  }
+                  return <li key={duplicatedNewPath}>{duplicatedNewPath}</li>;
+                })
+              }
             </ul>
           </div>
           <div> {getSubordinatedError} </div>

+ 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 = {

+ 33 - 23
src/client/js/components/SlackNotification.jsx

@@ -1,6 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { withTranslation } from 'react-i18next';
+
 /**
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
@@ -10,7 +12,7 @@ import PropTypes from 'prop-types';
  * @extends {React.Component}
  */
 
-export default class SlackNotification extends React.Component {
+class SlackNotification extends React.Component {
 
   constructor(props) {
     super(props);
@@ -34,32 +36,36 @@ export default class SlackNotification extends React.Component {
   }
 
   render() {
+    const { t } = this.props;
+
     return (
-      <div className="input-group input-group-sm input-group-slack extended-setting">
-        <label className="input-group-addon bg-light">
-          <img id="slack-mark-white" alt="slack-mark" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18" />
-          <img id="slack-mark-black" alt="slack-mark" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18" />
+      <div className="grw-slack-notification">
+        <div className="input-group input-group-sm extended-setting">
+          <label className="input-group-addon bg-light">
+            <img id="slack-mark-white" alt="slack-mark" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18" />
+            <img id="slack-mark-black" alt="slack-mark" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18" />
+
+            <input
+              type="checkbox"
+              value="1"
+              checked={this.props.isSlackEnabled}
+              onChange={this.updateCheckboxHandler}
+            />
 
+          </label>
           <input
-            type="checkbox"
-            value="1"
-            checked={this.props.isSlackEnabled}
-            onChange={this.updateCheckboxHandler}
+            className="form-control"
+            type="text"
+            value={this.props.slackChannels}
+            placeholder="Input channels"
+            data-toggle="popover"
+            title={t('slack_notification.popover_title')}
+            data-content={t('slack_notification.popover_desc')}
+            data-trigger="focus"
+            data-placement="top"
+            onChange={this.updateSlackChannelsHandler}
           />
-
-        </label>
-        <input
-          className="form-control"
-          type="text"
-          value={this.props.slackChannels}
-          placeholder="slack channel name"
-          data-toggle="popover"
-          title="Slack通知"
-          data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
-          data-trigger="focus"
-          data-placement="top"
-          onChange={this.updateSlackChannelsHandler}
-        />
+        </div>
       </div>
     );
   }
@@ -67,8 +73,12 @@ export default class SlackNotification extends React.Component {
 }
 
 SlackNotification.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
   isSlackEnabled: PropTypes.bool.isRequired,
   slackChannels: PropTypes.string.isRequired,
   onEnabledFlagChange: PropTypes.func,
   onChannelChange: PropTypes.func,
 };
+
+export default withTranslation()(SlackNotification);

+ 0 - 11
src/client/styles/scss/_notification.scss

@@ -1,11 +0,0 @@
-// Slack
-.input-group-slack {
-  .input-group-addon {
-    padding: 2px 8px;
-    line-height: 1em;
-    img,
-    input {
-      vertical-align: middle;
-    }
-  }
-}

+ 20 - 0
src/client/styles/scss/_on-edit.scss

@@ -122,6 +122,26 @@ body.on-edit {
   .grw-editor-navbar-bottom {
     height: $grw-editor-navbar-bottom-height;
 
+    .grw-slack-notification {
+      .input-group-addon {
+        padding: 2px 8px;
+        line-height: 1em;
+        img,
+        input {
+          vertical-align: middle;
+        }
+      }
+      .form-control {
+        width: 80px;
+        @include media-breakpoint-up(sm) {
+          width: 130px;
+        }
+        @include media-breakpoint-up(md) {
+          width: 180px;
+        }
+      }
+    }
+
     .grw-grant-selector {
       @include media-breakpoint-down(sm) {
         .btn .label {

+ 9 - 0
src/client/styles/scss/_page-duplicate-modal.scss

@@ -0,0 +1,9 @@
+.grw-duplicate-page {
+  .duplicate-name {
+    list-style: none;
+  }
+
+  .duplicate-exist {
+    color: #c7254e;
+  }
+}

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

@@ -45,8 +45,8 @@
 @import 'mirror_mode';
 @import 'navbar';
 @import 'navbar_kibela';
-@import 'notification';
 @import 'on-edit';
+@import 'page-duplicate-modal';
 @import 'page_list';
 @import 'page-path';
 @import 'page';

+ 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', loginRequired, 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;
 };

+ 116 - 16
src/server/routes/apiv3/pages.js

@@ -142,6 +142,22 @@ module.exports = (crowi) => {
     ],
   };
 
+  async function createPageAction({
+    path, body, user, options,
+  }) {
+    const createdPage = Page.create(path, body, user, options);
+    return createdPage;
+  }
+
+  async function saveTagsAction({ createdPage, pageTags }) {
+    if (pageTags != null) {
+      await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+      return PageTagRelation.listTagNamesByPage(createdPage.id);
+    }
+
+    return [];
+  }
+
   /**
    * @swagger
    *
@@ -181,6 +197,7 @@ module.exports = (crowi) => {
     const {
       body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, socketClientId, pageTags,
     } = req.body;
+
     let { path } = req.body;
 
     // check whether path starts slash
@@ -198,13 +215,11 @@ module.exports = (crowi) => {
       options.grantUserGroupId = grantUserGroupId;
     }
 
-    const createdPage = await Page.create(path, body, req.user, options);
+    const createdPage = await createPageAction({
+      path, body, user: req.user, options,
+    });
 
-    let savedTags;
-    if (pageTags != null) {
-      await PageTagRelation.updatePageTags(createdPage.id, pageTags);
-      savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
-    }
+    const savedTags = await saveTagsAction({ createdPage, pageTags });
 
     const result = { page: pageService.serializeToObj(createdPage), tags: savedTags };
 
@@ -283,6 +298,7 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
+   *
    *    /pages/rename:
    *      post:
    *        tags: [Pages]
@@ -397,16 +413,16 @@ module.exports = (crowi) => {
 
 
   /**
-  * @swagger
-  *
-  *    /pages/empty-trash:
-  *      delete:
-  *        tags: [Pages]
-  *        description: empty trash
-  *        responses:
-  *          200:
-  *            description: Succeeded to remove all trash pages
-  */
+   * @swagger
+   *
+   *    /pages/empty-trash:
+   *      delete:
+   *        tags: [Pages]
+   *        description: empty trash
+   *        responses:
+   *          200:
+   *            description: Succeeded to remove all trash pages
+   */
   router.delete('/empty-trash', loginRequired, adminRequired, csrf, async(req, res) => {
     try {
       const pages = await Page.completelyDeletePageRecursively('/trash', req.user);
@@ -417,6 +433,90 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *
+   *    /pages/duplicate:
+   *      post:
+   *        tags: [Pages]
+   *        operationId: duplicatePage
+   *        description: Duplicate page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  pageId:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                  pageNameInput:
+   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                required:
+   *                  - pageId
+   *        responses:
+   *          200:
+   *            description: Succeeded to duplicate page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *
+   *          403:
+   *            description: Forbidden to duplicate page.
+   *          500:
+   *            description: Internal server error.
+   */
+  router.post('/duplicate', accessTokenParser, loginRequiredStrictly, csrf, async(req, res) => {
+    const { pageId } = req.body;
+
+    const newPagePath = pathUtils.normalizePath(req.body.pageNameInput);
+
+    // check page existence
+    const isExist = (await Page.count({ path: newPagePath })) > 0;
+    if (isExist) {
+      return res.apiv3Err(new ErrorV3(`Page exists '${newPagePath})'`, 'already_exists'), 409);
+    }
+
+    const page = await Page.findByIdAndViewer(pageId, req.user);
+
+    // null check
+    if (page == null) {
+      res.code = 'Page is not found';
+      logger.error('Failed to find the pages');
+      return res.apiv3Err(new ErrorV3('Not Founded the page', 'notfound_or_forbidden'), 404);
+    }
+
+    // populate
+    await page.populate({ path: 'revision', model: 'Revision', select: 'body' }).execPopulate();
+
+    // create option
+    const options = { page };
+    options.grant = page.grant;
+    options.grantUserGroupId = page.grantedGroup;
+    options.grantedUsers = page.grantedUsers;
+
+    const createdPage = await createPageAction({
+      path: newPagePath, user: req.user, body: page.revision.body, options,
+    });
+    const originTags = await page.findRelatedTagsById();
+    const savedTags = await saveTagsAction({ page, createdPage, pageTags: originTags });
+    const result = { page: pageService.serializeToObj(createdPage), tags: savedTags };
+
+    // global notification
+    if (globalNotificationService != null) {
+      try {
+        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
+      }
+      catch (err) {
+        logger.error('Create grobal notification failed', err);
+      }
+    }
+
+    return res.apiv3(result);
+  });
+
   router.get('/subordinated-list', accessTokenParser, loginRequired, async(req, res) => {
     const { path } = req.query;
 

+ 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"