Skip to content

Commit

Permalink
add support for file pairing
Browse files Browse the repository at this point in the history
  • Loading branch information
ayoisaiah committed Sep 6, 2024
1 parent 8ca4293 commit 27b9086
Show file tree
Hide file tree
Showing 11 changed files with 297 additions and 31 deletions.
3 changes: 3 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ var supportedDefaultOpts = []string{
flagMaxDepth.Name,
flagNoColor.Usage,
flagOnlyDir.Name,
flagPair.Name,
flagQuiet.Name,
flagRecursive.Name,
flagReplaceLimit.Name,
Expand Down Expand Up @@ -270,6 +271,8 @@ offers several options for fine-grained control over the renaming process.`,
flagMaxDepth,
flagNoColor,
flagOnlyDir,
flagPair,
flagPairOrder,
flagQuiet,
flagRecursive,
flagReplaceLimit,
Expand Down
37 changes: 24 additions & 13 deletions app/app_test/testdata/help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,12 @@ Project repository: https://github.com/ayoisaiah/f2
USAGE
f2 FLAGS [OPTIONS] [PATHS TO FILES AND DIRECTORIES...]
command | f2 FLAGS [OPTIONS]
command | f2 FIND [REPLACE]

POSITIONAL ARGUMENTS
<FIND>
A regular expression pattern used for matching files and directories.
It accepts the syntax defined by the RE2 standard.

[REPLACE]
The replacement string which replaces each match in the file name.
It supports capture variables, built-in variables, and exiftool variables.
If omitted, it defaults to an empty string.

[PATHS]
[PATHS TO FILES AND DIRECTORIES...]
Optionally provide one or more files and directories to search for matches.
If omitted, it searches the current directory alone. Also, note that
directories are not searched recursively.
directories are not searched recursively unless --recursive/-R is used.

FLAGS
--csv
Expand All @@ -36,7 +26,7 @@ FLAGS
It accepts the syntax defined by the RE2 standard and defaults to .*
if omitted which matches the entire file/directory name.

When -s/--string-mode is used, this pattern is treated as a literal string
When -s/--string-mode is used, this pattern is treated as a literal string.

-r, --replace
The replacement string which replaces each match in the file name.
Expand Down Expand Up @@ -128,6 +118,27 @@ OPTIONS
-D, --only-dir
Renames only directories, not files (implies -d/--include-dir).

--p, --pair
Enable pair renaming to rename files with the same name (but different
extensions) in the same directory to the same new name. In pair mode,
file extensions are ignored and --sort/--sortr has no effect.

Example:
Before: DSC08533.ARW DSC08533.JPG DSC08534.ARW DSC08534.JPG

$ f2 -r "Photo_{%03d}" --pair -x

After: Photo_001.ARW Photo_001.JPG Photo_002.ARW Photo_002.JPG

--pair-order
Order the paired files according to their extension. This helps you control
the file to be renamed first, and whose metadata should be extracted when
using variables.

Example:
--pair-order 'dng,jpg' # rename dng files before jpg
--pair-order 'xmp,arw' # rename xmp files before arw

--quiet
Don't print anything to stdout. If no matches are found, f2 will exit with
an error code instead of the normal success code without this flag.
Expand Down
1 change: 0 additions & 1 deletion app/app_test/testdata/short_help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ The batch renaming tool you'll actually enjoy using.
USAGE
f2 FLAGS [OPTIONS] [PATHS TO FILES AND DIRECTORIES...]
command | f2 FLAGS [OPTIONS]
command | f2 FIND [REPLACE]

EXAMPLES
$ f2 -f 'jpeg' -r 'jpg'
Expand Down
30 changes: 29 additions & 1 deletion app/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ var (
It accepts the syntax defined by the RE2 standard and defaults to .*
if omitted which matches the entire file/directory name.
When -s/--string-mode is used, this pattern is treated as a literal string`,
When -s/--string-mode is used, this pattern is treated as a literal string.`,
DefaultText: "<pattern>",
}

Expand Down Expand Up @@ -181,6 +181,34 @@ var (
Renames only directories, not files (implies -d/--include-dir).`,
}

flagPair = &cli.BoolFlag{
Name: "pair",
Aliases: []string{"p"},
Usage: `
Enable pair renaming to rename files with the same name (but different
extensions) in the same directory to the same new name. In pair mode,
file extensions are ignored and --sort/--sortr has no effect.
Example:
Before: DSC08533.ARW DSC08533.JPG DSC08534.ARW DSC08534.JPG
$ f2 -r "Photo_{%03d}" --pair -x
After: Photo_001.ARW Photo_001.JPG Photo_002.ARW Photo_002.JPG`,
}

flagPairOrder = &cli.StringFlag{
Name: "pair-order",
Usage: `
Order the paired files according to their extension. This helps you control
the file to be renamed first, and whose metadata should be extracted when
using variables.
Example:
--pair-order 'dng,jpg' # rename dng files before jpg
--pair-order 'xmp,arw' # rename xmp files before arw`,
}

flagQuiet = &cli.BoolFlag{
Name: "quiet",
Aliases: []string{"q"},
Expand Down
37 changes: 22 additions & 15 deletions app/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import (
)

const usageText = `f2 FLAGS [OPTIONS] [PATHS TO FILES AND DIRECTORIES...]
command | f2 FLAGS [OPTIONS]
command | f2 FIND [REPLACE]`
command | f2 FLAGS [OPTIONS]`

func helpText(app *cli.App) string {
flagCSVHelp := fmt.Sprintf(
Expand Down Expand Up @@ -138,6 +137,19 @@ func helpText(app *cli.App) string {
flagOnlyDir.GetUsage(),
)

flagPairHelp := fmt.Sprintf(
`%s, %s %s`,
pterm.Green("--", flagPair.Aliases[0]),

This comment has been minimized.

Copy link
@muzimuzhi

muzimuzhi Sep 8, 2024

Contributor

Maybe "-" for -p, instead of --p?

This comment has been minimized.

Copy link
@ayoisaiah

ayoisaiah Sep 8, 2024

Author Owner

@muzimuzhi Fixed, thank you! 52c5371

pterm.Green("--", flagPair.Name),
flagPair.GetUsage(),
)

flagPairOrderHelp := fmt.Sprintf(
`%s %s`,
pterm.Green("--", flagPairOrder.Name),
flagPairOrder.GetUsage(),
)

flagQuietHelp := fmt.Sprintf(
`%s %s`,
pterm.Green("--", flagQuiet.Name),
Expand Down Expand Up @@ -207,19 +219,10 @@ Project repository: https://github.com/ayoisaiah/f2
%s
%s
%s
A regular expression pattern used for matching files and directories.
It accepts the syntax defined by the RE2 standard.
%s
The replacement string which replaces each match in the file name.
It supports capture variables, built-in variables, and exiftool variables.
If omitted, it defaults to an empty string.
%s
Optionally provide one or more files and directories to search for matches.
If omitted, it searches the current directory alone. Also, note that
directories are not searched recursively.
directories are not searched recursively unless --recursive/-R is used.
%s
%s
Expand Down Expand Up @@ -279,6 +282,10 @@ Project repository: https://github.com/ayoisaiah/f2
%s
%s
%s
%s
%s
Expand All @@ -292,9 +299,7 @@ Project repository: https://github.com/ayoisaiah/f2
pterm.Bold.Sprintf("USAGE"),
usageText,
pterm.Bold.Sprintf("POSITIONAL ARGUMENTS"),
pterm.Green("<FIND>"),
pterm.Green("[REPLACE]"),
pterm.Green("[PATHS]"),
pterm.Green("[PATHS TO FILES AND DIRECTORIES...]"),
pterm.Bold.Sprintf("FLAGS"),
flagCSVHelp,
flagFindHelp,
Expand All @@ -316,6 +321,8 @@ Project repository: https://github.com/ayoisaiah/f2
flagMaxDepthHelp,
flagNoColorHelp,
flagOnlyDirHelp,
flagPairHelp,
flagPairOrderHelp,
flagQuietHelp,
flagRecursiveHelp,
flagReplaceLimitHelp,
Expand Down
5 changes: 5 additions & 0 deletions find/find.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,11 @@ func Find(conf *config.Config) (changes file.Changes, err error) {
}

defer func() {
if conf.Pair && err == nil {
sortfiles.Pairs(changes, conf.PairOrder)
return
}

if conf.Sort != config.SortDefault && err == nil {
sortfiles.Changes(
changes,
Expand Down
8 changes: 8 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ type Config struct {
IsOutputToPipe bool `json:"is_output_to_pipe"`
BackupLocation io.Writer `json:"-"`
BackupFilename string `json:"backup_filename"`
Pair bool `json:"pair"`
PairOrder []string `json:"pair_order"`
}

// SetFindStringRegex compiles a regular expression for the
Expand Down Expand Up @@ -189,6 +191,12 @@ func (c *Config) setDefaultOpts(ctx *cli.Context) error {
c.ResetIndexPerDir = ctx.Bool("reset-index-per-dir")
c.SortPerDir = ctx.Bool("sort-per-dir")
c.NoColor = ctx.Bool("no-color")
c.Pair = ctx.Bool("pair")
c.PairOrder = strings.Split(ctx.String("pair-order"), ",")

if c.Pair {
c.IgnoreExt = true
}

if c.FixConflictsPattern == "" {
c.FixConflictsPattern = defaultFixConflictsPattern
Expand Down
31 changes: 31 additions & 0 deletions internal/sortfiles/sortfiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"slices"
"sort"
"strings"

"gopkg.in/djherbis/times.v1"

Expand All @@ -17,8 +18,38 @@ import (

"github.com/ayoisaiah/f2/internal/config"
"github.com/ayoisaiah/f2/internal/file"
"github.com/ayoisaiah/f2/internal/pathutil"
)

// Pairs sorts the given file changes based on a custom pairing order.
// Files with extensions matching earlier entries in pairOrder are sorted
// before those matching later entries.
func Pairs(changes file.Changes, pairOrder []string) {
slices.SortStableFunc(changes, func(a, b *file.Change) int {
// Compare stripped paths
if result := strings.Compare(
pathutil.StripExtension(a.SourcePath),
pathutil.StripExtension(b.SourcePath),
); result != 0 {
return result
}

// Compare extensions based on pairOrder
aExt, bExt := filepath.Ext(a.Source), filepath.Ext(b.Source)
for _, v := range pairOrder {
v = "." + v
switch {
case strings.EqualFold(aExt, v):
return -1
case strings.EqualFold(bExt, v):
return 1
}
}

return 0
})
}

// ForRenamingAndUndo is used to sort files before directories to avoid renaming
// conflicts. It also ensures that child directories are renamed before their
// parents and vice versa in undo mode.
Expand Down
92 changes: 92 additions & 0 deletions internal/sortfiles/sortfiles_test/sortfiles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -815,3 +815,95 @@ func TestSortFiles_ForRenamingAndUndo(t *testing.T) {
})
}
}

func TestSortFiles_Pairs(t *testing.T) {
testCases := []sortTestCase{
{
Name: "sort file pairs",
Unsorted: []string{
"image.dng",
"a.jpg",
"image.heif",
"b.arw",
"image.jpg",
},
Sorted: []string{
"a.jpg",
"b.arw",
"image.dng",
"image.heif",
"image.jpg",
},
},
{
Name: "provide a pair order",
Unsorted: []string{
"image.dng",
"a.jpg",
"image.heif",
"b.arw",
"image.jpg",
},
Sorted: []string{
"a.jpg",
"b.arw",
"image.heif",
"image.dng",
"image.jpg",
},
Order: []string{"heif", "dng", "jpg"},
},
{
Name: "sort multiple file pairs",
Unsorted: []string{
"image.dng",
"a.jpg",
"image.heif",
"b.arw",
"image.jpg",
"a.pdf",
},
Sorted: []string{
"a.jpg",
"a.pdf",
"b.arw",
"image.jpg",
"image.dng",
"image.heif",
},
Order: []string{"jpg"},
},
{
Name: "order multiple file pairings",
Unsorted: []string{
"image.dng",
"a.jpg",
"image.heif",
"b.arw",
"image.jpg",
"a.pdf",
},
Sorted: []string{
"a.pdf",
"a.jpg",
"b.arw",
"image.jpg",
"image.dng",
"image.heif",
},
Order: []string{"pdf", "jpg"},
},
}

for i := range testCases {
tc := testCases[i]

t.Run(tc.Name, func(t *testing.T) {
unsorted := sortTest(t, tc.Unsorted)

sortfiles.Pairs(unsorted, tc.Order)

testutil.CompareSourcePath(t, tc.Sorted, unsorted)
})
}
}
Loading

0 comments on commit 27b9086

Please sign in to comment.