Răsfoiți Sursa

Resolved conflict

Taichi Masuyama 4 ani în urmă
părinte
comite
44370798d8

+ 2 - 1
packages/app/resource/locales/en_US/translation.json

@@ -155,7 +155,8 @@
   "Sign out": "Logout",
   "Disassociate": "Disassociate",
   "No bookmarks yet": "No bookmarks yet",
-  "Add to bookmark": "Add to bookmark",
+  "add_bookmark": "Add to Bookmarks",
+  "remove_bookmark": "Remove from Bookmarks",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",

+ 2 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -157,7 +157,8 @@
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode on Editor": "サイドバーモード(編集時)",
   "No bookmarks yet": "No bookmarks yet",
-  "Add to bookmark": "ブックマークに追加",
+  "add_bookmark": "ブックマークに追加",
+  "remove_bookmark": "ブックマークから削除",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",

+ 2 - 1
packages/app/resource/locales/zh_CN/translation.json

@@ -163,7 +163,8 @@
 	"Sign out": "退出",
   "Disassociate": "解除关联",
   "No bookmarks yet": "暂无书签",
-  "Add to bookmark": "添加到书签",
+  "add_bookmark": "添加到书签",
+  "remove_bookmark": "从书签中删除",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",

+ 37 - 6
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -1,12 +1,15 @@
-import React, { FC } from 'react';
+import React, { FC, useState } from 'react';
 import {
-  UncontrolledDropdown, DropdownMenu, DropdownToggle, DropdownItem,
+  Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
 
 import toastr from 'toastr';
 import { useTranslation } from 'react-i18next';
 
 import { IPageHasId } from '~/interfaces/page';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError } from '~/client/util/apiNotification';
+import { useSWRBookmarkInfo } from '~/stores/bookmark';
 
 type PageItemControlProps = {
   page: Partial<IPageHasId>
@@ -21,14 +24,42 @@ const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps)
     page, isEnableActions, onClickDeleteButton, isDeletable,
   } = props;
   const { t } = useTranslation('');
+  const [isOpen, setIsOpen] = useState(false);
+  const { data: bookmarkInfo, error: bookmarkInfoError, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(page._id, isOpen);
 
   const deleteButtonHandler = () => {
     if (onClickDeleteButton != null && page._id != null) {
       onClickDeleteButton(page._id);
     }
   };
+
+
+  const bookmarkToggleHandler = (async() => {
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      await apiv3Put('/bookmarks', { pageId: page._id, bool: !bookmarkInfo!.isBookmarked });
+      mutateBookmarkInfo();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  });
+
+  const renderBookmarkText = () => {
+    if (bookmarkInfoError != null || bookmarkInfo == null) {
+      return '';
+    }
+    return bookmarkInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark');
+  };
+
+
+  const dropdownToggle = () => {
+    setIsOpen(!isOpen);
+  };
+
+
   return (
-    <UncontrolledDropdown>
+    <Dropdown isOpen={isOpen} toggle={dropdownToggle}>
       <DropdownToggle color="transparent" className="btn-link border-0 rounded grw-btn-page-management p-0">
         <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
       </DropdownToggle>
@@ -62,9 +93,9 @@ const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps)
           </DropdownItem>
         )}
         {isEnableActions && (
-          <DropdownItem onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+          <DropdownItem onClick={bookmarkToggleHandler}>
             <i className="fa fa-fw fa-bookmark-o"></i>
-            {t('Add to bookmark')}
+            {renderBookmarkText()}
           </DropdownItem>
         )}
         {isEnableActions && (
@@ -91,7 +122,7 @@ const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps)
       </DropdownMenu>
 
 
-    </UncontrolledDropdown>
+    </Dropdown>
   );
 
 };

+ 1 - 1
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -34,7 +34,7 @@ const SubNavButtons: FC<SubNavButtonsProps> = (props: SubNavButtonsProps) => {
 
   const { data: pageInfo, error: pageInfoError, mutate: mutatePageInfo } = useSWRPageInfo(pageId);
   const { data: likers } = useSWRxLikerList(pageInfo?.likerIds);
-  const { data: bookmarkInfo, error: bookmarkInfoError, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
+  const { data: bookmarkInfo, error: bookmarkInfoError, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId, true);
 
   const likeClickhandler = useCallback(async() => {
     if (isGuestUser == null || isGuestUser) {

+ 21 - 19
packages/app/src/components/SearchPage/SearchResultListItem.tsx → packages/app/src/components/Page/PageListItem.tsx

@@ -13,19 +13,21 @@ const { isTopPage } = pagePathUtils;
 
 type Props = {
   page: IPageSearchResultData,
-  isSelected: boolean,
-  isChecked: boolean,
+  isSelected: boolean, // is item selected(focused)
+  isChecked: boolean, // is checkbox of item checked
   isEnableActions: boolean,
   shortBody?: string
+  showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onClickCheckbox?: (pageId: string) => void,
   onClickSearchResultItem?: (pageId: string) => void,
   onClickDeleteButton?: (pageId: string) => void,
 }
 
-const SearchResultListItem: FC<Props> = memo((props:Props) => {
+const PageListItem: FC<Props> = memo((props:Props) => {
   const {
     // todo: refactoring variable name to clear what changed
     page: { pageData, pageMeta }, isSelected, onClickSearchResultItem, onClickCheckbox, isChecked, isEnableActions, shortBody,
+    showPageUpdatedTime,
   } = props;
 
   const { data: isDeviceSmallerThanLg } = useIsDeviceSmallerThanLg();
@@ -60,6 +62,7 @@ const SearchResultListItem: FC<Props> = memo((props:Props) => {
     }
   }, [isDeviceSmallerThanLg, onClickSearchResultItem, pageData._id]);
 
+  // background color of list item changes when class "active" exists under 'grw-search-result-item'
   const responsiveListStyleClass = `${isDeviceSmallerThanLg ? '' : `list-group-item-action ${isSelected ? 'active' : ''}`}`;
   return (
     <li
@@ -72,26 +75,25 @@ const SearchResultListItem: FC<Props> = memo((props:Props) => {
       >
         <div className="d-flex h-100">
           {/* checkbox */}
-          <div className="form-check d-flex align-items-center justify-content-center px-md-2 pl-3 pr-2 search-item-checkbox">
-            <input
-              className="form-check-input position-relative m-0"
-              type="checkbox"
-              id="flexCheckDefault"
-              onChange={() => {
-                if (onClickCheckbox != null) {
-                  onClickCheckbox(pageData._id);
-                }
-              }}
-              checked={isChecked}
-            />
-          </div>
+          {onClickCheckbox != null && (
+            <div className="form-check d-flex align-items-center justify-content-center px-md-2 pl-3 pr-2 search-item-checkbox">
+              <input
+                className="form-check-input position-relative m-0"
+                type="checkbox"
+                id="flexCheckDefault"
+                onChange={() => { onClickCheckbox(pageData._id) }}
+                checked={isChecked}
+              />
+            </div>
+          )}
           <div className="search-item-text p-md-3 pl-2 py-3 pr-3 flex-grow-1">
             {/* page path */}
-            <h6 className="mb-1 py-1">
-              <a href={pagePath.isRoot ? pagePath.latter : pagePath.former}>
+            <h6 className="mb-1 py-1 d-flex">
+              <a className="d-inline-block" href={pagePath.isRoot ? pagePath.latter : pagePath.former}>
                 <i className="icon-fw icon-home"></i>
                 {pagePathElem}
               </a>
+              {showPageUpdatedTime && (<p className="ml-auto mb-0 mr-4 list-item-updated-time">Updated: 0000/00/00 00:00:00</p>)}
             </h6>
             <div className="d-flex align-items-center mb-2">
               {/* Picture */}
@@ -138,4 +140,4 @@ const SearchResultListItem: FC<Props> = memo((props:Props) => {
   );
 });
 
-export default SearchResultListItem;
+export default PageListItem;

+ 1 - 1
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -135,7 +135,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
     await interceptorManager.process('prePostProcess', context);
     context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
 
-    if (this.props.highlightKeywords != null) {
+    if (highlightKeywords != null) {
       context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
     }
     await interceptorManager.process('postPostProcess', context);

+ 10 - 0
packages/app/src/components/SearchPage.jsx

@@ -4,6 +4,11 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import {
+  DetachCodeBlockInterceptor,
+  RestoreCodeBlockInterceptor,
+} from '../client/util/interceptor/detach-code-blocks';
+
 import { withUnstatedContainers } from './UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
@@ -47,6 +52,11 @@ class SearchPage extends React.Component {
       deleteTargetPageIds: new Set(),
     };
 
+    // TODO: Move this code to the right place after completing the "omit unstated" initiative.
+    const { interceptorManager } = props.appContainer;
+    interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(props.appContainer), 10); // process as soon as possible
+    interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(props.appContainer), 900); // process as late as possible
+
     this.changeURL = this.changeURL.bind(this);
     this.search = this.search.bind(this);
     this.onSearchInvoked = this.onSearchInvoked.bind(this);

+ 2 - 2
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -1,5 +1,5 @@
 import React, { FC } from 'react';
-import SearchResultListItem from './SearchResultListItem';
+import PageListItem from '../Page/PageListItem';
 import PaginationWrapper from '../PaginationWrapper';
 import { IPageSearchResultData } from '../../interfaces/search';
 
@@ -32,7 +32,7 @@ const SearchResultList: FC<Props> = (props:Props) => {
         const isChecked = selectedPagesIdList.has(page.pageData._id);
 
         return (
-          <SearchResultListItem
+          <PageListItem
             key={page.pageData._id}
             page={page}
             isEnableActions={isEnableActions}

+ 92 - 14
packages/app/src/server/models/page.ts

@@ -13,7 +13,7 @@ import Crowi from '../crowi';
 import { IPage } from '../../interfaces/page';
 import { getPageSchema, PageQueryBuilder } from './obsolete-page';
 
-const { isTopPage } = pagePathUtils;
+const { isTopPage, collectAncestorPaths } = pagePathUtils;
 
 const logger = loggerFactory('growi:models:page');
 
@@ -96,19 +96,6 @@ const schema = new Schema<PageDocument, PageModel>({
 schema.plugin(mongoosePaginate);
 schema.plugin(uniqueValidator);
 
-
-/*
- * Methods
- */
-const collectAncestorPaths = (path: string, ancestorPaths: string[] = []): string[] => {
-  if (isTopPage(path)) return ancestorPaths;
-
-  const parentPath = nodePath.dirname(path);
-  ancestorPaths.push(parentPath);
-  return collectAncestorPaths(parentPath, ancestorPaths);
-};
-
-
 const hasSlash = (str: string): boolean => {
   return str.includes('/');
 };
@@ -354,6 +341,97 @@ async function pushRevision(pageData, newRevision, user) {
   return pageData.save();
 }
 
+/**
+ * return aggregate condition to get following pages
+ * - page that has the same path as the provided path
+ * - pages that are descendants of the above page
+ * pages without parent will be ignored
+ */
+schema.statics.getAggrConditionForPageWithProvidedPathAndDescendants = function(path:string) {
+  let match;
+  if (isTopPage(path)) {
+    match = {
+      // https://regex101.com/r/Kip2rV/1
+      $match: { $or: [{ path: { $regex: '^/.*' }, parent: { $ne: null } }, { path: '/' }] },
+    };
+  }
+  else {
+    match = {
+      // https://regex101.com/r/mJvGrG/1
+      $match: { path: { $regex: `^${path}(/.*|$)` }, parent: { $ne: null } },
+    };
+  }
+  return [
+    match,
+    {
+      $project: {
+        path: 1,
+        parent: 1,
+        field_length: { $strLenCP: '$path' },
+      },
+    },
+    { $sort: { field_length: -1 } },
+    { $project: { field_length: 0 } },
+  ];
+};
+
+/**
+ * add/subtract descendantCount of pages with provided paths by increment.
+ * increment can be negative number
+ */
+schema.statics.incrementDescendantCountOfPaths = async function(paths:string[], increment: number):Promise<void> {
+  const pages = await this.aggregate([{ $match: { path: { $in: paths } } }]);
+  const operations = pages.map((page) => {
+    return {
+      updateOne: {
+        filter: { path: page.path },
+        update: { descendantCount: page.descendantCount + increment },
+      },
+    };
+  });
+  await this.bulkWrite(operations);
+};
+
+// update descendantCount of a page with provided id
+schema.statics.recountDescendantCountOfSelfAndDescendants = async function(id:mongoose.Types.ObjectId):Promise<void> {
+  const res = await this.aggregate(
+    [
+      {
+        $match: {
+          parent: id,
+        },
+      },
+      {
+        $project: {
+          path: 1,
+          parent: 1,
+          descendantCount: 1,
+        },
+      },
+      {
+        $group: {
+          _id: '$parent',
+          sumOfDescendantCount: {
+            $sum: '$descendantCount',
+          },
+          sumOfDocsCount: {
+            $sum: 1,
+          },
+        },
+      },
+      {
+        $set: {
+          descendantCount: {
+            $sum: ['$sumOfDescendantCount', '$sumOfDocsCount'],
+          },
+        },
+      },
+    ],
+  );
+
+  const query = { descendantCount: res.length === 0 ? 0 : res[0].descendantCount };
+  await this.findByIdAndUpdate(id, query);
+};
 
 /*
  * Merge obsolete page model methods and define new methods which depend on crowi instance

+ 52 - 1
packages/app/src/server/service/page.js

@@ -17,7 +17,9 @@ const debug = require('debug')('growi:services:page');
 const { Writable } = require('stream');
 const { createBatchStream } = require('~/server/util/batch-stream');
 
-const { isCreatablePage, isDeletablePage, isTrashPage } = pagePathUtils;
+const {
+  isCreatablePage, isDeletablePage, isTrashPage, collectAncestorPaths,
+} = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 
 const BULK_REINDEX_SIZE = 100;
@@ -1035,6 +1037,16 @@ class PageService {
       throw err;
     }
 
+    // update descendantCount of all public pages
+    try {
+      await this.updateDescendantCountOfSelfAndDescendants('/');
+      logger.info('Successfully updated all descendantCount of public pages.');
+    }
+    catch (err) {
+      logger.error('Failed updating descendantCount of public pages.', err);
+      throw err;
+    }
+
     await this._setIsV5CompatibleTrue();
   }
 
@@ -1248,6 +1260,45 @@ class PageService {
     return Page.count({ parent: null, creator: user, grant: { $ne: Page.GRANT_PUBLIC } });
   }
 
+  /**
+   * update descendantCount of the following pages
+   * - page that has the same path as the provided path
+   * - pages that are descendants of the above page
+   */
+  async updateDescendantCountOfSelfAndDescendants(path = '/') {
+    const BATCH_SIZE = 200;
+    const Page = this.crowi.model('Page');
+
+    const aggregateCondition = Page.getAggrConditionForPageWithProvidedPathAndDescendants(path);
+    const aggregatedPages = await Page.aggregate(aggregateCondition).cursor({ batchSize: BATCH_SIZE });
+
+    const recountWriteStream = new Writable({
+      objectMode: true,
+      async write(pageDocuments, encoding, callback) {
+        for (const document of pageDocuments) {
+          // eslint-disable-next-line no-await-in-loop
+          await Page.recountDescendantCountOfSelfAndDescendants(document._id);
+        }
+        callback();
+      },
+      final(callback) {
+        callback();
+      },
+    });
+    aggregatedPages
+      .pipe(createBatchStream(BATCH_SIZE))
+      .pipe(recountWriteStream);
+
+    await streamToPromise(recountWriteStream);
+  }
+
+  // update descendantCount of all pages that are ancestors of a provided path by count
+  async updateDescendantCountOfAncestors(path = '/', count = 0) {
+    const Page = this.crowi.model('Page');
+    const ancestors = collectAncestorPaths(path);
+    await Page.incrementDescendantCountOfPaths(ancestors, count);
+  }
+
 }
 
 module.exports = PageService;

+ 11 - 9
packages/app/src/stores/bookmark.ts

@@ -3,13 +3,15 @@ import { apiv3Get } from '../client/util/apiv3-client';
 import { IBookmarkInfo } from '../interfaces/bookmark-info';
 
 
-export const useSWRBookmarkInfo = (pageId: string | null): SWRResponse<IBookmarkInfo, Error> => {
-  return useSWR(pageId != null
-    ? `/bookmarks/info?pageId=${pageId}` : null,
-  endpoint => apiv3Get(endpoint).then((response) => {
-    return {
-      sumOfBookmarks: response.data.sumOfBookmarks,
-      isBookmarked: response.data.isBookmarked,
-    };
-  }));
+export const useSWRBookmarkInfo = (pageId: string | null | undefined, isOpen = false): SWRResponse<IBookmarkInfo, Error> => {
+  return useSWR(
+    pageId != null && isOpen
+      ? `/bookmarks/info?pageId=${pageId}` : null,
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return {
+        sumOfBookmarks: response.data.sumOfBookmarks,
+        isBookmarked: response.data.isBookmarked,
+      };
+    }),
+  );
 };

+ 16 - 0
packages/core/src/utils/page-path-utils.ts

@@ -1,3 +1,5 @@
+import nodePath from 'path';
+
 import escapeStringRegexp from 'escape-string-regexp';
 
 /**
@@ -132,3 +134,17 @@ export const generateEditorPath = (...paths: string[]): string => {
     throw new Error('Invalid path format');
   }
 };
+
+/**
+ * returns ancestors paths
+ * @param {string} path
+ * @param {string[]} ancestorPaths
+ * @returns {string[]}
+ */
+export const collectAncestorPaths = (path: string, ancestorPaths: string[] = []): string[] => {
+  if (isTopPage(path)) return ancestorPaths;
+
+  const parentPath = nodePath.dirname(path);
+  ancestorPaths.push(parentPath);
+  return collectAncestorPaths(parentPath, ancestorPaths);
+};