diff --git a/src/app.go b/src/app.go index d763668..901188a 100644 --- a/src/app.go +++ b/src/app.go @@ -98,6 +98,18 @@ func GetApp() *cli.App { Aliases: []string{"r"}, Usage: "Replacement ``. If omitted, defaults to an empty string. Supports built-in and regex capture variables", }, + &cli.UintFlag{ + Name: "replace-limit", + Aliases: []string{"l"}, + Usage: "Limit the number of replacements to be made (replaces all matches if set to 0)", + Value: 0, + DefaultText: "0", + }, + &cli.BoolFlag{ + Name: "string-mode", + Aliases: []string{"s"}, + Usage: "Opt into string literal mode by treating find expressions as non-regex strings", + }, &cli.StringSliceFlag{ Name: "exclude", Aliases: []string{"E"}, @@ -113,7 +125,7 @@ func GetApp() *cli.App { Aliases: []string{"R"}, Usage: "Rename files recursively", }, - &cli.IntFlag{ + &cli.UintFlag{ Name: "max-depth", Aliases: []string{"m"}, Usage: "positive `` indicating the maximum depth for a recursive search (set to 0 for no limit)", @@ -168,11 +180,6 @@ func GetApp() *cli.App { Aliases: []string{"F"}, Usage: "Fix any detected conflicts with auto indexing", }, - &cli.BoolFlag{ - Name: "string-mode", - Aliases: []string{"s"}, - Usage: "Opt into string literal mode by treating find expressions as non-regex strings", - }, }, UseShortOptionHandling: true, Action: func(c *cli.Context) error { diff --git a/src/operation.go b/src/operation.go index 60a4284..c35f55b 100644 --- a/src/operation.go +++ b/src/operation.go @@ -61,32 +61,33 @@ type renameError struct { // Operation represents a batch renaming operation type Operation struct { - paths []Change - matches []Change - conflicts map[conflict][]Conflict - findString string - replacement string - startNumber int - exec bool - fixConflicts bool - includeHidden bool - includeDir bool - onlyDir bool - ignoreCase bool - ignoreExt bool - searchRegex *regexp.Regexp - directories []string - recursive bool - workingDir string - stringMode bool - excludeFilter []string - maxDepth int - sort string - reverseSort bool - quiet bool - errors []renameError - revert bool - numberOffset []int + paths []Change + matches []Change + conflicts map[conflict][]Conflict + findString string + replacement string + startNumber int + exec bool + fixConflicts bool + includeHidden bool + includeDir bool + onlyDir bool + ignoreCase bool + ignoreExt bool + searchRegex *regexp.Regexp + directories []string + recursive bool + workingDir string + stringLiteralMode bool + excludeFilter []string + maxDepth int + sort string + reverseSort bool + quiet bool + errors []renameError + revert bool + numberOffset []int + replaceLimit int } type backupFile struct { @@ -427,20 +428,6 @@ func (op *Operation) findMatches() error { f = filenameWithoutExtension(f) } - if op.stringMode { - findStr := op.findString - - if op.ignoreCase { - f = strings.ToLower(f) - findStr = strings.ToLower(findStr) - } - - if strings.Contains(f, findStr) { - op.matches = append(op.matches, v) - } - continue - } - matched := op.searchRegex.MatchString(f) if matched { op.matches = append(op.matches, v) @@ -564,11 +551,12 @@ func setOptions(op *Operation, c *cli.Context) error { op.recursive = c.Bool("recursive") op.directories = c.Args().Slice() op.onlyDir = c.Bool("only-dir") - op.stringMode = c.Bool("string-mode") + op.stringLiteralMode = c.Bool("string-mode") op.excludeFilter = c.StringSlice("exclude") - op.maxDepth = c.Int("max-depth") + op.maxDepth = int(c.Uint("max-depth")) op.quiet = c.Bool("quiet") op.revert = c.Bool("undo") + op.replaceLimit = int(c.Uint("replace-limit")) // Sorting if c.String("sort") != "" { @@ -583,6 +571,12 @@ func setOptions(op *Operation, c *cli.Context) error { } findPattern := c.String("find") + + // Escape all regular expression metacharacters in string literal mode + if op.stringLiteralMode { + findPattern = regexp.QuoteMeta(findPattern) + } + // Match entire string if find pattern is empty if findPattern == "" { findPattern = ".*" diff --git a/src/operation_linux_test.go b/src/operation_linux_test.go index 144dafd..2fca850 100644 --- a/src/operation_linux_test.go +++ b/src/operation_linux_test.go @@ -4,7 +4,6 @@ package f2 import ( "path/filepath" - "regexp" "testing" ) @@ -59,41 +58,3 @@ func TestCaseConversion(t *testing.T) { runFindReplace(t, cases) } - -func TestTransformation(t *testing.T) { - cases := []struct { - input string - transform string - find string - output string - }{ - { - input: `abc<>_{}*?\/\.epub`, - transform: `\Twin`, - find: `abc.*`, - output: "abc_{}.epub", - }, - { - input: `abc<>_{}*:?\/\.epub`, - transform: `\Tmac`, - find: `abc.*`, - output: `abc<>_{}*?\/\.epub`, - }, - } - - for _, v := range cases { - op := &Operation{} - op.replacement = v.transform - regex, err := regexp.Compile(v.find) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - op.searchRegex = regex - out := op.replaceString(v.input) - - if out != v.output { - t.Fatalf("Expected %s, but got: %s", v.output, out) - } - } -} diff --git a/src/operation_test.go b/src/operation_test.go index 5bb1d53..5d3ebe9 100644 --- a/src/operation_test.go +++ b/src/operation_test.go @@ -236,192 +236,6 @@ func runFindReplace(t *testing.T, cases []testCase) { } } -func TestFindReplace(t *testing.T) { - testDir := setupFileSystem(t) - - cases := []testCase{ - { - want: []Change{ - { - Source: "No Pressure (2021) S1.E1.1080p.mkv", - BaseDir: testDir, - Target: "1.mkv", - }, - { - Source: "No Pressure (2021) S1.E2.1080p.mkv", - BaseDir: testDir, - Target: "2.mkv", - }, - { - Source: "No Pressure (2021) S1.E3.1080p.mkv", - BaseDir: testDir, - Target: "3.mkv", - }, - }, - args: []string{ - "-f", - ".*E(\\d+).*", - "-r", - "$1.mkv", - testDir, - }, - }, - { - want: []Change{ - { - Source: "No Pressure (2021) S1.E1.1080p.mkv", - BaseDir: testDir, - Target: "No Pressure 98.mkv", - }, - { - Source: "No Pressure (2021) S1.E2.1080p.mkv", - BaseDir: testDir, - Target: "No Pressure 99.mkv", - }, - { - Source: "No Pressure (2021) S1.E3.1080p.mkv", - BaseDir: testDir, - Target: "No Pressure 100.mkv", - }, - }, - args: []string{ - "-f", - "(No Pressure).*", - "-r", - "$1 98%d.mkv", - testDir, - }, - }, - { - want: []Change{ - { - Source: "index.js", - BaseDir: filepath.Join(testDir, "scripts"), - Target: "index.ts", - }, - { - Source: "main.js", - BaseDir: filepath.Join(testDir, "scripts"), - Target: "main.ts", - }, - }, - args: []string{ - "-f", - "js", - "-r", - "ts", - filepath.Join(testDir, "scripts"), - }, - }, - { - want: []Change{ - { - Source: "index.js", - BaseDir: filepath.Join(testDir, "scripts"), - Target: "i n d e x .js", - }, - { - Source: "main.js", - BaseDir: filepath.Join(testDir, "scripts"), - Target: "m a i n .js", - }, - }, - args: []string{ - "-f", - "(.)", - "-r", - "$1 ", - "-e", - filepath.Join(testDir, "scripts"), - }, - }, - { - want: []Change{ - { - Source: "a.jpg", - BaseDir: filepath.Join(testDir, "images"), - Target: "a.jpeg", - }, - { - Source: "b.jPg", - BaseDir: filepath.Join(testDir, "images"), - Target: "b.jpeg", - }, - { - Source: "123.JPG", - BaseDir: filepath.Join(testDir, "images", "pics"), - Target: "123.jpeg", - }, - { - Source: "free.jpg", - BaseDir: filepath.Join(testDir, "images", "pics"), - Target: "free.jpeg", - }, - { - Source: "img.jpg", - BaseDir: filepath.Join(testDir, "morepics", "nested"), - Target: "img.jpeg", - }, - }, - args: []string{ - "-f", - "jpg", - "-r", - "jpeg", - "-R", - "-i", - testDir, - }, - }, - { - want: []Change{ - { - Source: "pics", - IsDir: true, - BaseDir: filepath.Join(testDir, "images"), - Target: "images", - }, - { - Source: "morepics", - IsDir: true, - BaseDir: testDir, - Target: "moreimages", - }, - { - Source: "pic-1.avif", - BaseDir: filepath.Join(testDir, "morepics"), - Target: "image-1.avif", - }, - { - Source: "pic-2.avif", - BaseDir: filepath.Join(testDir, "morepics"), - Target: "image-2.avif", - }, - }, - args: []string{"-f", "pic", "-r", "image", "-d", "-R", testDir}, - }, - { - want: []Change{ - { - Source: "pics", - IsDir: true, - BaseDir: filepath.Join(testDir, "images"), - Target: "images", - }, - { - Source: "morepics", - IsDir: true, - BaseDir: testDir, - Target: "moreimages", - }, - }, - args: []string{"-f", "pic", "-r", "image", "-D", "-R", testDir}, - }, - } - - runFindReplace(t, cases) -} - func TestHidden(t *testing.T) { testDir := setupFileSystem(t) cases := []testCase{ diff --git a/src/replace.go b/src/replace.go index cf84dcf..c75bfad 100644 --- a/src/replace.go +++ b/src/replace.go @@ -383,50 +383,79 @@ func getAllVariables(str string) (replaceVars, error) { return v, nil } -func (op *Operation) replaceString(fileName string) (str string) { - findString := op.findString - if findString == "" { - findString = fileName +// regexReplace handles string replacement in regex mode +func (op *Operation) regexReplace( + r *regexp.Regexp, + fileName, replacement string, +) string { + var output string + if op.replaceLimit > 0 { + counter := 0 + output = r.ReplaceAllStringFunc( + fileName, + func(val string) string { + if counter == op.replaceLimit { + return val + } + + counter++ + return r.ReplaceAllString(val, replacement) + }, + ) + } else { + output = r.ReplaceAllString(fileName, replacement) } - replacement := op.replacement - slice := []string{`\Tcu`, `\Tcl`, `\Tct`, `\Twin`, `\Tmac`} - if contains(slice, replacement) { - matches := op.searchRegex.FindAllString(fileName, -1) - str = fileName - for _, v := range matches { - switch replacement { - case `\Tcu`: - str = strings.ReplaceAll(str, v, strings.ToUpper(v)) - case `\Tcl`: - str = strings.ReplaceAll(str, v, strings.ToLower(v)) - case `\Tct`: - str = strings.ReplaceAll( - str, - v, - strings.Title(strings.ToLower(v)), - ) - case `\Twin`: - str = fullWindowsForbiddenRegex.ReplaceAllString(str, "") - case `\Tmac`: - str = strings.ReplaceAll(str, ":", "") - } - } + return output +} - return +// transformString handles string transformations like uppercase, +// lowercase, stripping characters, e.t.c +func (op *Operation) transformString( + fileName, replacement string, +) (out string) { + matches := op.searchRegex.FindAllString(fileName, -1) + if len(matches) == 0 { + return fileName } - if op.stringMode { - if op.ignoreCase { - str = op.searchRegex.ReplaceAllString(fileName, replacement) - } else { - str = strings.ReplaceAll(fileName, findString, replacement) - } - } else { - str = op.searchRegex.ReplaceAllString(fileName, replacement) + switch replacement { + case `\Tcu`: + out = op.regexReplace( + op.searchRegex, + fileName, + strings.ToUpper(matches[0]), + ) + case `\Tcl`: + out = op.regexReplace( + op.searchRegex, + fileName, + strings.ToLower(matches[0]), + ) + case `\Tct`: + out = op.regexReplace( + op.searchRegex, + fileName, + strings.Title(strings.ToLower(matches[0])), + ) + case `\Twin`: + out = op.regexReplace(fullWindowsForbiddenRegex, fileName, "") + case `\Tmac`: + out = op.regexReplace(macForbiddenRegex, fileName, "") + } + + return out +} + +func (op *Operation) replaceString(fileName string) (str string) { + replacement := op.replacement + + slice := []string{`\Tcu`, `\Tcl`, `\Tct`, `\Twin`, `\Tmac`} + if contains(slice, replacement) { + return op.transformString(fileName, replacement) } - return str + return op.regexReplace(op.searchRegex, fileName, replacement) } // replace replaces the matched text in each path with the diff --git a/src/replace_test.go b/src/replace_test.go new file mode 100644 index 0000000..ea1513b --- /dev/null +++ b/src/replace_test.go @@ -0,0 +1,259 @@ +package f2 + +import ( + "path/filepath" + "regexp" + "testing" +) + +func TestFindReplace(t *testing.T) { + testDir := setupFileSystem(t) + + cases := []testCase{ + { + want: []Change{ + { + Source: "No Pressure (2021) S1.E1.1080p.mkv", + BaseDir: testDir, + Target: "No Pressure (2025) S5.E1.1080p.mkv", + }, + { + Source: "No Pressure (2021) S1.E2.1080p.mkv", + BaseDir: testDir, + Target: "No Pressure (2025) S5.E2.1080p.mkv", + }, + { + Source: "No Pressure (2021) S1.E3.1080p.mkv", + BaseDir: testDir, + Target: "No Pressure (2025) S5.E3.1080p.mkv", + }, + }, + args: []string{ + "-f", + "1", + "-r", + "5", + "-l", + "2", + testDir, + }, + }, + { + want: []Change{ + { + Source: "No Pressure (2021) S1.E1.1080p.mkv", + BaseDir: testDir, + Target: "1.mkv", + }, + { + Source: "No Pressure (2021) S1.E2.1080p.mkv", + BaseDir: testDir, + Target: "2.mkv", + }, + { + Source: "No Pressure (2021) S1.E3.1080p.mkv", + BaseDir: testDir, + Target: "3.mkv", + }, + }, + args: []string{ + "-f", + ".*E(\\d+).*", + "-r", + "$1.mkv", + testDir, + }, + }, + { + want: []Change{ + { + Source: "No Pressure (2021) S1.E1.1080p.mkv", + BaseDir: testDir, + Target: "No Pressure 98.mkv", + }, + { + Source: "No Pressure (2021) S1.E2.1080p.mkv", + BaseDir: testDir, + Target: "No Pressure 99.mkv", + }, + { + Source: "No Pressure (2021) S1.E3.1080p.mkv", + BaseDir: testDir, + Target: "No Pressure 100.mkv", + }, + }, + args: []string{ + "-f", + "(No Pressure).*", + "-r", + "$1 98%d.mkv", + testDir, + }, + }, + { + want: []Change{ + { + Source: "index.js", + BaseDir: filepath.Join(testDir, "scripts"), + Target: "index.ts", + }, + { + Source: "main.js", + BaseDir: filepath.Join(testDir, "scripts"), + Target: "main.ts", + }, + }, + args: []string{ + "-f", + "js", + "-r", + "ts", + filepath.Join(testDir, "scripts"), + }, + }, + { + want: []Change{ + { + Source: "index.js", + BaseDir: filepath.Join(testDir, "scripts"), + Target: "i n d e x .js", + }, + { + Source: "main.js", + BaseDir: filepath.Join(testDir, "scripts"), + Target: "m a i n .js", + }, + }, + args: []string{ + "-f", + "(.)", + "-r", + "$1 ", + "-e", + filepath.Join(testDir, "scripts"), + }, + }, + { + want: []Change{ + { + Source: "a.jpg", + BaseDir: filepath.Join(testDir, "images"), + Target: "a.jpeg", + }, + { + Source: "b.jPg", + BaseDir: filepath.Join(testDir, "images"), + Target: "b.jpeg", + }, + { + Source: "123.JPG", + BaseDir: filepath.Join(testDir, "images", "pics"), + Target: "123.jpeg", + }, + { + Source: "free.jpg", + BaseDir: filepath.Join(testDir, "images", "pics"), + Target: "free.jpeg", + }, + { + Source: "img.jpg", + BaseDir: filepath.Join(testDir, "morepics", "nested"), + Target: "img.jpeg", + }, + }, + args: []string{ + "-f", + "jpg", + "-r", + "jpeg", + "-R", + "-i", + testDir, + }, + }, + { + want: []Change{ + { + Source: "pics", + IsDir: true, + BaseDir: filepath.Join(testDir, "images"), + Target: "images", + }, + { + Source: "morepics", + IsDir: true, + BaseDir: testDir, + Target: "moreimages", + }, + { + Source: "pic-1.avif", + BaseDir: filepath.Join(testDir, "morepics"), + Target: "image-1.avif", + }, + { + Source: "pic-2.avif", + BaseDir: filepath.Join(testDir, "morepics"), + Target: "image-2.avif", + }, + }, + args: []string{"-f", "pic", "-r", "image", "-d", "-R", testDir}, + }, + { + want: []Change{ + { + Source: "pics", + IsDir: true, + BaseDir: filepath.Join(testDir, "images"), + Target: "images", + }, + { + Source: "morepics", + IsDir: true, + BaseDir: testDir, + Target: "moreimages", + }, + }, + args: []string{"-f", "pic", "-r", "image", "-D", "-R", testDir}, + }, + } + + runFindReplace(t, cases) +} + +func TestTransformation(t *testing.T) { + cases := []struct { + input string + transform string + find string + output string + }{ + { + input: `abc<>_{}*?\/\.epub`, + transform: `\Twin`, + find: `abc.*`, + output: "abc_{}.epub", + }, + { + input: `abc<>_{}*:?\/\.epub`, + transform: `\Tmac`, + find: `abc.*`, + output: `abc<>_{}*?\/\.epub`, + }, + } + + for _, v := range cases { + op := &Operation{} + op.replacement = v.transform + regex, err := regexp.Compile(v.find) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + op.searchRegex = regex + out := op.replaceString(v.input) + + if out != v.output { + t.Fatalf("Expected %s, but got: %s", v.output, out) + } + } +} diff --git a/src/validation.go b/src/validation.go index 3f5020f..3db0698 100644 --- a/src/validation.go +++ b/src/validation.go @@ -20,6 +20,9 @@ var ( // fullWindowsForbiddenRegex is like windowsForbiddenRegex but includes // forward and backslashes fullWindowsForbiddenRegex = regexp.MustCompile(`<|>|:|"|\||\?|\*|/|\\`) + // macForbiddenRegex is used to match the strings that contain forbidden + // characters in macOS' file names. + macForbiddenRegex = regexp.MustCompile(`:`) ) const (