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

Merge branch 'imprv/refacter-rename-recursively' into imprv/gw-4776

# Conflicts:
#	src/server/models/page.js
#	src/server/service/search-delegator/elasticsearch.js
takeru0001 5 лет назад
Родитель
Сommit
1a599afceb
36 измененных файлов с 355 добавлено и 133 удалено
  1. 17 1
      CHANGES.md
  2. 2 2
      package.json
  3. 1 1
      resource/cdn-manifests.js
  4. 17 0
      resource/locales/en_US/translation.json
  5. 17 0
      resource/locales/ja_JP/translation.json
  6. 18 1
      resource/locales/zh_CN/translation.json
  7. 4 0
      src/client/js/boot.js
  8. 5 3
      src/client/js/components/EmptyTrashModal.jsx
  9. 1 1
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  10. 2 2
      src/client/js/components/Page/RevisionBody.jsx
  11. 2 2
      src/client/js/components/Page/TagEditModal.jsx
  12. 5 2
      src/client/js/components/Page/TagsInput.jsx
  13. 8 1
      src/client/js/components/PageCreateModal.jsx
  14. 2 1
      src/client/js/components/PageDuplicateModal.jsx
  15. 32 26
      src/client/js/components/PageEditor/LinkEditModal.jsx
  16. 1 0
      src/client/js/components/PageHistory.jsx
  17. 8 1
      src/client/js/components/PageHistory/PageRevisionList.jsx
  18. 4 1
      src/client/js/components/PagePathAutoComplete.jsx
  19. 2 1
      src/client/js/components/PageRenameModal.jsx
  20. 1 1
      src/client/js/components/SearchForm.jsx
  21. 3 3
      src/client/js/components/SearchTypeahead.jsx
  22. 2 2
      src/client/js/services/PageContainer.js
  23. 13 4
      src/client/js/services/PageHistoryContainer.js
  24. 18 0
      src/client/js/util/old-ios.js
  25. 9 0
      src/client/styles/scss/_old-ios.scss
  26. 2 1
      src/client/styles/scss/_on-edit.scss
  27. 1 0
      src/client/styles/scss/style-app.scss
  28. 2 2
      src/server/middlewares/access-token-parser.js
  29. 18 24
      src/server/models/page.js
  30. 1 1
      src/server/models/user-group-relation.js
  31. 14 4
      src/server/routes/apiv3/pages.js
  32. 3 3
      src/server/routes/page.js
  33. 5 6
      src/server/service/attachment.js
  34. 86 29
      src/server/service/page.js
  35. 28 7
      src/server/service/search-delegator/elasticsearch.js
  36. 1 0
      src/server/service/search.js

+ 17 - 1
CHANGES.md

@@ -1,9 +1,21 @@
 # CHANGES
 
-## v4.2.4
+## v4.2.6-RC
 
 * 
 
+## v4.2.5-RC
+
+* Improvement: Invoke garbage collection when reindex all pages by elasticsearch
+* Fix: MathJax rendering does not work
+
+## 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
@@ -45,6 +57,10 @@
     * migrate-mongo
     * mongoose
 
+## v4.1.13
+
+* Fix: MathJax rendering does not work
+
 ## v4.1.12
 
 * Fix: Adjust line-height for pre under li

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.2.4-RC",
+  "version": "4.2.6-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",

+ 1 - 1
resource/cdn-manifests.js

@@ -39,7 +39,7 @@ module.exports = {
     },
     {
       name: 'mathjax',
-      url: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js',
+      url: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js',
       args: {
         async: true,
         integrity: '',

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

+ 4 - 0
src/client/js/boot.js

@@ -1,5 +1,9 @@
 import {
   applyColorScheme,
 } from './util/color-scheme';
+import {
+  applyOldIos,
+} from './util/old-ios';
 
 applyColorScheme();
+applyOldIos();

+ 5 - 3
src/client/js/components/EmptyTrashModal.jsx

@@ -8,12 +8,13 @@ import {
 import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
 
+import SocketIoContainer from '../services/SocketIoContainer';
 import AppContainer from '../services/AppContainer';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 const EmptyTrashModal = (props) => {
   const {
-    t, isOpen, onClose, appContainer,
+    t, isOpen, onClose, appContainer, socketIoContainer,
   } = props;
 
   const [errs, setErrs] = useState(null);
@@ -22,7 +23,7 @@ const EmptyTrashModal = (props) => {
     setErrs(null);
 
     try {
-      await appContainer.apiv3Delete('/pages/empty-trash');
+      await appContainer.apiv3Delete('/pages/empty-trash', { socketClientId: socketIoContainer.getSocketClientId() });
       window.location.reload();
     }
     catch (err) {
@@ -55,12 +56,13 @@ const EmptyTrashModal = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const EmptyTrashModalWrapper = withUnstatedContainers(EmptyTrashModal, [AppContainer]);
+const EmptyTrashModalWrapper = withUnstatedContainers(EmptyTrashModal, [AppContainer, SocketIoContainer]);
 
 
 EmptyTrashModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  socketIoContainer: PropTypes.instanceOf(SocketIoContainer),
 
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,

+ 1 - 1
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -44,7 +44,7 @@ const PagePathNav = ({
   }
 
   const copyDropdownId = `copydropdown${isCompactMode ? '-subnav-compact' : ''}-${pageId}`;
-  const copyDropdownToggleClassName = 'd-block text-muted bg-transparent btn-copy border-0';
+  const copyDropdownToggleClassName = 'd-block text-muted bg-transparent btn-copy border-0 py-0';
 
   return (
     <div className="grw-page-path-nav">

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

@@ -42,8 +42,8 @@ export default class RevisionBody extends React.PureComponent {
     //   So, before MathJax is initialized, execute renderMathJaxWithDebounce again.
     //   Avoiding initialization of MathJax of draw.io solves the problem.
     //   refs: https://github.com/jgraph/drawio/pull/831
-    if (MathJax != null && MathJax.Hub != null) {
-      MathJax.Hub.Queue(['Typeset', MathJax.Hub, this.element]);
+    if (MathJax != null) {
+      MathJax.typesetPromise([this.element]);
     }
     else {
       this.renderMathJaxWithDebounce();

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

@@ -35,12 +35,12 @@ function TagEditModal(props) {
   }
 
   return (
-    <Modal isOpen={props.isOpen} toggle={closeModalHandler} id="edit-tag-modal">
+    <Modal isOpen={props.isOpen} toggle={closeModalHandler} id="edit-tag-modal" autoFocus={false}>
       <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-primary text-light">
         Edit Tags
       </ModalHeader>
       <ModalBody>
-        <TagsInput tags={tags} onTagsUpdated={onTagsUpdatedByTagsInput} />
+        <TagsInput tags={tags} onTagsUpdated={onTagsUpdatedByTagsInput} autoFocus />
       </ModalBody>
       <ModalFooter>
         <button type="button" className="btn btn-primary" onClick={handleSubmit}>

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

@@ -82,6 +82,7 @@ class TagsInput extends React.Component {
           options={this.state.resultTags} // Search result (Some tag names)
           placeholder="tag name"
           selectHintOnEnter
+          autoFocus={this.props.autoFocus}
         />
       </div>
     );
@@ -95,13 +96,15 @@ class TagsInput extends React.Component {
 const TagsInputWrapper = withUnstatedContainers(TagsInput, [AppContainer]);
 
 TagsInput.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  appContainer:  PropTypes.instanceOf(AppContainer).isRequired,
 
-  tags: PropTypes.array.isRequired,
+  tags:          PropTypes.array.isRequired,
   onTagsUpdated: PropTypes.func.isRequired,
+  autoFocus:     PropTypes.bool,
 };
 
 TagsInput.defaultProps = {
+  autoFocus:     false,
 };
 
 export default TagsInputWrapper;

+ 8 - 1
src/client/js/components/PageCreateModal.jsx

@@ -166,6 +166,7 @@ const PageCreateModal = (props) => {
                     addTrailingSlash
                     onSubmit={ppacSubmitHandler}
                     onInputChange={ppacInputChangeHandler}
+                    autoFocus
                   />
                 )
                 : (
@@ -243,7 +244,13 @@ const PageCreateModal = (props) => {
   }
 
   return (
-    <Modal size="lg" isOpen={navigationContainer.state.isPageCreateModalShown} toggle={navigationContainer.closePageCreateModal} className="grw-create-page">
+    <Modal
+      size="lg"
+      isOpen={navigationContainer.state.isPageCreateModalShown}
+      toggle={navigationContainer.closePageCreateModal}
+      className="grw-create-page"
+      autoFocus={false}
+    >
       <ModalHeader tag="h4" toggle={navigationContainer.closePageCreateModal} className="bg-primary text-light">
         { t('New Page') }
       </ModalHeader>

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

@@ -120,7 +120,7 @@ const PageDuplicateModal = (props) => {
   }
 
   return (
-    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose} className="grw-duplicate-page">
+    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose} className="grw-duplicate-page" autoFocus={false}>
       <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
         { t('modal_duplicate.label.Duplicate page') }
       </ModalHeader>
@@ -141,6 +141,7 @@ const PageDuplicateModal = (props) => {
                   initializedPath={path}
                   onSubmit={ppacSubmitHandler}
                   onInputChange={ppacInputChangeHandler}
+                  autoFocus
                 />
               )
               : (

+ 32 - 26
src/client/js/components/PageEditor/LinkEditModal.jsx

@@ -5,12 +5,14 @@ import {
   Modal,
   ModalHeader,
   ModalBody,
+  ModalFooter,
   Popover,
   PopoverBody,
 } from 'reactstrap';
 
 import path from 'path';
 import validator from 'validator';
+import { withTranslation } from 'react-i18next';
 import PreviewWithSuspense from './PreviewWithSuspense';
 import PagePreviewIcon from '../Icons/PagePreviewIcon';
 
@@ -147,6 +149,7 @@ class LinkEditModal extends React.PureComponent {
   }
 
   async setMarkdown() {
+    const { t } = this.props;
     const path = this.state.linkInputValue;
     let markdown = '';
     let previewError = '';
@@ -168,7 +171,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 +286,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 +320,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 +339,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 +356,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 +369,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 +387,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 +400,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 +414,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 +426,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,21 +442,19 @@ 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
-              </button>
-              <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={this.save}>
-                Done
-              </button>
-            </div>
-          </div>
         </ModalBody>
+        <ModalFooter>
+          <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={this.hide}>
+            {t('Cancel')}
+          </button>
+          <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={this.save}>
+            {t('Done')}
+          </button>
+        </ModalFooter>
       </Modal>
     );
   }
@@ -457,6 +462,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 +473,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,

+ 4 - 1
src/client/js/components/PagePathAutoComplete.jsx

@@ -44,6 +44,7 @@ const PagePathAutoComplete = (props) => {
       emptyLabelExceptError={null}
       placeholder="Input page path"
       keywordOnInit={getKeywordOnInit(initializedPath)}
+      autoFocus={props.autoFocus}
     />
   );
 
@@ -55,10 +56,12 @@ PagePathAutoComplete.propTypes = {
 
   onSubmit:         PropTypes.func,
   onInputChange:    PropTypes.func,
+  autoFocus:        PropTypes.bool,
 };
 
 PagePathAutoComplete.defaultProps = {
-  initializedPath: '/',
+  initializedPath:  '/',
+  autoFocus:        false,
 };
 
 export default PagePathAutoComplete;

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

@@ -134,7 +134,7 @@ const PageRenameModal = (props) => {
   }
 
   return (
-    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose}>
+    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose} autoFocus={false}>
       <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
         { t('modal_rename.label.Move/Rename page') }
       </ModalHeader>
@@ -156,6 +156,7 @@ const PageRenameModal = (props) => {
                 className="form-control"
                 onChange={e => inputChangeHandler(e.target.value)}
                 required
+                autoFocus
               />
             </form>
           </div>

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

@@ -75,7 +75,7 @@ class SearchForm extends React.Component {
     }
 
     if (!isShownHelp) {
-      return null;
+      return <></>;
     }
 
     return (

+ 3 - 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,7 @@ class SearchTypeahead extends React.Component {
           renderMenuItemChildren={this.renderMenuItemChildren}
           caseSensitive={false}
           defaultSelected={defaultSelected}
+          autoFocus={this.props.autoFocus}
           onBlur={this.props.onBlur}
           onFocus={this.props.onFocus}
         />
@@ -256,6 +254,7 @@ SearchTypeahead.propTypes = {
   placeholder:     PropTypes.string,
   keywordOnInit:   PropTypes.string,
   helpElement:     PropTypes.object,
+  autoFocus:       PropTypes.bool,
   behaviorOfResetBtn: PropTypes.oneOf(['restore', 'clear']),
 };
 
@@ -269,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;

+ 18 - 0
src/client/js/util/old-ios.js

@@ -0,0 +1,18 @@
+const userAgent = window.navigator.userAgent.toLowerCase();
+// TODO: impl more accurate logic
+// https://youtrack.weseek.co.jp/issue/GW-4826
+const isOldIos = /iphone os 12/.test(userAgent);
+
+/**
+ * Apply 'oldIos' attribute to <html></html>
+ */
+function applyOldIos() {
+  if (isOldIos) {
+    document.documentElement.setAttribute('old-ios', 'true');
+  }
+}
+
+export {
+  // eslint-disable-next-line import/prefer-default-export
+  applyOldIos,
+};

+ 9 - 0
src/client/styles/scss/_old-ios.scss

@@ -0,0 +1,9 @@
+html[old-ios] body:not(.on-edit) {
+  .grw-navbar {
+    position: initial !important;
+    top: 0 !important;
+  }
+  .grw-subnav-fixed-container {
+    top: 0 !important;
+  }
+}

+ 2 - 1
src/client/styles/scss/_on-edit.scss

@@ -139,7 +139,8 @@ body.on-edit {
       .grw-tag-labels.form-inline {
         flex-flow: row nowrap;
         width: 100%;
-        overflow-x: scroll;
+        overflow-x: auto;
+        scrollbar-width: thin;
       }
     }
   }

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

@@ -46,6 +46,7 @@
 @import 'mirror_mode';
 @import 'modal';
 @import 'navbar';
+@import 'old-ios';
 @import 'on-edit';
 @import 'page-duplicate-modal';
 @import 'page_list';

+ 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');

+ 18 - 24
src/server/models/page.js

@@ -1129,35 +1129,18 @@ module.exports = function(crowi) {
 
   pageSchema.statics.deletePageRecursively = async function(targetPage, user, options = {}) {
     const isTrashed = checkIfTrashed(targetPage.path);
-
+    const newPath = this.getDeletedPageName(targetPage.path);
     if (isTrashed) {
       throw new Error('This method does NOT supports deleting trashed pages.');
     }
 
-    // find manageable descendants (this array does not include GRANT_RESTRICTED)
-    const pages = await this.findManageableListWithDescendants(targetPage, user, options);
-
-    await Promise.all(pages.map((page) => {
-      return this.deletePage(page, user, options);
-    }));
+    const socketClientId = options.socketClientId || null;
+    if (this.isDeletableName(targetPage.path)) {
+      targetPage.status = STATUS_DELETED;
+    }
+    return await this.renameRecursively(targetPage, newPath, user, { socketClientId, createRedirectPage: true });
   };
 
-  pageSchema.statics.revertDeletedPageRecursively = async function(targetPage, user, options = {}) {
-    const findOpts = { includeTrashed: true };
-    const pages = await this.findManageableListWithDescendants(targetPage, user, findOpts);
-
-    let updatedPage = null;
-    await Promise.all(pages.map((page) => {
-      const isParent = (page.path === targetPage.path);
-      const p = crowi.pageService.revertDeletedPage(page, user, options);
-      if (isParent) {
-        updatedPage = p;
-      }
-      return p;
-    }));
-
-    return updatedPage;
-  };
 
   pageSchema.statics.removeByPath = function(path) {
     if (path == null) {
@@ -1232,7 +1215,7 @@ module.exports = function(crowi) {
 
     const path = targetPage.path;
     const pathRegExp = new RegExp(`^${escapeStringRegexp(path)}`, 'i');
-    const updateMetadata = options.updateMetadata;
+    const { updateMetadata, createRedirectPage } = options;
     const socketClientId = options.socketClientId || null;
 
     // sanitize path
@@ -1242,6 +1225,7 @@ module.exports = function(crowi) {
     const pages = await this.findManageableListWithDescendants(targetPage, user, options);
 
     const unorderedBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const createRediectPageBulkOp = pageCollection.initializeUnorderedBulkOp();
     const revisionUnorderedBulkOp = revisionCollection.initializeUnorderedBulkOp();
 
     pages.forEach((page) => {
@@ -1252,6 +1236,11 @@ module.exports = function(crowi) {
       else {
         unorderedBulkOp.find({ _id: page._id }).update({ $set: { path: newPagePath } });
       }
+      if (createRedirectPage) {
+        createRediectPageBulkOp.insert({
+          path: page.path, body: `redirect ${newPagePath}`, creator: user, lastUpdateUser: user, status: STATUS_PUBLISHED, redirectTo: newPagePath,
+        });
+      }
       revisionUnorderedBulkOp.find({ path: page.path }).update({ $set: { path: newPagePath } }, { multi: true });
     });
 
@@ -1270,6 +1259,11 @@ module.exports = function(crowi) {
 
     pageEvent.emit('createMany', newParentPage, user, socketClientId);
 
+    // Execute after unorderedBulkOp to prevent duplication
+    if (createRedirectPage) {
+      await createRediectPageBulkOp.execute();
+    }
+
     targetPage.path = newPagePathPrefix;
     return targetPage;
 

+ 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();
 

+ 14 - 4
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) {
@@ -418,7 +423,9 @@ module.exports = (crowi) => {
     return res.apiv3(result);
   });
 
-
+  validator.emptyTrash = [
+    query('socketClientId').if(value => value != null).isInt().withMessage('socketClientId must be int'),
+  ];
   /**
    * @swagger
    *
@@ -430,9 +437,12 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to remove all trash pages
    */
-  router.delete('/empty-trash', loginRequired, adminRequired, csrf, async(req, res) => {
+  router.delete('/empty-trash', accessTokenParser, loginRequired, adminRequired, csrf, validator.emptyTrash, apiV3FormValidator, async(req, res) => {
+    const socketClientId = parseInt(req.query.socketClientId);
+    const options = { socketClientId };
+
     try {
-      const pages = await crowi.pageService.completelyDeletePageRecursively({ path: '/trash' }, req.user);
+      const pages = await crowi.pageService.completelyDeletePageRecursively({ path: '/trash' }, req.user, options);
       return res.apiv3({ pages });
     }
     catch (err) {

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

@@ -1197,7 +1197,7 @@ module.exports = function(crowi, app) {
           await crowi.pageService.completelyDeletePageRecursively(page, req.user, options);
         }
         else {
-          await crowi.pageService.completelyDeletePage(page, req.user, options);
+          await crowi.pageService.completelyDeleteSinglePage(page, req.user, options);
         }
       }
       else {
@@ -1255,10 +1255,10 @@ module.exports = function(crowi, app) {
       }
 
       if (isRecursively) {
-        page = await Page.revertDeletedPageRecursively(page, req.user, { socketClientId });
+        page = await crowi.pageService.revertDeletedPageRecursively(page, req.user, { socketClientId });
       }
       else {
-        page = await crowi.pageService.revertDeletedPage(page, req.user, { socketClientId });
+        page = await crowi.pageService.revertSingleDeletedPage(page, req.user, { socketClientId });
       }
     }
     catch (err) {

+ 5 - 6
src/server/service/attachment.js

@@ -43,14 +43,13 @@ class AttachmentService {
     return attachment;
   }
 
-  async removeAttachment(attachmentId) {
-    const Attachment = this.crowi.model('Attachment');
+  async removeAttachment(attachments) {
     const { fileUploadService } = this.crowi;
 
-    const attachment = await Attachment.findById(attachmentId);
-    await fileUploadService.deleteFile(attachment);
-
-    return attachment.remove();
+    return attachments.forEach((attachment) => {
+      fileUploadService.deleteFile(attachment);
+      attachment.remove();
+    });
   }
 
 }

+ 86 - 29
src/server/service/page.js

@@ -12,7 +12,7 @@ class PageService {
     this.crowi = crowi;
   }
 
-  async deleteCompletely(pageId, pagePath) {
+  async deleteCompletely(pageIds, pagePaths) {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     const Bookmark = this.crowi.model('Bookmark');
     const Comment = this.crowi.model('Comment');
@@ -22,28 +22,23 @@ class PageService {
     const Revision = this.crowi.model('Revision');
 
     return Promise.all([
-      Bookmark.removeBookmarksByPageId(pageId),
-      Comment.removeCommentsByPageId(pageId),
-      PageTagRelation.remove({ relatedPage: pageId }),
-      ShareLink.remove({ relatedPage: pageId }),
-      Revision.removeRevisionsByPath(pagePath),
-      Page.findByIdAndRemove(pageId),
-      Page.removeRedirectOriginPageByPath(pagePath),
-      this.removeAllAttachments(pageId),
+      Bookmark.find({ page: { $in: pageIds } }).remove({}),
+      Comment.find({ page: { $in: pageIds } }).remove({}),
+      PageTagRelation.find({ relatedPage: { $in: pageIds } }).remove({}),
+      ShareLink.find({ relatedPage: { $in: pageIds } }).remove({}),
+      Revision.find({ path: { $in: pagePaths } }).remove({}),
+      Page.find({ _id: { $in: pageIds } }).remove({}),
+      Page.find({ path: { $in: pagePaths } }).remove({}),
+      this.removeAllAttachments(pageIds),
     ]);
   }
 
-  async removeAllAttachments(pageId) {
+  async removeAllAttachments(pageIds) {
     const Attachment = this.crowi.model('Attachment');
     const { attachmentService } = this.crowi;
+    const attachments = await Attachment.find({ page: { $in: pageIds } });
 
-    const attachments = await Attachment.find({ page: pageId });
-
-    const promises = attachments.map(async(attachment) => {
-      return attachmentService.removeAttachment(attachment._id);
-    });
-
-    return Promise.all(promises);
+    return attachmentService.removeAttachment(attachments);
   }
 
   async duplicate(page, newPagePath, user) {
@@ -98,8 +93,33 @@ class PageService {
     return newParentpage;
   }
 
+  // delete multiple pages
+  async completelyDeletePages(pagesData, 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);
+    }
 
-  async completelyDeletePage(pageData, user, options = {}) {
+    const ids = pagesData.map(page => (page._id));
+    const paths = pagesData.map(page => (page.path));
+    const socketClientId = options.socketClientId || null;
+
+    logger.debug('Deleting completely', paths);
+
+    await this.deleteCompletely(ids, paths);
+
+    if (socketClientId != null) {
+      pageEvent.emit('deleteCompletely', pagesData, user, socketClientId); // update as renamed page
+    }
+    return;
+  }
+
+  // delete single page completely
+  async completelyDeleteSinglePage(pageData, user, options = {}) {
     this.validateCrowi();
     let pageEvent;
     // init event
@@ -109,17 +129,18 @@ class PageService {
       pageEvent.on('update', pageEvent.onUpdate);
     }
 
-    const { _id, path } = pageData;
+    const ids = [pageData._id];
+    const paths = [pageData.path];
     const socketClientId = options.socketClientId || null;
 
-    logger.debug('Deleting completely', path);
+    logger.debug('Deleting completely', paths);
 
-    await this.deleteCompletely(_id, path);
+    await this.deleteCompletely(ids, paths);
 
     if (socketClientId != null) {
       pageEvent.emit('delete', pageData, user, socketClientId); // update as renamed page
     }
-    return pageData;
+    return;
   }
 
   /**
@@ -132,13 +153,52 @@ class PageService {
     // find manageable descendants (this array does not include GRANT_RESTRICTED)
     const pages = await Page.findManageableListWithDescendants(targetPage, user, findOpts);
 
+    // TODO streaming bellow action
+    return this.completelyDeletePages(pages, user, options);
+  }
+
+  async revertDeletedPageRecursively(targetPage, user, options = {}) {
+    const Page = this.crowi.model('Page');
+    const findOpts = { includeTrashed: true };
+    const pages = await Page.findManageableListWithDescendants(targetPage, user, findOpts);
+
+    let updatedPage = null;
     await Promise.all(pages.map((page) => {
-      return this.completelyDeletePage(page, user, options);
+      const isParent = (page.path === targetPage.path);
+      const p = this.revertDeletedPages(page, user, options);
+      if (isParent) {
+        updatedPage = p;
+      }
+      return p;
     }));
+
+    return updatedPage;
   }
 
+  // revert pages recursively
+  async revertDeletedPages(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.');
+      }
+      // originPage is object.
+      await this.completelyDeletePages([originPage], options);
+    }
 
-  async revertDeletedPage(page, user, 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 revertSingleDeletedPage(page, user, options = {}) {
     const Page = this.crowi.model('Page');
     const newPath = Page.getRevertDeletedPageName(page.path);
     const originPage = await Page.findByPath(newPath);
@@ -149,7 +209,7 @@ class PageService {
       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);
+      await this.completelyDeleteSinglePage(originPage, options);
     }
 
     page.status = STATUS_PUBLISHED;
@@ -170,10 +230,7 @@ class PageService {
         }));
         break;
       case 'delete':
-        await Promise.all(pages.map((page) => {
-          return this.completelyDeletePage(page);
-        }));
-        break;
+        return this.completelyDeletePages(pages);
       case 'transfer':
         await Promise.all(pages.map((page) => {
           return Page.transferPageToGroup(page, transferToUserGroupId);

+ 28 - 7
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) {
@@ -363,7 +363,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');
@@ -472,6 +474,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) {
@@ -498,11 +510,7 @@ class ElasticsearchDelegator {
   deletePages(pages) {
     const self = this;
     const body = [];
-
-    pages.map((page) => {
-      self.prepareBodyForDelete(body, page);
-      return;
-    });
+    pages.forEach(page => self.prepareBodyForDelete(body, page));
 
     logger.debug('deletePages(): Sending Request to ES', body);
     return this.client.bulk({
@@ -984,6 +992,19 @@ class ElasticsearchDelegator {
     return this.updateOrInsertDescendantsPagesById(page, user);
   }
 
+  async syncPagesDeletedCompletely(pages, user) {
+    for (let i = 0; i < pages.length; i++) {
+      logger.debug('SearchClient.syncPageDeleted', pages[i].path);
+    }
+
+    try {
+      return await this.deletePages(pages);
+    }
+    catch (err) {
+      logger.error('deletePages:ES Error', err);
+    }
+  }
+
   async syncPageDeleted(page, user) {
     logger.debug('SearchClient.syncPageDeleted', page.path);
 

+ 1 - 0
src/server/service/search.js

@@ -58,6 +58,7 @@ class SearchService {
     const pageEvent = this.crowi.event('page');
     pageEvent.on('create', this.delegator.syncPageUpdated.bind(this.delegator));
     pageEvent.on('update', this.delegator.syncPageUpdated.bind(this.delegator));
+    pageEvent.on('deleteCompletely', this.delegator.syncPagesDeletedCompletely.bind(this.delegator));
     pageEvent.on('delete', this.delegator.syncPageDeleted.bind(this.delegator));
     pageEvent.on('createMany', this.delegator.syncDescendantsPagesUpdated.bind(this.delegator));