2
0
Эх сурвалжийг харах

Merge branch 'master' into fix-gw7957-fix-put-back-page-on-bookmark-sidebar

Mudana-Grune 2 жил өмнө
parent
commit
a67daecea5
53 өөрчлөгдсөн 1119 нэмэгдсэн , 441 устгасан
  1. 5 0
      .eslintrc.js
  2. 4 0
      .github/dependabot.yml
  3. 16 16
      .github/release-drafter.yml
  4. 4 3
      .github/workflows/auto-approve.yml
  5. 2 2
      .github/workflows/pr-to-master.yml
  6. 1 1
      .github/workflows/release-slackbot-proxy.yml
  7. 2 2
      .github/workflows/release.yml
  8. 1 2
      .mergify.yml
  9. 12 1
      CHANGELOG.md
  10. 14 12
      apps/app/package.json
  11. 1 1
      apps/app/public/static/locales/en_US/translation.json
  12. 1 1
      apps/app/public/static/locales/ja_JP/translation.json
  13. 1 1
      apps/app/public/static/locales/zh_CN/translation.json
  14. 4 2
      apps/app/src/client/util/bookmark-utils.ts
  15. 47 41
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  16. 3 0
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  17. 31 28
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  18. 1 1
      apps/app/src/components/PageList/PageList.tsx
  19. 2 2
      apps/app/src/components/PageRenameModal.tsx
  20. 21 31
      apps/app/src/components/PageTimeline.tsx
  21. 1 1
      apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx
  22. 5 1
      apps/app/src/components/UsersHomePageFooter.tsx
  23. 10 13
      apps/app/src/interfaces/bookmark-info.ts
  24. 12 0
      apps/app/src/server/models/.eslintrc.js
  25. 21 71
      apps/app/src/server/models/bookmark-folder.ts
  26. 27 0
      apps/app/src/server/models/eslint-rules-dir/no-populate.js
  27. 25 0
      apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts
  28. 25 0
      apps/app/src/server/models/serializers/bookmark-serializer.js
  29. 59 4
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  30. 17 7
      apps/app/src/server/routes/apiv3/bookmarks.js
  31. 54 0
      apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.spec.ts
  32. 6 7
      apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.ts
  33. 64 0
      apps/app/src/services/renderer/rehype-plugins/relative-links.spec.ts
  34. 12 9
      apps/app/src/services/renderer/rehype-plugins/relative-links.ts
  35. 45 0
      apps/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.spec.ts
  36. 27 0
      apps/app/src/stores/page-timeline.tsx
  37. 0 92
      apps/app/test/unit/services/renderer/pukiwiki-like-linker.test.ts
  38. 7 0
      apps/app/vitest.config.unit.ts
  39. 2 2
      apps/slackbot-proxy/package.json
  40. 9 5
      package.json
  41. 1 1
      packages/core/package.json
  42. 1 1
      packages/hackmd/package.json
  43. 2 2
      packages/presentation/package.json
  44. 1 1
      packages/preset-themes/package.json
  45. 4 4
      packages/remark-attachment-refs/package.json
  46. 2 0
      packages/remark-attachment-refs/vite.client.config.ts
  47. 1 1
      packages/remark-drawio/package.json
  48. 1 1
      packages/remark-growi-directive/package.json
  49. 4 4
      packages/remark-lsx/package.json
  50. 1 1
      packages/slack/package.json
  51. 2 2
      packages/ui/package.json
  52. 4 0
      vitest.workspace.ts
  53. 494 64
      yarn.lock

+ 5 - 0
.eslintrc.js

@@ -20,6 +20,11 @@ module.exports = {
       'warn',
       {
         pathGroups: [
+          {
+            pattern: 'vitest',
+            group: 'builtin',
+            position: 'before',
+          },
           {
             pattern: 'react',
             group: 'builtin',

+ 4 - 0
.github/dependabot.yml

@@ -5,6 +5,8 @@ updates:
     open-pull-requests-limit: 3
     schedule:
       interval: monthly
+    labels:
+      - "type/dependencies"
     commit-message:
       prefix: ci
       include: scope
@@ -14,6 +16,8 @@ updates:
     open-pull-requests-limit: 3
     schedule:
       interval: weekly
+    labels:
+      - "type/dependencies"
     commit-message:
       prefix: ci
       include: scope

+ 16 - 16
.github/release-drafter.yml

@@ -1,35 +1,35 @@
 categories:
   - title: 'BREAKING CHANGES'
     labels:
-      - 'breaking'
+      - 'type/reaking'
   - title: '💎 Features'
     labels:
-      - 'feature'
+      - 'type/feature'
   - title: '🚀 Improvement'
     labels:
-      - 'improvement'
+      - 'type/improvement'
   - title: '🐛 Bug Fixes'
     labels:
-      - 'bug'
+      - 'type/bug'
   - title: '🧰 Maintenance'
     labels:
-      - 'support'
-      - 'dependencies'
+      - 'type/support'
+      - 'type/dependencies'
 category-template: '### $TITLE'
 change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
 autolabeler:
   - label: 'feature'
     branch:
       - '/^feat\/.+/'
-  - label: 'improvement'
+  - label: 'type/improvement'
     branch:
       - '/^imprv\/.+/'
-  - label: 'bug'
+  - label: 'type/bug'
     branch:
       - '/^fix\/.+/'
     title:
       - '/^fix/i'
-  - label: 'support'
+  - label: 'type/support'
     branch:
       - '/^support\/.+/'
     title:
@@ -39,13 +39,13 @@ autolabeler:
       - '/^docs/i'
       - '/^test/i'
 include-labels:
-  - breaking
-  - feature
-  - improvement
-  - bug
-  - support
-  - dependencies
+  - type/breaking
+  - type/feature
+  - type/improvement
+  - type/bug
+  - type/support
+  - type/dependencies
 exclude-labels:
-  - 'exclude from changelog'
+  - 'flag/exclude-from-changelog'
 template: |
   $CHANGES

+ 4 - 3
.github/workflows/dependabot-auto-approve.yml → .github/workflows/auto-approve.yml

@@ -1,5 +1,4 @@
-# by https://zenn.dev/nemuki/articles/dependabot-auto-merge
-name: Auto approve on dependabot PR at patch update
+name: Auto approve PR
 
 on:
   pull_request_target:
@@ -9,7 +8,9 @@ permissions:
   pull-requests: write
 
 jobs:
-  dependabot-auto-approve:
+  # Auto approve on dependabot PR at patch update
+  #   by https://zenn.dev/nemuki/articles/dependabot-auto-merge
+  approve-updating-patch-version:
     runs-on: ubuntu-latest
     if: ${{ github.actor == 'dependabot[bot]' }}
     steps:

+ 2 - 2
.github/workflows/pr-to-master.yml

@@ -19,7 +19,7 @@ jobs:
     runs-on: ubuntu-latest
 
     if: |
-      !contains(github.event.pull_request.labels.*.name, 'exclude from changelog')
+      !contains(github.event.pull_request.labels.*.name, 'flag/exclude-from-changelog')
 
     steps:
       - uses: release-drafter/release-drafter@v5
@@ -32,7 +32,7 @@ jobs:
     runs-on: ubuntu-latest
 
     if: |
-      (!contains( github.event.pull_request.labels.*.name, 'exclude from changelog' ) &&
+      (!contains( github.event.pull_request.labels.*.name, 'flag/exclude-from-changelog' ) &&
         !startsWith( github.head_ref, 'dependabot/' ))
 
     steps:

+ 1 - 1
.github/workflows/release-slackbot-proxy.yml

@@ -135,6 +135,6 @@ jobs:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
-        pr_label: exclude from changelog
+        pr_label: flag/exclude-from-changelog
         pr_body: "An automated PR generated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
         github_token: ${{ secrets.GITHUB_TOKEN }}

+ 2 - 2
.github/workflows/release.yml

@@ -118,8 +118,8 @@ jobs:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
-        pr_label: exclude from changelog,prepare next version
-        pr_body: "An automated PR generated by create-pr-for-next-rc"
+        pr_label: flag/exclude-from-changelog,type/prepare-next-version
+        pr_body: "[skip ci] An automated PR generated by create-pr-for-next-rc"
         github_token: ${{ secrets.GITHUB_TOKEN }}
 
 

+ 1 - 2
.mergify.yml

@@ -15,8 +15,7 @@ pull_request_rules:
   - name: Automatic merge for Preparing next version
     conditions:
       - author = github-actions[bot]
-      - '#approved-reviews-by >= 1'
-      - label = "prepare next version"
+      - label = "type/prepare-next-version"
     actions:
       merge:
         method: merge

+ 12 - 1
CHANGELOG.md

@@ -1,9 +1,20 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.1.0...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.1.1...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v6.1.1](https://github.com/weseek/growi/compare/v6.1.0...v6.1.1) - 2023-05-24
+
+### 🐛 Bug Fixes
+
+- fix: Bookmark folders owned by others are accessible for manipulation (#7688) @miya
+- fix: remark-attachment-refs does not work in production (#7681) @yuki-takei
+- fix: User picture of bookmark not showing inside bookmark folder (#7678) @mudana-grune
+- fix: Update name attribute of PageRenameModal.tsx (#7677) @jam411
+- fix: The user's bookmarks are displayed on unrelated user's home (#7668) @miya
+- fix: The user's bookmarks are updated by unrelated user's operation (#7670) @jam411
+
 ## [v6.1.0](https://github.com/weseek/growi/compare/v6.0.15...v6.1.0) - 2023-05-17
 
 ### BREAKING CHANGES

+ 14 - 12
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.1.1-RC.0",
+  "version": "6.1.2-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -33,7 +33,9 @@
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint": "run-p lint:*",
     "prelint:swagger2openapi": "yarn openapi:v3",
-    "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
+    "test": "run-p test:*",
+    "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
+    "test:vitest": "cross-env NODE_ENV=test vitest run src",
     "jest:run": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
     "//// misc": "",
@@ -59,14 +61,14 @@
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/core": "^6.1.1-RC.0",
-    "@growi/hackmd": "^6.1.1-RC.0",
-    "@growi/preset-themes": "^6.1.1-RC.0",
-    "@growi/remark-attachment-refs": "^6.1.1-RC.0",
-    "@growi/remark-drawio": "^6.1.1-RC.0",
-    "@growi/remark-growi-directive": "^6.1.1-RC.0",
-    "@growi/remark-lsx": "^6.1.1-RC.0",
-    "@growi/slack": "^6.1.1-RC.0",
+    "@growi/core": "^6.1.2-RC.0",
+    "@growi/hackmd": "^6.1.2-RC.0",
+    "@growi/preset-themes": "^6.1.2-RC.0",
+    "@growi/remark-attachment-refs": "^6.1.2-RC.0",
+    "@growi/remark-drawio": "^6.1.2-RC.0",
+    "@growi/remark-growi-directive": "^6.1.2-RC.0",
+    "@growi/remark-lsx": "^6.1.2-RC.0",
+    "@growi/slack": "^6.1.2-RC.0",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
@@ -202,8 +204,8 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@growi/presentation": "^6.1.1-RC.0",
-    "@growi/ui": "^6.1.1-RC.0",
+    "@growi/presentation": "^6.1.2-RC.0",
+    "@growi/ui": "^6.1.2-RC.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^13.2.3",

+ 1 - 1
apps/app/public/static/locales/en_US/translation.json

@@ -168,7 +168,7 @@
     "could_not_creata_path": "Couldn't create path."
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page."
+    "no_pages_under_this_page": "There are no pages under this page."
   },
   "installer": {
     "tab": "Create account",

+ 1 - 1
apps/app/public/static/locales/ja_JP/translation.json

@@ -169,7 +169,7 @@
     "could_not_creata_path": "パスを作成できませんでした。"
   },
   "custom_navigation": {
-    "no_page_list": "このページの配下にはページが存在しません。"
+    "no_pages_under_this_page": "このページの配下にはページが存在しません。"
   },
   "installer": {
     "tab": "アカウント作成",

+ 1 - 1
apps/app/public/static/locales/zh_CN/translation.json

@@ -175,7 +175,7 @@
     "could_not_creata_path": "无法创建路径"
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page."
+    "no_pages_under_this_page": "There are no pages under this page."
   },
 	"installer": {
     "tab": "创建账户",

+ 4 - 2
apps/app/src/client/util/bookmark-utils.ts

@@ -41,6 +41,8 @@ export const toggleBookmark = async(pageId: string, status: boolean): Promise<vo
 };
 
 // Update Bookmark folder
-export const updateBookmarkFolder = async(bookmarkFolderId: string, name: string, parent: string | null): Promise<void> => {
-  await apiv3Put('/bookmark-folder', { bookmarkFolderId, name, parent });
+export const updateBookmarkFolder = async(bookmarkFolderId: string, name: string, parent: string | null, children: BookmarkFolderItems[]): Promise<void> => {
+  await apiv3Put('/bookmark-folder', {
+    bookmarkFolderId, name, parent, children,
+  });
 };

+ 47 - 41
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -26,6 +26,7 @@ type BookmarkFolderItemProps = {
   isReadOnlyUser: boolean
   bookmarkFolder: BookmarkFolderItems
   isOpen?: boolean
+  isOperable: boolean,
   level: number
   root: string
   isUserHomePage?: boolean
@@ -38,7 +39,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const BASE_FOLDER_PADDING = 15;
   const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const {
-    isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, level, root, isUserHomePage,
+    isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomePage,
     onClickDeleteBookmarkHandler, bookmarkFolderTreeMutation, onPagePutBacked
   } = props;
 
@@ -57,25 +58,26 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
 
   const paddingLeft = BASE_FOLDER_PADDING * level;
 
-  const loadChildFolder = useCallback(async() => {
+  const loadChildFolder = useCallback(async () => {
     setIsOpen(!isOpen);
     setTargetFolder(folderId);
   }, [folderId, isOpen]);
 
   // Rename for bookmark folder handler
-  const onPressEnterHandlerForRename = useCallback(async(folderName: string) => {
+  const onPressEnterHandlerForRename = useCallback(async (folderName: string) => {
     try {
-      await updateBookmarkFolder(folderId, folderName, parent);
+      // TODO: do not use any type
+      await updateBookmarkFolder(folderId, folderName, parent as any, children);
       bookmarkFolderTreeMutation();
       setIsRenameAction(false);
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolderTreeMutation, folderId, parent]);
+  }, [bookmarkFolderTreeMutation, children, folderId, parent]);
 
   // Create new folder / subfolder handler
-  const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
+  const onPressEnterHandlerForCreate = useCallback(async (folderName: string) => {
     try {
       await addNewFolder(folderName, targetFolder);
       setIsOpen(true);
@@ -87,7 +89,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     }
   }, [bookmarkFolderTreeMutation, targetFolder]);
 
-  const onClickPlusButton = useCallback(async(e) => {
+  const onClickPlusButton = useCallback(async (e) => {
     e.stopPropagation();
     if (!isOpen && childrenExists) {
       setIsOpen(true);
@@ -95,11 +97,11 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     setIsCreateAction(true);
   }, [childrenExists, isOpen]);
 
-  const itemDropHandler = async(item: DragItemDataType, dragItemType: string | symbol | null) => {
+  const itemDropHandler = async (item: DragItemDataType, dragItemType: string | symbol | null) => {
     if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
       try {
         if (item.bookmarkFolder != null) {
-          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id);
+          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id, item.bookmarkFolder.children);
           bookmarkFolderTreeMutation();
         }
       }
@@ -120,7 +122,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     }
   };
 
-  const isDropable = (item: DragItemDataType, type: string | null| symbol): boolean => {
+  const isDropable = (item: DragItemDataType, type: string | null | symbol): boolean => {
     if (type === DRAG_ITEM_TYPE.FOLDER) {
       if (item.bookmarkFolder.parent === bookmarkFolder._id || item.bookmarkFolder._id === bookmarkFolder._id) {
         return false;
@@ -150,6 +152,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
           <BookmarkFolderItem
             key={childFolder._id}
             isReadOnlyUser={isReadOnlyUser}
+            isOperable={props.isOperable}
             bookmarkFolder={childFolder}
             level={level + 1}
             root={root}
@@ -169,6 +172,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         <BookmarkItem
           key={bookmark._id}
           isReadOnlyUser={isReadOnlyUser}
+          isOperable={props.isOperable}
           bookmarkedPage={bookmark.page}
           level={level + 1}
           parentFolder={bookmarkFolder}
@@ -199,15 +203,15 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     openDeleteBookmarkFolderModal(bookmarkFolder, { onDeleted: bookmarkFolderDeleteHandler });
   }, [bookmarkFolder, bookmarkFolderTreeMutation, openDeleteBookmarkFolderModal]);
 
-  const onClickMoveToRootHandlerForBookmarkFolderItemControl = useCallback(async() => {
+  const onClickMoveToRootHandlerForBookmarkFolderItemControl = useCallback(async () => {
     try {
-      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null);
+      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null, bookmarkFolder.children);
       bookmarkFolderTreeMutation();
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolder._id, bookmarkFolder.name, bookmarkFolderTreeMutation]);
+  }, [bookmarkFolder._id, bookmarkFolder.children, bookmarkFolder.name, bookmarkFolderTreeMutation]);
 
   return (
     <div id={`grw-bookmark-folder-item-${folderId}`} className="grw-foldertree-item-container">
@@ -215,8 +219,8 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         key={folderId}
         type={acceptedTypes}
         item={props}
-        useDragMode={true}
-        useDropMode={true}
+        useDragMode={isOperable}
+        useDropMode={isOperable}
         onDropItem={itemDropHandler}
         isDropable={isDropable}
       >
@@ -256,33 +260,35 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
               </div>
             </>
           )}
-          <div className="grw-foldertree-control d-flex">
-            <BookmarkFolderItemControl
-              onClickRename={onClickRenameHandler}
-              onClickDelete={onClickDeleteHandler}
-              onClickMoveToRoot={bookmarkFolder.parent != null
-                ? onClickMoveToRootHandlerForBookmarkFolderItemControl
-                : undefined
-              }
-            >
-              <div onClick={e => e.stopPropagation()}>
-                <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
-                  <i className="icon-options fa fa-rotate-90 p-1"></i>
-                </DropdownToggle>
-              </div>
-            </BookmarkFolderItemControl>
-            {/* Maximum folder hierarchy of 2 levels */}
-            {!(bookmarkFolder.parent != null) && (
-              <button
-                id='create-bookmark-folder-button'
-                type="button"
-                className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
-                onClick={onClickPlusButton}
+          {isOperable && (
+            <div className="grw-foldertree-control d-flex">
+              <BookmarkFolderItemControl
+                onClickRename={onClickRenameHandler}
+                onClickDelete={onClickDeleteHandler}
+                onClickMoveToRoot={bookmarkFolder.parent != null
+                  ? onClickMoveToRootHandlerForBookmarkFolderItemControl
+                  : undefined
+                }
               >
-                <i className="icon-plus d-block p-0" />
-              </button>
-            )}
-          </div>
+                <div onClick={e => e.stopPropagation()}>
+                  <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
+                    <i className="icon-options fa fa-rotate-90 p-1"></i>
+                  </DropdownToggle>
+                </div>
+              </BookmarkFolderItemControl>
+              {/* Maximum folder hierarchy of 2 levels */}
+              {!(bookmarkFolder.parent != null) && (
+                <button
+                  id='create-bookmark-folder-button'
+                  type="button"
+                  className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
+                  onClick={onClickPlusButton}
+                >
+                  <i className="icon-plus d-block p-0" />
+                </button>
+              )}
+            </div>
+          )}
         </li>
       </DragAndDropWrapper>
       {isCreateAction && (

+ 3 - 0
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -29,6 +29,7 @@ import { useRouter } from 'next/router';
 type Props = {
   isUserHomePage?: boolean,
   userId?: string,
+  isOperable: boolean,
 }
 
 export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
@@ -131,6 +132,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
             <BookmarkFolderItem
               key={bookmarkFolder._id}
               isReadOnlyUser={!!isReadOnlyUser}
+              isOperable={props.isOperable}
               bookmarkFolder={bookmarkFolder}
               isOpen={false}
               level={0}
@@ -146,6 +148,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
             <BookmarkItem
               key={userBookmark._id}
               isReadOnlyUser={!!isReadOnlyUser}
+              isOperable={props.isOperable}
               bookmarkedPage={userBookmark}
               level={0}
               parentFolder={null}

+ 31 - 28
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -26,6 +26,7 @@ import { useRouter } from 'next/router';
 
 type Props = {
   isReadOnlyUser: boolean
+  isOperable: boolean,
   bookmarkedPage: IPageHasId,
   level: number,
   parentFolder: BookmarkFolderItems | null,
@@ -43,7 +44,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const router = useRouter();
 
   const {
-    isReadOnlyUser, bookmarkedPage, onClickDeleteBookmarkHandler,
+    isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteBookmarkHandler,
     parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation, onPagePutBacked
   } = props;
   const { open: openPutBackPageModal } = usePutBackPageModal();
@@ -60,7 +61,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     ...bookmarkedPage, parentFolder,
   };
 
-  const onClickMoveToRootHandler = useCallback(async() => {
+  const onClickMoveToRootHandler = useCallback(async () => {
     try {
       await addBookmarkToFolder(bookmarkedPage._id, null);
       bookmarkFolderTreeMutation();
@@ -70,7 +71,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     }
   }, [bookmarkFolderTreeMutation, bookmarkedPage._id]);
 
-  const bookmarkMenuItemClickHandler = useCallback(async() => {
+  const bookmarkMenuItemClickHandler = useCallback(async () => {
     await unbookmark(bookmarkedPage._id);
     bookmarkFolderTreeMutation();
   }, [bookmarkedPage._id, bookmarkFolderTreeMutation]);
@@ -79,7 +80,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     setRenameInputShown(true);
   }, []);
 
-  const pressEnterForRenameHandler = useCallback(async(inputText: string) => {
+  const pressEnterForRenameHandler = useCallback(async (inputText: string) => {
     const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPage.path ?? ''));
     const newPagePath = nodePath.resolve(parentPath, inputText);
     if (newPagePath === bookmarkedPage.path) {
@@ -98,7 +99,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     }
   }, [bookmarkedPage, bookmarkFolderTreeMutation]);
 
-  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
+  const deleteMenuItemClickHandler = useCallback(async (_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
     if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
       throw Error('_id and path must not be null.');
     }
@@ -115,26 +116,26 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     onClickDeleteBookmarkHandler(pageToDelete);
   }, [bookmarkedPage._id, bookmarkedPage.path, bookmarkedPage.revision, onClickDeleteBookmarkHandler]);
 
-  const putBackClickHandler = useCallback(async() => {
-      const { _id: pageId, path } = bookmarkedPage
-      const putBackedHandler = async() => {
-        try {
-          await unlink(path);
-          mutateAllPageInfo();
-          bookmarkFolderTreeMutation();
-          if(pageId === currentPage?._id){
-            router.push(`/${pageId}`);
-          }
-          toastSuccess(t('page_has_been_reverted', { path }))
+  const putBackClickHandler = useCallback(async () => {
+    const { _id: pageId, path } = bookmarkedPage
+    const putBackedHandler = async () => {
+      try {
+        await unlink(path);
+        mutateAllPageInfo();
+        bookmarkFolderTreeMutation();
+        if (pageId === currentPage?._id) {
+          router.push(`/${pageId}`);
         }
-        catch (err) {
-          toastError(err);
-        }
-        if(onPagePutBacked != null){
-          onPagePutBacked(path)
-        }
-      };
-      openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
+        toastSuccess(t('page_has_been_reverted', { path }))
+      }
+      catch (err) {
+        toastError(err);
+      }
+      if (onPagePutBacked != null) {
+        onPagePutBacked(path)
+      }
+    };
+    openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
 
   }, [bookmarkedPage, openPutBackPageModal, mutateAllPageInfo, bookmarkFolderTreeMutation, router]);
 
@@ -142,7 +143,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     <DragAndDropWrapper
       item={dragItem}
       type={[DRAG_ITEM_TYPE.BOOKMARK]}
-      useDragMode={true}
+      useDragMode={isOperable}
     >
       <li
         className="grw-bookmark-item-list list-group-item list-group-item-action border-0 py-0 mr-auto d-flex align-items-center"
@@ -150,7 +151,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         id={bookmarkItemId}
         style={{ paddingLeft }}
       >
-        { isRenameInputShown ? (
+        {isRenameInputShown ? (
           <ClosableTextInput
             value={nodePath.basename(bookmarkedPage.path ?? '')}
             placeholder={t('Input page name')}
@@ -158,7 +159,8 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             onPressEnter={pressEnterForRenameHandler}
             validationTarget={ValidationTarget.PAGE}
           />
-        ) : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle}/>}
+        ) : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle} />}
+
         <div className='grw-foldertree-control'>
           <PageItemControl
             pageId={bookmarkedPage._id}
@@ -171,7 +173,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickRevertMenuItem={putBackClickHandler}
             additionalMenuItemOnTopRenderer={canMoveToRoot
-              ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler}/>
+              ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler} />
               : undefined}
           >
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
@@ -179,6 +181,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             </DropdownToggle>
           </PageItemControl>
         </div>
+
         <UncontrolledTooltip
           modifiers={{ preventOverflow: { boundariesElement: 'window' } }}
           autohide={false}

+ 1 - 1
apps/app/src/components/PageList/PageList.tsx

@@ -51,7 +51,7 @@ const PageList = (props: Props<IPageInfoForEntity>): JSX.Element => {
   if (pageList.length === 0) {
     return (
       <div className="mt-2">
-        <p>{t('custom_navigation.no_page_list')}</p>
+        <p>{t('custom_navigation.no_pages_under_this_page')}</p>
       </div>
     );
   }

+ 2 - 2
apps/app/src/components/PageRenameModal.tsx

@@ -255,7 +255,7 @@ const PageRenameModal = (): JSX.Element => {
             <div className="custom-control custom-radio custom-radio-warning">
               <input
                 className="custom-control-input"
-                name="recursively"
+                name="withoutExistRecursively"
                 id="cbRenameThisPageOnly"
                 type="radio"
                 checked={!isRenameRecursively}
@@ -268,7 +268,7 @@ const PageRenameModal = (): JSX.Element => {
             <div className="custom-control custom-radio custom-radio-warning mt-1">
               <input
                 className="custom-control-input"
-                name="withoutExistRecursively"
+                name="recursively"
                 id="cbForceRenameRecursively"
                 type="radio"
                 checked={isRenameRecursively}

+ 21 - 31
apps/app/src/components/PageTimeline.tsx

@@ -1,15 +1,15 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 
-import { apiv3Get } from '~/client/util/apiv3-client';
 import { IPageHasId } from '~/interfaces/page';
 import { useCurrentPagePath } from '~/stores/page';
+import { useSWRINFxPageTimeline } from '~/stores/page-timeline';
 import { useTimelineOptions } from '~/stores/renderer';
 
+import InfiniteScroll from './InfiniteScroll';
 import { RevisionLoader } from './Page/RevisionLoader';
-import PaginationWrapper from './PaginationWrapper';
 
 import styles from './PageTimeline.module.scss';
 
@@ -42,48 +42,38 @@ const TimelineCard = ({ page }: TimelineCardProps): JSX.Element => {
   );
 };
 
-
 export const PageTimeline = (): JSX.Element => {
-  const [activePage, setActivePage] = useState(1);
-  const [totalPageItems, setTotalPageItems] = useState(0);
-  const [limit, setLimit] = useState(10);
-  const [pages, setPages] = useState<IPageHasId[] | null>(null);
 
-  const { data: currentPagePath } = useCurrentPagePath();
+  const PER_PAGE = 3;
   const { t } = useTranslation();
+  const { data: currentPagePath } = useCurrentPagePath();
 
-  const handlePage = useCallback(async(selectedPage: number) => {
-    if (currentPagePath == null) { return }
-    const res = await apiv3Get('/pages/list', { path: currentPagePath, page: selectedPage });
-    setTotalPageItems(res.data.totalCount);
-    setPages(res.data.pages);
-    setLimit(res.data.limit);
-    setActivePage(selectedPage);
-  }, [currentPagePath]);
+  const swrInfinitexPageTimeline = useSWRINFxPageTimeline(currentPagePath, PER_PAGE);
+  const { data } = swrInfinitexPageTimeline;
 
-  useEffect(() => {
-    handlePage(1);
-  }, [handlePage]);
+  const isEmpty = data?.[0]?.pages.length === 0;
+  const isReachingEnd = isEmpty || (data != null && data[data.length - 1]?.pages.length < PER_PAGE);
 
-  if (pages == null || pages.length === 0) {
+  if (data == null || isEmpty) {
     return (
       <div className="mt-2">
-        {/* eslint-disable-next-line react/no-danger */}
-        <p>{t('custom_navigation.no_page_list')}</p>
+        <p>{t('custom_navigation.no_pages_under_this_page')}</p>
       </div>
     );
   }
 
   return (
     <div>
-      { pages.map(page => <TimelineCard key={page._id} page={page} />) }
-      <PaginationWrapper
-        activePage={activePage}
-        changePage={handlePage}
-        totalItemsCount={totalPageItems}
-        pagingLimit={limit}
-        align="center"
-      />
+      <InfiniteScroll
+        swrInifiniteResponse={swrInfinitexPageTimeline}
+        isReachingEnd={isReachingEnd}
+      >
+        { data != null && data.flatMap(apiResult => apiResult.pages)
+          .map(page => (
+            <TimelineCard key={page._id} page={page} />
+          ))
+        }
+      </InfiniteScroll>
     </div>
   );
 };

+ 1 - 1
apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx

@@ -56,7 +56,7 @@ export const BookmarkContents = (): JSX.Element => {
           />
         </div>
       )}
-      <BookmarkFolderTree userId={currentUser?._id} />
+      <BookmarkFolderTree isOperable userId={currentUser?._id} />
     </>
   );
 };

+ 5 - 1
apps/app/src/components/UsersHomePageFooter.tsx

@@ -2,9 +2,11 @@ import React, { useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
+
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
 import { RecentCreated } from '~/components/RecentCreated/RecentCreated';
 import styles from '~/components/UsersHomePageFooter.module.scss';
+import { useCurrentUser } from '~/stores/context';
 
 import { BookmarkFolderTree } from './Bookmarks/BookmarkFolderTree';
 import { CompressIcon } from './Icons/CompressIcon';
@@ -18,6 +20,8 @@ export const UsersHomePageFooter = (props: UsersHomePageFooterProps): JSX.Elemen
   const { t } = useTranslation();
   const { creatorId } = props;
   const [isExpanded, setIsExpanded] = useState<boolean>(false);
+  const { data: currentUser } = useCurrentUser();
+  const isOperable = currentUser?._id === creatorId;
 
   return (
     <div className={`container-lg user-page-footer py-5 ${styles['user-page-footer']}`}>
@@ -39,7 +43,7 @@ export const UsersHomePageFooter = (props: UsersHomePageFooterProps): JSX.Elemen
         </h2>
         {/* TODO: In bookmark folders v1, the button to create a new folder does not exist. The button should be included in the bookmark component. */}
         <div className={`${isExpanded ? `${styles['grw-bookarks-contents-expanded']}` : `${styles['grw-bookarks-contents-compressed']}`}`}>
-          <BookmarkFolderTree isUserHomePage={true} userId={creatorId} />
+          <BookmarkFolderTree isUserHomePage={true} isOperable={isOperable} userId={creatorId} />
         </div>
       </div>
       <div className="grw-user-page-list-m mt-5 d-edit-none">

+ 10 - 13
apps/app/src/interfaces/bookmark-info.ts

@@ -3,13 +3,13 @@ import { Ref } from '@growi/core';
 import { IPageHasId } from '~/interfaces/page';
 import { IUser } from '~/interfaces/user';
 
-export type IBookmarkInfo = {
+export interface IBookmarkInfo {
   sumOfBookmarks: number;
   isBookmarked: boolean,
   bookmarkedUsers: IUser[]
-};
+}
 
-type BookmarkedPage = {
+export interface BookmarkedPage {
   _id: string,
   page: IPageHasId,
   user: Ref<IUser>,
@@ -24,12 +24,10 @@ export interface IBookmarkFolder {
   parent?: Ref<this>
 }
 
-export interface BookmarkFolderItems {
-  _id: string
-  name: string
-  parent: string
-  children: this[]
-  bookmarks: BookmarkedPage[]
+export interface BookmarkFolderItems extends IBookmarkFolder {
+  _id: string;
+  children: BookmarkFolderItems[];
+  bookmarks: BookmarkedPage[];
 }
 
 export const DRAG_ITEM_TYPE = {
@@ -37,15 +35,14 @@ export const DRAG_ITEM_TYPE = {
   BOOKMARK: 'BOOKMARK',
 } as const;
 
-type BookmarkDragItem = {
+interface BookmarkDragItem {
   bookmarkFolder: BookmarkFolderItems
   level: number
   root: string
 }
 
-export type DragItemDataType = BookmarkDragItem & {
+export interface DragItemDataType extends BookmarkDragItem, IPageHasId {
   parentFolder: BookmarkFolderItems | null
-} & IPageHasId
-
+}
 
 export type DragItemType = typeof DRAG_ITEM_TYPE[keyof typeof DRAG_ITEM_TYPE];

+ 12 - 0
apps/app/src/server/models/.eslintrc.js

@@ -0,0 +1,12 @@
+const rulesDirPlugin = require('eslint-plugin-rulesdir');
+
+rulesDirPlugin.RULES_DIR = 'src/server/models/eslint-rules-dir';
+
+module.exports = {
+  plugins: [
+    'rulesdir',
+  ],
+  rules: {
+    'rulesdir/no-populate': 'warn',
+  },
+};

+ 21 - 71
apps/app/src/server/models/bookmark-folder.ts

@@ -3,7 +3,7 @@ import monggoose, {
   Types, Document, Model, Schema,
 } from 'mongoose';
 
-import { IBookmarkFolder, BookmarkFolderItems, MyBookmarkList } from '~/interfaces/bookmark-info';
+import { BookmarkFolderItems, IBookmarkFolder } from '~/interfaces/bookmark-info';
 import { IPageHasId } from '~/interfaces/page';
 
 import loggerFactory from '../../utils/logger';
@@ -26,11 +26,9 @@ export interface BookmarkFolderDocument extends Document {
 
 export interface BookmarkFolderModel extends Model<BookmarkFolderDocument>{
   createByParameters(params: IBookmarkFolder): Promise<BookmarkFolderDocument>
-  findFolderAndChildren(user: Types.ObjectId | string, parentId?: Types.ObjectId | string): Promise<BookmarkFolderItems[]>
   deleteFolderAndChildren(bookmarkFolderId: Types.ObjectId | string): Promise<{deletedCount: number}>
-  updateBookmarkFolder(bookmarkFolderId: string, name: string, parent: string | null): Promise<BookmarkFolderDocument>
+  updateBookmarkFolder(bookmarkFolderId: string, name: string, parent: string | null, children: BookmarkFolderItems[]): Promise<BookmarkFolderDocument>
   insertOrUpdateBookmarkedPage(pageId: IPageHasId, userId: Types.ObjectId | string, folderId: string | null): Promise<BookmarkFolderDocument | null>
-  findUserRootBookmarksItem(userId: Types.ObjectId| string): Promise<MyBookmarkList>
   updateBookmark(pageId: Types.ObjectId | string, status: boolean, userId: Types.ObjectId| string): Promise<BookmarkFolderDocument | null>
 }
 
@@ -83,45 +81,6 @@ bookmarkFolderSchema.statics.createByParameters = async function(params: IBookma
   return bookmarkFolder;
 };
 
-bookmarkFolderSchema.statics.findFolderAndChildren = async function(
-    userId: Types.ObjectId | string,
-    parentId?: Types.ObjectId | string,
-): Promise<BookmarkFolderItems[]> {
-  const folderItems: BookmarkFolderItems[] = [];
-
-  const folders = await this.find({ owner: userId, parent: parentId })
-    .populate('children')
-    .populate({
-      path: 'bookmarks',
-      model: 'Bookmark',
-      populate: {
-        path: 'page',
-        model: 'Page',
-      },
-    });
-
-  const promises = folders.map(async(folder) => {
-    const children = await this.findFolderAndChildren(userId, folder._id);
-    const {
-      _id, name, owner, bookmarks, parent,
-    } = folder;
-
-    const res = {
-      _id: _id.toString(),
-      name,
-      owner,
-      bookmarks,
-      children,
-      parent,
-    };
-    return res;
-  });
-
-  const results = await Promise.all(promises) as unknown as BookmarkFolderItems[];
-  folderItems.push(...results);
-  return folderItems;
-};
-
 bookmarkFolderSchema.statics.deleteFolderAndChildren = async function(bookmarkFolderId: Types.ObjectId | string): Promise<{deletedCount: number}> {
   const bookmarkFolder = await this.findById(bookmarkFolderId);
   // Delete parent and all children folder
@@ -145,7 +104,12 @@ bookmarkFolderSchema.statics.deleteFolderAndChildren = async function(bookmarkFo
   return { deletedCount };
 };
 
-bookmarkFolderSchema.statics.updateBookmarkFolder = async function(bookmarkFolderId: string, name: string, parentId: string | null):
+bookmarkFolderSchema.statics.updateBookmarkFolder = async function(
+    bookmarkFolderId: string,
+    name: string,
+    parentId: string | null,
+    children: BookmarkFolderItems[],
+):
  Promise<BookmarkFolderDocument> {
   const updateFields: {name: string, parent: Types.ObjectId | null} = {
     name: '',
@@ -163,8 +127,7 @@ bookmarkFolderSchema.statics.updateBookmarkFolder = async function(bookmarkFolde
     if (parentFolder?.parent != null) {
       throw new Error('Update bookmark folder failed');
     }
-    const bookmarkFolder = await this.findById(bookmarkFolderId).populate('children');
-    if (bookmarkFolder?.children?.length !== 0) {
+    if (children.length !== 0) {
       throw new Error('Update bookmark folder failed');
     }
   }
@@ -179,36 +142,23 @@ bookmarkFolderSchema.statics.updateBookmarkFolder = async function(bookmarkFolde
 
 bookmarkFolderSchema.statics.insertOrUpdateBookmarkedPage = async function(pageId: IPageHasId, userId: Types.ObjectId | string, folderId: string | null):
 Promise<BookmarkFolderDocument | null> {
-
-  // Create bookmark or update existing
-  const bookmarkedPage = await Bookmark.findOneAndUpdate({ page: pageId, user: userId }, { page: pageId, user: userId }, { new: true, upsert: true });
+  // Find bookmark
+  const bookmarkedPage = await Bookmark.findOne({ page: pageId, user: userId }, { new: true, upsert: true });
 
   // Remove existing bookmark in bookmark folder
-  await this.updateMany({}, { $pull: { bookmarks:  bookmarkedPage._id } });
-
-  // Insert bookmark into bookmark folder
-  if (folderId != null) {
-    const bookmarkFolder = await this.findByIdAndUpdate(folderId, { $addToSet: { bookmarks: bookmarkedPage } }, { new: true, upsert: true });
-    return bookmarkFolder;
+  await this.updateMany({ owner: userId }, { $pull: { bookmarks:  bookmarkedPage?._id } });
+  if (folderId == null) {
+    return null;
   }
 
-  return null;
-};
+  // Insert bookmark into bookmark folder
+  const bookmarkFolder = await this.findByIdAndUpdate(
+    { _id: folderId, owner: userId },
+    { $addToSet: { bookmarks: bookmarkedPage } },
+    { new: true, upsert: true },
+  );
 
-bookmarkFolderSchema.statics.findUserRootBookmarksItem = async function(userId: Types.ObjectId | string): Promise<MyBookmarkList> {
-  const bookmarkIdsInFolders = await this.distinct('bookmarks', { owner: userId });
-  const userRootBookmarks: MyBookmarkList = await Bookmark.find({
-    _id: { $nin: bookmarkIdsInFolders },
-    user: userId,
-  }).populate({
-    path: 'page',
-    model: 'Page',
-    populate: {
-      path: 'lastUpdateUser',
-      model: 'User',
-    },
-  });
-  return userRootBookmarks;
+  return bookmarkFolder;
 };
 
 bookmarkFolderSchema.statics.updateBookmark = async function(pageId: Types.ObjectId | string, status: boolean, userId: Types.ObjectId | string):

+ 27 - 0
apps/app/src/server/models/eslint-rules-dir/no-populate.js

@@ -0,0 +1,27 @@
+/**
+ * @typedef {import('eslint').Rule} Rule
+ * @typedef {import('./lib/html.js').HtmlOptions} HtmlOptions
+ */
+
+/** @type {Rule.RuleModule} */
+module.exports = {
+  meta: {
+    type: 'problem',
+  },
+  /**
+   * @property {Rule.RuleContext} context
+   * @return {Rule.RuleListener}
+   */
+  create: (context) => {
+    return {
+      CallExpression(node) {
+        if (node.callee.property && node.callee.property.name === 'populate') {
+          context.report({
+            node,
+            message: "The 'populate' method should not be called in model modules.",
+          });
+        }
+      },
+    };
+  },
+};

+ 25 - 0
apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts

@@ -0,0 +1,25 @@
+import { test } from 'vitest';
+
+import { RuleTester } from 'eslint';
+
+import noPopulate from '../no-populate';
+
+const ruleTester = new RuleTester({
+  parserOptions: {
+    ecmaVersion: 2015,
+  },
+});
+
+test('test no-populate', () => {
+  ruleTester.run('no-populate', noPopulate, {
+    valid: [
+      { code: 'Model.find();' },
+    ],
+    invalid: [
+      {
+        code: "Model.find().populate('children');",
+        errors: [{ message: "The 'populate' method should not be called in model modules." }],
+      },
+    ],
+  });
+});

+ 25 - 0
apps/app/src/server/models/serializers/bookmark-serializer.js

@@ -0,0 +1,25 @@
+const { serializePageSecurely } = require('./page-serializer');
+
+function serializeInsecurePageAttributes(bookmark) {
+  if (bookmark.page != null && bookmark.page._id != null) {
+    bookmark.page = serializePageSecurely(bookmark.page);
+  }
+  return bookmark;
+}
+
+function serializeBookmarkSecurely(bookmark) {
+  let serialized = bookmark;
+
+  // invoke toObject if bookmark is a model instance
+  if (bookmark.toObject != null) {
+    serialized = bookmark.toObject();
+  }
+
+  serializeInsecurePageAttributes(serialized);
+
+  return serialized;
+}
+
+module.exports = {
+  serializeBookmarkSecurely,
+};

+ 59 - 4
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -1,14 +1,16 @@
 import { ErrorV3 } from '@growi/core';
 import { body } from 'express-validator';
+import { Types } from 'mongoose';
 
+import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
+import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
 import loggerFactory from '~/utils/logger';
 
 import BookmarkFolder from '../../models/bookmark-folder';
 
 const logger = loggerFactory('growi:routes:apiv3:bookmark-folder');
-
 const express = require('express');
 
 const router = express.Router();
@@ -23,6 +25,8 @@ const validator = {
           throw new Error('Maximum folder hierarchy of 2 levels');
         }
       }),
+    body('children').optional().isArray().withMessage('Children must be an array'),
+    body('bookmarkFolderId').optional().isMongoId().withMessage('Bookark Folder ID must be a valid mongo ID'),
   ],
   bookmarkPage: [
     body('pageId').isMongoId().withMessage('Page ID must be a valid mongo ID'),
@@ -52,6 +56,7 @@ module.exports = (crowi) => {
       return res.apiv3({ bookmarkFolder });
     }
     catch (err) {
+      logger.error(err);
       if (err instanceof InvalidParentBookmarkFolderError) {
         return res.apiv3Err(new ErrorV3(err.message, 'failed_to_create_bookmark_folder'));
       }
@@ -63,12 +68,57 @@ module.exports = (crowi) => {
   router.get('/list/:userId', accessTokenParser, loginRequiredStrictly, async(req, res) => {
     const { userId } = req.params;
 
+    const getBookmarkFolders = async(
+        userId: Types.ObjectId | string,
+        parentFolderId?: Types.ObjectId | string,
+    ) => {
+      const folders = await BookmarkFolder.find({ owner: userId, parent: parentFolderId })
+        .populate('children')
+        .populate({
+          path: 'bookmarks',
+          model: 'Bookmark',
+          populate: {
+            path: 'page',
+            model: 'Page',
+            populate: {
+              path: 'lastUpdateUser',
+              model: 'User',
+            },
+          },
+        }).exec();
+
+      const returnValue: BookmarkFolderItems[] = [];
+
+      const promises = folders.map(async(folder: BookmarkFolderItems) => {
+        const children = await getBookmarkFolders(userId, folder._id);
+
+        // !! DO NOT THIS SERIALIZING OUTSIDE OF PROMISES !! -- 05.23.2023 ryoji-s
+        // Serializing outside of promises will cause not populated.
+        const bookmarks = folder.bookmarks.map(bookmark => serializeBookmarkSecurely(bookmark));
+
+        const res = {
+          _id: folder._id.toString(),
+          name: folder.name,
+          owner: folder.owner,
+          bookmarks,
+          children,
+          parent: folder.parent,
+        };
+        return res;
+      });
+
+      const results = await Promise.all(promises) as unknown as BookmarkFolderItems[];
+      returnValue.push(...results);
+      return returnValue;
+    };
+
     try {
-      const bookmarkFolderItems = await BookmarkFolder.findFolderAndChildren(userId);
+      const bookmarkFolderItems = await getBookmarkFolders(userId, undefined);
 
       return res.apiv3({ bookmarkFolderItems });
     }
     catch (err) {
+      logger.error(err);
       return res.apiv3Err(err, 500);
     }
   });
@@ -88,12 +138,15 @@ module.exports = (crowi) => {
   });
 
   router.put('/', accessTokenParser, loginRequiredStrictly, validator.bookmarkFolder, async(req, res) => {
-    const { bookmarkFolderId, name, parent } = req.body;
+    const {
+      bookmarkFolderId, name, parent, children,
+    } = req.body;
     try {
-      const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(bookmarkFolderId, name, parent);
+      const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(bookmarkFolderId, name, parent, children);
       return res.apiv3({ bookmarkFolder });
     }
     catch (err) {
+      logger.error(err);
       return res.apiv3Err(err, 500);
     }
   });
@@ -108,6 +161,7 @@ module.exports = (crowi) => {
       return res.apiv3({ bookmarkFolder });
     }
     catch (err) {
+      logger.error(err);
       return res.apiv3Err(err, 500);
     }
   });
@@ -120,6 +174,7 @@ module.exports = (crowi) => {
       return res.apiv3({ bookmarkFolder });
     }
     catch (err) {
+      logger.error(err);
       return res.apiv3Err(err, 500);
     }
   });

+ 17 - 7
apps/app/src/server/routes/apiv3/bookmarks.js

@@ -1,5 +1,6 @@
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -201,14 +202,23 @@ module.exports = (crowi) => {
       return res.apiv3Err('User id is not found or forbidden', 400);
     }
     try {
-      const userRootBookmarks = await BookmarkFolder.findUserRootBookmarksItem(userId);
-      userRootBookmarks.forEach((bookmark) => {
-        if (bookmark.page.lastUpdateUser != null && bookmark.page.lastUpdateUser instanceof User) {
-          bookmark.page.lastUpdateUser = serializeUserSecurely(bookmark.page.lastUpdateUser);
-        }
-      });
+      const bookmarkIdsInFolders = await BookmarkFolder.distinct('bookmarks', { owner: userId });
+      const userRootBookmarks = await Bookmark.find({
+        _id: { $nin: bookmarkIdsInFolders },
+        user: userId,
+      }).populate({
+        path: 'page',
+        model: 'Page',
+        populate: {
+          path: 'lastUpdateUser',
+          model: 'User',
+        },
+      }).exec();
+
+      // serialize Bookmark
+      const serializedUserRootBookmarks = userRootBookmarks.map(bookmark => serializeBookmarkSecurely(bookmark));
 
-      return res.apiv3({ userRootBookmarks });
+      return res.apiv3({ userRootBookmarks: serializedUserRootBookmarks });
     }
     catch (err) {
       logger.error('get-bookmark-failed', err);

+ 54 - 0
apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.spec.ts

@@ -0,0 +1,54 @@
+import { describe, test, expect } from 'vitest';
+
+import { type HastNode, select } from 'hast-util-select';
+import parse from 'remark-parse';
+import rehype from 'remark-rehype';
+import { unified } from 'unified';
+
+import { pukiwikiLikeLinker } from '../remark-plugins/pukiwiki-like-linker';
+
+import { relativeLinksByPukiwikiLikeLinker } from './relative-links-by-pukiwiki-like-linker';
+
+describe('relativeLinksByPukiwikiLikeLinker', () => {
+
+  /* eslint-disable indent */
+  describe.each`
+    input                                   | expectedHref                        | expectedValue
+    ${'[[/page]]'}                          | ${'/page'}                          | ${'/page'}
+    ${'[[./page]]'}                         | ${'/user/admin/page'}               | ${'./page'}
+    ${'[[Title>./page]]'}                   | ${'/user/admin/page'}               | ${'Title'}
+    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}            | ${'Title'}
+    ${'[[/page?q=foo#header]]'}             | ${'/page?q=foo#header'}             | ${'/page?q=foo#header'}
+    ${'[[./page?q=foo#header]]'}            | ${'/user/admin/page?q=foo#header'}  | ${'./page?q=foo#header'}
+    ${'[[Title>./page?q=foo#header]]'}      | ${'/user/admin/page?q=foo#header'}  | ${'Title'}
+    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}            | ${'Title'}
+  `('should convert relative links correctly', ({ input, expectedHref, expectedValue }) => {
+  /* eslint-enable indent */
+
+    test(`when the input is '${input}'`, () => {
+      // setup:
+      const processor = unified()
+        .use(parse)
+        .use(pukiwikiLikeLinker)
+        .use(rehype)
+        .use(relativeLinksByPukiwikiLikeLinker, { pagePath: '/user/admin' });
+
+      // when:
+      const mdast = processor.parse(input);
+      const hast = processor.runSync(mdast) as HastNode;
+      const anchorElement = select('a', hast);
+
+      // then
+      expect(anchorElement).not.toBeNull();
+      expect(anchorElement?.properties).not.toBeNull();
+      expect((anchorElement?.properties?.className as string).startsWith('pukiwiki-like-linker')).toBeTruthy();
+      expect(anchorElement?.properties?.href).toEqual(expectedHref);
+
+      expect(anchorElement?.children[0]).not.toBeNull();
+      expect(anchorElement?.children[0].type).toEqual('text');
+      expect(anchorElement?.children[0].value).toEqual(expectedValue);
+
+    });
+  });
+
+});

+ 6 - 7
apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.ts

@@ -1,27 +1,26 @@
 import { pathUtils } from '@growi/core';
 import { selectAll } from 'hast-util-select';
-import { Plugin } from 'unified';
+import type { Plugin } from 'unified';
 
 import {
-  IAnchorsSelector, IHrefResolver, relativeLinks, RelativeLinksPluginParams,
+  relativeLinks,
+  type IAnchorsSelector, type IUrlResolver, type RelativeLinksPluginParams,
 } from './relative-links';
 
 const customAnchorsSelector: IAnchorsSelector = (node) => {
   return selectAll('a[href].pukiwiki-like-linker', node);
 };
 
-const customHrefResolver: IHrefResolver = (relativeHref, basePath) => {
+const customUrlResolver: IUrlResolver = (relativeHref, basePath) => {
   // generate relative pathname
   const baseUrl = new URL(pathUtils.addTrailingSlash(basePath), 'https://example.com');
-  const relativeUrl = new URL(relativeHref, baseUrl);
-
-  return relativeUrl.pathname;
+  return new URL(relativeHref, baseUrl);
 };
 
 export const relativeLinksByPukiwikiLikeLinker: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
   return relativeLinks.bind(this)({
     ...options,
     anchorsSelector: customAnchorsSelector,
-    hrefResolver: customHrefResolver,
+    urlResolver: customUrlResolver,
   });
 };

+ 64 - 0
apps/app/src/services/renderer/rehype-plugins/relative-links.spec.ts

@@ -0,0 +1,64 @@
+
+import { describe, test, expect } from 'vitest';
+
+import { select, type HastNode } from 'hast-util-select';
+import parse from 'remark-parse';
+import remarkRehype from 'remark-rehype';
+import { unified } from 'unified';
+
+import { relativeLinks } from './relative-links';
+
+describe('relativeLinks', () => {
+
+  test.concurrent.each`
+    originalHref
+      ${'http://example.com/Sandbox'}
+      ${'#header'}
+    `('leaves the original href \'$originalHref\' as-is', ({ originalHref }) => {
+
+    // setup
+    const processor = unified()
+      .use(parse)
+      .use(remarkRehype)
+      .use(relativeLinks, {});
+
+    // when
+    const mdastTree = processor.parse(`[link](${originalHref})`);
+    const hastTree = processor.runSync(mdastTree) as HastNode;
+
+    // then
+    const anchorElement = select('a', hastTree);
+    expect(anchorElement?.properties?.href).toBe(originalHref);
+  });
+
+  test.concurrent.each`
+    originalHref                        | expectedHref
+      ${'/Sandbox'}                     | ${'/Sandbox'}
+      ${'/Sandbox?q=foo'}               | ${'/Sandbox?q=foo'}
+      ${'/Sandbox#header'}              | ${'/Sandbox#header'}
+      ${'/Sandbox?q=foo#header'}        | ${'/Sandbox?q=foo#header'}
+      ${'./Sandbox'}                    | ${'/foo/bar/Sandbox'}
+      ${'./Sandbox?q=foo'}              | ${'/foo/bar/Sandbox?q=foo'}
+      ${'./Sandbox#header'}             | ${'/foo/bar/Sandbox#header'}
+      ${'./Sandbox?q=foo#header'}       | ${'/foo/bar/Sandbox?q=foo#header'}
+    `('rewrites the original href \'$originalHref\' to \'$expectedHref\'', ({ originalHref, expectedHref }) => {
+
+    // setup
+    const pagePath = '/foo/bar/baz';
+    const processor = unified()
+      .use(parse)
+      .use(remarkRehype)
+      .use(relativeLinks, { pagePath });
+
+    // when
+    const mdastTree = processor.parse(`[link](${originalHref})`);
+    const hastTree = processor.runSync(mdastTree) as HastNode;
+
+    // then
+    const anchorElement = select('a', hastTree);
+    expect(anchorElement).not.toBeNull();
+    expect(anchorElement?.properties).not.toBeNull();
+    expect(anchorElement?.properties?.href).toBe(expectedHref);
+  });
+
+});

+ 12 - 9
apps/app/src/services/renderer/rehype-plugins/relative-links.ts

@@ -1,20 +1,23 @@
-import { selectAll, HastNode, Element } from 'hast-util-select';
+import { selectAll, type HastNode, type Element } from 'hast-util-select';
 import isAbsolute from 'is-absolute-url';
-import { Plugin } from 'unified';
+import type { Plugin } from 'unified';
 
 export type IAnchorsSelector = (node: HastNode) => Element[];
-export type IHrefResolver = (relativeHref: string, basePath: string) => string;
+export type IUrlResolver = (relativeHref: string, basePath: string) => URL;
 
 const defaultAnchorsSelector: IAnchorsSelector = (node) => {
   return selectAll('a[href]', node);
 };
 
-const defaultHrefResolver: IHrefResolver = (relativeHref, basePath) => {
+const defaultUrlResolver: IUrlResolver = (relativeHref, basePath) => {
   // generate relative pathname
   const baseUrl = new URL(basePath, 'https://example.com');
-  const relativeUrl = new URL(relativeHref, baseUrl);
+  return new URL(relativeHref, baseUrl);
+};
 
-  return relativeUrl.pathname;
+const urlToHref = (url: URL): string => {
+  const { pathname, search, hash } = url;
+  return `${pathname}${search}${hash}`;
 };
 
 const isAnchorLink = (href: string): boolean => {
@@ -24,12 +27,12 @@ const isAnchorLink = (href: string): boolean => {
 export type RelativeLinksPluginParams = {
   pagePath?: string,
   anchorsSelector?: IAnchorsSelector,
-  hrefResolver?: IHrefResolver,
+  urlResolver?: IUrlResolver,
 }
 
 export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
   const anchorsSelector = options.anchorsSelector ?? defaultAnchorsSelector;
-  const hrefResolver = options.hrefResolver ?? defaultHrefResolver;
+  const urlResolver = options.urlResolver ?? defaultUrlResolver;
 
   return (tree) => {
     if (options.pagePath == null) {
@@ -49,7 +52,7 @@ export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {})
         return;
       }
 
-      anchor.properties.href = hrefResolver(href, pagePath);
+      anchor.properties.href = urlToHref(urlResolver(href, pagePath));
     });
   };
 };

+ 45 - 0
apps/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.spec.ts

@@ -0,0 +1,45 @@
+import { describe, test, expect } from 'vitest';
+
+import parse from 'remark-parse';
+import { unified } from 'unified';
+import { visit } from 'unist-util-visit';
+
+import { pukiwikiLikeLinker } from './pukiwiki-like-linker';
+
+describe('pukiwikiLikeLinker', () => {
+
+  describe.each`
+    input                                   | expectedHref                    | expectedValue
+    ${'[[/page]]'}                          | ${'/page'}                      | ${'/page'}
+    ${'[[./page]]'}                         | ${'./page'}                     | ${'./page'}
+    ${'[[Title>./page]]'}                   | ${'./page'}                     | ${'Title'}
+    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}        | ${'Title'}
+    ${'[[/page?q=foo#header]]'}             | ${'/page?q=foo#header'}         | ${'/page?q=foo#header'}
+    ${'[[./page?q=foo#header]]'}            | ${'./page?q=foo#header'}        | ${'./page?q=foo#header'}
+    ${'[[Title>./page?q=foo#header]]'}      | ${'./page?q=foo#header'}        | ${'Title'}
+    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}        | ${'Title'}
+  `('should parse correctly', ({ input, expectedHref, expectedValue }) => {
+
+    test(`when the input is '${input}'`, () => {
+      // setup:
+      const processor = unified()
+        .use(parse)
+        .use(pukiwikiLikeLinker);
+
+      // when:
+      const ast = processor.parse(input);
+
+      expect(ast).not.toBeNull();
+
+      visit(ast, 'wikiLink', (node: any) => {
+        expect(node.data.alias).toEqual(expectedValue);
+        expect(node.data.permalink).toEqual(expectedHref);
+        expect(node.data.hName).toEqual('a');
+        expect(node.data.hProperties.className.startsWith('pukiwiki-like-linker')).toBeTruthy();
+        expect(node.data.hProperties.href).toEqual(expectedHref);
+        expect(node.data.hChildren[0].value).toEqual(expectedValue);
+      });
+    });
+  });
+
+});

+ 27 - 0
apps/app/src/stores/page-timeline.tsx

@@ -0,0 +1,27 @@
+
+import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { IPageHasId } from '~/interfaces/page';
+
+
+type PageTimelineResult = {
+  pages: IPageHasId[],
+  totalCount: number,
+  offset: number,
+}
+export const useSWRINFxPageTimeline = (path: string | undefined, limit: number) : SWRInfiniteResponse<PageTimelineResult, Error> => {
+  return useSWRInfinite(
+    (pageIndex, previousPageData) => {
+      if (previousPageData != null && previousPageData.pages.length === 0) return null;
+      if (path === undefined) return null;
+
+      return ['/pages/list', path, pageIndex + 1, limit];
+    },
+    ([endpoint, path, page, limit]) => apiv3Get<PageTimelineResult>(endpoint, { path, page, limit }).then(response => response.data),
+    {
+      revalidateFirstPage: false,
+      revalidateAll: false,
+    },
+  );
+};

+ 0 - 92
apps/app/test/unit/services/renderer/pukiwiki-like-linker.test.ts

@@ -1,92 +0,0 @@
-import { HastNode, selectAll } from 'hast-util-select';
-import parse from 'remark-parse';
-import rehype from 'remark-rehype';
-import { unified } from 'unified';
-import { visit } from 'unist-util-visit';
-
-import { relativeLinksByPukiwikiLikeLinker } from '../../../../src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker';
-import { pukiwikiLikeLinker } from '../../../../src/services/renderer/remark-plugins/pukiwiki-like-linker';
-
-describe('pukiwikiLikeLinker', () => {
-
-  /* eslint-disable indent */
-  describe.each`
-    input                                   | expectedHref                | expectedValue
-    ${'[[/page]]'}                          | ${'/page'}                  | ${'/page'}
-    ${'[[./page]]'}                         | ${'./page'}                 | ${'./page'}
-    ${'[[Title>./page]]'}                   | ${'./page'}                 | ${'Title'}
-    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}    | ${'Title'}
-  `('should parse correctly', ({ input, expectedHref, expectedValue }) => {
-  /* eslint-enable indent */
-
-    test(`when the input is '${input}'`, () => {
-      // setup:
-      const processor = unified()
-        .use(parse)
-        .use(pukiwikiLikeLinker);
-
-      // when:
-      const ast = processor.parse(input);
-
-      expect(ast).not.toBeNull();
-
-      visit(ast, 'wikiLink', (node: any) => {
-        expect(node.data.alias).toEqual(expectedValue);
-        expect(node.data.permalink).toEqual(expectedHref);
-        expect(node.data.hName).toEqual('a');
-        expect(node.data.hProperties.className.startsWith('pukiwiki-like-linker')).toBeTruthy();
-        expect(node.data.hProperties.href).toEqual(expectedHref);
-        expect(node.data.hChildren[0].value).toEqual(expectedValue);
-      });
-
-    });
-  });
-
-});
-
-
-describe('relativeLinksByPukiwikiLikeLinker', () => {
-
-  /* eslint-disable indent */
-  describe.each`
-    input                                   | expectedHref                | expectedValue
-    ${'[[/page]]'}                          | ${'/page'}                  | ${'/page'}
-    ${'[[./page]]'}                         | ${'/user/admin/page'}       | ${'./page'}
-    ${'[[Title>./page]]'}                   | ${'/user/admin/page'}       | ${'Title'}
-    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}    | ${'Title'}
-  `('should convert relative links correctly', ({ input, expectedHref, expectedValue }) => {
-  /* eslint-enable indent */
-
-    test(`when the input is '${input}'`, () => {
-      // setup:
-      const processor = unified()
-        .use(parse)
-        .use(pukiwikiLikeLinker)
-        .use(rehype)
-        .use(relativeLinksByPukiwikiLikeLinker, { pagePath: '/user/admin' });
-
-      // when:
-      const mdast = processor.parse(input);
-      const hast = processor.runSync(mdast);
-
-      expect(hast).not.toBeNull();
-      expect((hast as any).children[0].type).toEqual('element');
-
-      const anchors = selectAll('a', hast as HastNode);
-
-      expect(anchors.length).toEqual(1);
-
-      const anchor = anchors[0];
-
-      expect(anchor.tagName).toEqual('a');
-      expect((anchor.properties as any).className.startsWith('pukiwiki-like-linker')).toBeTruthy();
-      expect(anchor.properties?.href).toEqual(expectedHref);
-
-      expect(anchor.children[0]).not.toBeNull();
-      expect(anchor.children[0].type).toEqual('text');
-      expect(anchor.children[0].value).toEqual(expectedValue);
-
-    });
-  });
-
-});

+ 7 - 0
apps/app/vitest.config.unit.ts

@@ -0,0 +1,7 @@
+import { defineProject } from 'vitest/config';
+
+export default defineProject({
+  test: {
+    environment: 'node',
+  },
+});

+ 2 - 2
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "6.1.1-slackbot-proxy.0",
+  "version": "6.1.2-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^6.1.1-RC.0",
+    "@growi/slack": "^6.1.2-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 9 - 5
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "6.1.1-RC.0",
+  "version": "6.1.2-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -60,15 +60,17 @@
     "@swc/jest": "^0.2.24",
     "@testing-library/cypress": "^8.0.2",
     "@types/css-modules": "^1.0.2",
+    "@types/eslint": "^8.37.0",
+    "@types/estree": "^1.0.1",
     "@types/jest": "^26.0.22",
     "@types/node": "^17.0.43",
     "@types/rewire": "^2.5.28",
-    "@typescript-eslint/eslint-plugin": "^5.54.0",
-    "@typescript-eslint/parser": "^5.54.0",
+    "@typescript-eslint/eslint-plugin": "^5.59.7",
+    "@typescript-eslint/parser": "^5.59.7",
     "@vitejs/plugin-react": "^3.1.0",
     "cypress": "^12.0.1",
     "cypress-wait-until": "^1.7.2",
-    "eslint": "^8.35.0",
+    "eslint": "^8.41.0",
     "eslint-config-next": "^12.1.6",
     "eslint-config-weseek": "^2.1.1",
     "eslint-import-resolver-typescript": "^3.2.5",
@@ -76,6 +78,7 @@
     "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-react": "^7.30.1",
     "eslint-plugin-react-hooks": "^4.6.0",
+    "eslint-plugin-rulesdir": "^0.2.2",
     "glob": "^8.1.0",
     "jest": "^28.1.3",
     "jest-date-mock": "^1.0.8",
@@ -95,7 +98,8 @@
     "typescript": "~4.9",
     "unplugin-swc": "^1.3.2",
     "vite": "^4.2.2",
-    "vite-plugin-dts": "^2.0.0-beta.0"
+    "vite-plugin-dts": "^2.0.0-beta.0",
+    "vitest": "^0.31.1"
   },
   "engines": {
     "node": "^16 || ^18",

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "6.1.1-RC.0",
+  "version": "6.1.2-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/hackmd/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/hackmd",
-  "version": "6.1.1-RC.0",
+  "version": "6.1.2-RC.0",
   "description": "GROWI js and css files to use hackmd",
   "license": "MIT",
   "type": "module",

+ 2 - 2
packages/presentation/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/presentation",
-  "version": "6.1.1-RC.0",
+  "version": "6.1.2-RC.0",
   "description": "GROWI plugin for presentation",
   "license": "MIT",
   "keywords": ["growi", "growi-plugin"],
@@ -18,7 +18,7 @@
     "lint": "run-p lint:*"
   },
   "dependencies": {
-    "@growi/core": "^6.1.1-RC.0"
+    "@growi/core": "^6.1.2-RC.0"
   },
   "devDependencies": {
     "@marp-team/marp-core": "^3.6.0",

+ 1 - 1
packages/preset-themes/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@growi/preset-themes",
   "description": "GROWI preset themes",
-  "version": "6.1.1-RC.0",
+  "version": "6.1.2-RC.0",
   "license": "MIT",
   "main": "dist/libs/preset-themes.umd.js",
   "module": "dist/libs/preset-themes.mjs",

+ 4 - 4
packages/remark-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-attachment-refs",
-  "version": "6.1.1-RC.0",
+  "version": "6.1.2-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": ["growi", "growi-plugin"],
@@ -26,9 +26,9 @@
   "dependencies": {
     "bunyan": "^1.8.15",
     "universal-bunyan": "^0.9.2",
-    "@growi/core": "^6.1.1-RC.0",
-    "@growi/remark-growi-directive": "^6.1.1-RC.0",
-    "@growi/ui": "^6.1.1-RC.0"
+    "@growi/core": "^6.1.2-RC.0",
+    "@growi/remark-growi-directive": "^6.1.2-RC.0",
+    "@growi/ui": "^6.1.2-RC.0"
   },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",

+ 2 - 0
packages/remark-attachment-refs/vite.client.config.ts

@@ -1,9 +1,11 @@
+import react from '@vitejs/plugin-react';
 import { defineConfig } from 'vite';
 import dts from 'vite-plugin-dts';
 
 // https://vitejs.dev/config/
 export default defineConfig({
   plugins: [
+    react(),
     dts(),
   ],
   build: {

+ 1 - 1
packages/remark-drawio/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-drawio",
-  "version": "6.1.1-RC.0",
+  "version": "6.1.2-RC.0",
   "description": "remark plugin to draw diagrams with draw.io (diagrams.net)",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/remark-growi-directive/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-growi-directive",
-  "version": "6.1.1-RC.0",
+  "version": "6.1.2-RC.0",
   "description": "remark plugin to support GROWI plugin (forked from remark-directive@2.0.1)",
   "license": "MIT",
   "keywords": [

+ 4 - 4
packages/remark-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-lsx",
-  "version": "6.1.1-RC.0",
+  "version": "6.1.2-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": ["growi", "growi-plugin"],
@@ -25,9 +25,9 @@
     "escape-string-regexp": "5.0.0 or above exports only ESM"
   },
   "dependencies": {
-    "@growi/core": "^6.1.1-RC.0",
-    "@growi/remark-growi-directive": "^6.1.1-RC.0",
-    "@growi/ui": "^6.1.1-RC.0",
+    "@growi/core": "^6.1.2-RC.0",
+    "@growi/remark-growi-directive": "^6.1.2-RC.0",
+    "@growi/ui": "^6.1.2-RC.0",
     "escape-string-regexp": "^4.0.0",
     "swr": "^2.0.3"
   },

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "6.1.1-RC.0",
+  "version": "6.1.2-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "module": "dist/index.mjs",

+ 2 - 2
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "6.1.1-RC.0",
+  "version": "6.1.2-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": ["growi"],
@@ -16,7 +16,7 @@
     "lint": "npm-run-all -p lint:*"
   },
   "dependencies": {
-    "@growi/core": "^6.1.1-RC.0"
+    "@growi/core": "^6.1.2-RC.0"
   },
   "devDependencies": {
     "react": "^18.2.0"

+ 4 - 0
vitest.workspace.ts

@@ -0,0 +1,4 @@
+export default [
+  'apps/*/vitest.config.{e2e,unit}.ts',
+  'packages/*/vitest.config.{e2e,unit}.ts',
+];

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 494 - 64
yarn.lock


Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно