Explorar o código

Merge branch 'dev/7.0.x' into imprv/140199-141158-all-option-in-editor-config

reiji-h %!s(int64=2) %!d(string=hai) anos
pai
achega
24161fe5c6
Modificáronse 33 ficheiros con 816 adicións e 73 borrados
  1. 1 1
      .github/workflows/ci-app-prod.yml
  2. 1 1
      .mergify.yml
  3. 2 2
      apps/app/public/static/locales/en_US/translation.json
  4. 2 2
      apps/app/public/static/locales/ja_JP/translation.json
  5. 2 2
      apps/app/public/static/locales/zh_CN/translation.json
  6. 3 1
      apps/app/src/client/services/create-page/use-create-template-page.ts
  7. 2 0
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  8. 3 0
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  9. 4 0
      apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.module.scss
  10. 2 3
      apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx
  11. 2 2
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  12. 3 1
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  13. 7 2
      apps/app/src/components/PageCreateModal.tsx
  14. 2 2
      apps/app/src/components/PageEditor/PageEditor.tsx
  15. 9 5
      apps/app/src/components/PageSideContents/PageSideContents.tsx
  16. 2 1
      apps/app/src/components/Sidebar/Custom/CustomSidebarNotFound.tsx
  17. 8 1
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts
  18. 2 1
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx
  19. 7 3
      apps/app/src/components/TableOfContents.tsx
  20. 4 1
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  21. 5 2
      apps/app/src/interfaces/apiv3/page.ts
  22. 3 1
      apps/app/src/interfaces/page.ts
  23. 9 2
      apps/app/src/server/models/obsolete-page.js
  24. 5 1
      apps/app/src/server/models/revision.js
  25. 4 2
      apps/app/src/server/routes/apiv3/page/create-page.ts
  26. 12 6
      apps/app/src/server/routes/apiv3/page/update-page.ts
  27. 5 5
      apps/app/src/server/service/page/index.ts
  28. 2 0
      bin/data-migrations/README.md
  29. 682 0
      bin/data-migrations/src/migrations/v70x/bootstrap5.js
  30. 3 0
      bin/data-migrations/src/migrations/v70x/index.js
  31. 10 0
      packages/core/src/interfaces/revision.ts
  32. 2 3
      packages/editor/src/components/CodeMirrorEditorMain.tsx
  33. 6 20
      packages/editor/src/stores/use-collaborative-editor-mode.ts

+ 1 - 1
.github/workflows/ci-app-prod.yml

@@ -48,7 +48,7 @@ concurrency:
 
 jobs:
 
-  test-prod-node16:
+  test-prod-node18:
     uses: weseek/growi/.github/workflows/reusable-app-prod.yml@dev/7.0.x
     with:
       node-version: 18.x

+ 1 - 1
.mergify.yml

@@ -6,8 +6,8 @@ pull_request_rules:
       - check-success = "lint (20.x)"
       - check-success = "test (20.x)"
       - check-success = "launch-dev (20.x)"
-      - check-success = "test-prod-node16 / launch-prod"
       - check-success = "test-prod-node18 / launch-prod"
+      - check-success = "test-prod-node20 / launch-prod"
     actions:
       merge:
         method: merge

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

@@ -833,10 +833,10 @@
     "select_page_location": "Select page location"
   },
   "wip_page": {
-    "save_as_wip": "Save as WIP (Currently drafting)",
+    "save_as_wip": "Save as WIP (still being written)",
     "success_save_as_wip": "Successfully saved as a WIP page",
     "fail_save_as_wip": "Failed to save as a WIP page",
-    "alert": "This page is a work in progress",
+    "alert": "This page is still being written",
     "publish_page": "Publish page",
     "success_publish_page": "Page has been published",
     "fail_publish_page": "Failed to publish the Page"

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

@@ -866,10 +866,10 @@
     "select_page_location": "ページの場所を選択"
   },
   "wip_page": {
-    "save_as_wip": "WIP (執筆中) として保存",
+    "save_as_wip": "WIP (執筆中) として保存",
     "success_save_as_wip": "WIP ページとして保存しました",
     "fail_save_as_wip": "WIP ページとして保存できませんでした",
-    "alert": "このページは作業途中です",
+    "alert": "このページは執筆途中です",
     "publish_page": "WIP を解除",
     "success_publish_page": "WIP を解除しました",
     "fail_publish_page": "WIP を解除できませんでした"

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

@@ -836,10 +836,10 @@
     "select_page_location": "选择页面位置"
   },
   "wip_page": {
-    "save_as_wip": "保存为 WIP(书面)",
+    "save_as_wip": "保存为 WIP(仍在撰写中)",
     "success_save_as_wip": "成功保存为 WIP 页面",
     "fail_save_as_wip": "保存为 WIP 页失败",
-    "alert": "本页面正在制作中",
+    "alert": "本页仍在编写中",
     "publish_page": "发布 WIP",
     "success_publish_page": "WIP 已停用",
     "fail_publish_page": "无法停用 WIP"

+ 3 - 1
apps/app/src/client/services/create-page/use-create-template-page.ts

@@ -1,11 +1,13 @@
 import { useCallback } from 'react';
 
+import { Origin } from '@growi/core';
 import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 
 import type { LabelType } from '~/interfaces/template';
 import { useCurrentPagePath } from '~/stores/page';
 
+
 import { useCreatePageAndTransit } from './use-create-page-and-transit';
 
 type UseCreateTemplatePage = () => {
@@ -25,7 +27,7 @@ export const useCreateTemplatePage: UseCreateTemplatePage = () => {
     if (isLoadingPagePath || !isCreatable) return;
 
     return createAndTransit(
-      { path: normalizePath(`${currentPagePath}/${label}`), wip: false },
+      { path: normalizePath(`${currentPagePath}/${label}`), wip: false, origin: Origin.View },
       { shouldCheckPageExists: true },
     );
   }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);

+ 2 - 0
apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -2,6 +2,7 @@ import { useCallback, useEffect } from 'react';
 
 import type EventEmitter from 'events';
 
+import { Origin } from '@growi/core';
 import type { DrawioEditByViewerProps } from '@growi/remark-drawio';
 
 import { replaceDrawioInMarkdown } from '~/components/Page/markdown-drawio-util-for-view';
@@ -47,6 +48,7 @@ export const useDrawioModalLauncherForView = (opts?: {
         pageId: currentPage._id,
         revisionId: currentRevisionId,
         body: newMarkdown,
+        origin: Origin.View,
       });
 
       opts?.onSaveSuccess?.();

+ 3 - 0
apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -2,6 +2,8 @@ import { useCallback, useEffect } from 'react';
 
 import type EventEmitter from 'events';
 
+import { Origin } from '@growi/core';
+
 import type MarkdownTable from '~/client/models/MarkdownTable';
 import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/Page/markdown-table-util-for-view';
 import { useShareLinkId } from '~/stores/context';
@@ -46,6 +48,7 @@ export const useHandsontableModalLauncherForView = (opts?: {
         pageId: currentPage._id,
         revisionId: currentRevisionId,
         body: newMarkdown,
+        origin: Origin.View,
       });
 
       opts?.onSaveSuccess?.();

+ 4 - 0
apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.module.scss

@@ -2,3 +2,7 @@
   margin-right: 0.2em;
   margin-left: 0.2em;
 }
+
+.material-symbols-outlined {
+  font-size: inherit;
+}

+ 2 - 3
apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx

@@ -41,7 +41,7 @@ export const PagePathHierarchicalLink = memo((props: PagePathHierarchicalLinkPro
         <RootElm>
           <span className="path-segment">
             <Link href="/trash" prefetch={false}>
-              <span className="material-symbols-outlined">delete</span>
+              <span className={`material-symbols-outlined ${styles['material-symbols-outlined']}`}>delete</span>
             </Link>
           </span>
           <span className={`separator ${styles.separator}`}><a href="/">/</a></span>
@@ -51,8 +51,7 @@ export const PagePathHierarchicalLink = memo((props: PagePathHierarchicalLinkPro
         <RootElm>
           <span className="path-segment">
             <Link href="/" prefetch={false}>
-              {/* TODO: Size adjust */}
-              <span className="material-symbols-outlined">home</span>
+              <span className={`material-symbols-outlined ${styles['material-symbols-outlined']}`}>home</span>
               <span className={`separator ${styles.separator}`}>/</span>
             </Link>
           </span>

+ 2 - 2
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -72,10 +72,10 @@ export const PagePathNav: FC<Props> = (props: Props) => {
     const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
     const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
     formerLink = (
-      <>
+      <div className="fs-5">
         <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} isInTrash={isInTrash} />
         <Separator />
-      </>
+      </div>
     );
     latterLink = (
       <>

+ 3 - 1
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,7 +1,9 @@
 import React, { type ReactNode, useCallback } from 'react';
 
+import { Origin } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
+
 import { useCreatePageAndTransit } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
 import { useIsNotFound } from '~/stores/page';
@@ -74,7 +76,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
     try {
       await createAndTransit(
-        { path, wip: shouldCreateWipPage(path) },
+        { path, wip: shouldCreateWipPage(path), origin: Origin.View },
         { shouldCheckPageExists: true },
       );
     }

+ 7 - 2
apps/app/src/components/PageCreateModal.tsx

@@ -2,6 +2,7 @@ import React, {
   useEffect, useState, useMemo, useCallback,
 } from 'react';
 
+import { Origin } from '@growi/core';
 import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
@@ -94,7 +95,7 @@ const PageCreateModal: React.FC = () => {
   const createTodayPage = useCallback(async() => {
     const joinedPath = [todaysParentPath, todayInput].join('/');
     return createAndTransit(
-      { path: joinedPath, wip: true },
+      { path: joinedPath, wip: true, origin: Origin.View },
       { shouldCheckPageExists: true, onTerminated: closeCreateModal },
     );
   }, [closeCreateModal, createAndTransit, todayInput, todaysParentPath]);
@@ -104,7 +105,11 @@ const PageCreateModal: React.FC = () => {
    */
   const createInputPage = useCallback(async() => {
     return createAndTransit(
-      { path: pageNameInput, optionalParentPath: '/', wip: true },
+      {
+        path: pageNameInput,
+        wip: true,
+        origin: Origin.View,
+      },
       { shouldCheckPageExists: true, onTerminated: closeCreateModal },
     );
   }, [closeCreateModal, createAndTransit, pageNameInput]);

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

@@ -6,7 +6,7 @@ import React, {
 import type EventEmitter from 'events';
 import nodePath from 'path';
 
-import type { IPageHasId, IUserHasId } from '@growi/core';
+import { type IPageHasId, Origin } from '@growi/core';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 import { pathUtils } from '@growi/core/dist/utils';
 import {
@@ -205,9 +205,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
       const { page } = await updatePage({
         pageId,
-        revisionId: currentRevisionId,
         body: codeMirrorEditor?.getDoc() ?? '',
         grant: grantData?.grant,
+        origin: Origin.Editor,
         userRelatedGrantUserGroupIds: grantData?.userRelatedGrantedGroups?.map((group) => {
           return { item: group.id, type: group.type };
         }),

+ 9 - 5
apps/app/src/components/PageSideContents/PageSideContents.tsx

@@ -1,4 +1,4 @@
-import React, { Suspense, useCallback } from 'react';
+import React, { Suspense, useCallback, useRef } from 'react';
 
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { getIdForRef, type IPageInfoForOperation } from '@growi/core';
@@ -82,6 +82,8 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
 
   const { page, isSharedUser } = props;
 
+  const tagsRef = useRef<HTMLDivElement>(null);
+
   const { data: pageInfo } = useSWRxPageInfo(page._id);
 
   const pagePath = page.path;
@@ -93,9 +95,11 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
     <>
       {/* Tags */}
       { page.revision != null && (
-        <Suspense fallback={<PageTagsSkeleton />}>
-          <Tags pageId={page._id} revisionId={page.revision._id} />
-        </Suspense>
+        <div ref={tagsRef}>
+          <Suspense fallback={<PageTagsSkeleton />}>
+            <Tags pageId={page._id} revisionId={page.revision._id} />
+          </Suspense>
+        </div>
       ) }
 
       <div className={`${styles['grw-page-accessories-controls']} d-flex flex-column gap-2`}>
@@ -127,7 +131,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
       </div>
 
       <div className="d-none d-xl-block">
-        <TableOfContents />
+        <TableOfContents tagsElementHeight={tagsRef.current?.clientHeight} />
         {isUsersHomepagePath && <ContentLinkButtons author={page?.creator} />}
       </div>
     </>

+ 2 - 1
apps/app/src/components/Sidebar/Custom/CustomSidebarNotFound.tsx

@@ -1,5 +1,6 @@
 import { useCallback } from 'react';
 
+import { Origin } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 
 import { useCreatePageAndTransit } from '~/client/services/create-page';
@@ -10,7 +11,7 @@ export const SidebarNotFound = (): JSX.Element => {
   const { createAndTransit } = useCreatePageAndTransit();
 
   const clickCreateButtonHandler = useCallback(async() => {
-    createAndTransit({ path: '/Sidebar', wip: false });
+    createAndTransit({ path: '/Sidebar', wip: false, origin: Origin.View });
   }, [createAndTransit]);
 
   return (

+ 8 - 1
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts

@@ -1,5 +1,7 @@
 import { useCallback } from 'react';
 
+import { Origin } from '@growi/core';
+
 import { useCreatePageAndTransit } from '~/client/services/create-page';
 import { useCurrentPagePath } from '~/stores/page';
 
@@ -18,7 +20,12 @@ export const useCreateNewPage: UseCreateNewPage = () => {
     if (isLoadingPagePath) return;
 
     return createAndTransit(
-      { parentPath: currentPagePath, optionalParentPath: '/', wip: true },
+      {
+        parentPath: currentPagePath,
+        optionalParentPath: '/',
+        wip: true,
+        origin: Origin.View,
+      },
     );
   }, [createAndTransit, currentPagePath, isLoadingPagePath]);
 

+ 2 - 1
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx

@@ -1,5 +1,6 @@
 import { useCallback } from 'react';
 
+import { Origin } from '@growi/core';
 import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 import { format } from 'date-fns';
 import { useTranslation } from 'react-i18next';
@@ -32,7 +33,7 @@ export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
     if (!isCreatable || todaysPath == null) return;
 
     return createAndTransit(
-      { path: todaysPath, wip: true },
+      { path: todaysPath, wip: true, origin: Origin.View },
       { shouldCheckPageExists: true },
     );
   }, [createAndTransit, isCreatable, todaysPath]);

+ 7 - 3
apps/app/src/components/TableOfContents.tsx

@@ -16,7 +16,11 @@ const { isUsersHomepage: _isUsersHomepage } = pagePathUtils;
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:TableOfContents');
 
-const TableOfContents = (): JSX.Element => {
+type Props = {
+  tagsElementHeight?: number
+}
+
+const TableOfContents = ({ tagsElementHeight }: Props): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
 
   const isUsersHomePage = currentPagePath != null && _isUsersHomepage(currentPagePath);
@@ -30,7 +34,7 @@ const TableOfContents = (): JSX.Element => {
 
     // rendererOptions for redo calcViewHeight()
     // see: https://github.com/weseek/growi/pull/6791
-    if (parentElem == null || containerElem == null || rendererOptions == null) {
+    if (parentElem == null || containerElem == null || rendererOptions == null || tagsElementHeight == null) {
       return 0;
     }
     const parentBottom = parentElem.getBoundingClientRect().bottom;
@@ -47,7 +51,7 @@ const TableOfContents = (): JSX.Element => {
     }
     // bottom - revisionToc top
     return bottom - (containerTop + containerPaddingTop);
-  }, [isUsersHomePage, rendererOptions]);
+  }, [isUsersHomePage, rendererOptions, tagsElementHeight]);
 
   return (
     <div id="revision-toc" className={`revision-toc ${styles['revision-toc']}`}>

+ 4 - 1
apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -1,5 +1,7 @@
 import React, { useState, type FC, useCallback } from 'react';
 
+import { Origin } from '@growi/core';
+
 import { createPage } from '~/client/services/page-operation';
 import { useSWRxPageChildren, mutatePageTree } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
@@ -75,6 +77,7 @@ export const useNewPageInput = (): UseNewPageInput => {
         // keep grant info undefined to inherit from parent
         grant: undefined,
         grantUserGroupIds: undefined,
+        origin: Origin.View,
         wip: shouldCreateWipPage(newPagePath),
       });
 
@@ -83,7 +86,7 @@ export const useNewPageInput = (): UseNewPageInput => {
       if (!hasDescendants) {
         stateHandlers?.setIsOpen(true);
       }
-    }, [hasDescendants, mutateChildren, stateHandlers]);
+    }, [hasDescendants, stateHandlers]);
 
     const submittionFailedHandler = useCallback(() => {
       setProcessingSubmission(false);

+ 5 - 2
apps/app/src/interfaces/apiv3/page.ts

@@ -1,5 +1,5 @@
 import type {
-  IPageHasId, IRevisionHasId, ITag,
+  IPageHasId, IRevisionHasId, ITag, Origin,
 } from '@growi/core';
 
 import type { IOptionsForCreate, IOptionsForUpdate } from '../page';
@@ -12,6 +12,8 @@ export type IApiv3PageCreateParams = IOptionsForCreate & {
   body?: string,
   pageTags?: string[],
 
+  origin?: Origin,
+
   isSlackEnabled?: boolean,
   slackChannels?: string,
 };
@@ -24,9 +26,10 @@ export type IApiv3PageCreateResponse = {
 
 export type IApiv3PageUpdateParams = IOptionsForUpdate & {
   pageId: string,
-  revisionId: string,
+  revisionId?: string,
   body: string,
 
+  origin?: Origin,
   isSlackEnabled?: boolean,
   slackChannels?: string,
 };

+ 3 - 1
apps/app/src/interfaces/page.ts

@@ -1,5 +1,5 @@
 import type {
-  GroupType, IGrantedGroup, IPageHasId, Nullable, PageGrant,
+  GroupType, IGrantedGroup, IPageHasId, Nullable, PageGrant, Origin,
 } from '@growi/core';
 
 import type { IPageOperationProcessData } from './page-operation';
@@ -33,6 +33,7 @@ export type IDeleteManyPageApiv3Result = {
 };
 
 export type IOptionsForUpdate = {
+  origin?: Origin
   grant?: PageGrant,
   userRelatedGrantUserGroupIds?: IGrantedGroup[],
   // isSyncRevisionToHackmd?: boolean,
@@ -44,5 +45,6 @@ export type IOptionsForCreate = {
   grantUserGroupIds?: IGrantedGroup[],
   overwriteScopesOfDescendants?: boolean,
 
+  origin?: Origin
   wip?: boolean,
 };

+ 9 - 2
apps/app/src/server/models/obsolete-page.js

@@ -1,4 +1,4 @@
-import { PageGrant, GroupType } from '@growi/core';
+import { GroupType, Origin } from '@growi/core';
 import { templateChecker, pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import escapeStringRegexp from 'escape-string-regexp';
 
@@ -141,7 +141,14 @@ export const getPageSchema = (crowi) => {
     return relations.map((relation) => { return relation.relatedTag.name });
   };
 
-  pageSchema.methods.isUpdatable = function(previousRevision) {
+  pageSchema.methods.isUpdatable = async function(previousRevision, origin) {
+    const populatedPageDataWithRevisionOrigin = await this.populate('revision', 'origin');
+    const latestRevisionOrigin = populatedPageDataWithRevisionOrigin.revision.origin;
+    const ignoreLatestRevision = origin === Origin.Editor && (latestRevisionOrigin === Origin.Editor || latestRevisionOrigin === Origin.View);
+    if (ignoreLatestRevision) {
+      return true;
+    }
+
     const revision = this.latestRevision || this.revision;
     // comparing ObjectId with string
     // eslint-disable-next-line eqeqeq

+ 5 - 1
apps/app/src/server/models/revision.js

@@ -1,3 +1,5 @@
+import { allOrigin } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 // disable no-return-await for model functions
@@ -29,6 +31,7 @@ module.exports = function(crowi) {
     format: { type: String, default: 'markdown' },
     author: { type: ObjectId, ref: 'User' },
     hasDiffToPrev: { type: Boolean },
+    origin: { type: String, enum: allOrigin },
   }, {
     timestamps: { createdAt: true, updatedAt: false },
   });
@@ -38,7 +41,7 @@ module.exports = function(crowi) {
     return this.updateMany({ pageId }, { $set: updateData });
   };
 
-  revisionSchema.statics.prepareRevision = function(pageData, body, previousBody, user, options) {
+  revisionSchema.statics.prepareRevision = function(pageData, body, previousBody, user, origin, options) {
     const Revision = this;
 
     if (!options) {
@@ -56,6 +59,7 @@ module.exports = function(crowi) {
     newRevision.body = body;
     newRevision.format = format;
     newRevision.author = user._id;
+    newRevision.origin = origin;
     if (pageData.revision != null) {
       newRevision.hasDiffToPrev = body !== previousBody;
     }

+ 4 - 2
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -1,3 +1,4 @@
+import { allOrigin } from '@growi/core';
 import type {
   IPage, IUser, IUserHasId,
 } from '@growi/core';
@@ -117,6 +118,7 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
     body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
     body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
     body('wip').optional().isBoolean().withMessage('wip must be boolean'),
+    body('origin').optional().isIn(allOrigin).withMessage('origin must be "view" or "editor"'),
   ];
 
 
@@ -227,10 +229,10 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
       let createdPage;
       try {
         const {
-          grant, grantUserGroupIds, overwriteScopesOfDescendants, wip,
+          grant, grantUserGroupIds, overwriteScopesOfDescendants, wip, origin,
         } = req.body;
 
-        const options: IOptionsForCreate = { overwriteScopesOfDescendants, wip };
+        const options: IOptionsForCreate = { overwriteScopesOfDescendants, wip, origin };
         if (grant != null) {
           options.grant = grant;
           options.grantUserGroupIds = grantUserGroupIds;

+ 12 - 6
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -1,3 +1,4 @@
+import { allOrigin } from '@growi/core';
 import type {
   IPage, IRevisionHasId, IUserHasId,
 } from '@growi/core';
@@ -8,7 +9,7 @@ import { body } from 'express-validator';
 import mongoose from 'mongoose';
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
-import type { IApiv3PageUpdateParams } from '~/interfaces/apiv3';
+import { type IApiv3PageUpdateParams } from '~/interfaces/apiv3';
 import type { IOptionsForUpdate } from '~/interfaces/page';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import type Crowi from '~/server/crowi';
@@ -63,7 +64,8 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
   const validator: ValidationChain[] = [
     body('pageId').exists().not().isEmpty({ ignore_whitespace: true })
       .withMessage("'pageId' must be specified"),
-    body('revisionId').exists().not().isEmpty({ ignore_whitespace: true })
+    body('revisionId').optional().exists().not()
+      .isEmpty({ ignore_whitespace: true })
       .withMessage("'revisionId' must be specified"),
     body('body').exists().isString()
       .withMessage("The empty value is not allowd for the 'body'"),
@@ -72,6 +74,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
     body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
     body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
+    body('origin').optional().isIn(allOrigin).withMessage('origin must be "view" or "editor"'),
   ];
 
 
@@ -101,7 +104,8 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     const { revisionId, isSlackEnabled, slackChannels } = req.body;
     if (isSlackEnabled) {
       try {
-        const results = await crowi.userNotificationService.fire(updatedPage, req.user, slackChannels, 'update', { previousRevision: revisionId });
+        const option = revisionId != null ? { previousRevision: revisionId } : undefined;
+        const results = await crowi.userNotificationService.fire(updatedPage, req.user, slackChannels, 'update', option);
         results.forEach((result) => {
           if (result.status === 'rejected') {
             logger.error('Create user notification failed', result.reason);
@@ -120,7 +124,9 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
     validator, apiV3FormValidator,
     async(req: UpdatePageRequest, res: ApiV3Response) => {
-      const { pageId, revisionId, body } = req.body;
+      const {
+        pageId, revisionId, body, origin,
+      } = req.body;
 
       // check page existence
       const isExist = await Page.count({ _id: pageId }) > 0;
@@ -130,7 +136,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
 
       // check revision
       const currentPage = await Page.findByIdAndViewer(pageId, req.user);
-      if (currentPage != null && !currentPage.isUpdatable(revisionId)) {
+      if (currentPage != null && !currentPage.isUpdatable(revisionId, origin)) {
         const latestRevision = await Revision.findById(currentPage.revision).populate('author');
         const returnLatestRevision = {
           revisionId: latestRevision?._id.toString(),
@@ -146,7 +152,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
       let updatedPage;
       try {
         const { grant, userRelatedGrantUserGroupIds, overwriteScopesOfDescendants } = req.body;
-        const options: IOptionsForUpdate = { overwriteScopesOfDescendants };
+        const options: IOptionsForUpdate = { overwriteScopesOfDescendants, origin };
         if (grant != null) {
           options.grant = grant;
           options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;

+ 5 - 5
apps/app/src/server/service/page/index.ts

@@ -3825,7 +3825,7 @@ class PageService implements IPageService {
 
     // Create revision
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user);
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user, options.origin);
     savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
 
@@ -3919,7 +3919,7 @@ class PageService implements IPageService {
     page.applyScope(user, grant, grantUserGroupIds);
 
     let savedPage = await page.save();
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user, undefined, { format });
     savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
 
@@ -4214,15 +4214,15 @@ class PageService implements IPageService {
     let savedPage = await newPageData.save();
 
     // Update body
-    const isBodyPresent = body != null && previousBody != null;
+    const isBodyPresent = body != null;
     const shouldUpdateBody = isBodyPresent;
     if (shouldUpdateBody) {
-      const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
+      const origin = options.origin;
+      const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user, origin);
       savedPage = await pushRevision(savedPage, newRevision, user);
       await savedPage.populateDataToShowRevision();
     }
 
-
     this.pageEvent.emit('update', savedPage, user);
 
     // Update ex children's parent

+ 2 - 0
bin/data-migrations/README.md

@@ -46,6 +46,8 @@ reference](https://docs.growi.org/ja/admin-guide/upgrading/60x.html#%E6%9C%AA%E5
 - `v60x` or `v60x/index`: Migration for all notations in v6.0.x series
 - `v61x/mdcont`: Migration for mdcont notation only([reference](https://docs.growi.org/ja/admin-guide/upgrading/61x.html#%E4%BB%95%E6%A7%98%E5%A4%89%E6%9B%B4-%E3%82%A2%E3%83%B3%E3%82%AB%E3%83%BC%E3%83%AA%E3%83%B3%E3%82%AF%E3%81%AB%E8%87%AA%E5%8B%95%E4%BB%98%E4%B8%8E%E3%81%95%E3%82%8C%E3%82%8B-mdcont-%E3%83%95%E3%82%9A%E3%83%AC%E3%83%95%E3%82%A3%E3%82%AF%E3%82%B9%E3%81%AE%E5%BB%83%E6%AD%A2))
 - `v61x` or `v61x/index`: Migration for all notations in v6.1.x series
+- `v70x/bootstrap5`: Migration for Bootstrap4 to Bootstrap5 
+- `v70x` or `v70x/index`: Migration for all notations in v7.0.x series
 - `custom`: You can define your own processors and apply them to `revision` (see "Advanced" below for details)
 
 ### Optional

+ 682 - 0
bin/data-migrations/src/migrations/v70x/bootstrap5.js

@@ -0,0 +1,682 @@
+/**
+ * @typedef {import('../../types').MigrationModule} MigrationModule
+ */
+
+// Script for migrating from Bootstrap4 to Bootstrap5 syntax
+// Inspired by https://github.com/coliff/bootstrap-5-migrate-tool/blob/main/gulpfile.js
+
+module.exports = [
+  /**
+   * @type {MigrationModule}
+   */
+  (body) => {
+    let replacedBody = body;
+
+    replacedBody = replacedBody.replace(
+      // eslint-disable-next-line max-len
+      /\sdata-(animation|autohide|boundary|container|content|custom-class|delay|dismiss|display|html|interval|keyboard|method|offset|pause|placement|popper-config|reference|ride|selector|slide(-to)?|target|template|title|toggle|touch|trigger|wrap)=/g,
+      (match, p1) => {
+        if (p1 === 'toggle' && match.includes('data-bs-toggle="')) {
+          return match;
+        }
+        return ` data-bs-${p1}=`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(/\[data-toggle=/g, '[data-bs-toggle=');
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-danger\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-danger${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-dark\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-dark${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-info\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-info${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-light\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-light${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-pill\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}rounded-pill${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-primary\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-primary${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-secondary\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-secondary${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-success\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-success${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-warning\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-warning${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bborder-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}border-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bborder-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}border-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bclose\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}btn-close${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-control-input\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-check-input${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-control-label\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-check-label${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-control custom-checkbox\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-check${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-control custom-radio\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-check${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-file-input\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-control${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-file-label\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-label${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-range\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-range${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-select-sm\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-select-sm${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-select-lg\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-select-lg${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-select\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-select${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-control custom-switch\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-check form-switch${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-sm-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-sm-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-md-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-md-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-lg-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-lg-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-xl-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-xl-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-sm-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-sm-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-md-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-md-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-lg-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-lg-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-xl-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-xl-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropleft\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropstart${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropright\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropend${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-sm-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-sm-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-md-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-md-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-lg-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-lg-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-xl-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-xl-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-sm-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-sm-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-md-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-md-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-lg-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-lg-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-xl-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-xl-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfont-italic\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}fst-italic${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfont-weight-bold\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}fw-bold${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfont-weight-bolder\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}fw-bolder${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfont-weight-light\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}fw-light${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfont-weight-lighter\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}fw-lighter${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfont-weight-normal\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}fw-normal${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bform-control-file\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-control${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bform-control-range\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-range${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bform-group\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}mb-3${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bform-inline\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}d-flex align-items-center${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bform-row\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}row${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bjumbotron-fluid\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}rounded-0 px-0${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bjumbotron\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}bg-light mb-4 rounded-2 py-5 px-3${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bmedia-body\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}flex-grow-1${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bmedia\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}d-flex${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bml-\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ms-${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bml-n\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ms-n${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bmr-\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}me-${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bmr-n\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}me-n${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bno-gutters\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}g-0${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bpl-\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ps-${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bpr-\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}pe-${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bpre-scrollable\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}overflow-y-scroll${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bembed-responsive-item\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bembed-responsive-16by9\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ratio-16x9${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bembed-responsive-1by1\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ratio-1x1${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bembed-responsive-21by9\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ratio-21x9${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bembed-responsive-4by3\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ratio-4x3${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bembed-responsive\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ratio${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\brounded-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}rounded-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\brounded-lg\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}rounded-3${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\brounded-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}rounded-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\brounded-sm\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}rounded-1${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bsr-only-focusable\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}visually-hidden-focusable${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bsr-only\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}visually-hidden${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-hide\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}d-none${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-sm-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-sm-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-md-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-md-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-lg-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-lg-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-xl-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-xl-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-sm-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-sm-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-md-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-md-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-lg-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-lg-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-xl-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-xl-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-monospace\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}font-monospace${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /<select([^>]*)\bclass=['"]([^'"]*)form-control(-lg|-sm)?([^'"]*)['"]([^>]*)>/g, '<select$1class="$2form-select$3$4"$5>',
+    );
+
+    replacedBody = replacedBody.replace(/<select([^>]*)\bclass=['"]([^'"]*)form-control\b([^'"]*['"])/g, '<select$1class="$2form-select$3');
+
+    replacedBody = replacedBody.replace('<span aria-hidden="true">&times;</span>', '');
+
+    return replacedBody;
+  },
+];

+ 3 - 0
bin/data-migrations/src/migrations/v70x/index.js

@@ -0,0 +1,3 @@
+const bootstrap5 = require('./bootstrap5');
+
+module.exports = [...bootstrap5];

+ 10 - 0
packages/core/src/interfaces/revision.ts

@@ -1,12 +1,22 @@
 import type { HasObjectId } from './has-object-id';
 import type { IUser } from './user';
 
+export const Origin = {
+  View: 'view',
+  Editor: 'editor',
+} as const;
+
+export type Origin = typeof Origin[keyof typeof Origin];
+
+export const allOrigin = Object.values(Origin);
+
 export type IRevision = {
   body: string,
   author: IUser,
   hasDiffToPrev: boolean;
   createdAt: Date,
   updatedAt: Date,
+  origin?: Origin,
 }
 
 export type IRevisionHasId = IRevision & HasObjectId;

+ 2 - 3
packages/editor/src/components/CodeMirrorEditorMain.tsx

@@ -1,4 +1,4 @@
-import { useEffect } from 'react';
+useEffect } from 'react';
 
 import { type Extension } from '@codemirror/state';
 import { keymap, scrollPastEnd } from '@codemirror/view';
@@ -21,7 +21,6 @@ type Props = CodeMirrorEditorProps & {
   user?: IUserHasId,
   pageId?: string,
   initialValue?: string,
-  onOpenEditor?: (markdown: string) => void,
   onEditorsUpdated?: (userList: IUserHasId[]) => void,
 }
 
@@ -33,7 +32,7 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
 
-  useCollaborativeEditorMode(user, pageId, initialValue, onOpenEditor, onEditorsUpdated, codeMirrorEditor);
+  useCollaborativeEditorMode(user, pageId, initialValue, onEditorsUpdated, codeMirrorEditor);
 
   // setup additional extensions
   useEffect(() => {

+ 6 - 20
packages/editor/src/stores/use-collaborative-editor-mode.ts

@@ -23,18 +23,16 @@ export const useCollaborativeEditorMode = (
     user?: IUserHasId,
     pageId?: string,
     initialValue?: string,
-    onOpenEditor?: (markdown: string) => void,
     onEditorsUpdated?: (userList: IUserHasId[]) => void,
     codeMirrorEditor?: UseCodeMirrorEditor,
 ): void => {
   const [ydoc, setYdoc] = useState<Y.Doc | null>(null);
   const [provider, setProvider] = useState<SocketIOProvider | null>(null);
-  const [isInit, setIsInit] = useState(false);
   const [cPageId, setCPageId] = useState(pageId);
 
   const { data: socket } = useGlobalSocket();
 
-  const cleanupYDocAndProvider = () => {
+  const cleanupYDoc = () => {
     if (cPageId === pageId) {
       return;
     }
@@ -49,7 +47,6 @@ export const useCollaborativeEditorMode = (
     // TODO: catch ydoc:sync:error GlobalSocketEventName.YDocSyncError
     socket?.off(GlobalSocketEventName.YDocSync);
 
-    setIsInit(false);
     setCPageId(pageId);
 
     // reset editors
@@ -112,35 +109,24 @@ export const useCollaborativeEditorMode = (
   };
 
   const setupYDocExtensions = () => {
-    if (ydoc == null || provider == null) {
+    if (ydoc == null || provider == null || codeMirrorEditor == null) {
       return;
     }
 
     const ytext = ydoc.getText('codemirror');
     const undoManager = new Y.UndoManager(ytext);
 
-    const cleanup = codeMirrorEditor?.appendExtensions?.([
+    codeMirrorEditor.initDoc(ytext.toString());
+
+    const cleanup = codeMirrorEditor.appendExtensions([
       yCollab(ytext, provider.awareness, { undoManager }),
     ]);
 
     return cleanup;
   };
 
-  const initializeEditor = () => {
-    if (ydoc == null || onOpenEditor == null || isInit === true) {
-      return;
-    }
-
-    const ytext = ydoc.getText('codemirror');
-    codeMirrorEditor?.initDoc(ytext.toString());
-    onOpenEditor(ytext.toString());
-
-    setIsInit(true);
-  };
-
-  useEffect(cleanupYDocAndProvider, [cPageId, onEditorsUpdated, pageId, provider, socket, ydoc]);
+  useEffect(cleanupYDoc, [cPageId, onEditorsUpdated, pageId, provider, socket, ydoc]);
   useEffect(setupYDoc, [provider, ydoc]);
   useEffect(setupProvider, [initialValue, onEditorsUpdated, pageId, provider, socket, user, ydoc]);
   useEffect(setupYDocExtensions, [codeMirrorEditor, provider, ydoc]);
-  useEffect(initializeEditor, [codeMirrorEditor, isInit, onOpenEditor, ydoc]);
 };