From ed894d14c65c9a0eb7085b2962a001760719970a Mon Sep 17 00:00:00 2001 From: Ayooluwa Isaiah Date: Thu, 17 Oct 2024 08:29:56 +0100 Subject: [PATCH] add --target-dir option --- app/app.go | 1 + app/app_test/testdata/help_stdout.golden | 5 +- app/flag.go | 8 ++++ app/help.go | 9 ++++ f2.go | 2 +- find/csv.go | 12 ++++- find/find.go | 17 +++++-- internal/config/config.go | 13 ++++++ internal/config/errors.go | 4 ++ internal/file/file.go | 12 +++-- internal/testutil/testutil.go | 16 ++++++- rename/rename.go | 12 ++++- rename/rename_test/rename_test.go | 46 +++++++++++++------ rename/rename_test/rename_windows_test.go | 27 +++++++++++ .../testdata/rename_a_file_backup.golden | 2 +- replace/replace.go | 7 ++- ...eport_file_conflicts_in_JSON_stdout.golden | 2 +- .../report_file_status_in_JSON_stdout.golden | 2 +- 18 files changed, 161 insertions(+), 36 deletions(-) create mode 100644 rename/rename_test/rename_windows_test.go diff --git a/app/app.go b/app/app.go index 022fea7..0131b46 100644 --- a/app/app.go +++ b/app/app.go @@ -264,6 +264,7 @@ offers several options for fine-grained control over the renaming process.`, flagSortr, flagSortPerDir, flagStringMode, + flagTargetDir, flagVerbose, }, UseShortOptionHandling: true, diff --git a/app/app_test/testdata/help_stdout.golden b/app/app_test/testdata/help_stdout.golden index 2c8c62b..fe75269 100644 --- a/app/app_test/testdata/help_stdout.golden +++ b/app/app_test/testdata/help_stdout.golden @@ -179,9 +179,12 @@ Project repository: https://github.com/ayoisaiah/f2 Treats the search pattern (specified by -f/--find) as a literal string instead of a regular expression. + -t, --target-dir + Specify a target directory to move renamed files and reorganize your + filesystem. + -V, --verbose Enables verbose output during the renaming operation. - ENVIRONMENTAL VARIABLES F2_DEFAULT_OPTS Override the default options according to your preferences. For example, diff --git a/app/flag.go b/app/flag.go index 986a108..b4c8061 100644 --- a/app/flag.go +++ b/app/flag.go @@ -281,6 +281,14 @@ var ( instead of a regular expression.`, } + flagTargetDir = &cli.StringFlag{ + Name: "target-dir", + Aliases: []string{"t"}, + Usage: ` + Specify a target directory to move renamed files and reorganize your + filesystem.`, + } + flagVerbose = &cli.BoolFlag{ Name: "verbose", Aliases: []string{"V"}, diff --git a/app/help.go b/app/help.go index b2121f1..f0c30df 100644 --- a/app/help.go +++ b/app/help.go @@ -201,6 +201,13 @@ func helpText(app *cli.App) string { flagStringMode.GetUsage(), ) + flagTargetDirHelp := fmt.Sprintf( + `%s, %s %s`, + pterm.Green("-", flagTargetDir.Aliases[0]), + pterm.Green("--", flagTargetDir.Name), + flagTargetDir.GetUsage(), + ) + flagVerboseHelp := fmt.Sprintf( `%s, %s %s`, pterm.Green("-", flagVerbose.Aliases[0]), @@ -286,6 +293,7 @@ Project repository: https://github.com/ayoisaiah/f2 %s + %s %s %s @@ -331,6 +339,7 @@ Project repository: https://github.com/ayoisaiah/f2 flagSortrHelp, flagSortPerDirHelp, flagStringModeHelp, + flagTargetDirHelp, flagVerboseHelp, pterm.Bold.Sprintf("ENVIRONMENTAL VARIABLES"), envHelp(), diff --git a/f2.go b/f2.go index 47c4f65..21c6cbf 100644 --- a/f2.go +++ b/f2.go @@ -58,7 +58,7 @@ func execute(_ *cli.Context) error { return nil } - err = rename.Rename(changes) + err = rename.Rename(appConfig, changes) rename.PostRename(appConfig, changes, err) diff --git a/find/csv.go b/find/csv.go index bd73fee..4b992a2 100644 --- a/find/csv.go +++ b/find/csv.go @@ -91,6 +91,7 @@ func handleCSV(conf *config.Config) (file.Changes, error) { match := &file.Change{ BaseDir: sourceDir, + TargetDir: sourceDir, IsDir: fileInfo.IsDir(), Source: fileName, Target: fileName, @@ -100,11 +101,20 @@ func handleCSV(conf *config.Config) (file.Changes, error) { Position: i, } - changes = append(changes, match) + if conf.TargetDir != "" { + match.TargetDir = conf.TargetDir + } if len(record) > 1 { match.Target = strings.TrimSpace(record[1]) + + if filepath.IsAbs(match.Target) { + match.TargetDir = "" + continue + } } + + changes = append(changes, match) } return changes, nil diff --git a/find/find.go b/find/find.go index 4831767..4f44aa5 100644 --- a/find/find.go +++ b/find/find.go @@ -99,18 +99,27 @@ func isMaxDepth(rootPath, currentPath string, maxDepth int) bool { return depthCount > maxDepth } -func createFileChange(dirPath string, fileInfo fs.FileInfo) *file.Change { +func createFileChange( + conf *config.Config, + dirPath string, + fileInfo fs.FileInfo, +) *file.Change { baseDir := filepath.Dir(dirPath) fileName := fileInfo.Name() match := &file.Change{ BaseDir: baseDir, + TargetDir: baseDir, IsDir: fileInfo.IsDir(), Source: fileName, OriginalName: fileName, SourcePath: filepath.Join(baseDir, fileName), } + if conf.TargetDir != "" { + match.TargetDir = conf.TargetDir + } + return match } @@ -135,7 +144,7 @@ func searchPaths(conf *config.Config) (file.Changes, error) { } if conf.Search.Regex.MatchString(fileInfo.Name()) { - match := createFileChange(rootPath, fileInfo) + match := createFileChange(conf, rootPath, fileInfo) if !shouldFilter(conf, match) { matches = append(matches, match) @@ -203,7 +212,7 @@ func searchPaths(conf *config.Config) (file.Changes, error) { return infoErr } - match := createFileChange(currentPath, fileInfo) + match := createFileChange(conf, currentPath, fileInfo) if !shouldFilter(conf, match) { matches = append(matches, match) @@ -250,7 +259,7 @@ func loadFromBackup(conf *config.Config) (file.Changes, error) { for i := range changes { ch := changes[i] ch.Source, ch.Target = ch.Target, ch.Source - ch.SourcePath = filepath.Join(ch.BaseDir, ch.Source) + ch.SourcePath = filepath.Join(ch.TargetDir, ch.Source) ch.TargetPath = filepath.Join(ch.BaseDir, ch.Target) ch.Status = status.OK diff --git a/internal/config/config.go b/internal/config/config.go index 52b7ffa..4150716 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -72,6 +72,7 @@ type Config struct { FixConflictsPattern string `json:"fix_conflicts_pattern"` CSVFilename string `json:"csv_filename"` BackupFilename string `json:"backup_filename"` + TargetDir string `json:"target_dir"` ExiftoolOpts ExiftoolOpts `json:"exiftool_opts"` PairOrder []string `json:"pair_order"` FindSlice []string `json:"find_slice"` @@ -158,6 +159,18 @@ func (c *Config) setOptions(ctx *cli.Context) error { c.Revert = ctx.Bool("undo") c.Debug = ctx.Bool("debug") c.FilesAndDirPaths = ctx.Args().Slice() + c.TargetDir = ctx.String("target-dir") + + if c.TargetDir != "" { + info, err := os.Stat(c.TargetDir) + if err == nil && !info.IsDir() { + return errInvalidTargetDir.Fmt(c.TargetDir) + } + + if err != nil && os.IsExist(err) { + return err + } + } if c.CSVFilename != "" { absPath, err := filepath.Abs(filepath.Dir(c.CSVFilename)) diff --git a/internal/config/errors.go b/internal/config/errors.go index 31df202..303f440 100644 --- a/internal/config/errors.go +++ b/internal/config/errors.go @@ -14,4 +14,8 @@ var ( errInvalidSort = &apperr.Error{ Message: "the provided sort '%s' is invalid", } + + errInvalidTargetDir = &apperr.Error{ + Message: "target path '%s' exists but is not a directory", + } ) diff --git a/internal/file/file.go b/internal/file/file.go index 94084d1..4177f92 100644 --- a/internal/file/file.go +++ b/internal/file/file.go @@ -16,16 +16,18 @@ import ( // Change represents a single renaming change. type Change struct { Error error `json:"error,omitempty"` - // The original filename which can be different from Source in + // The original filename can be different from the `Source` in // a multi-step renaming operation OriginalName string `json:"-"` Status status.Status `json:"status"` BaseDir string `json:"base_dir"` - Source string `json:"source"` - Target string `json:"target"` + // TargetDir is the same as BaseDir unless `--target-dir` is provided + TargetDir string `json:"target_dir"` + Source string `json:"source"` + Target string `json:"target"` // SourcePath is BaseDir + Source SourcePath string `json:"-"` - // TargetPath is BaseDir + Target + // TargetPath is TargetDir + Target TargetPath string `json:"-"` CSVRow []string `json:"-"` Position int `json:"-"` @@ -36,7 +38,7 @@ type Change struct { // AutoFixTarget sets the new target name. func (c *Change) AutoFixTarget(newTarget string) { c.Target = newTarget - c.TargetPath = filepath.Join(c.BaseDir, c.Target) + c.TargetPath = filepath.Join(c.TargetDir, c.Target) // Ensure empty targets is reported as empty instead of as a dot if c.TargetPath == "." { diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 4988adf..8e4e9fe 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -182,6 +182,10 @@ func UpdateFileChanges(files file.Changes) { for i := range files { ch := files[i] + if ch.TargetDir == "" { + ch.TargetDir = ch.BaseDir + } + files[i].OriginalName = ch.Source files[i].Position = i files[i].SourcePath = filepath.Join( @@ -189,7 +193,7 @@ func UpdateFileChanges(files file.Changes) { ch.Source, ) files[i].TargetPath = filepath.Join( - ch.BaseDir, + ch.TargetDir, ch.Target, ) } @@ -219,6 +223,10 @@ func ProcessTestCaseChanges(t *testing.T, cases []TestCase) { for j := range tc.Changes { ch := tc.Changes[j] + if ch.TargetDir == "" { + ch.TargetDir = ch.BaseDir + } + if ch.Status == "" { cases[i].Changes[j].Status = status.OK } @@ -234,7 +242,7 @@ func ProcessTestCaseChanges(t *testing.T, cases []TestCase) { if cases[i].Changes[j].TargetPath == "" { cases[i].Changes[j].TargetPath = filepath.Join( - ch.BaseDir, + ch.TargetDir, ch.Target, ) } @@ -250,6 +258,10 @@ func GetConfig(t *testing.T, tc *TestCase, testDir string) *config.Config { t.Setenv(k, v) } + if len(tc.Args) == 0 { + tc.Args = []string{"-f", "", "-r", ""} + } + // add fake binary name as first argument args := append([]string{"f2_test"}, tc.Args...) diff --git a/rename/rename.go b/rename/rename.go index de7395c..7ae72a1 100644 --- a/rename/rename.go +++ b/rename/rename.go @@ -54,7 +54,7 @@ func commit(fileChanges file.Changes) []int { isCaseChangeOnly = true timeStr := fmt.Sprintf("%d", time.Now().UnixNano()) targetPath = filepath.Join( - change.BaseDir, + change.TargetDir, "__"+timeStr+"__"+change.Target+"__"+timeStr+"__", // step 1 ) } @@ -69,7 +69,7 @@ func commit(fileChanges file.Changes) []int { dir := filepath.Dir(change.Target) err := os.MkdirAll( - filepath.Join(change.BaseDir, dir), + filepath.Join(change.TargetDir, dir), osutil.DirPermission, ) if err != nil { @@ -99,8 +99,16 @@ func commit(fileChanges file.Changes) []int { // Rename renames files according to the provided changes and configuration // handling conflicts and backups. func Rename( + conf *config.Config, fileChanges file.Changes, ) error { + if conf.TargetDir != "" { + err := os.MkdirAll(conf.TargetDir, osutil.DirPermission) + if err != nil { + return err + } + } + renameErrs := commit(fileChanges) if len(renameErrs) > 0 { return errRenameFailed.WithCtx(renameErrs) diff --git a/rename/rename_test/rename_test.go b/rename/rename_test/rename_test.go index 4c7a81d..81c972a 100644 --- a/rename/rename_test/rename_test.go +++ b/rename/rename_test/rename_test.go @@ -15,24 +15,21 @@ import ( func renameTest(t *testing.T, cases []testutil.TestCase) { t.Helper() + workingDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for i := range cases { tc := cases[i] - baseDirPath, err := os.MkdirTemp("", "f2_test") - if err != nil { - t.Fatal(err) - } + conf := testutil.GetConfig(t, &tc, ".") - workingDir, err := os.Getwd() + baseDirPath, err := os.MkdirTemp(".", "f2_test") if err != nil { t.Fatal(err) } - t.Cleanup(func() { - _ = os.RemoveAll(baseDirPath) - _ = os.Chdir(workingDir) - }) - err = os.Chdir(baseDirPath) if err != nil { t.Fatal(err) @@ -41,13 +38,12 @@ func renameTest(t *testing.T, cases []testutil.TestCase) { for j := range tc.Changes { ch := tc.Changes[j] - cases[i].Changes[j].BaseDir = baseDirPath cases[i].Changes[j].SourcePath = filepath.Join( ch.BaseDir, ch.Source, ) cases[i].Changes[j].TargetPath = filepath.Join( - ch.BaseDir, + ch.TargetDir, ch.Target, ) @@ -63,20 +59,29 @@ func renameTest(t *testing.T, cases []testutil.TestCase) { } t.Run(tc.Name, func(t *testing.T) { - err := rename.Rename(tc.Changes) + err := rename.Rename(conf, tc.Changes) if err != nil { - // TODO: better error report t.Fatal(err) } for j := range tc.Changes { ch := tc.Changes[j] - if _, err := os.Stat(ch.Target); err != nil { + if _, err := os.Stat(ch.TargetPath); err != nil { t.Fatal(err) } } }) + + err = os.Chdir(workingDir) + if err != nil { + t.Fatal(err) + } + + err = os.RemoveAll(baseDirPath) + if err != nil { + t.Log(err) + } } } @@ -122,6 +127,17 @@ func TestRename(t *testing.T) { }, }, }, + { + Name: "rename with a different target directory", + Changes: file.Changes{ + { + Source: "File.txt", + Target: "myFile.txt", + TargetDir: "one/two", + }, + }, + Args: []string{"-f", "", "--target-dir", "one/two"}, + }, } renameTest(t, testCases) diff --git a/rename/rename_test/rename_windows_test.go b/rename/rename_test/rename_windows_test.go new file mode 100644 index 0000000..dc680ca --- /dev/null +++ b/rename/rename_test/rename_windows_test.go @@ -0,0 +1,27 @@ +//go:build windows +// +build windows + +package rename_test + +import ( + "testing" + + "github.com/ayoisaiah/f2/internal/file" + "github.com/ayoisaiah/f2/internal/testutil" +) + +func TestRenameWindows(t *testing.T) { + testCases := []testutil.TestCase{ + { + Name: "rename with new directory (backslash)", + Changes: file.Changes{ + { + Source: "File.txt", + Target: `new_folder\myFile.txt`, + }, + }, + }, + } + + renameTest(t, testCases) +} diff --git a/rename/rename_test/testdata/rename_a_file_backup.golden b/rename/rename_test/testdata/rename_a_file_backup.golden index e9d7a23..8e99519 100644 --- a/rename/rename_test/testdata/rename_a_file_backup.golden +++ b/rename/rename_test/testdata/rename_a_file_backup.golden @@ -1 +1 @@ -[{"status":"","base_dir":"","source":"File.txt","target":"myFile.txt","is_dir":false}] \ No newline at end of file +[{"status":"","base_dir":"","target_dir":"","source":"File.txt","target":"myFile.txt","is_dir":false}] \ No newline at end of file diff --git a/replace/replace.go b/replace/replace.go index a98390b..a42f47b 100644 --- a/replace/replace.go +++ b/replace/replace.go @@ -661,7 +661,7 @@ func applyReplacement( change.Target = strings.TrimSpace(filepath.Clean(change.Target)) change.Status = status.OK - change.TargetPath = filepath.Join(change.BaseDir, change.Target) + change.TargetPath = filepath.Join(change.TargetDir, change.Target) return nil } @@ -702,7 +702,10 @@ func replaceMatches( ext := filepath.Ext(change.Source) common := pathutil.StripExtension(prev.Target) change.Target = common + ext - change.TargetPath = filepath.Join(change.BaseDir, change.Target) + change.TargetPath = filepath.Join( + change.TargetDir, + change.Target, + ) change.Status = status.OK pairs++ diff --git a/report/report_test/testdata/report_file_conflicts_in_JSON_stdout.golden b/report/report_test/testdata/report_file_conflicts_in_JSON_stdout.golden index c9c0435..7f09d5e 100644 --- a/report/report_test/testdata/report_file_conflicts_in_JSON_stdout.golden +++ b/report/report_test/testdata/report_file_conflicts_in_JSON_stdout.golden @@ -1 +1 @@ -[{"status":"empty filename","base_dir":"","source":"original.txt","target":"","is_dir":false},{"status":"trailing periods present","base_dir":"","source":"original.txt","target":"new_file.","is_dir":false},{"status":"target exists","base_dir":"","source":"file1.txt","target":"existing_file.txt","is_dir":false},{"status":"forbidden characters present","base_dir":"","source":"original.txt","target":"new:file.txt","is_dir":false},{"status":"overwriting new path","base_dir":"","source":"file2.txt","target":"new_file.txt","is_dir":false},{"status":"filename too long","base_dir":"","source":"original.txt","target":"this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length.txt","is_dir":false},{"status":"target file is changing","base_dir":"","source":"1.txt","target":"2.txt","is_dir":false},{"status":"source not found","base_dir":"","source":"nonexistent_file.txt","target":"new_name.txt","is_dir":false}] \ No newline at end of file +[{"status":"empty filename","base_dir":"","target_dir":"","source":"original.txt","target":"","is_dir":false},{"status":"trailing periods present","base_dir":"","target_dir":"","source":"original.txt","target":"new_file.","is_dir":false},{"status":"target exists","base_dir":"","target_dir":"","source":"file1.txt","target":"existing_file.txt","is_dir":false},{"status":"forbidden characters present","base_dir":"","target_dir":"","source":"original.txt","target":"new:file.txt","is_dir":false},{"status":"overwriting new path","base_dir":"","target_dir":"","source":"file2.txt","target":"new_file.txt","is_dir":false},{"status":"filename too long","base_dir":"","target_dir":"","source":"original.txt","target":"this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length.txt","is_dir":false},{"status":"target file is changing","base_dir":"","target_dir":"","source":"1.txt","target":"2.txt","is_dir":false},{"status":"source not found","base_dir":"","target_dir":"","source":"nonexistent_file.txt","target":"new_name.txt","is_dir":false}] \ No newline at end of file diff --git a/report/report_test/testdata/report_file_status_in_JSON_stdout.golden b/report/report_test/testdata/report_file_status_in_JSON_stdout.golden index 113ca7a..7325e03 100644 --- a/report/report_test/testdata/report_file_status_in_JSON_stdout.golden +++ b/report/report_test/testdata/report_file_status_in_JSON_stdout.golden @@ -1 +1 @@ -[{"status":"unchanged","base_dir":"","source":"macos_update_notes_2023.txt","target":"macos_update_notes_2023.txt","is_dir":false},{"status":"ok","base_dir":"","source":"file with spaces.txt","target":"file_with_underscores.txt","is_dir":false},{"status":"overwriting","base_dir":"","source":"file1.txt","target":"existing_file.txt","is_dir":false},{"status":"ignored","base_dir":"","source":"nonexistent_file.txt","target":"file_with_underscores.txt","is_dir":false}] \ No newline at end of file +[{"status":"unchanged","base_dir":"","target_dir":"","source":"macos_update_notes_2023.txt","target":"macos_update_notes_2023.txt","is_dir":false},{"status":"ok","base_dir":"","target_dir":"","source":"file with spaces.txt","target":"file_with_underscores.txt","is_dir":false},{"status":"overwriting","base_dir":"","target_dir":"","source":"file1.txt","target":"existing_file.txt","is_dir":false},{"status":"ignored","base_dir":"","target_dir":"","source":"nonexistent_file.txt","target":"file_with_underscores.txt","is_dir":false}] \ No newline at end of file