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

Merge branch 'master' into feat/page-bulk-export-pdf-included

Futa Arai 1 год назад
Родитель
Сommit
b26227c356

+ 290 - 8
apps/app/src/server/routes/apiv3/page/index.ts

@@ -190,7 +190,7 @@ module.exports = (crowi) => {
    *      get:
    *        tags: [Page]
    *        operationId: getPage
-   *        summary: /page
+   *        summary: Get page
    *        description: get page by pagePath or pageId
    *        parameters:
    *          - name: pageId
@@ -267,6 +267,31 @@ module.exports = (crowi) => {
     return res.apiv3({ page, pages });
   });
 
+  /**
+   * @swagger
+   *   /page/exist:
+   *     get:
+   *       tags: [Page]
+   *       summary: Check if page exists
+   *       description: Check if a page exists at the specified path
+   *       parameters:
+   *         - name: path
+   *           in: query
+   *           description: The path to check for existence
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully checked page existence.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   isExist:
+   *                     type: boolean
+   */
   router.get('/exist', checkPageExistenceHandlersFactory(crowi));
 
   /**
@@ -275,6 +300,7 @@ module.exports = (crowi) => {
    *    /page:
    *      post:
    *        tags: [Page]
+   *        summary: Create page
    *        operationId: createPage
    *        description: Create page
    *        requestBody:
@@ -398,7 +424,7 @@ module.exports = (crowi) => {
    *    /page/likes:
    *      put:
    *        tags: [Page]
-   *        summary: /page/likes
+   *        summary: Get page likes
    *        description: Update liked status
    *        operationId: updateLikedStatus
    *        requestBody:
@@ -466,7 +492,7 @@ module.exports = (crowi) => {
    *    /page/info:
    *      get:
    *        tags: [Page]
-   *        summary: /page/info
+   *        summary: Get page info
    *        description: Retrieve current page info
    *        operationId: getPageInfo
    *        requestBody:
@@ -510,7 +536,7 @@ module.exports = (crowi) => {
    *    /page/grant-data:
    *      get:
    *        tags: [Page]
-   *        summary: /page/info
+   *        summary: Get page grant data
    *        description: Retrieve current page's grant data
    *        operationId: getPageGrantData
    *        parameters:
@@ -605,6 +631,37 @@ module.exports = (crowi) => {
 
   // Check if non user related groups are granted page access.
   // If specified page does not exist, check the closest ancestor.
+  /**
+   * @swagger
+   *   /page/non-user-related-groups-granted:
+   *     get:
+   *       tags: [Page]
+   *       security:
+   *         - cookieAuth: []
+   *       summary: Check if non-user related groups are granted page access
+   *       description: Check if non-user related groups are granted access to a specific page or its closest ancestor
+   *       parameters:
+   *         - name: path
+   *           in: query
+   *           description: Path of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully checked non-user related groups access.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   isNonUserRelatedGroupsGranted:
+   *                     type: boolean
+   *         403:
+   *           description: Forbidden. Cannot access page or ancestor.
+   *         500:
+   *           description: Internal server error.
+   */
   router.get('/non-user-related-groups-granted', loginRequiredStrictly, validator.nonUserRelatedGroupsGranted, apiV3FormValidator,
     async(req, res: ApiV3Response) => {
       const { user } = req;
@@ -636,7 +693,45 @@ module.exports = (crowi) => {
         return res.apiv3Err(err, 500);
       }
     });
-
+  /**
+   * @swagger
+   *   /page/applicable-grant:
+   *     get:
+   *       tags: [Page]
+   *       security:
+   *         - cookieAuth: []
+   *       summary: Get applicable grant data
+   *       description: Retrieve applicable grant data for a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: query
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully retrieved applicable grant data.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   grant:
+   *                     type: number
+   *                   grantedUsers:
+   *                     type: array
+   *                     items:
+   *                       type: string
+   *                   grantedGroups:
+   *                     type: array
+   *                     items:
+   *                       type: string
+   *         400:
+   *           description: Bad request. Page is unreachable or empty.
+   *         500:
+   *           description: Internal server error.
+   */
   router.get('/applicable-grant', loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.query;
 
@@ -660,6 +755,43 @@ module.exports = (crowi) => {
     return res.apiv3(data);
   });
 
+  /**
+   * @swagger
+   *   /:pageId/grant:
+   *     put:
+   *       tags: [Page]
+   *       security:
+   *         - cookieAuth: []
+   *       summary: Update page grant
+   *       description: Update the grant of a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       requestBody:
+   *         content:
+   *           application/json:
+   *             schema:
+   *               properties:
+   *                 grant:
+   *                   type: number
+   *                   description: Grant level
+   *                 userRelatedGrantedGroups:
+   *                   type: array
+   *                   items:
+   *                     type: string
+   *                   description: Array of user-related granted group IDs
+   *       responses:
+   *         200:
+   *           description: Successfully updated page grant.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 $ref: '#/components/schemas/Page'
+   */
   router.put('/:pageId/grant', loginRequiredStrictly, excludeReadOnlyUser, validator.updateGrant, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.params;
     const { grant, userRelatedGrantedGroups } = req.body;
@@ -693,6 +825,8 @@ module.exports = (crowi) => {
   *    /page/export:
   *      get:
   *        tags: [Page]
+  *        security:
+  *          - cookieAuth: []
   *        description: return page's markdown
   *        responses:
   *          200:
@@ -796,7 +930,9 @@ module.exports = (crowi) => {
    *    /page/exist-paths:
    *      get:
    *        tags: [Page]
-   *        summary: /page/exist-paths
+   *        security:
+   *          - cookieAuth: []
+   *        summary: Get already exist paths
    *        description: Get already exist paths
    *        operationId: getAlreadyExistPaths
    *        parameters:
@@ -857,7 +993,7 @@ module.exports = (crowi) => {
    *    /page/subscribe:
    *      put:
    *        tags: [Page]
-   *        summary: /page/subscribe
+   *        summary: Update subscription status
    *        description: Update subscription status
    *        operationId: updateSubscriptionStatus
    *        requestBody:
@@ -904,6 +1040,39 @@ module.exports = (crowi) => {
   });
 
 
+  /**
+   * @swagger
+   *
+   *   /:pageId/content-width:
+   *     put:
+   *       tags: [Page]
+   *       summary: Update content width
+   *       description: Update the content width setting for a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       requestBody:
+   *         content:
+   *           application/json:
+   *             schema:
+   *               properties:
+   *                 expandContentWidth:
+   *                   type: boolean
+   *                   description: Whether to expand the content width
+   *       responses:
+   *         200:
+   *           description: Successfully updated content width.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 properties:
+   *                   page:
+   *                     $ref: '#/components/schemas/Page'
+   */
   router.put('/:pageId/content-width', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser,
     validator.contentWidth, apiV3FormValidator, async(req, res) => {
       const { pageId } = req.params;
@@ -925,13 +1094,126 @@ module.exports = (crowi) => {
       }
     });
 
-
+  /**
+   * @swagger
+   *   /:pageId/publish:
+   *     put:
+   *       tags: [Page]
+   *       summary: Publish page
+   *       description: Publish a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully published the page.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 $ref: '#/components/schemas/Page'
+   */
   router.put('/:pageId/publish', publishPageHandlersFactory(crowi));
 
+  /**
+   * @swagger
+   *   /:pageId/unpublish:
+   *     put:
+   *       tags: [Page]
+   *       summary: Unpublish page
+   *       description: Unpublish a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully unpublished the page.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 $ref: '#/components/schemas/Page'
+   */
   router.put('/:pageId/unpublish', unpublishPageHandlersFactory(crowi));
 
+  /**
+   * @swagger
+   *   /:pageId/yjs-data:
+   *     get:
+   *       tags: [Page]
+   *       summary: Get Yjs data
+   *       description: Retrieve Yjs data for a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully retrieved Yjs data.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   yjsData:
+   *                     type: object
+   *                     description: Yjs data
+   *                     properties:
+   *                       hasYdocsNewerThanLatestRevision:
+   *                         type: boolean
+   *                         description: Whether Yjs documents are newer than the latest revision
+   *                       awarenessStateSize:
+   *                         type: number
+   *                         description: Size of the awareness state
+   */
   router.get('/:pageId/yjs-data', getYjsDataHandlerFactory(crowi));
 
+  /**
+   * @swagger
+   *   /:pageId/sync-latest-revision-body-to-yjs-draft:
+   *     put:
+   *       tags: [Page]
+   *       summary: Sync latest revision body to Yjs draft
+   *       description: Sync the latest revision body to the Yjs draft for a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       requestBody:
+   *         content:
+   *           application/json:
+   *             schema:
+   *               properties:
+   *                 editingMarkdownLength:
+   *                   type: integer
+   *                   description: Length of the editing markdown
+   *       responses:
+   *         200:
+   *           description: Successfully synced the latest revision body to Yjs draft.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   synced:
+   *                     type: boolean
+   *                     description: Whether the latest revision body is synced to the Yjs draft
+   *                   isYjsDataBroken:
+   *                     type: boolean
+   *                     description: Whether Yjs data is broken
+   */
   router.put('/:pageId/sync-latest-revision-body-to-yjs-draft', syncLatestRevisionBodyToYjsDraftHandlerFactory(crowi));
 
   return router;

+ 232 - 7
apps/app/src/server/routes/apiv3/pages/index.js

@@ -129,10 +129,27 @@ module.exports = (crowi) => {
    *      get:
    *        tags: [Pages]
    *        description: Get recently updated pages
+   *        parameters:
+   *          - name: limit
+   *            in: query
+   *            description: Limit of acquisitions
+   *            schema:
+   *              type: number
+   *            example: 10
+   *          - name: offset
+   *            in: query
+   *            description: Offset of acquisitions
+   *            schema:
+   *              type: number
+   *            example: 0
+   *          - name: includeWipPage
+   *            in: query
+   *            description: Whether to include WIP pages
+   *            schema:
+   *              type: string
    *        responses:
    *          200:
    *            description: Return pages recently updated
-   *
    */
   router.get('/recent', accessTokenParser, loginRequired, validator.recent, apiV3FormValidator, async(req, res) => {
     const limit = parseInt(req.query.limit) || 20;
@@ -233,6 +250,9 @@ module.exports = (crowi) => {
    *                  isRecursively:
    *                    type: boolean
    *                    description: whether rename page with descendants
+   *                  isMoveMode:
+   *                    type: boolean
+   *                    description: whether rename page with moving
    *                required:
    *                  - pageId
    *                  - revisionId
@@ -328,6 +348,28 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+    * @swagger
+    *    /pages/resume-rename:
+    *      post:
+    *        tags: [Pages]
+    *        operationId: resumeRenamePage
+    *        description: Resume rename page operation
+    *        requestBody:
+    *          content:
+    *            application/json:
+    *              schema:
+    *                properties:
+    *                  pageId:
+    *                    $ref: '#/components/schemas/Page/properties/_id'
+    *                required:
+    *                  - pageId
+    *        responses:
+    *          200:
+    *            description: Succeeded to resume rename page operation.
+    *            content:
+    *              description: Empty response
+    */
   router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator,
     async(req, res) => {
 
@@ -369,6 +411,14 @@ module.exports = (crowi) => {
    *        responses:
    *          200:
    *            description: Succeeded to remove all trash pages
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    deletablePages:
+   *                      type: array
+   *                      items:
+   *                        $ref: '#/components/schemas/Page'
    */
   router.delete('/empty-trash', accessTokenParser, loginRequired, excludeReadOnlyUser, addActivity, apiV3FormValidator, async(req, res) => {
     const options = {};
@@ -423,6 +473,59 @@ module.exports = (crowi) => {
     query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
   ];
 
+  /**
+    * @swagger
+    *
+    *    /pages/list:
+    *      get:
+    *        tags: [Pages]
+    *        operationId: getList
+    *        description: Get list of pages
+    *        parameters:
+    *          - name: path
+    *            in: query
+    *            description: Path to search
+    *            schema:
+    *              type: string
+    *          - name: limit
+    *            in: query
+    *            description: Limit of acquisitions
+    *            schema:
+    *              type: number
+    *          - name: page
+    *            in: query
+    *            description: Page number
+    *            schema:
+    *              type: number
+    *        responses:
+    *          200:
+    *            description: Succeeded to retrieve pages.
+    *            content:
+    *              application/json:
+    *                schema:
+    *                  properties:
+    *                    totalCount:
+    *                      type: number
+    *                      description: Total count of pages
+    *                      example: 3
+    *                    offset:
+    *                      type: number
+    *                      description: Offset of pages
+    *                      example: 0
+    *                    limit:
+    *                      type: number
+    *                      description: Limit of pages
+    *                      example: 10
+    *                    pages:
+    *                      type: array
+    *                      items:
+    *                        allOf:
+    *                          - $ref: '#/components/schemas/Page'
+    *                          - type: object
+    *                            properties:
+    *                              lastUpdateUser:
+    *                                $ref: '#/components/schemas/User'
+    */
   router.get('/list', accessTokenParser, loginRequired, validator.displayList, apiV3FormValidator, async(req, res) => {
 
     const { path } = req.query;
@@ -480,6 +583,9 @@ module.exports = (crowi) => {
    *                  isRecursively:
    *                    type: boolean
    *                    description: whether duplicate page with descendants
+   *                  onlyDuplicateUserRelatedResources:
+   *                    type: boolean
+   *                    description: whether duplicate only user related resources
    *                required:
    *                  - pageId
    *        responses:
@@ -589,11 +695,10 @@ module.exports = (crowi) => {
    *              application/json:
    *                schema:
    *                  properties:
-   *                    subordinatedPaths:
-   *                      type: object
-   *                      description: descendants page
-   *          500:
-   *            description: Internal server error.
+   *                    subordinatedPages:
+   *                      type: array
+   *                      items:
+   *                        $ref: '#/components/schemas/Page'
    */
   router.get('/subordinated-list', accessTokenParser, loginRequired, async(req, res) => {
     const { path } = req.query;
@@ -611,6 +716,50 @@ module.exports = (crowi) => {
 
   });
 
+  /**
+    * @swagger
+    *    /pages/delete:
+    *      post:
+    *        tags: [Pages]
+    *        operationId: deletePages
+    *        description: Delete pages
+    *        requestBody:
+    *          content:
+    *            application/json:
+    *              schema:
+    *                properties:
+    *                  pageIdToRevisionIdMap:
+    *                    type: object
+    *                    description: Map of page IDs to revision IDs
+    *                    example: { "5e2d6aede35da4004ef7e0b7": "5e07345972560e001761fa63" }
+    *                  isCompletely:
+    *                    type: boolean
+    *                    description: Whether to delete pages completely
+    *                  isRecursively:
+    *                    type: boolean
+    *                    description: Whether to delete pages recursively
+    *                  isAnyoneWithTheLink:
+    *                    type: boolean
+    *                    description: Whether the page is restricted to anyone with the link
+    *        responses:
+    *          200:
+    *            description: Succeeded to delete pages.
+    *            content:
+    *              application/json:
+    *                schema:
+    *                  properties:
+    *                    paths:
+    *                      type: array
+    *                      items:
+    *                        type: string
+    *                      description: List of deleted page paths
+    *                    isRecursively:
+    *                      type: boolean
+    *                      description: Whether pages were deleted recursively
+    *                    isCompletely:
+    *                      type: boolean
+    *                      description: Whether pages were deleted completely
+    */
   router.post('/delete', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, validator.deletePages, apiV3FormValidator, async(req, res) => {
     const {
       pageIdToRevisionIdMap, isCompletely, isRecursively, isAnyoneWithTheLink,
@@ -665,7 +814,32 @@ module.exports = (crowi) => {
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
   });
 
-
+  /**
+   * @swagger
+   *
+   *    /pages/convert-pages-by-path:
+   *      post:
+   *        tags: [Pages]
+   *        operationId: convertPagesByPath
+   *        description: Convert pages by path
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  convertPath:
+   *                    type: string
+   *                    description: Path to convert
+   *                    example: /user/alice
+   *        responses:
+   *          200:
+   *            description: Succeeded to convert pages.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
+   *                  description: Empty object
+   */
   // eslint-disable-next-line max-len
   router.post('/convert-pages-by-path', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, adminRequired, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
     const { convertPath } = req.body;
@@ -688,6 +862,36 @@ module.exports = (crowi) => {
     return res.apiv3({});
   });
 
+  /**
+   * @swagger
+   *
+   *    /pages/legacy-pages-migration:
+   *      post:
+   *        tags: [Pages]
+   *        operationId: legacyPagesMigration
+   *        description: Migrate legacy pages
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  pageIds:
+   *                    type: array
+   *                    items:
+   *                      type: string
+   *                    description: List of page IDs to migrate
+   *                  isRecursively:
+   *                    type: boolean
+   *                    description: Whether to migrate pages recursively
+   *        responses:
+   *          200:
+   *            description: Succeeded to migrate legacy pages.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
+   *                  description: Empty object
+  */
   // eslint-disable-next-line max-len
   router.post('/legacy-pages-migration', accessTokenParser, loginRequired, excludeReadOnlyUser, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
     const { pageIds: _pageIds, isRecursively } = req.body;
@@ -717,6 +921,27 @@ module.exports = (crowi) => {
     return res.apiv3({});
   });
 
+  /**
+   * @swagger
+   *
+   *    /pages/v5-migration-status:
+   *      get:
+   *        tags: [Pages]
+   *        description: Get V5 migration status
+   *        responses:
+   *          200:
+   *            description: Return V5 migration status
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    isV5Compatible:
+   *                      type: boolean
+   *                      description: Whether the app is V5 compatible
+   *                    migratablePagesCount:
+   *                      type: number
+   *                      description: Number of pages that can be migrated
+   */
   router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
     try {
       const isV5Compatible = crowi.configManager.getConfig('app:isV5Compatible');

+ 26 - 4
apps/app/src/server/routes/apiv3/slack-integration-legacy-settings.js

@@ -55,6 +55,8 @@ module.exports = (crowi) => {
    *    /slack-integration-legacy-setting/:
    *      get:
    *        tags: [SlackIntegrationLegacySetting]
+   *        security:
+   *          - cookieAuth: []
    *        description: Get slack configuration setting
    *        responses:
    *          200:
@@ -63,9 +65,15 @@ module.exports = (crowi) => {
    *              application/json:
    *                schema:
    *                  properties:
-   *                    notificationParams:
+   *                    slackIntegrationParams:
    *                      type: object
-   *                      description: slack configuration setting params
+   *                      allOf:
+   *                        - $ref: '#/components/schemas/SlackConfigurationParams'
+   *                        - type: object
+   *                          properties:
+   *                            isSlackbotConfigured:
+   *                              type: boolean
+   *                              description: whether slackbot is configured
    */
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
 
@@ -84,20 +92,34 @@ module.exports = (crowi) => {
    *    /slack-integration-legacy-setting/:
    *      put:
    *        tags: [SlackIntegrationLegacySetting]
+   *        security:
+   *          - cookieAuth: []
    *        description: Update slack configuration setting
    *        requestBody:
    *          required: true
    *          content:
    *            application/json:
    *              schema:
-   *                $ref: '#/components/schemas/SlackConfigurationParams'
+   *                properties:
+   *                  webhookUrl:
+   *                    type: string
+   *                    description: incoming webhooks url
+   *                  isIncomingWebhookPrioritized:
+   *                    type: boolean
+   *                    description: use incoming webhooks even if Slack App settings are enabled
+   *                  slackToken:
+   *                    type: string
+   *                    description: OAuth access token
    *        responses:
    *          200:
    *            description: Succeeded to update slack configuration setting
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/SlackConfigurationParams'
+   *                  properties:
+   *                    responseParams:
+   *                      type: object
+   *                      $ref: '#/components/schemas/SlackConfigurationParams'
    */
   router.put('/', loginRequiredStrictly, adminRequired, addActivity, validator.slackConfiguration, apiV3FormValidator, async(req, res) => {
 

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

@@ -8,8 +8,9 @@
 git clone https://github.com/weseek/growi
 cd growi/bin/data-migrations
 
-NETWORK=growi_devcontainer_default \
-MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi \
+NETWORK=growi_devcontainer_default
+MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi
+
 docker run --rm \
   --network $NETWORK \
   -v "$(pwd)"/src:/opt \

+ 2 - 1
bin/data-migrations/src/migrations/v60x/index.js

@@ -2,6 +2,7 @@ const bracketlink = require('./bracketlink');
 const csv = require('./csv');
 const drawio = require('./drawio');
 const plantUML = require('./plantuml');
+const remarkGrowiDirective = require('./remark-growi-directive');
 const tsv = require('./tsv');
 
-module.exports = [...bracketlink, ...csv, ...drawio, ...plantUML, ...tsv];
+module.exports = [...bracketlink, ...csv, ...drawio, ...plantUML, ...tsv, ...remarkGrowiDirective];

+ 25 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/README.ja.md

@@ -0,0 +1,25 @@
+# remark-growi-directive
+
+以下の要領で replace する
+
+なお、`$foo()` は一例であり、`$bar()`, `$baz()`, `$foo-2()` など、さまざまな directive に対応する必要がある
+
+## 1. HTMLタグ内で `$foo()` を利用している箇所
+- 置換対象文章の詳細
+  - `$foo()`がHTMLタグ内かつ、当該`$foo()`記述の1行前が空行ではない場合に1行前に空行を挿入する
+  - `$foo()`がHTMLタグ内かつ、`$foo()`記述行の行頭にインデントがついている場合に当該行のインデントを削除する
+  - `$foo()`がHTMLタグ内かつ、当該`$foo()`記述の1行後のHTMLタグ記述行にインデントがついている場合にその行頭のインデントを削除する
+
+## 2. `$foo()` を利用している箇所
+- 置換対象文章の詳細
+  - `$foo()`の引数内で `filter=` あるいは `except=` に対する値に括弧 `()` を使用している場合、括弧を削除する
+    - before: `$foo()`(depth=2, filter=(AAA), except=(BBB))
+    - after: `$foo()`(depth=2, filter=AAA, except=BBB)
+
+## テストについて
+
+以下を満たす
+
+- input が `example.md` のとき、`example-expected.md` を出力する
+- input が `example-expected.md` のとき、`example-expected.md` を出力する (変更が起こらない)
+

+ 43 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/example-expected.md

@@ -0,0 +1,43 @@
+# Should not be replaced
+
+filter=(FOO), except=(word1|word2|word3)
+
+# Should be replaced
+
+<div class="container-fluid">
+    <div class="row">
+        <div>
+            <div>FOO</div>
+
+$foo(depth=2, filter=FOO)
+</div>
+        <div>
+            <div>BAR</div>
+
+$bar(depth=2, filter=BAR)
+</div>
+        <div>
+            <div>BAZ</div>
+
+$baz(depth=2, filter=BAZ)
+</div>
+    </div>
+    <hr>
+    <div class="row">
+        <div>
+            <div>FOO</div>
+
+$foo(depth=2, filter=FOO, except=word1|word2|word3)
+</div>
+        <div>
+            <div>BAR</div>
+
+$bar(depth=2, filter=BAR, except=word1|word2|word3)
+</div>
+        <div>
+                <div>BAZ</div>
+
+$baz(depth=2, filter=BAZ, except=word1|word2|word3)
+</div>
+    </div>
+</div>

+ 37 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/example.md

@@ -0,0 +1,37 @@
+# Should not be replaced
+
+filter=(FOO), except=(word1|word2|word3)
+
+# Should be replaced
+
+<div class="container-fluid">
+    <div class="row">
+        <div>
+            <div>FOO</div>
+            $foo(depth=2, filter=(FOO))
+        </div>
+        <div>
+            <div>BAR</div>
+            $bar(depth=2, filter=(BAR))
+        </div>
+        <div>
+            <div>BAZ</div>
+            $baz(depth=2, filter=(BAZ))
+        </div>
+    </div>
+    <hr>
+    <div class="row">
+        <div>
+            <div>FOO</div>
+            $foo(depth=2, filter=(FOO), except=(word1|word2|word3))
+        </div>
+        <div>
+            <div>BAR</div>
+            $bar(depth=2, filter=(BAR), except=(word1|word2|word3))
+        </div>
+        <div>
+                <div>BAZ</div>
+            $baz(depth=2, filter=(BAZ), except=(word1|word2|word3))
+        </div>
+    </div>
+</div>

+ 1 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/index.js

@@ -0,0 +1 @@
+module.exports = require('./remark-growi-directive');

+ 65 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/remark-growi-directive.js

@@ -0,0 +1,65 @@
+/**
+ * @typedef {import('../../../types').MigrationModule} MigrationModule
+ */
+
+module.exports = [
+  /**
+   * Adjust line breaks and indentation for any directives within HTML tags
+   * @type {MigrationModule}
+   */
+  (body) => {
+    const lines = body.split('\n');
+    const directivePattern = /\$[\w\-_]+\([^)]*\)/;
+    let lastDirectiveLineIndex = -1;
+
+    for (let i = 0; i < lines.length; i++) {
+      if (directivePattern.test(lines[i])) {
+        const currentLine = lines[i];
+        const prevLine = i > 0 ? lines[i - 1] : '';
+        const nextLine = i < lines.length - 1 ? lines[i + 1] : '';
+
+        // Always remove indentation from directive line
+        lines[i] = currentLine.trimStart();
+
+        // Insert empty line only if:
+        // 1. Previous line contains an HTML tag (ends with >)
+        // 2. Previous line is not empty
+        // 3. Previous line is not a directive line
+        const isPrevLineHtmlTag = prevLine.match(/>[^\n]*$/) && !prevLine.match(directivePattern);
+        const isNotAfterDirective = i - 1 !== lastDirectiveLineIndex;
+
+        if (isPrevLineHtmlTag && prevLine.trim() !== '' && isNotAfterDirective) {
+          lines.splice(i, 0, '');
+          i++;
+        }
+
+        // Update the last directive line index
+        lastDirectiveLineIndex = i;
+
+        // Handle next line if it's a closing tag
+        if (nextLine.match(/^\s*<\//)) {
+          lines[i + 1] = nextLine.trimStart();
+        }
+      }
+    }
+
+    return lines.join('\n');
+  },
+
+  /**
+   * Remove unnecessary parentheses in directive arguments
+   * @type {MigrationModule}
+   */
+  (body) => {
+    // Detect and process directive-containing lines in multiline mode
+    return body.replace(/^.*\$[\w\-_]+\([^)]*\).*$/gm, (line) => {
+      // Convert filter=(value) to filter=value
+      let processedLine = line.replace(/filter=\(([^)]+)\)/g, 'filter=$1');
+
+      // Convert except=(value) to except=value
+      processedLine = processedLine.replace(/except=\(([^)]+)\)/g, 'except=$1');
+
+      return processedLine;
+    });
+  },
+];

+ 43 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/remark-growi-directive.spec.js

@@ -0,0 +1,43 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { describe, test, expect } from 'vitest';
+
+import migrations from './remark-growi-directive';
+
+describe('remark-growi-directive migrations', () => {
+  test('should transform example.md to match example-expected.md', () => {
+    const input = fs.readFileSync(path.join(__dirname, 'example.md'), 'utf8');
+    const expected = fs.readFileSync(path.join(__dirname, 'example-expected.md'), 'utf8');
+
+    const result = migrations.reduce((text, migration) => migration(text), input);
+    expect(result).toBe(expected);
+  });
+
+  test('should not modify example-expected.md', () => {
+    const input = fs.readFileSync(path.join(__dirname, 'example-expected.md'), 'utf8');
+
+    const result = migrations.reduce((text, migration) => migration(text), input);
+    expect(result).toBe(input);
+  });
+
+  test('should handle various directive patterns', () => {
+    const input = `
+<div>
+    $foo(filter=(AAA))
+    $bar-2(except=(BBB))
+    $baz_3(filter=(CCC), except=(DDD))
+</div>`;
+
+    const expected = `
+<div>
+
+$foo(filter=AAA)
+$bar-2(except=BBB)
+$baz_3(filter=CCC, except=DDD)
+</div>`;
+
+    const result = migrations.reduce((text, migration) => migration(text), input);
+    expect(result).toBe(expected);
+  });
+});

+ 9 - 0
bin/vitest.config.ts

@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    environment: 'node',
+    clearMocks: true,
+    globals: true,
+  },
+});

+ 7 - 1
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/Toolbar.module.scss

@@ -1,3 +1,9 @@
 .codemirror-editor-toolbar :global {
-  @import './scss/toolbar-button.scss';
+  @import './scss/toolbar-button';
+
+  // center the toolbar vertically
+  .simplebar-offset {
+    display: flex;
+    align-items: center;
+  }
 }

+ 1 - 1
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/Toolbar.tsx

@@ -32,7 +32,7 @@ export const Toolbar = memo((props: Props): JSX.Element => {
 
   return (
     <>
-      <div className={`d-flex gap-2 py-1 px-2 px-md-3 border-top ${styles['codemirror-editor-toolbar']}`}>
+      <div className={`d-flex gap-2 py-1 px-2 px-md-3 border-top ${styles['codemirror-editor-toolbar']} align-items-center`}>
         <AttachmentsDropup editorKey={editorKey} onUpload={onUpload} acceptedUploadFileType={acceptedUploadFileType} />
         <div className="flex-grow-1">
           <SimpleBar ref={simpleBarRef} autoHide style={{ overflowY: 'hidden' }}>

+ 1 - 0
vitest.workspace.mts

@@ -1,6 +1,7 @@
 export default [
   'apps/*/vitest.config.ts',
   'apps/*/vitest.workspace.ts',
+  'bin/vitest.config.ts',
   'packages/*/vitest.config.ts',
   'packages/*/vitest.workspace.ts',
 ];