Repository: AlistGo/alist (language: go)

Issue:
正则表达式重命名预览正确但修改错误
### Please make sure of the following things

- [X] I have read the [documentation](https://alist.nn.ci).
- [X] I'm sure there are no duplicate issues or discussions.
- [X] I'm sure it's due to `alist` and not something else(such as `Dependencies` or `Operational`).
- [X] I'm sure I'm using the latest version

### Alist Version / Alist 版本

v3.19.0

### Driver used / 使用的存储驱动

aliyundrive_open

### Describe the bug / 问题描述

正则表达式文件重命名 预览显示正确 但修改结果错误

```
源文件名: ^(\d+).mp4$
新文件名: 第$1话.mp4
```

源文件: `250.mp4`，实际修改为`第.mp4`

修改预览：
![image](https://github.com/alist-org/alist/assets/20322043/7096da18-eff1-41bd-aeaa-b32dc7734aea)

api请求体：
![image](https://github.com/alist-org/alist/assets/20322043/98717ff8-3f2b-45c6-b4d7-d8690259af0d)

修改结果：
![image](https://github.com/alist-org/alist/assets/20322043/b0946e94-15c0-43ed-bf1d-de53b582dcd4)


### Reproduction / 复现链接

没有公网链接

### Logs / 日志

_No response_


Discussion / hints:
Thanks for opening your first issue here! Be sure to follow the issue template!

@varg1714 
是的，一般引用匹配 $1 $2就经常出这样的问题
我排查了下，似乎是Go解析正则和JS有些区别导致的。第$1话.mp4中分组被解析成了"$1话"而不是"$1"，使用“第${1}话.mp4”这个才可以。（不过按文中描述，只有字母数字下划线会被匹配成分组名，中文不应该被匹配才对。）

<img width="548" alt="image" src="https://github.com/alist-org/alist/assets/51254954/6ff3353c-f07f-49fe-a4a1-cd363762c888">
<img width="707" alt="image" src="https://github.com/alist-org/alist/assets/51254954/4a499343-af6e-44db-a0d6-39eef7502b93">

或许有其他人对Go的正则了解多些的么？这个PR是当时为了做这个功能现看go写的，所以可能没时间仔细研究这一块的。如果没有比较好的方式解决这一点那我就把这个重命名的调用放到前端去，保持和预览看到的一致。

Fix patch:
diff --git a/server/handles/fsbatch.go b/server/handles/fsbatch.go
new file mode 100644
index 00000000000..fa7971dfbe1
--- /dev/null
+++ b/server/handles/fsbatch.go
@@ -0,0 +1,211 @@
+package handles
+
+import (
+	"fmt"
+	"regexp"
+
+	"github.com/alist-org/alist/v3/internal/errs"
+	"github.com/alist-org/alist/v3/internal/fs"
+	"github.com/alist-org/alist/v3/internal/model"
+	"github.com/alist-org/alist/v3/internal/op"
+	"github.com/alist-org/alist/v3/pkg/generic"
+	"github.com/alist-org/alist/v3/server/common"
+	"github.com/gin-gonic/gin"
+	"github.com/pkg/errors"
+)
+
+type BatchRenameReq struct {
+	SrcDir        string `json:"src_dir"`
+	RenameObjects []struct {
+		SrcName string `json:"src_name"`
+		NewName string `json:"new_name"`
+	} `json:"rename_objects"`
+}
+
+func FsBatchRename(c *gin.Context) {
+	var req BatchRenameReq
+	if err := c.ShouldBind(&req); err != nil {
+		common.ErrorResp(c, err, 400)
+		return
+	}
+	user := c.MustGet("user").(*model.User)
+	if !user.CanRename() {
+		common.ErrorResp(c, errs.PermissionDenied, 403)
+		return
+	}
+
+	reqPath, err := user.JoinPath(req.SrcDir)
+	if err != nil {
+		common.ErrorResp(c, err, 403)
+		return
+	}
+
+	meta, err := op.GetNearestMeta(reqPath)
+	if err != nil {
+		if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
+			common.ErrorResp(c, err, 500, true)
+			return
+		}
+	}
+	c.Set("meta", meta)
+	for _, renameObject := range req.RenameObjects {
+		if renameObject.SrcName == "" || renameObject.NewName == "" {
+			continue
+		}
+		filePath := fmt.Sprintf("%s/%s", reqPath, renameObject.SrcName)
+		if err := fs.Rename(c, filePath, renameObject.NewName); err != nil {
+			common.ErrorResp(c, err, 500)
+			return
+		}
+	}
+	common.SuccessResp(c)
+}
+
+type RecursiveMoveReq struct {
+	SrcDir string `json:"src_dir"`
+	DstDir string `json:"dst_dir"`
+}
+
+func FsRecursiveMove(c *gin.Context) {
+	var req RecursiveMoveReq
+	if err := c.ShouldBind(&req); err != nil {
+		common.ErrorResp(c, err, 400)
+		return
+	}
+
+	user := c.MustGet("user").(*model.User)
+	if !user.CanMove() {
+		common.ErrorResp(c, errs.PermissionDenied, 403)
+		return
+	}
+	srcDir, err := user.JoinPath(req.SrcDir)
+	if err != nil {
+		common.ErrorResp(c, err, 403)
+		return
+	}
+	dstDir, err := user.JoinPath(req.DstDir)
+	if err != nil {
+		common.ErrorResp(c, err, 403)
+		return
+	}
+
+	meta, err := op.GetNearestMeta(srcDir)
+	if err != nil {
+		if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
+			common.ErrorResp(c, err, 500, true)
+			return
+		}
+	}
+	c.Set("meta", meta)
+
+	rootFiles, err := fs.List(c, srcDir, &fs.ListArgs{})
+	if err != nil {
+		common.ErrorResp(c, err, 500)
+		return
+	}
+
+	// record the file path
+	filePathMap := make(map[model.Obj]string)
+	movingFiles := generic.NewQueue[model.Obj]()
+	for _, file := range rootFiles {
+		movingFiles.Push(file)
+		filePathMap[file] = srcDir
+	}
+
+	for !movingFiles.IsEmpty() {
+
+		movingFile := movingFiles.Pop()
+		movingFilePath := filePathMap[movingFile]
+		movingFileName := fmt.Sprintf("%s/%s", movingFilePath, movingFile.GetName())
+		if movingFile.IsDir() {
+			// directory, recursive move
+			subFilePath := movingFileName
+			subFiles, err := fs.List(c, movingFileName, &fs.ListArgs{Refresh: true})
+			if err != nil {
+				common.ErrorResp(c, err, 500)
+				return
+			}
+			for _, subFile := range subFiles {
+				movingFiles.Push(subFile)
+				filePathMap[subFile] = subFilePath
+			}
+		} else {
+
+			if movingFilePath == dstDir {
+				// same directory, don't move
+				continue
+			}
+
+			// move
+			err := fs.Move(c, movingFileName, dstDir, movingFiles.IsEmpty())
+			if err != nil {
+				common.ErrorResp(c, err, 500)
+				return
+			}
+		}
+
+	}
+
+	common.SuccessResp(c)
+}
+
+type RegexRenameReq struct {
+	SrcDir       string `json:"src_dir"`
+	SrcNameRegex string `json:"src_name_regex"`
+	NewNameRegex string `json:"new_name_regex"`
+}
+
+func FsRegexRename(c *gin.Context) {
+	var req RegexRenameReq
+	if err := c.ShouldBind(&req); err != nil {
+		common.ErrorResp(c, err, 400)
+		return
+	}
+	user := c.MustGet("user").(*model.User)
+	if !user.CanRename() {
+		common.ErrorResp(c, errs.PermissionDenied, 403)
+		return
+	}
+
+	reqPath, err := user.JoinPath(req.SrcDir)
+	if err != nil {
+		common.ErrorResp(c, err, 403)
+		return
+	}
+
+	meta, err := op.GetNearestMeta(reqPath)
+	if err != nil {
+		if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
+			common.ErrorResp(c, err, 500, true)
+			return
+		}
+	}
+	c.Set("meta", meta)
+
+	srcRegexp, err := regexp.Compile(req.SrcNameRegex)
+	if err != nil {
+		common.ErrorResp(c, err, 500)
+		return
+	}
+
+	files, err := fs.List(c, reqPath, &fs.ListArgs{})
+	if err != nil {
+		common.ErrorResp(c, err, 500)
+		return
+	}
+
+	for _, file := range files {
+
+		if srcRegexp.MatchString(file.GetName()) {
+			filePath := fmt.Sprintf("%s/%s", reqPath, file.GetName())
+			newFileName := srcRegexp.ReplaceAllString(file.GetName(), req.NewNameRegex)
+			if err := fs.Rename(c, filePath, newFileName); err != nil {
+				common.ErrorResp(c, err, 500)
+				return
+			}
+		}
+
+	}
+
+	common.SuccessResp(c)
+}
diff --git a/server/handles/fsmanage.go b/server/handles/fsmanage.go
index adeca8635e6..835582c649c 100644
--- a/server/handles/fsmanage.go
+++ b/server/handles/fsmanage.go
@@ -4,7 +4,6 @@ import (
 	"fmt"
 	"io"
 	stdpath "path"
-	"regexp"
 
 	"github.com/alist-org/alist/v3/internal/errs"
 	"github.com/alist-org/alist/v3/internal/fs"
@@ -96,94 +95,6 @@ func FsMove(c *gin.Context) {
 	common.SuccessResp(c)
 }
 
-type RecursiveMoveReq struct {
-	SrcDir string `json:"src_dir"`
-	DstDir string `json:"dst_dir"`
-}
-
-func FsRecursiveMove(c *gin.Context) {
-	var req RecursiveMoveReq
-	if err := c.ShouldBind(&req); err != nil {
-		common.ErrorResp(c, err, 400)
-		return
-	}
-
-	user := c.MustGet("user").(*model.User)
-	if !user.CanMove() {
-		common.ErrorResp(c, errs.PermissionDenied, 403)
-		return
-	}
-	srcDir, err := user.JoinPath(req.SrcDir)
-	if err != nil {
-		common.ErrorResp(c, err, 403)
-		return
-	}
-	dstDir, err := user.JoinPath(req.DstDir)
-	if err != nil {
-		common.ErrorResp(c, err, 403)
-		return
-	}
-
-	meta, err := op.GetNearestMeta(srcDir)
-	if err != nil {
-		if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
-			common.ErrorResp(c, err, 500, true)
-			return
-		}
-	}
-	c.Set("meta", meta)
-
-	rootFiles, err := fs.List(c, srcDir, &fs.ListArgs{})
-	if err != nil {
-		common.ErrorResp(c, err, 500)
-		return
-	}
-
-	// record the file path
-	filePathMap := make(map[model.Obj]string)
-	movingFiles := generic.NewQueue[model.Obj]()
-	for _, file := range rootFiles {
-		movingFiles.Push(file)
-		filePathMap[file] = srcDir
-	}
-
-	for !movingFiles.IsEmpty() {
-
-		movingFile := movingFiles.Pop()
-		movingFilePath := filePathMap[movingFile]
-		movingFileName := fmt.Sprintf("%s/%s", movingFilePath, movingFile.GetName())
-		if movingFile.IsDir() {
-			// directory, recursive move
-			subFilePath := movingFileName
-			subFiles, err := fs.List(c, movingFileName, &fs.ListArgs{Refresh: true})
-			if err != nil {
-				common.ErrorResp(c, err, 500)
-				return
-			}
-			for _, subFile := range subFiles {
-				movingFiles.Push(subFile)
-				filePathMap[subFile] = subFilePath
-			}
-		} else {
-
-			if movingFilePath == dstDir {
-				// same directory, don't move
-				continue
-			}
-
-			// move
-			err := fs.Move(c, movingFileName, dstDir, movingFiles.IsEmpty())
-			if err != nil {
-				common.ErrorResp(c, err, 500)
-				return
-			}
-		}
-
-	}
-
-	common.SuccessResp(c)
-}
-
 func FsCopy(c *gin.Context) {
 	var req MoveCopyReq
 	if err := c.ShouldBind(&req); err != nil {
@@ -255,67 +166,6 @@ func FsRename(c *gin.Context) {
 	common.SuccessResp(c)
 }
 
-type RegexRenameReq struct {
-	SrcDir       string `json:"src_dir"`
-	SrcNameRegex string `json:"src_name_regex"`
-	NewNameRegex string `json:"new_name_regex"`
-}
-
-func FsRegexRename(c *gin.Context) {
-	var req RegexRenameReq
-	if err := c.ShouldBind(&req); err != nil {
-		common.ErrorResp(c, err, 400)
-		return
-	}
-	user := c.MustGet("user").(*model.User)
-	if !user.CanRename() {
-		common.ErrorResp(c, errs.PermissionDenied, 403)
-		return
-	}
-
-	reqPath, err := user.JoinPath(req.SrcDir)
-	if err != nil {
-		common.ErrorResp(c, err, 403)
-		return
-	}
-
-	meta, err := op.GetNearestMeta(reqPath)
-	if err != nil {
-		if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
-			common.ErrorResp(c, err, 500, true)
-			return
-		}
-	}
-	c.Set("meta", meta)
-
-	srcRegexp, err := regexp.Compile(req.SrcNameRegex)
-	if err != nil {
-		common.ErrorResp(c, err, 500)
-		return
-	}
-
-	files, err := fs.List(c, reqPath, &fs.ListArgs{})
-	if err != nil {
-		common.ErrorResp(c, err, 500)
-		return
-	}
-
-	for _, file := range files {
-
-		if srcRegexp.MatchString(file.GetName()) {
-			filePath := fmt.Sprintf("%s/%s", reqPath, file.GetName())
-			newFileName := srcRegexp.ReplaceAllString(file.GetName(), req.NewNameRegex)
-			if err := fs.Rename(c, filePath, newFileName); err != nil {
-				common.ErrorResp(c, err, 500)
-				return
-			}
-		}
-
-	}
-
-	common.SuccessResp(c)
-}
-
 type RemoveReq struct {
 	Dir   string   `json:"dir"`
 	Names []string `json:"names"`
diff --git a/server/router.go b/server/router.go
index 62f4fe7c8a8..6a483cfef31 100644
--- a/server/router.go
+++ b/server/router.go
@@ -130,6 +130,7 @@ func _fs(g *gin.RouterGroup) {
 	g.Any("/dirs", handles.FsDirs)
 	g.POST("/mkdir", handles.FsMkdir)
 	g.POST("/rename", handles.FsRename)
+	g.POST("/batch_rename", handles.FsBatchRename)
 	g.POST("/regex_rename", handles.FsRegexRename)
 	g.POST("/move", handles.FsMove)
 	g.POST("/recursive_move", handles.FsRecursiveMove)


Test patch: