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

Merge branch 'imprv/refacter-recursively' into imprv/4685-4775-reduce-stress-about-creating-redirect-pages

itizawa 5 лет назад
Родитель
Сommit
f363fc0469

+ 8 - 1
CHANGES.md

@@ -1,9 +1,16 @@
 # CHANGES
 
-## v4.2.4
+## v4.2.5-RC
 
 * 
 
+## v4.2.4
+
+* Fix: Fixed an error when creating a new page with `Ctrl-S`
+    * Introduced by v4.2.2
+* Fix: Fixed a strange diff in PageHistory due to Pagination
+* Fix: Fixed that the user group page could not be found when using api from the outside
+
 ## v4.2.3
 
 * Feature: Insert/edit links with GUI

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.2.4-RC",
+  "version": "4.2.5-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -58,7 +58,7 @@
     "i18n-json-merge:noTran": "rs-i18n -lan --",
     "i18n-json-merge": "npm run i18n-json-merge:withTran --",
     "server:nolazy": "env-cmd -f config/env.dev.js node-dev --nolazy --inspect src/server/app.js",
-    "server:dev": "env-cmd -f config/env.dev.js node-dev --inspect src/server/app.js",
+    "server:dev": "env-cmd -f config/env.dev.js node-dev --expose_gc --inspect src/server/app.js",
     "server:prod:ci": "npm run server:prod -- --ci",
     "server:prod": "env-cmd -f config/env.prod.js node src/server/app.js",
     "server": "npm run server:dev",

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

@@ -13,6 +13,7 @@
   "Unlinked": "Unlinked",
   "Like!": "Like!",
   "Seen by": "Seen by",
+  "Done": "Done",
   "Cancel": "Cancel",
   "Create": "Create",
   "Admin": "Admin",
@@ -396,6 +397,22 @@
       "Post": "Post"
     }
   },
+  "link_edit": {
+    "edit_link": "Edit Link",
+    "set_link_and_label": "Set link and label",
+    "link": "Link",
+    "placeholder_of_link_input": "Input page path or URL",
+    "label": "Label",
+    "path_format": "Path format",
+    "use_relative_path": "Use relative path",
+    "use_permanent_link": "Use permanent link",
+    "notation": "Notation",
+    "markdown": "Markdown",
+    "GROWI_original": "GROWI original",
+    "pukiwiki": "Pukiwiki",
+    "preview": "Preview",
+    "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
+  },
   "toaster": {
     "update_successed": "Succeeded to update {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",

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

@@ -13,6 +13,7 @@
   "Unlinked": "リダイレクト削除",
   "Like!": "いいね!",
   "Seen by": "見た人",
+  "Done": "完了",
   "Cancel": "キャンセル",
   "Create": "作成",
   "Admin": "管理",
@@ -398,6 +399,22 @@
       "Post": "投稿"
     }
   },
+  "link_edit": {
+    "edit_link": "リンク編集",
+    "set_link_and_label": "リンク情報",
+    "link": "リンク",
+    "placeholder_of_link_input": "ページパスまたはURLを入力してください",
+    "label": "ラベル",
+    "path_format": "ページパス設定",
+    "use_relative_path": "相対パスを使う",
+    "use_permanent_link": "パーマリンクを使う",
+    "notation": "リンクの形式",
+    "markdown": "マークダウン 記法",
+    "GROWI_original": "GROWI 独自記法",
+    "pukiwiki": "Pukiwiki 記法",
+    "preview": "プレビュー",
+    "page_not_found_in_preview": "\"{{path}}\" というページはありません。"
+  },
   "toaster": {
     "update_successed": "{{target}}を更新しました",
     "initialize_successed": "{{target}}を初期化しました",

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

@@ -14,7 +14,8 @@
 	"Unlinked": "Unlinked",
 	"Like!": "Like!",
 	"Seen by": "Seen by",
-	"Cancel": "取消",
+  "Done": "Done",
+  "Cancel": "取消",
 	"Create": "创建",
 	"Admin": "管理",
 	"administrator": "管理员",
@@ -376,6 +377,22 @@
 			"Post": "提交"
 		}
 	},
+  "link_edit": {
+    "edit_link": "Edit Link",
+    "set_link_and_label": "Set link and label",
+    "link": "Link",
+    "placeholder_of_link_input": "Input page path or URL",
+    "label": "Label",
+    "path_format": "Path format",
+    "use_relative_path": "Use relative path",
+    "use_permanent_link": "Use permanent link",
+    "notation": "Notation",
+    "markdown": "Markdown",
+    "GROWI_original": "GROWI original",
+    "pukiwiki": "Pukiwiki",
+    "preview": "Preview",
+    "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
+  },
 	"toaster": {
 		"update_successed": "Succeeded to update {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",

+ 25 - 18
src/client/js/components/PageEditor/LinkEditModal.jsx

@@ -11,6 +11,7 @@ import {
 
 import path from 'path';
 import validator from 'validator';
+import { withTranslation } from 'react-i18next';
 import PreviewWithSuspense from './PreviewWithSuspense';
 import PagePreviewIcon from '../Icons/PagePreviewIcon';
 
@@ -147,6 +148,7 @@ class LinkEditModal extends React.PureComponent {
   }
 
   async setMarkdown() {
+    const { t } = this.props;
     const path = this.state.linkInputValue;
     let markdown = '';
     let previewError = '';
@@ -168,7 +170,7 @@ class LinkEditModal extends React.PureComponent {
       }
     }
     else {
-      previewError = `'${path}' is not a GROWI page.`;
+      previewError = t('link_edit.page_not_found_in_preview', { path });
     }
     this.setState({ markdown, previewError, permalink });
   }
@@ -283,22 +285,24 @@ class LinkEditModal extends React.PureComponent {
   }
 
   renderLinkAndLabelForm() {
+    const { t } = this.props;
     return (
       <>
-        <h3 className="grw-modal-head">Set link and label</h3>
+        <h3 className="grw-modal-head">{t('link_edit.set_link_and_label')}</h3>
         <form className="form-group">
           <div className="form-gorup my-3">
             <div className="input-group flex-nowrap">
               <div className="input-group-prepend">
-                <span className="input-group-text">link</span>
+                <span className="input-group-text">{t('link_edit.link')}</span>
               </div>
               <SearchTypeahead
                 onChange={this.handleChangeTypeahead}
                 onInputChange={this.handleChangeLinkInput}
                 inputName="link"
-                placeholder="Input page path or URL"
+                placeholder={t('link_edit.placeholder_of_link_input')}
                 keywordOnInit={this.state.linkInputValue}
                 behaviorOfResetBtn="clear"
+                autoFocus
               />
               <div className="d-none d-sm-block input-group-append">
                 <button type="button" id="preview-btn" className="btn btn-info btn-page-preview">
@@ -315,7 +319,7 @@ class LinkEditModal extends React.PureComponent {
           <div className="form-gorup my-3">
             <div className="input-group flex-nowrap">
               <div className="input-group-prepend">
-                <span className="input-group-text">label</span>
+                <span className="input-group-text">{t('link_edit.label')}</span>
               </div>
               <input
                 type="text"
@@ -334,11 +338,12 @@ class LinkEditModal extends React.PureComponent {
   }
 
   renderPathFormatForm() {
+    const { t } = this.props;
     return (
       <div className="card well pt-3">
         <form className="form-group mb-0">
           <div className="form-group row">
-            <label className="col-sm-3">Path format</label>
+            <label className="col-sm-3">{t('link_edit.path_format')}</label>
             <div className="col-sm-9">
               <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
                 <input
@@ -350,7 +355,7 @@ class LinkEditModal extends React.PureComponent {
                   disabled={!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink}
                 />
                 <label className="custom-control-label" htmlFor="relativePath">
-                  Use relative path
+                  {t('link_edit.use_relative_path')}
                 </label>
               </div>
               <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
@@ -363,13 +368,13 @@ class LinkEditModal extends React.PureComponent {
                   disabled={this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink}
                 />
                 <label className="custom-control-label" htmlFor="permanentLink">
-                  Use permanent link
+                  {t('link_edit.use_permanent_link')}
                 </label>
               </div>
             </div>
           </div>
           <div className="form-group row mb-0">
-            <label className="col-sm-3">Notation</label>
+            <label className="col-sm-3">{t('link_edit.notation')}</label>
             <div className="col-sm-9">
               <div className="custom-control custom-radio custom-control-inline">
                 <input
@@ -381,7 +386,7 @@ class LinkEditModal extends React.PureComponent {
                   onChange={e => this.handleSelecteLinkerType(e.target.value)}
                 />
                 <label className="custom-control-label" htmlFor="markdownType">
-                  Markdown
+                  {t('link_edit.markdown')}
                 </label>
               </div>
               <div className="custom-control custom-radio custom-control-inline">
@@ -394,7 +399,7 @@ class LinkEditModal extends React.PureComponent {
                   onChange={e => this.handleSelecteLinkerType(e.target.value)}
                 />
                 <label className="custom-control-label" htmlFor="growiType">
-                  Growi original
+                  {t('link_edit.GROWI_original')}
                 </label>
               </div>
               {this.isApplyPukiwikiLikeLinkerPlugin && (
@@ -408,7 +413,7 @@ class LinkEditModal extends React.PureComponent {
                     onChange={e => this.handleSelecteLinkerType(e.target.value)}
                   />
                   <label className="custom-control-label" htmlFor="pukiwikiType">
-                    Pukiwiki
+                    {t('link_edit.pukiwiki')}
                   </label>
                 </div>
               )}
@@ -420,10 +425,11 @@ class LinkEditModal extends React.PureComponent {
   }
 
   render() {
+    const { t } = this.props;
     return (
-      <Modal className="link-edit-modal" isOpen={this.state.show} toggle={this.cancel} size="lg">
+      <Modal className="link-edit-modal" isOpen={this.state.show} toggle={this.cancel} size="lg" autoFocus={false}>
         <ModalHeader tag="h4" toggle={this.cancel} className="bg-primary text-light">
-          Edit Links
+          {t('link_edit.edit_link')}
         </ModalHeader>
 
         <ModalBody className="container">
@@ -435,17 +441,17 @@ class LinkEditModal extends React.PureComponent {
           </div>
           <div className="row">
             <div className="col-12">
-              <h3 className="grw-modal-head">Preview</h3>
+              <h3 className="grw-modal-head">{t('link_edit.preview')}</h3>
               {this.renderLinkPreview()}
             </div>
           </div>
           <div className="row">
             <div className="col-12 text-center">
               <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={this.hide}>
-                Cancel
+                {t('Cancel')}
               </button>
               <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={this.save}>
-                Done
+                {t('Done')}
               </button>
             </div>
           </div>
@@ -457,6 +463,7 @@ class LinkEditModal extends React.PureComponent {
 }
 
 LinkEditModal.propTypes = {
+  t: PropTypes.func.isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   onSave: PropTypes.func,
@@ -467,4 +474,4 @@ LinkEditModal.propTypes = {
  */
 const LinkEditModalWrapper = withUnstatedContainers(LinkEditModal, [AppContainer, PageContainer]);
 
-export default LinkEditModalWrapper;
+export default withTranslation('translation', { withRef: true })(LinkEditModalWrapper);

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

@@ -68,6 +68,7 @@ function PageHistory(props) {
   return (
     <div>
       <PageRevisionList
+        pageHistoryContainer={pageHistoryContainer}
         revisions={revisions}
         diffOpened={diffOpened}
         getPreviousRevision={getPreviousRevision}

+ 8 - 1
src/client/js/components/PageHistory/PageRevisionList.jsx

@@ -2,6 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
+import PageHistroyContainer from '../../services/PageHistoryContainer';
 
 import Revision from './Revision';
 import RevisionDiff from './RevisionDiff';
@@ -64,7 +65,7 @@ class PageRevisionList extends React.Component {
   }
 
   render() {
-    const { t } = this.props;
+    const { t, pageHistoryContainer } = this.props;
 
     const revisions = this.props.revisions;
     const revisionCount = this.props.revisions.length;
@@ -72,6 +73,11 @@ class PageRevisionList extends React.Component {
     let hasDiffPrev;
 
     const revisionList = this.props.revisions.map((revision, idx) => {
+      // Returns null because the last revision is for the bottom diff display
+      if (idx === pageHistoryContainer.state.pagingLimit) {
+        return null;
+      }
+
       let previousRevision;
       if (idx + 1 < revisionCount) {
         previousRevision = revisions[idx + 1];
@@ -117,6 +123,7 @@ class PageRevisionList extends React.Component {
 
 PageRevisionList.propTypes = {
   t: PropTypes.func.isRequired, // i18next
+  pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
 
   revisions: PropTypes.array,
   diffOpened: PropTypes.object,

+ 25 - 0
src/client/js/components/SearchForm.jsx

@@ -13,10 +13,13 @@ class SearchForm extends React.Component {
 
     this.state = {
       searchError: null,
+      isShownHelp: false,
     };
 
     this.onSearchError = this.onSearchError.bind(this);
     this.onChange = this.onChange.bind(this);
+    this.onBlur = this.onBlur.bind(this);
+    this.onFocus = this.onFocus.bind(this);
   }
 
   componentDidMount() {
@@ -40,12 +43,28 @@ class SearchForm extends React.Component {
     }
   }
 
+  onBlur() {
+    this.setState({
+      isShownHelp: false,
+    });
+
+    this.getHelpElement();
+  }
+
+  onFocus() {
+    this.setState({
+      isShownHelp: true,
+    });
+  }
+
   getHelpElement() {
     const { t, appContainer } = this.props;
+    const { isShownHelp } = this.state;
 
     const config = appContainer.getConfig();
     const isReachable = config.isSearchServiceReachable;
 
+
     if (!isReachable) {
       return (
         <>
@@ -55,6 +74,10 @@ class SearchForm extends React.Component {
       );
     }
 
+    if (!isShownHelp) {
+      return null;
+    }
+
     return (
       <table className="table grw-search-table search-help m-0">
         <caption className="text-left text-primary p-2">
@@ -124,6 +147,8 @@ class SearchForm extends React.Component {
         placeholder={placeholder}
         helpElement={this.getHelpElement()}
         keywordOnInit={this.props.keyword}
+        onBlur={this.onBlur}
+        onFocus={this.onFocus}
       />
     );
   }

+ 7 - 3
src/client/js/components/SearchTypeahead.jsx

@@ -44,9 +44,6 @@ 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() {
@@ -222,6 +219,9 @@ class SearchTypeahead extends React.Component {
           renderMenuItemChildren={this.renderMenuItemChildren}
           caseSensitive={false}
           defaultSelected={defaultSelected}
+          autoFocus={this.props.autoFocus}
+          onBlur={this.props.onBlur}
+          onFocus={this.props.onFocus}
         />
         {resetFormButton}
       </div>
@@ -244,6 +244,8 @@ SearchTypeahead.propTypes = {
   onSearchSuccess: PropTypes.func,
   onSearchError:   PropTypes.func,
   onChange:        PropTypes.func,
+  onBlur:          PropTypes.func,
+  onFocus:         PropTypes.func,
   onSubmit:        PropTypes.func,
   onInputChange:   PropTypes.func,
   inputName:       PropTypes.string,
@@ -252,6 +254,7 @@ SearchTypeahead.propTypes = {
   placeholder:     PropTypes.string,
   keywordOnInit:   PropTypes.string,
   helpElement:     PropTypes.object,
+  autoFocus:       PropTypes.bool,
   behaviorOfResetBtn: PropTypes.oneOf(['restore', 'clear']),
 };
 
@@ -265,6 +268,7 @@ SearchTypeahead.defaultProps = {
   placeholder:     '',
   keywordOnInit:   '',
   behaviorOfResetBtn: 'restore',
+  autoFocus:       false,
   onInputChange: () => {},
 };
 

+ 2 - 2
src/client/js/services/PageContainer.js

@@ -469,9 +469,9 @@ export default class PageContainer extends Container {
     });
 
     const res = await this.appContainer.apiv3Post('/pages/', params);
-    const { page, tags } = res.data;
+    const { page, tags, revision } = res.data;
 
-    return { page, tags };
+    return { page, tags, revision };
   }
 
   async updatePage(pageId, revisionId, markdown, tmpParams) {

+ 13 - 4
src/client/js/services/PageHistoryContainer.js

@@ -28,7 +28,7 @@ export default class PageHistoryContainer extends Container {
 
       totalPages: 0,
       activePage: 1,
-      pagingLimit: Infinity,
+      pagingLimit: 10,
     };
 
     this.retrieveRevisions = this.retrieveRevisions.bind(this);
@@ -50,25 +50,34 @@ export default class PageHistoryContainer extends Container {
    */
   async retrieveRevisions(selectedPage) {
     const { pageId, shareLinkId } = this.pageContainer.state;
+    const { pagingLimit } = this.state;
     const page = selectedPage;
+    const pagingLimitForApiParam = pagingLimit + 1;
 
     if (!pageId) {
       return;
     }
 
+    // Get one more for the bottom display
     const res = await this.appContainer.apiv3Get('/revisions/list', {
-      pageId, shareLinkId, page,
+      pageId, shareLinkId, page, limit: pagingLimitForApiParam,
     });
     const rev = res.data.docs;
     // set Pagination state
     this.setState({
       activePage: selectedPage,
       totalPages: res.data.totalDocs,
-      pagingLimit: res.data.limit,
+      pagingLimit,
     });
 
     const diffOpened = {};
-    const lastId = rev.length - 1;
+
+    let lastId = rev.length - 1;
+
+    // If the number of rev count is the same, the last rev is for diff display, so exclude it.
+    if (rev.length > pagingLimit) {
+      lastId = rev.length - 2;
+    }
 
     res.data.docs.forEach((revision, i) => {
       const user = revision.author;

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

@@ -134,6 +134,13 @@ body.on-edit {
 
     .grw-taglabels-container {
       margin-bottom: 0;
+
+      // To scroll tags horizontally
+      .grw-tag-labels.form-inline {
+        flex-flow: row nowrap;
+        width: 100%;
+        overflow-x: scroll;
+      }
     }
   }
 
@@ -141,6 +148,7 @@ body.on-edit {
   .grw-subnav-left-side {
     overflow: hidden;
     .grw-path-nav-container {
+      margin-right: 1rem;
       overflow: hidden;
       .grw-page-path-nav {
         white-space: nowrap;

+ 2 - 2
src/server/middlewares/access-token-parser.js

@@ -1,4 +1,5 @@
 const loggerFactory = require('@alias/logger');
+const { serializeUserSecurely } = require('../models/serializers/user-serializer');
 
 const logger = loggerFactory('growi:middleware:access-token-parser');
 
@@ -23,8 +24,7 @@ module.exports = (crowi) => {
     }
 
     // transforming attributes
-    // see User model
-    req.user = user.toObject();
+    req.user = serializeUserSecurely(user);
     req.skipCsrfVerify = true;
 
     logger.debug('Access token parsed: skipCsrfVerify');

+ 1 - 87
src/server/models/page.js

@@ -1141,30 +1141,6 @@ module.exports = function(crowi) {
     }));
   };
 
-  // TODO: transplant to service/page.js because page deletion affects various models data
-  pageSchema.statics.revertDeletedPage = async function(page, user, options = {}) {
-    const newPath = this.getRevertDeletedPageName(page.path);
-
-    const originPage = await this.findByPath(newPath);
-    if (originPage != null) {
-      // 削除時、元ページの path には必ず redirectTo 付きで、ページが作成される。
-      // そのため、そいつは削除してOK
-      // が、redirectTo ではないページが存在している場合それは何かがおかしい。(データ補正が必要)
-      if (originPage.redirectTo !== page.path) {
-        throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
-      }
-
-      await this.completelyDeletePage(originPage, options);
-    }
-
-    page.status = STATUS_PUBLISHED;
-    page.lastUpdateUser = user;
-    debug('Revert deleted the page', page, newPath);
-    const updatedPage = await this.rename(page, newPath, user, {});
-
-    return updatedPage;
-  };
-
   pageSchema.statics.revertDeletedPageRecursively = async function(targetPage, user, options = {}) {
     const findOpts = { includeTrashed: true };
     const pages = await this.findManageableListWithDescendants(targetPage, user, findOpts);
@@ -1172,7 +1148,7 @@ module.exports = function(crowi) {
     let updatedPage = null;
     await Promise.all(pages.map((page) => {
       const isParent = (page.path === targetPage.path);
-      const p = this.revertDeletedPage(page, user, options);
+      const p = crowi.pageService.revertDeletedPage(page, user, options);
       if (isParent) {
         updatedPage = p;
       }
@@ -1182,41 +1158,6 @@ module.exports = function(crowi) {
     return updatedPage;
   };
 
-  /**
-   * This is danger.
-   */
-  // TODO: transplant to service/page.js because page deletion affects various models data
-  pageSchema.statics.completelyDeletePage = async function(pageData, user, options = {}) {
-    validateCrowi();
-
-    const { _id, path } = pageData;
-    const socketClientId = options.socketClientId || null;
-
-    logger.debug('Deleting completely', path);
-
-    await crowi.pageService.deleteCompletely(_id, path);
-
-    if (socketClientId != null) {
-      pageEvent.emit('delete', pageData, user, socketClientId); // update as renamed page
-    }
-    return pageData;
-  };
-
-  /**
-   * Delete Bookmarks, Attachments, Revisions, Pages and emit delete
-   */
-  // TODO: transplant to service/page.js because page deletion affects various models data
-  pageSchema.statics.completelyDeletePageRecursively = async function(targetPage, user, options = {}) {
-    const findOpts = { includeTrashed: true };
-
-    // find manageable descendants (this array does not include GRANT_RESTRICTED)
-    const pages = await this.findManageableListWithDescendants(targetPage, user, findOpts);
-
-    await Promise.all(pages.map((page) => {
-      return this.completelyDeletePage(page, user, options);
-    }));
-  };
-
   pageSchema.statics.removeByPath = function(path) {
     if (path == null) {
       throw new Error('path is required');
@@ -1327,33 +1268,6 @@ module.exports = function(crowi) {
     return await queryBuilder.query.exec();
   };
 
-  // TODO: transplant to service/page.js because page deletion affects various models data
-  pageSchema.statics.handlePrivatePagesForDeletedGroup = async function(deletedGroup, action, transferToUserGroupId) {
-    const Page = mongoose.model('Page');
-
-    const pages = await this.find({ grantedGroup: deletedGroup });
-
-    switch (action) {
-      case 'public':
-        await Promise.all(pages.map((page) => {
-          return Page.publicizePage(page);
-        }));
-        break;
-      case 'delete':
-        await Promise.all(pages.map((page) => {
-          return Page.completelyDeletePage(page);
-        }));
-        break;
-      case 'transfer':
-        await Promise.all(pages.map((page) => {
-          return Page.transferPageToGroup(page, transferToUserGroupId);
-        }));
-        break;
-      default:
-        throw new Error('Unknown action for private pages');
-    }
-  };
-
   pageSchema.statics.publicizePage = async function(page) {
     page.grantedGroup = null;
     page.grant = GRANT_PUBLIC;

+ 1 - 1
src/server/models/user-group-relation.js

@@ -139,7 +139,7 @@ class UserGroupRelation {
    * @returns {Promise<ObjectId[]>}
    */
   static async findAllUserGroupIdsRelatedToUser(user) {
-    const relations = await this.find({ relatedUser: user.id })
+    const relations = await this.find({ relatedUser: user._id })
       .select('relatedGroup')
       .exec();
 

+ 1 - 2
src/server/models/user-group.js

@@ -92,7 +92,6 @@ class UserGroup {
   // グループの完全削除
   static async removeCompletelyById(deleteGroupId, action, transferToUserGroupId) {
     const UserGroupRelation = mongoose.model('UserGroupRelation');
-    const Page = mongoose.model('Page');
 
     const groupToDelete = await this.findById(deleteGroupId);
     if (groupToDelete == null) {
@@ -102,7 +101,7 @@ class UserGroup {
 
     await Promise.all([
       UserGroupRelation.removeAllByUserGroup(deletedGroup),
-      Page.handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId),
+      UserGroup.crowi.pageService.handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId),
     ]);
 
     return deletedGroup;

+ 7 - 2
src/server/routes/apiv3/pages.js

@@ -119,6 +119,7 @@ module.exports = (crowi) => {
   const userNotificationService = crowi.getUserNotificationService();
 
   const { serializePageSecurely } = require('../../models/serializers/page-serializer');
+  const { serializeRevisionSecurely } = require('../../models/serializers/revision-serializer');
 
   const validator = {
     createPage: [
@@ -229,7 +230,11 @@ module.exports = (crowi) => {
 
     const savedTags = await saveTagsAction({ createdPage, pageTags });
 
-    const result = { page: serializePageSecurely(createdPage), tags: savedTags };
+    const result = {
+      page: serializePageSecurely(createdPage),
+      tags: savedTags,
+      revision: serializeRevisionSecurely(createdPage.revision),
+    };
 
     // update scopes for descendants
     if (overwriteScopesOfDescendants) {
@@ -432,7 +437,7 @@ module.exports = (crowi) => {
    */
   router.delete('/empty-trash', loginRequired, adminRequired, csrf, async(req, res) => {
     try {
-      const pages = await Page.completelyDeletePageRecursively({ path: '/trash' }, req.user);
+      const pages = await crowi.pageService.completelyDeletePageRecursively({ path: '/trash' }, req.user);
       return res.apiv3({ pages });
     }
     catch (err) {

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

@@ -181,6 +181,8 @@ module.exports = (crowi) => {
       [sort]: (sortOrder === 'desc') ? -1 : 1,
     };
 
+    //  For more information about the external specification of the User API, see here (https://dev.growi.org/5fd7466a31d89500488248e3)
+
     const orConditions = [
       { name: { $in: searchWord } },
       { username: { $in: searchWord } },

+ 3 - 3
src/server/routes/page.js

@@ -1194,10 +1194,10 @@ module.exports = function(crowi, app) {
           return res.json(ApiResponse.error('You can not delete completely', 'user_not_admin'));
         }
         if (isRecursively) {
-          await Page.completelyDeletePageRecursively(page, req.user, options);
+          await crowi.pageService.completelyDeletePageRecursively(page, req.user, options);
         }
         else {
-          await Page.completelyDeletePage(page, req.user, options);
+          await crowi.pageService.completelyDeletePage(page, req.user, options);
         }
       }
       else {
@@ -1258,7 +1258,7 @@ module.exports = function(crowi, app) {
         page = await Page.revertDeletedPageRecursively(page, req.user, { socketClientId });
       }
       else {
-        page = await Page.revertDeletedPage(page, req.user, { socketClientId });
+        page = await crowi.pageService.revertDeletedPage(page, req.user, { socketClientId });
       }
     }
     catch (err) {

+ 95 - 0
src/server/service/page.js

@@ -1,7 +1,11 @@
 const mongoose = require('mongoose');
 const escapeStringRegexp = require('escape-string-regexp');
+const logger = require('@alias/logger')('growi:models:page');
+const debug = require('debug')('growi:models:page');
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 
+const STATUS_PUBLISHED = 'published';
+
 class PageService {
 
   constructor(crowi) {
@@ -95,6 +99,97 @@ class PageService {
   }
 
 
+  async completelyDeletePage(pageData, user, options = {}) {
+    this.validateCrowi();
+    let pageEvent;
+    // init event
+    if (this.crowi != null) {
+      pageEvent = this.crowi.event('page');
+      pageEvent.on('create', pageEvent.onCreate);
+      pageEvent.on('update', pageEvent.onUpdate);
+    }
+
+    const { _id, path } = pageData;
+    const socketClientId = options.socketClientId || null;
+
+    logger.debug('Deleting completely', path);
+
+    await this.deleteCompletely(_id, path);
+
+    if (socketClientId != null) {
+      pageEvent.emit('delete', pageData, user, socketClientId); // update as renamed page
+    }
+    return pageData;
+  }
+
+  /**
+   * Delete Bookmarks, Attachments, Revisions, Pages and emit delete
+   */
+  async completelyDeletePageRecursively(targetPage, user, options = {}) {
+    const findOpts = { includeTrashed: true };
+    const Page = this.crowi.model('Page');
+
+    // find manageable descendants (this array does not include GRANT_RESTRICTED)
+    const pages = await Page.findManageableListWithDescendants(targetPage, user, findOpts);
+
+    await Promise.all(pages.map((page) => {
+      return this.completelyDeletePage(page, user, options);
+    }));
+  }
+
+
+  async revertDeletedPage(page, user, options = {}) {
+    const Page = this.crowi.model('Page');
+    const newPath = Page.getRevertDeletedPageName(page.path);
+    const originPage = await Page.findByPath(newPath);
+    if (originPage != null) {
+      // When the page is deleted, it will always be created with "redirectTo" in the path of the original page.
+      // So, it's ok to delete the page
+      // However, If a page exists that is not "redirectTo", something is wrong. (Data correction is needed).
+      if (originPage.redirectTo !== page.path) {
+        throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
+      }
+      await this.completelyDeletePage(originPage, options);
+    }
+
+    page.status = STATUS_PUBLISHED;
+    page.lastUpdateUser = user;
+    debug('Revert deleted the page', page, newPath);
+    const updatedPage = await Page.rename(page, newPath, user, {});
+    return updatedPage;
+  }
+
+  async handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId) {
+    const Page = this.crowi.model('Page');
+    const pages = await Page.find({ grantedGroup: deletedGroup });
+
+    switch (action) {
+      case 'public':
+        await Promise.all(pages.map((page) => {
+          return Page.publicizePage(page);
+        }));
+        break;
+      case 'delete':
+        await Promise.all(pages.map((page) => {
+          return this.completelyDeletePage(page);
+        }));
+        break;
+      case 'transfer':
+        await Promise.all(pages.map((page) => {
+          return Page.transferPageToGroup(page, transferToUserGroupId);
+        }));
+        break;
+      default:
+        throw new Error('Unknown action for private pages');
+    }
+  }
+
+  validateCrowi() {
+    if (this.crowi == null) {
+      throw new Error('"crowi" is null. Init User model with "crowi" argument first.');
+    }
+  }
+
 }
 
 module.exports = PageService;

+ 14 - 2
src/server/service/search-delegator/elasticsearch.js

@@ -344,7 +344,7 @@ class ElasticsearchDelegator {
 
   addAllPages() {
     const Page = mongoose.model('Page');
-    return this.updateOrInsertPages(() => Page.find(), true);
+    return this.updateOrInsertPages(() => Page.find(), { isEmittingProgressEvent: true, invokeGarbageCollection: true });
   }
 
   updateOrInsertPageById(pageId) {
@@ -355,7 +355,9 @@ class ElasticsearchDelegator {
   /**
    * @param {function} queryFactory factory method to generate a Mongoose Query instance
    */
-  async updateOrInsertPages(queryFactory, isEmittingProgressEvent = false) {
+  async updateOrInsertPages(queryFactory, option = {}) {
+    const { isEmittingProgressEvent = false, invokeGarbageCollection = false } = option;
+
     const Page = mongoose.model('Page');
     const { PageQueryBuilder } = Page;
     const Bookmark = mongoose.model('Bookmark');
@@ -465,6 +467,16 @@ class ElasticsearchDelegator {
           logger.error('addAllPages error on add anyway: ', err);
         }
 
+        if (invokeGarbageCollection) {
+          try {
+            // First aid to prevent unexplained memory leaks
+            global.gc();
+          }
+          catch (err) {
+            logger.error('fail garbage collection: ', err);
+          }
+        }
+
         callback();
       },
       final(callback) {