├── app ├── app_test │ ├── testdata │ │ ├── a.txt │ │ ├── b.txt │ │ ├── c.txt │ │ ├── d.txt │ │ ├── version_stdout.golden │ │ └── short_help_stdout.golden │ └── app_unix_test.go ├── errors.go ├── app.go └── flag.go ├── find ├── find_test │ ├── testdata │ │ ├── a.txt │ │ ├── b.txt │ │ ├── c.txt │ │ ├── input.csv │ │ ├── DSC100_John-Doe_20211012.dng │ │ ├── DSC100_John-Doe_20211012.jpg │ │ ├── DSC200_Auba-Hall_20240909.dng │ │ ├── DSC200_Auba-Hall_20240909.jpg │ │ └── DSC400_Tim-Scott_20200102.dng │ ├── find_csv_test.go │ ├── find_unix_test.go │ └── find_windows_test.go ├── doc.go ├── find_unix.go ├── find_windows.go ├── find_internal_test.go └── csv.go ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.yml ├── workflows │ ├── labeler.yml │ ├── golangci-lint.yml │ ├── npm.yml │ ├── release-stable.yml │ ├── test.yml │ └── release-nightly.yml └── labels.yml ├── replace ├── replace_test │ ├── testdata │ │ ├── file.tar.gz │ │ ├── gps.jpg │ │ ├── pic.jpg │ │ ├── audio.flac │ │ ├── audio.mp3 │ │ ├── binary.mp3 │ │ ├── image.dng │ │ ├── embedded.mp4 │ │ └── 19. D_1993 F2.flac │ ├── variables_darwin_test.go │ ├── variables_windows_test.go │ └── indexing_test.go └── variables │ ├── variable_regex.go │ ├── variable_types.go │ └── variables_internal_test.go ├── validate ├── validate_test │ ├── testdata │ │ └── images │ │ │ ├── dsc-001.arw │ │ │ └── dsc-002.arw │ ├── validate_unix_test.go │ ├── validate_windows_test.go │ └── validate_test.go ├── validate_internal_test.go └── doc.go ├── rename ├── rename_test │ ├── testdata │ │ ├── rename_a_file.golden │ │ ├── rename_a_file_backup_stderr.golden │ │ └── rename_a_file_backup.golden │ ├── rename_windows_test.go │ └── rename_test.go ├── backup.go └── rename.go ├── report ├── report_test │ └── testdata │ │ ├── report_no_matches_(backup)_stderr.golden │ │ ├── print_results_without_errors_(piped_output)_stdout.golden │ │ ├── report_backup_failure_stderr.golden │ │ ├── report_no_matches_(csv)_stderr.golden │ │ ├── report_no_matches_(standard)_stderr.golden │ │ ├── print_results_without_errors_(verbose)_stderr.golden │ │ ├── report_non_existent_file_stderr.golden │ │ ├── print_results_with_errors_stderr.golden │ │ ├── report_backup_file_removal_failure_stderr.golden │ │ ├── report_file_status_stderr.golden │ │ ├── report_file_status_with_F2_NO_COLOR_env_stderr.golden │ │ ├── report_file_status_in_JSON_stdout.golden │ │ ├── report_file_status_with_F2_NO_COLOR_env_stdout.golden │ │ ├── report_file_status_stdout.golden │ │ ├── report_file_conflicts_in_JSON_stdout.golden │ │ ├── report_file_conflicts_no_color_stdout.golden │ │ └── report_file_conflicts_stdout.golden └── report.go ├── f2_test ├── testdata │ ├── img34.dng │ ├── img34.jpg │ ├── img66.dng │ ├── img66.jpg │ ├── image_pair_renaming_stderr.golden │ ├── my-wedding │ │ ├── img33.dng │ │ ├── img33.jpg │ │ ├── img67.dng │ │ └── img67.jpg │ ├── birthday-2024 │ │ ├── img44.dng │ │ ├── img44.jpg │ │ ├── img78.dng │ │ └── img78.jpg │ ├── only_match_files_named_img33.dng_stderr.golden │ ├── use_dates_to_conditionally_match_files_stderr.golden │ ├── family trip - berlin │ │ ├── img90.dng │ │ ├── img90.jpg │ │ ├── img99.dng │ │ ├── img99.jpg │ │ ├── img101.dng │ │ └── img101.jpg │ ├── family trip - london │ │ ├── img1.dng │ │ ├── img1.jpg │ │ ├── img2.dng │ │ └── img2.jpg │ ├── only_match_files_named_img33.dng_stdout.golden │ ├── image_pair_renaming_stdout.golden │ └── use_dates_to_conditionally_match_files_stdout.golden └── f2_test.go ├── .gitattributes ├── internal ├── timeutil │ └── time.go ├── pathutil │ └── pathutil.go ├── config │ ├── errors.go │ ├── sort.go │ └── config_internal_test.go ├── osutil │ └── osutil.go ├── apperr │ └── apperr.go ├── status │ └── status.go ├── localize │ └── localize.go ├── eval │ ├── eval_internal_test.go │ └── eval.go ├── sortfiles │ ├── custom.go │ └── sortfiles_test │ │ └── testdata │ │ ├── dir1 │ │ └── folder │ │ │ └── 3k.txt │ │ └── 4k.txt └── file │ └── file.go ├── .vscode └── launch.json ├── .env ├── .gitignore ├── Dockerfile ├── .pre-commit-config.yaml ├── package.json ├── scripts └── completions │ ├── f2.bash │ ├── f2.zsh │ └── f2.fish ├── cmd └── f2 │ └── main.go ├── LICENCE ├── justfile ├── f2.go ├── go.mod ├── .golangci.yml ├── docs ├── README.zh.md ├── README.pt.md ├── README.ru.md ├── README.es.md ├── README.de.md └── README.fr.md ├── README.md └── .goreleaser.yml /app/app_test/testdata/a.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/app_test/testdata/b.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/app_test/testdata/c.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/app_test/testdata/d.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /find/find_test/testdata/a.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /find/find_test/testdata/b.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /find/find_test/testdata/c.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: freshman 2 | -------------------------------------------------------------------------------- /replace/replace_test/testdata/file.tar.gz: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /validate/validate_test/testdata/images/dsc-001.arw: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /validate/validate_test/testdata/images/dsc-002.arw: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rename/rename_test/testdata/rename_a_file.golden: -------------------------------------------------------------------------------- 1 | renamed: 'File.txt' to 'myFile.txt' 2 | -------------------------------------------------------------------------------- /report/report_test/testdata/report_no_matches_(backup)_stderr.golden: -------------------------------------------------------------------------------- 1 | nothing to undo 2 | -------------------------------------------------------------------------------- /report/report_test/testdata/print_results_without_errors_(piped_output)_stdout.golden: -------------------------------------------------------------------------------- 1 | b.txt 2 | -------------------------------------------------------------------------------- /f2_test/testdata/img34.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/img34.dng -------------------------------------------------------------------------------- /f2_test/testdata/img34.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/img34.jpg -------------------------------------------------------------------------------- /f2_test/testdata/img66.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/img66.dng -------------------------------------------------------------------------------- /f2_test/testdata/img66.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/img66.jpg -------------------------------------------------------------------------------- /app/app_test/testdata/version_stdout.golden: -------------------------------------------------------------------------------- 1 | f2 version v2.1.0 2 | https://github.com/ayoisaiah/f2/releases/v2.1.0 -------------------------------------------------------------------------------- /f2_test/testdata/image_pair_renaming_stderr.golden: -------------------------------------------------------------------------------- 1 | dry run: commit the above changes with the -x/--exec flag 2 | -------------------------------------------------------------------------------- /rename/rename_test/testdata/rename_a_file_backup_stderr.golden: -------------------------------------------------------------------------------- 1 | renamed: 'File.txt' to 'myFile.txt' 2 | -------------------------------------------------------------------------------- /report/report_test/testdata/report_backup_failure_stderr.golden: -------------------------------------------------------------------------------- 1 | backup failed: unable to write file 2 | -------------------------------------------------------------------------------- /report/report_test/testdata/report_no_matches_(csv)_stderr.golden: -------------------------------------------------------------------------------- 1 | no renaming candidates found in CSV file 2 | -------------------------------------------------------------------------------- /report/report_test/testdata/report_no_matches_(standard)_stderr.golden: -------------------------------------------------------------------------------- 1 | the search criteria didn't match any files 2 | -------------------------------------------------------------------------------- /f2_test/testdata/my-wedding/img33.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/my-wedding/img33.dng -------------------------------------------------------------------------------- /f2_test/testdata/my-wedding/img33.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/my-wedding/img33.jpg -------------------------------------------------------------------------------- /f2_test/testdata/my-wedding/img67.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/my-wedding/img67.dng -------------------------------------------------------------------------------- /f2_test/testdata/my-wedding/img67.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/my-wedding/img67.jpg -------------------------------------------------------------------------------- /replace/replace_test/testdata/gps.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/replace/replace_test/testdata/gps.jpg -------------------------------------------------------------------------------- /replace/replace_test/testdata/pic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/replace/replace_test/testdata/pic.jpg -------------------------------------------------------------------------------- /report/report_test/testdata/print_results_without_errors_(verbose)_stderr.golden: -------------------------------------------------------------------------------- 1 | renamed: 'a.txt' to 'b.txt' 2 | -------------------------------------------------------------------------------- /f2_test/testdata/birthday-2024/img44.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/birthday-2024/img44.dng -------------------------------------------------------------------------------- /f2_test/testdata/birthday-2024/img44.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/birthday-2024/img44.jpg -------------------------------------------------------------------------------- /f2_test/testdata/birthday-2024/img78.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/birthday-2024/img78.dng -------------------------------------------------------------------------------- /f2_test/testdata/birthday-2024/img78.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/birthday-2024/img78.jpg -------------------------------------------------------------------------------- /f2_test/testdata/only_match_files_named_img33.dng_stderr.golden: -------------------------------------------------------------------------------- 1 | dry run: commit the above changes with the -x/--exec flag 2 | -------------------------------------------------------------------------------- /replace/replace_test/testdata/audio.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/replace/replace_test/testdata/audio.flac -------------------------------------------------------------------------------- /replace/replace_test/testdata/audio.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/replace/replace_test/testdata/audio.mp3 -------------------------------------------------------------------------------- /replace/replace_test/testdata/binary.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/replace/replace_test/testdata/binary.mp3 -------------------------------------------------------------------------------- /replace/replace_test/testdata/image.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/replace/replace_test/testdata/image.dng -------------------------------------------------------------------------------- /report/report_test/testdata/report_non_existent_file_stderr.golden: -------------------------------------------------------------------------------- 1 | skipping non existent source file at row 0: test_file.txt 2 | -------------------------------------------------------------------------------- /f2_test/testdata/use_dates_to_conditionally_match_files_stderr.golden: -------------------------------------------------------------------------------- 1 | dry run: commit the above changes with the -x/--exec flag 2 | -------------------------------------------------------------------------------- /replace/replace_test/testdata/embedded.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/replace/replace_test/testdata/embedded.mp4 -------------------------------------------------------------------------------- /report/report_test/testdata/print_results_with_errors_stderr.golden: -------------------------------------------------------------------------------- 1 | error: rename a.txt b.txt: operation not permitted 2 | -------------------------------------------------------------------------------- /report/report_test/testdata/report_backup_file_removal_failure_stderr.golden: -------------------------------------------------------------------------------- 1 | backup file cleanup failed: file not found 2 | -------------------------------------------------------------------------------- /report/report_test/testdata/report_file_status_stderr.golden: -------------------------------------------------------------------------------- 1 | dry run: commit the above changes with the -x/--exec flag 2 | -------------------------------------------------------------------------------- /find/find_test/testdata/input.csv: -------------------------------------------------------------------------------- 1 | Filename,Replacement,Random 2 | a.txt,aa.txt,GPLv3 3 | c.txt,{csv.3}.txt,myname 4 | d.txt,dd.txt, 5 | -------------------------------------------------------------------------------- /f2_test/testdata/family trip - berlin/img90.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/family trip - berlin/img90.dng -------------------------------------------------------------------------------- /f2_test/testdata/family trip - berlin/img90.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/family trip - berlin/img90.jpg -------------------------------------------------------------------------------- /f2_test/testdata/family trip - berlin/img99.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/family trip - berlin/img99.dng -------------------------------------------------------------------------------- /f2_test/testdata/family trip - berlin/img99.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/family trip - berlin/img99.jpg -------------------------------------------------------------------------------- /f2_test/testdata/family trip - london/img1.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/family trip - london/img1.dng -------------------------------------------------------------------------------- /f2_test/testdata/family trip - london/img1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/family trip - london/img1.jpg -------------------------------------------------------------------------------- /f2_test/testdata/family trip - london/img2.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/family trip - london/img2.dng -------------------------------------------------------------------------------- /f2_test/testdata/family trip - london/img2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/family trip - london/img2.jpg -------------------------------------------------------------------------------- /report/report_test/testdata/report_file_status_with_F2_NO_COLOR_env_stderr.golden: -------------------------------------------------------------------------------- 1 | dry run: commit the above changes with the -x/--exec flag 2 | -------------------------------------------------------------------------------- /f2_test/testdata/family trip - berlin/img101.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/family trip - berlin/img101.dng -------------------------------------------------------------------------------- /f2_test/testdata/family trip - berlin/img101.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/f2_test/testdata/family trip - berlin/img101.jpg -------------------------------------------------------------------------------- /replace/replace_test/testdata/19. D_1993 F2.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/replace/replace_test/testdata/19. D_1993 F2.flac -------------------------------------------------------------------------------- /find/find_test/testdata/DSC100_John-Doe_20211012.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/find/find_test/testdata/DSC100_John-Doe_20211012.dng -------------------------------------------------------------------------------- /find/find_test/testdata/DSC100_John-Doe_20211012.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/find/find_test/testdata/DSC100_John-Doe_20211012.jpg -------------------------------------------------------------------------------- /find/find_test/testdata/DSC200_Auba-Hall_20240909.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/find/find_test/testdata/DSC200_Auba-Hall_20240909.dng -------------------------------------------------------------------------------- /find/find_test/testdata/DSC200_Auba-Hall_20240909.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/find/find_test/testdata/DSC200_Auba-Hall_20240909.jpg -------------------------------------------------------------------------------- /find/find_test/testdata/DSC400_Tim-Scott_20200102.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/HEAD/find/find_test/testdata/DSC400_Tim-Scott_20200102.dng -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior for all files. 2 | * text=auto 3 | 4 | *.go text eol=lf 5 | 6 | # Don't modify line endings 7 | *.golden binary 8 | -------------------------------------------------------------------------------- /rename/rename_test/testdata/rename_a_file_backup.golden: -------------------------------------------------------------------------------- 1 | {"changes":[{"base_dir":"","target_dir":"","source":"File.txt","target":"myFile.txt","status":"","is_dir":false}]} -------------------------------------------------------------------------------- /internal/timeutil/time.go: -------------------------------------------------------------------------------- 1 | package timeutil 2 | 3 | const ( 4 | Mod = "mtime" 5 | Access = "atime" 6 | Birth = "btime" 7 | Change = "ctime" 8 | Current = "now" 9 | ) 10 | -------------------------------------------------------------------------------- /validate/validate_internal_test.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import "testing" 4 | 5 | // TODO: Test newTarget() function. 6 | func TestNewTarget(t *testing.T) { 7 | t.Skip("not implemented") 8 | } 9 | -------------------------------------------------------------------------------- /find/doc.go: -------------------------------------------------------------------------------- 1 | // Package find is used to find files that match the provided find pattern 2 | // or CSV file. It also filters out any files that match the exclude pattern (if 3 | // any) 4 | package find 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Ask a question 4 | about: | 5 | Seek help or discuss anything related to F2. 6 | url: https://github.com/ayoisaiah/f2/discussions/new 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug F2", 6 | "type": "go", 7 | "mode": "debug", 8 | "request": "launch", 9 | "program": "${workspaceFolder}/cmd/f2" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /internal/pathutil/pathutil.go: -------------------------------------------------------------------------------- 1 | package pathutil 2 | 3 | import ( 4 | "path/filepath" 5 | ) 6 | 7 | // StripExtension returns the input file name without its extension. 8 | func StripExtension(fileName string) string { 9 | return fileName[:len(fileName)-len(filepath.Ext(fileName))] 10 | } 11 | -------------------------------------------------------------------------------- /app/errors.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/ayoisaiah/f2/v2/internal/apperr" 5 | "github.com/ayoisaiah/f2/v2/internal/localize" 6 | ) 7 | 8 | var ( 9 | errDefaultOptsParsing = &apperr.Error{ 10 | Message: localize.T("error.default_opts_parsing"), 11 | } 12 | 13 | errPipeRead = &apperr.Error{ 14 | Message: localize.T("error.pipe_read"), 15 | } 16 | ) 17 | -------------------------------------------------------------------------------- /find/find_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package find 5 | 6 | // checkIfHidden checks if a file is hidden on Unix operating systems 7 | // the nil error is returned to match the signature of the Windows 8 | // version of the function. 9 | func checkIfHidden(filename, _ string) (bool, error) { 10 | return filename[0] == dotCharacter, nil 11 | } 12 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | GO_VERSION=1.25 2 | NIGHTLY_TAG=nightly 3 | NODE_VERSION=24 4 | REPO_AUTHOR_EMAIL=ayo@freshman.tech 5 | REPO_AUTHOR_NAME=Ayooluwa Isaiah 6 | REPO_BINARY_NAME=f2 7 | REPO_DESCRIPTION=F2 is a cross-platform command-line tool for batch renaming files and directories quickly and safely 8 | REPO_MAINTAINER=Ayooluwa Isaiah 9 | REPO_OWNER=ayoisaiah 10 | REPO_WEBSITE=https://f2.freshman.tech 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.swp 7 | *.dylib 8 | bin/ 9 | dist/ 10 | debug.test* 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # ctags 19 | tags 20 | 21 | # Dependency directories (remove the comment below to include it) 22 | # vendor/ 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25.0-alpine AS builder 2 | 3 | WORKDIR /build 4 | 5 | COPY go.mod go.sum ./ 6 | 7 | RUN go mod download 8 | 9 | COPY . ./ 10 | 11 | RUN go build -o /usr/bin/f2 ./cmd/f2... 12 | 13 | FROM alpine:3.22 AS final 14 | 15 | RUN apk add --no-cache exiftool 16 | 17 | WORKDIR /app 18 | 19 | COPY --from=builder /usr/bin/f2 /usr/bin/f2 20 | 21 | # Run the f2 command when the container starts 22 | ENTRYPOINT ["f2"] 23 | -------------------------------------------------------------------------------- /f2_test/testdata/only_match_files_named_img33.dng_stdout.golden: -------------------------------------------------------------------------------- 1 | *———————————————————————————————*———————————————————————————————*————————* 2 | | ORIGINAL | RENAMED | STATUS | 3 | *———————————————————————————————*———————————————————————————————*————————* 4 | | testdata/my-wedding/img33.dng | testdata/my-wedding/IMG33.DNG | ok | 5 | *———————————————————————————————*———————————————————————————————*————————* 6 | -------------------------------------------------------------------------------- /app/app_test/testdata/short_help_stdout.golden: -------------------------------------------------------------------------------- 1 | The batch renaming tool you'll actually enjoy using. 2 | 3 | USAGE 4 | f2 FLAGS [OPTIONS] [PATHS TO FILES AND DIRECTORIES...] 5 | command | f2 FLAGS [OPTIONS] 6 | 7 | EXAMPLES 8 | $ f2 -f 'jpeg' -r 'jpg' 9 | $ f2 -r '{id3.artist}/{id3.album}/${1}_{id3.title}{ext}' 10 | 11 | LEARN MORE 12 | Use f2 --help to view the command-line options. 13 | Read the manual at https://f2.freshman.tech 14 | -------------------------------------------------------------------------------- /find/find_test/find_csv_test.go: -------------------------------------------------------------------------------- 1 | package find_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ayoisaiah/f2/v2/internal/testutil" 7 | ) 8 | 9 | var csvCases = []testutil.TestCase{ 10 | { 11 | Name: "find matches from csv file", 12 | Want: []string{ 13 | "a.txt", 14 | "c.txt", 15 | }, 16 | Args: []string{"--csv", "testdata/input.csv"}, 17 | }, 18 | // TODO: Add more tests 19 | } 20 | 21 | // TestFindCSV tests file matching with CSV files. 22 | func TestFindCSV(t *testing.T) { 23 | findTest(t, csvCases, "") 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Update issue labels 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: 7 | - master 8 | paths: 9 | - .github/labels.yml 10 | - .github/workflows/labeler.yml 11 | 12 | jobs: 13 | labeler: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Run Labeler 20 | uses: crazy-max/ghaction-github-labeler@v5 21 | with: 22 | github-token: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/tekwizely/pre-commit-golang 3 | rev: v1.0.0-beta.5 4 | hooks: 5 | - id: my-cmd 6 | name: golines 7 | args: [golines, '-m', '80', '-w', '--'] 8 | always_run: false 9 | verbose: false 10 | - id: go-build-repo-mod 11 | - id: go-test-repo-mod 12 | - id: go-imports 13 | - id: my-cmd 14 | name: gofumpt 15 | args: [gofumpt, '-w', '--'] 16 | always_run: false 17 | verbose: false 18 | - id: golangci-lint-repo-mod 19 | -------------------------------------------------------------------------------- /report/report_test/testdata/report_file_status_in_JSON_stdout.golden: -------------------------------------------------------------------------------- 1 | [{"base_dir":"","target_dir":"","source":"macos_update_notes_2023.txt","target":"macos_update_notes_2023.txt","status":"unchanged","is_dir":false},{"base_dir":"","target_dir":"","source":"file with spaces.txt","target":"file_with_underscores.txt","status":"ok","is_dir":false},{"base_dir":"","target_dir":"","source":"file1.txt","target":"existing_file.txt","status":"overwriting","is_dir":false},{"base_dir":"","target_dir":"","source":"nonexistent_file.txt","target":"file_with_underscores.txt","status":"ignored","is_dir":false}] -------------------------------------------------------------------------------- /rename/rename_test/rename_windows_test.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package rename_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/ayoisaiah/f2/v2/internal/file" 10 | "github.com/ayoisaiah/f2/v2/internal/testutil" 11 | ) 12 | 13 | func TestRenameWindows(t *testing.T) { 14 | testCases := []testutil.TestCase{ 15 | { 16 | Name: "rename with new directory (backslash)", 17 | Changes: file.Changes{ 18 | { 19 | Source: "File.txt", 20 | Target: `new_folder\myFile.txt`, 21 | }, 22 | }, 23 | }, 24 | } 25 | 26 | renameTest(t, testCases) 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint code 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | golangci: 13 | name: lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Load .env file 19 | uses: xom9ikk/dotenv@v2.3.0 20 | with: 21 | load-mode: strict 22 | 23 | - name: Setup Go ${{ env.GO_VERSION }} 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: ${{ env.GO_VERSION }} 27 | 28 | - name: golangci-lint 29 | uses: golangci/golangci-lint-action@v8 30 | -------------------------------------------------------------------------------- /report/report_test/testdata/report_file_status_with_F2_NO_COLOR_env_stdout.golden: -------------------------------------------------------------------------------- 1 | *—————————————————————————————*—————————————————————————————*—————————————* 2 | | ORIGINAL | RENAMED | STATUS | 3 | *—————————————————————————————*—————————————————————————————*—————————————* 4 | | macos_update_notes_2023.txt | macos_update_notes_2023.txt | unchanged | 5 | | file with spaces.txt | file_with_underscores.txt | ok | 6 | | file1.txt | existing_file.txt | overwriting | 7 | | nonexistent_file.txt | file_with_underscores.txt | ignored | 8 | *—————————————————————————————*—————————————————————————————*—————————————* 9 | -------------------------------------------------------------------------------- /replace/replace_test/variables_darwin_test.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | // +build darwin 3 | 4 | package replace_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/ayoisaiah/f2/v2/internal/file" 10 | "github.com/ayoisaiah/f2/v2/internal/testutil" 11 | ) 12 | 13 | func TestMacOSTransforms(t *testing.T) { 14 | testCases := []testutil.TestCase{ 15 | { 16 | Name: "remove macOS disallowed characters", 17 | Changes: file.Changes{ 18 | { 19 | Source: "report:::project*details|on<2024/01/11>.txt", 20 | }, 21 | }, 22 | Want: []string{"reportproject*details|on<2024/01/11>.txt"}, 23 | Args: []string{"-f", ".*", "-r", "{.mac}"}, 24 | }, 25 | } 26 | 27 | replaceTest(t, testCases) 28 | } 29 | -------------------------------------------------------------------------------- /replace/replace_test/variables_windows_test.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package replace_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/ayoisaiah/f2/v2/internal/file" 10 | "github.com/ayoisaiah/f2/v2/internal/testutil" 11 | ) 12 | 13 | func TestWindowsTransforms(t *testing.T) { 14 | testCases := []testutil.TestCase{ 15 | { 16 | Name: "remove windows disallowed characters", 17 | Changes: file.Changes{ 18 | { 19 | Source: "report:::project*details|on<2024/01/11>.txt", 20 | }, 21 | }, 22 | Want: []string{"reportprojectdetailson20240111.txt"}, 23 | Args: []string{"-f", ".*", "-r", "{.win}"}, 24 | }, 25 | } 26 | 27 | replaceTest(t, testCases) 28 | } 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Propose a new feature or enhancement 3 | about: Suggest a new feature or enhancement for F2 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | #### Describe your feature request 10 | 11 | Please provide a detailed description of the desired behavior and the reasons 12 | behind your request. Also include examples illustrating how the proposed feature 13 | would be utilized if implemented. 14 | 15 | Before making a feature request, take a moment to review the 16 | [existing requests](https://github.com/ayoisaiah/f2/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3A%22new+feature%22++label%3A%22enhancement%22) 17 | to confirm that it hasn't already been suggested. 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ayoisaiah/f2", 3 | "version": "2.2.1", 4 | "description": "F2 is a command-line tool for batch renaming multiple files and directories quickly and safely", 5 | "main": "index.js", 6 | "repository": "https://github.com/ayoisaiah/f2", 7 | "author": "Ayooluwa Isaiah ", 8 | "license": "MIT", 9 | "dependencies": { 10 | "@ayoisaiah/go-npm": "^0.1.13" 11 | }, 12 | "scripts": { 13 | "postinstall": "go-npm install", 14 | "preuninstall": "go-npm uninstall" 15 | }, 16 | "goBinary": { 17 | "name": "f2", 18 | "path": "./bin", 19 | "url": "https://github.com/ayoisaiah/f2/releases/download/v{{version}}/f2_{{version}}_{{platform}}_{{arch}}.tar.gz" 20 | }, 21 | "private": false 22 | } 23 | -------------------------------------------------------------------------------- /internal/config/errors.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/ayoisaiah/f2/v2/internal/apperr" 5 | "github.com/ayoisaiah/f2/v2/internal/localize" 6 | ) 7 | 8 | var ( 9 | errInvalidArgument = &apperr.Error{ 10 | Message: localize.T("error.invalid_argument"), 11 | } 12 | 13 | errParsingFixConflictsPattern = &apperr.Error{ 14 | Message: localize.T("error.parsing_fix_conflicts_pattern"), 15 | } 16 | 17 | errInvalidSort = &apperr.Error{ 18 | Message: localize.T("error.invalid_sort"), 19 | } 20 | 21 | errInvalidSortVariable = &apperr.Error{ 22 | Message: localize.T("error.invalid_sort_variable"), 23 | } 24 | 25 | errInvalidTargetDir = &apperr.Error{ 26 | Message: localize.T("error.invalid_target_dir"), 27 | } 28 | ) 29 | -------------------------------------------------------------------------------- /report/report_test/testdata/report_file_status_stdout.golden: -------------------------------------------------------------------------------- 1 | *—————————————————————————————*—————————————————————————————*—————————————* 2 | |  ORIGINAL  |  RENAMED  |  STATUS  | 3 | *—————————————————————————————*—————————————————————————————*—————————————* 4 | | macos_update_notes_2023.txt | macos_update_notes_2023.txt | unchanged | 5 | | file with spaces.txt | file_with_underscores.txt | ok | 6 | | file1.txt | existing_file.txt | overwriting | 7 | | nonexistent_file.txt | file_with_underscores.txt | ignored | 8 | *—————————————————————————————*—————————————————————————————*—————————————* 9 | -------------------------------------------------------------------------------- /scripts/completions/f2.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | f2_opts=" 3 | --csv 4 | --find 5 | --replace 6 | --undo 7 | --allow-overwrites 8 | --clean 9 | --exclude 10 | --exclude-dir 11 | --exec 12 | --fix-conflicts 13 | --fix-conflicts-pattern 14 | --help 15 | --hidden 16 | --include-dir 17 | --ignore-case 18 | --ignore-ext 19 | --json 20 | --max-depth 21 | --no-color 22 | --only-dir 23 | --pair 24 | --pair-order 25 | --quiet 26 | --recursive 27 | --replace-limit 28 | --reset-index-per-dir 29 | --sort 30 | --sortr 31 | --sort-per-dir 32 | --sort-var 33 | --string-mode 34 | --target-dir 35 | --verbose 36 | --version 37 | " 38 | __f2_completions() 39 | { 40 | cur="${COMP_WORDS[COMP_CWORD]}" 41 | COMPREPLY=($(compgen -W "${f2_opts}" -- "$cur")) 42 | } 43 | 44 | complete -F __f2_completions f2 45 | -------------------------------------------------------------------------------- /validate/doc.go: -------------------------------------------------------------------------------- 1 | // Package validate is used to ensure that the renaming operation cannot result 2 | // in conflicts before the operation is carried out. It protects against the 3 | // following scenarios: 4 | // 5 | // 1. Overwriting a newly renamed path. 6 | // 2. Target destination contains forbidden characters (varies based on the operating system). 7 | // 3. Target destination already exists on the file system (except if 8 | // --allow-overwrite is specified) 9 | // 4. Target name exceeds the maximum allowed length (255 characters in windows, and 255 bytes on Linux and macOS). 10 | // 5. Target destination contains trailing periods in any of the sub paths (Windows only). 11 | // 6. Target destination is empty. 12 | // 13 | // It detects each conflicts and reports them, but it can also automatically fix 14 | // them according to predefined rules (if -F/--fix-conflicts is specified). 15 | package validate 16 | -------------------------------------------------------------------------------- /.github/workflows/npm.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | workflow_dispatch: 5 | workflow_run: 6 | workflows: [Release stable] 7 | types: [completed] 8 | 9 | jobs: 10 | publish_npm: 11 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 12 | name: Publish new F2 version 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Check out the code 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Load .env file 22 | uses: xom9ikk/dotenv@v2.3.0 23 | with: 24 | load-mode: strict 25 | 26 | - name: Setup Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | registry-url: https://registry.npmjs.org/ 30 | node-version: ${{ env.NODE_VERSION }} 31 | 32 | - name: Publish to NPM 33 | run: npm publish 34 | env: 35 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | -------------------------------------------------------------------------------- /cmd/f2/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | 8 | "github.com/lmittmann/tint" 9 | 10 | "github.com/ayoisaiah/f2/v2" 11 | "github.com/ayoisaiah/f2/v2/internal/config" 12 | "github.com/ayoisaiah/f2/v2/report" 13 | ) 14 | 15 | func init() { 16 | _, exists := os.LookupEnv(config.EnvDebug) 17 | if exists { 18 | slog.SetDefault( 19 | slog.New(tint.NewHandler(os.Stderr, &tint.Options{ 20 | Level: slog.LevelDebug, 21 | ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { 22 | if a.Key == slog.TimeKey || a.Key == slog.LevelKey { 23 | return slog.Attr{} 24 | } 25 | 26 | return a 27 | }, 28 | })), 29 | ) 30 | } 31 | } 32 | 33 | func main() { 34 | renamer, err := f2.New(os.Stdin, os.Stdout) 35 | if err != nil { 36 | report.ExitWithErr(err) 37 | } 38 | 39 | err = renamer.Run(context.Background(), os.Args) 40 | if err != nil { 41 | report.ExitWithErr(err) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /find/find_test/find_unix_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package find_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/ayoisaiah/f2/v2/internal/testutil" 10 | ) 11 | 12 | func setupWindowsHidden(_ *testing.T, _ string) (teardown func()) { 13 | return func() {} 14 | } 15 | 16 | var unixTestCases = []testutil.TestCase{ 17 | { 18 | Name: "exclude hidden files by default", 19 | Want: []string{}, 20 | Args: []string{"-f", "hidden", "-R"}, 21 | }, 22 | 23 | { 24 | Name: "include hidden files in search", 25 | Want: []string{ 26 | ".hidden_file", 27 | "backup/documents/.hidden_resume.txt", 28 | "documents/.hidden_file.txt", 29 | "photos/vacation/mountains/.hidden_photo.jpg", 30 | }, 31 | Args: []string{"-f", "hidden", "-RH"}, 32 | }, 33 | } 34 | 35 | // TestFindUnix only tests search behaviors perculiar to Linux and macOS. 36 | func TestFindUnix(t *testing.T) { 37 | testDir := testutil.SetupFileSystem(t, "find", findFileSystem) 38 | 39 | findTest(t, unixTestCases, testDir) 40 | } 41 | -------------------------------------------------------------------------------- /internal/osutil/osutil.go: -------------------------------------------------------------------------------- 1 | package osutil 2 | 3 | import ( 4 | "regexp" 5 | ) 6 | 7 | var ( 8 | // PartialWindowsForbiddenCharRegex is used to match the strings that contain forbidden 9 | // characters in Windows' file names. This does not include also forbidden 10 | // forward and back slash characters because their presence will cause a new 11 | // directory to be created. 12 | PartialWindowsForbiddenCharRegex = regexp.MustCompile(`<|>|:|"|\||\?|\*`) 13 | // CompleteWindowsForbiddenCharRegex is like windowsForbiddenRegex but includes 14 | // forward and backslashes. 15 | CompleteWindowsForbiddenCharRegex = regexp.MustCompile( 16 | `<|>|:|"|\||\?|\*|/|\\`, 17 | ) 18 | // MacForbiddenCharRegex is used to match the strings that contain forbidden 19 | // characters in macOS' file names. 20 | MacForbiddenCharRegex = regexp.MustCompile(`:`) 21 | ) 22 | 23 | const ( 24 | Windows = "windows" 25 | Darwin = "darwin" 26 | ) 27 | 28 | type exitCode int 29 | 30 | const ( 31 | ExitOK exitCode = 0 32 | ExitError exitCode = 1 33 | ) 34 | 35 | const DirPermission = 0o755 36 | -------------------------------------------------------------------------------- /report/report_test/testdata/report_file_conflicts_in_JSON_stdout.golden: -------------------------------------------------------------------------------- 1 | [{"base_dir":"","target_dir":"","source":"original.txt","target":"","status":"empty_filename","is_dir":false},{"base_dir":"","target_dir":"","source":"original.txt","target":"new_file.","status":"trailing_periods_present","is_dir":false},{"base_dir":"","target_dir":"","source":"file1.txt","target":"existing_file.txt","status":"target_exists","is_dir":false},{"base_dir":"","target_dir":"","source":"original.txt","target":"new:file.txt","status":"forbidden_characters_present","is_dir":false},{"base_dir":"","target_dir":"","source":"file2.txt","target":"new_file.txt","status":"overwriting_new_path","is_dir":false},{"base_dir":"","target_dir":"","source":"original.txt","target":"this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length.txt","status":"filename_too_long","is_dir":false},{"base_dir":"","target_dir":"","source":"1.txt","target":"2.txt","status":"source_already_renamed","is_dir":false},{"base_dir":"","target_dir":"","source":"nonexistent_file.txt","target":"new_name.txt","status":"source_not_found","is_dir":false}] -------------------------------------------------------------------------------- /internal/apperr/apperr.go: -------------------------------------------------------------------------------- 1 | package apperr 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | type Error struct { 9 | Cause error 10 | Context any 11 | Message string 12 | } 13 | 14 | func (e *Error) Error() string { 15 | if e.Cause == nil { 16 | return e.Message 17 | } 18 | 19 | return fmt.Sprintf("%s: %v", e.Message, e.Cause) 20 | } 21 | 22 | // Unwrap is used to make it work with errors.Is, errors.As. 23 | func (e *Error) Unwrap() error { 24 | // Return the inner error. 25 | return e.Cause 26 | } 27 | 28 | // Wrap associates the underlying error. 29 | func (e *Error) Wrap(err error) *Error { 30 | e.Cause = err 31 | return e 32 | } 33 | 34 | // Fmt calls fmt.Sprintf on the error message. 35 | func (e *Error) Fmt(str ...any) *Error { 36 | e.Message = fmt.Sprintf(e.Message, str...) 37 | return e 38 | } 39 | 40 | func (e *Error) WithCtx(ctx any) *Error { 41 | e.Context = ctx 42 | return e 43 | } 44 | 45 | func Unwrap(err error) error { 46 | ae := &Error{} 47 | 48 | ok := errors.As(err, &ae) 49 | if !ok { 50 | return err 51 | } 52 | 53 | return ae.Cause 54 | } 55 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT Licence 2 | 3 | Copyright (c) 2020 Ayooluwa Isaiah 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | - name: bug 2 | color: d93f0b 3 | description: Something isn't working as expected 4 | 5 | - name: new feature 6 | color: "159818" 7 | description: Requests to add new functionality 8 | 9 | - name: enhancement 10 | color: c5def5 11 | description: Changes to an existing feature 12 | 13 | - name: documentation 14 | color: e0d216 15 | description: Related to the docs 16 | 17 | - name: internal 18 | color: f4ca81 19 | description: Changes that aren't user-facing 20 | 21 | - name: "tag: duplicate" 22 | color: "2e3234" 23 | description: Similar issue already exists 24 | 25 | - name: "tag: in-progress" 26 | color: "2e3234" 27 | description: Task is in progress 28 | 29 | - name: "tag: completed" 30 | color: "2e3234" 31 | description: Issue was completed successfully 32 | 33 | - name: "tag: wontfix" 34 | color: "2e3234" 35 | description: Proposal will not be implemented 36 | 37 | - name: "tag: help wanted" 38 | color: "2e3234" 39 | description: Maintainer is looking for help with the issue 40 | 41 | - name: "tag: good first issue" 42 | color: "2e3234" 43 | description: Issues suitable for new contributors 44 | -------------------------------------------------------------------------------- /find/find_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package find 5 | 6 | import ( 7 | "path/filepath" 8 | "strings" 9 | "syscall" 10 | ) 11 | 12 | func isUNCPath(path string) bool { 13 | // UNC paths start with exactly two backslashes, e.g., \\Server\Share 14 | return strings.HasPrefix(path, `\\`) 15 | } 16 | 17 | // checkIfHidden checks if a file is hidden on Windows. 18 | func checkIfHidden(filename, baseDir string) (bool, error) { 19 | absPath, err := filepath.Abs(filepath.Join(baseDir, filename)) 20 | if err != nil { 21 | return false, err 22 | } 23 | 24 | p := `\\?\` + absPath 25 | 26 | if isUNCPath(absPath) { 27 | p = absPath 28 | } 29 | 30 | // Appending `\\?\` to the absolute path helps with 31 | // preventing 'Path Not Specified Error' when accessing 32 | // long paths and filenames 33 | // https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd 34 | pointer, err := syscall.UTF16PtrFromString(p) 35 | if err != nil { 36 | return false, err 37 | } 38 | 39 | attributes, err := syscall.GetFileAttributes(pointer) 40 | if err != nil { 41 | return false, err 42 | } 43 | 44 | return attributes&syscall.FILE_ATTRIBUTE_HIDDEN != 0, nil 45 | } 46 | -------------------------------------------------------------------------------- /internal/config/sort.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/ayoisaiah/f2/v2/internal/timeutil" 7 | ) 8 | 9 | type Sort int 10 | 11 | const ( 12 | SortDefault Sort = iota 13 | SortSize 14 | SortNatural 15 | SortMtime 16 | SortBtime 17 | SortAtime 18 | SortCtime 19 | SortTimeVar 20 | SortIntVar 21 | SortStringVar 22 | ) 23 | 24 | func (s Sort) String() string { 25 | return [...]string{"default", "size", "natural", timeutil.Mod, timeutil.Access, timeutil.Birth, timeutil.Change, "time_var", "int_var", "string_var"}[s] 26 | } 27 | 28 | func parseSortArg(arg string) (Sort, error) { 29 | arg = strings.TrimSpace(arg) 30 | 31 | switch arg { 32 | case "": 33 | return SortDefault, nil 34 | case SortDefault.String(): 35 | return SortDefault, nil 36 | case SortSize.String(): 37 | return SortSize, nil 38 | case SortNatural.String(): 39 | return SortNatural, nil 40 | case SortMtime.String(): 41 | return SortMtime, nil 42 | case SortBtime.String(): 43 | return SortBtime, nil 44 | case SortAtime.String(): 45 | return SortAtime, nil 46 | case SortCtime.String(): 47 | return SortCtime, nil 48 | case SortTimeVar.String(): 49 | return SortTimeVar, nil 50 | case SortIntVar.String(): 51 | return SortIntVar, nil 52 | case SortStringVar.String(): 53 | return SortStringVar, nil 54 | } 55 | 56 | return SortDefault, errInvalidSort.Fmt(arg) 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/release-stable.yml: -------------------------------------------------------------------------------- 1 | name: Release stable 2 | 3 | env: 4 | GORELEASER_GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 5 | FURY_PUSH_TOKEN: ${{ secrets.FURY_PUSH_TOKEN }} 6 | FURY_USERNAME: ${{ secrets.FURY_USERNAME }} 7 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 8 | 9 | on: 10 | repository_dispatch: 11 | types: [release_stable] 12 | 13 | jobs: 14 | release_stable: 15 | name: Release stable version 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Load .env file 24 | uses: xom9ikk/dotenv@v2.3.0 25 | with: 26 | load-mode: strict 27 | 28 | - name: Set up Go ${{ vars.GO_VERSION }} 29 | uses: actions/setup-go@v5 30 | with: 31 | go-version: ${{ vars.GO_VERSION }} 32 | 33 | - name: Create stable release 34 | uses: softprops/action-gh-release@v2 35 | with: 36 | tag_name: ${{ github.event.client_payload.tag_name }} 37 | name: ${{ github.event.client_payload.tag_name }} 38 | body: ${{ env.CHANGELOG }} 39 | draft: false 40 | prerelease: false 41 | 42 | - name: Run Goreleaser 43 | uses: goreleaser/goreleaser-action@v6 44 | with: 45 | version: ~> v2 46 | args: release --clean 47 | -------------------------------------------------------------------------------- /report/report_test/testdata/report_file_conflicts_no_color_stdout.golden: -------------------------------------------------------------------------------- 1 | *——————————————————————*——————————————————————————————————————————————————————————————————————————*——————————————————————————————* 2 | | ORIGINAL | RENAMED | STATUS | 3 | *——————————————————————*——————————————————————————————————————————————————————————————————————————*——————————————————————————————* 4 | | original.txt | | empty filename | 5 | | original.txt | new_file. | trailing periods present | 6 | | file1.txt | existing_file.txt | target exists | 7 | | original.txt | new:file.txt | forbidden characters present | 8 | | file2.txt | new_file.txt | overwriting new path | 9 | | original.txt | this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length.txt | filename too long | 10 | | 1.txt | 2.txt | source already renamed | 11 | | nonexistent_file.txt | new_name.txt | source not found | 12 | *——————————————————————*——————————————————————————————————————————————————————————————————————————*——————————————————————————————* 13 | -------------------------------------------------------------------------------- /rename/backup.go: -------------------------------------------------------------------------------- 1 | package rename 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "log/slog" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/ayoisaiah/f2/v2/internal/file" 11 | "github.com/ayoisaiah/f2/v2/internal/osutil" 12 | ) 13 | 14 | func createBackupFile(fileName string) (io.Writer, error) { 15 | backupFilePath := filepath.Join( 16 | os.TempDir(), 17 | "f2", 18 | "backups", 19 | fileName, 20 | ) 21 | 22 | slog.Debug( 23 | "creating backup file", 24 | slog.String("backup_file", backupFilePath), 25 | ) 26 | 27 | err := os.MkdirAll(filepath.Dir(backupFilePath), osutil.DirPermission) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | // Create or truncate backupFile 33 | backupFile, err := os.Create(backupFilePath) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return bufio.NewWriter(backupFile), nil 39 | } 40 | 41 | // backupChanges records the details of a renaming operation to the specified 42 | // writer so that it may be reverted if necessary. If a writer is not specified 43 | // it records the changes to the filesystem. 44 | func backupChanges( 45 | changes file.Changes, 46 | cleanedDirs []string, 47 | fileName string, 48 | w io.Writer, 49 | ) error { 50 | var err error 51 | 52 | if w == nil { 53 | w, err = createBackupFile(fileName) 54 | if err != nil { 55 | return err 56 | } 57 | } 58 | 59 | b := file.Backup{ 60 | Changes: changes, 61 | CleanedDirs: cleanedDirs, 62 | } 63 | 64 | slog.Debug("backing up changed", slog.Any("backup", b)) 65 | 66 | err = b.RenderJSON(w) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | if f, ok := w.(*bufio.Writer); ok { 72 | return f.Flush() 73 | } 74 | 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /report/report_test/testdata/report_file_conflicts_stdout.golden: -------------------------------------------------------------------------------- 1 | *——————————————————————*——————————————————————————————————————————————————————————————————————————*——————————————————————————————* 2 | |  ORIGINAL  |  RENAMED  |  STATUS  | 3 | *——————————————————————*——————————————————————————————————————————————————————————————————————————*——————————————————————————————* 4 | | original.txt | | empty filename | 5 | | original.txt | new_file. | trailing periods present | 6 | | file1.txt | existing_file.txt | target exists | 7 | | original.txt | new:file.txt | forbidden characters present | 8 | | file2.txt | new_file.txt | overwriting new path | 9 | | original.txt | this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length.txt | filename too long | 10 | | 1.txt | 2.txt | source already renamed | 11 | | nonexistent_file.txt | new_name.txt | source not found | 12 | *——————————————————————*——————————————————————————————————————————————————————————————————————————*——————————————————————————————* 13 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | APP := "f2" 2 | toolprefix := "go tool -modfile=" + justfile_directory() + "/tools.mod" 3 | toolsmod := "-modfile=" + justfile_directory() + "/tools.mod" 4 | 5 | # Run all tests 6 | test filter='.*': 7 | @go test ./... -coverprofile=coverage.out -coverpkg=. -json -run={{filter}} | {{toolprefix}} gotestfmt -hide 'empty-packages' 8 | 9 | [no-cd] 10 | test-pkg filter='.*': 11 | @go test ./... -json -coverprofile=coverage.out -coverpkg=. -run={{filter}} | {{toolprefix}} gotestfmt -hide 'empty-packages' 12 | 13 | [no-cd] 14 | update-golden filter='.*': 15 | @go test ./... -update -json -run={{filter}} | {{toolprefix}} gotestfmt 16 | 17 | alias i := install 18 | 19 | install: 20 | @go mod download 21 | @go mod download {{toolsmod}} 22 | 23 | add pkg: 24 | @go get -u {{pkg}} 25 | 26 | add-tool tool: 27 | @go get {{toolsmod}} -tool {{tool}} 28 | 29 | show-updates: 30 | @echo "# Main dependencies" 31 | @go list -u -f '{{"{{if (and (not (or .Main .Indirect)) .Update)}}{{.Path}}: {{.Version}} -> {{.Update.Version}}{{end}}"}}' -m all 32 | @echo "" 33 | @echo "# Tool dependencies" 34 | @go list {{toolsmod}} -u -f '{{"{{if (and (not (or .Main .Indirect)) .Update)}}{{.Path}}: {{.Version}} -> {{.Update.Version}}{{end}}"}}' -m all 35 | 36 | update-deps: 37 | @go get -u ./... 38 | 39 | update-tools: 40 | @go get {{toolsmod}} -u ./... 41 | 42 | update: && update-deps update-tools 43 | 44 | tools: 45 | @go tool {{toolsmod}} 46 | 47 | build: 48 | @go build -o bin/{{APP}} ./cmd... 49 | 50 | build-win: 51 | @go build -o bin/{{APP}}.exe ./cmd... 52 | 53 | lint: 54 | @{{toolprefix}} golangci-lint run ./... 55 | 56 | pre-commit: 57 | @pre-commit run 58 | 59 | clean: 60 | @rm -r bin 61 | @go clean 62 | 63 | scc: 64 | @{{toolprefix}} scc 65 | -------------------------------------------------------------------------------- /internal/status/status.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/ayoisaiah/f2/v2/internal/localize" 8 | ) 9 | 10 | type Status struct { 11 | ID string 12 | Message string 13 | } 14 | 15 | func (s Status) MarshalJSON() ([]byte, error) { 16 | return []byte(`"` + s.ID + `"`), nil 17 | } 18 | 19 | func (s *Status) UnmarshalJSON(data []byte) error { 20 | var id string 21 | 22 | if err := json.Unmarshal(data, &id); err != nil { 23 | return err 24 | } 25 | 26 | s.ID = id 27 | s.Message = localize.T("status." + id) 28 | 29 | return nil 30 | } 31 | 32 | func (s Status) String() string { 33 | return s.Message 34 | } 35 | 36 | // Append returns an updated Status with the provided string appended to the 37 | // original status message. 38 | func (s Status) Append(str string) Status { 39 | s.Message = fmt.Sprintf("%s -> %s", localize.T("status."+s.ID), str) 40 | return s 41 | } 42 | 43 | func newStatus(id string) Status { 44 | return Status{ 45 | ID: id, 46 | Message: localize.T("status." + id), 47 | } 48 | } 49 | 50 | var ( 51 | OK = newStatus("ok") 52 | Unchanged = newStatus("unchanged") 53 | Overwriting = newStatus("overwriting") 54 | EmptyFilename = newStatus("empty_filename") 55 | TrailingPeriod = newStatus("trailing_periods_present") 56 | PathExists = newStatus("target_exists") 57 | OverwritingNewPath = newStatus("overwriting_new_path") 58 | ForbiddenCharacters = newStatus("forbidden_characters_present") 59 | FilenameLengthExceeded = newStatus("filename_too_long") 60 | SourceAlreadyRenamed = newStatus("source_already_renamed") 61 | SourceNotFound = newStatus("source_not_found") 62 | Ignored = newStatus("ignored") 63 | ) 64 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - v* 9 | pull_request: 10 | branches: 11 | - master 12 | 13 | jobs: 14 | test: 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, macos-latest, windows-latest] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Load .env file 24 | uses: xom9ikk/dotenv@v2.3.0 25 | with: 26 | load-mode: strict 27 | 28 | - name: Setup Go ${{ env.GO_VERSION }} 29 | uses: actions/setup-go@v5 30 | with: 31 | go-version: ${{ env.GO_VERSION }} 32 | 33 | - name: Use Exiftool 34 | uses: woss/exiftool-action@v12.92 35 | if: matrix.os != 'windows-latest' 36 | 37 | - uses: MinoruSekine/setup-scoop@v4.0.1 38 | with: 39 | apps: exiftool 40 | if: matrix.os == 'windows-latest' 41 | 42 | - name: Set up gotestfmt 43 | run: go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@latest 44 | 45 | - name: Run tests 46 | run: go test ./... -json -v -race 2>&1 | gotestfmt -hide 'empty-packages' 47 | 48 | trigger_stable_release: 49 | runs-on: ubuntu-latest 50 | needs: test 51 | if: github.ref_type == 'tag' 52 | steps: 53 | - name: Trigger repository_dispatch event 54 | run: | 55 | curl -X POST \ 56 | -H "Accept: application/vnd.github+json" \ 57 | -H "Authorization: Bearer ${{ secrets.PERSONAL_ACCESS_TOKEN }}" \ 58 | https://api.github.com/repos/ayoisaiah/f2/dispatches \ 59 | -d '{"event_type": "release_stable", "client_payload":{"tag": "${{ github.ref }}", "tag_name": "${{ github.ref_name }}" }}' 60 | 61 | -------------------------------------------------------------------------------- /internal/localize/localize.go: -------------------------------------------------------------------------------- 1 | package localize 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/BurntSushi/toml" 10 | "github.com/nicksnyder/go-i18n/v2/i18n" 11 | "golang.org/x/text/language" 12 | ) 13 | 14 | var ( 15 | //go:embed all:i18n 16 | i18nFS embed.FS 17 | bundle *i18n.Bundle 18 | localizer *i18n.Localizer 19 | ) 20 | 21 | // getMessagesLocale determines the effective locale for program messages 22 | // by checking standard environment variables in the correct order. 23 | func getMessagesLocale() string { 24 | if locale := os.Getenv("LC_ALL"); locale != "" { 25 | return locale 26 | } 27 | 28 | if locale := os.Getenv("LC_MESSAGES"); locale != "" { 29 | return locale 30 | } 31 | 32 | if locale := os.Getenv("LANG"); locale != "" { 33 | return locale 34 | } 35 | 36 | return "" 37 | } 38 | 39 | func init() { 40 | bundle = i18n.NewBundle(language.English) 41 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 42 | 43 | fs, err := i18nFS.ReadDir("i18n") 44 | if err != nil { 45 | panic(err) 46 | } 47 | 48 | for _, f := range fs { 49 | path := fmt.Sprintf("i18n/%s", f.Name()) 50 | 51 | _, err = bundle.LoadMessageFileFS(i18nFS, path) 52 | if err != nil { 53 | panic(err) 54 | } 55 | } 56 | 57 | lang := language.English.String() 58 | 59 | langEnv := getMessagesLocale() 60 | 61 | if langEnv != "" { 62 | lang = langEnv 63 | } 64 | 65 | before, _, found := strings.Cut(langEnv, "_") 66 | if found { 67 | lang = before 68 | } 69 | 70 | localizer = i18n.NewLocalizer(bundle, lang) 71 | } 72 | 73 | func T(id string) string { 74 | return localizer.MustLocalize(&i18n.LocalizeConfig{ 75 | MessageID: id, 76 | }) 77 | } 78 | 79 | func TWithOpts(lc *i18n.LocalizeConfig) string { 80 | return localizer.MustLocalize(lc) 81 | } 82 | -------------------------------------------------------------------------------- /find/find_internal_test.go: -------------------------------------------------------------------------------- 1 | package find 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | func TestIsMaxDepth(t *testing.T) { 9 | cases := []struct { 10 | Name string 11 | RootPath string 12 | CurrentPath string 13 | MaxDepth int 14 | Expected bool 15 | }{ 16 | { 17 | Name: "current path is on same level as root path", 18 | RootPath: "/testdata/images", 19 | CurrentPath: "/testdata/images/bike.jpg", 20 | MaxDepth: -1, 21 | Expected: false, 22 | }, 23 | { 24 | Name: "current path is 1 level below root path", 25 | RootPath: "/testdata/images", 26 | CurrentPath: "/testdata/images/jpegs/bike.jpg", 27 | MaxDepth: -1, 28 | Expected: true, 29 | }, 30 | { 31 | Name: "infinite recursion means no max depth", 32 | RootPath: "/testdata/images", 33 | CurrentPath: "/testdata/images/jpegs/bike.jpg", 34 | MaxDepth: 0, 35 | Expected: false, 36 | }, 37 | { 38 | Name: "max depth value exceeded by 1", 39 | RootPath: "/testdata/images", 40 | CurrentPath: "/testdata/images/jpegs/unsplash/download/bike.jpg", 41 | MaxDepth: 2, 42 | Expected: true, 43 | }, 44 | { 45 | Name: "max depth value is equal to 3", 46 | RootPath: "/testdata/images", 47 | CurrentPath: "/testdata/images/jpegs/unsplash/download/bike.jpg", 48 | MaxDepth: 3, 49 | Expected: false, 50 | }, 51 | } 52 | 53 | for i := range cases { 54 | tc := cases[i] 55 | 56 | t.Run(tc.Name, func(t *testing.T) { 57 | // Ensure os-specifc separators are used 58 | rootPath, currentPath := filepath.FromSlash( 59 | tc.RootPath, 60 | ), filepath.FromSlash( 61 | tc.CurrentPath, 62 | ) 63 | 64 | got := isMaxDepth(rootPath, currentPath, tc.MaxDepth) 65 | 66 | if got != tc.Expected { 67 | t.Fatalf( 68 | "expected max depth to be: %t, but got: %t", 69 | tc.Expected, 70 | got, 71 | ) 72 | } 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /internal/eval/eval_internal_test.go: -------------------------------------------------------------------------------- 1 | package eval 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestEvaluate(t *testing.T) { 8 | testCases := []struct { 9 | name string 10 | expression string 11 | want bool 12 | wantErr bool 13 | }{ 14 | { 15 | name: "simple true expression", 16 | expression: "1 == 1", 17 | want: true, 18 | wantErr: false, 19 | }, 20 | { 21 | name: "simple false expression", 22 | expression: "1 != 1", 23 | want: false, 24 | wantErr: false, 25 | }, 26 | { 27 | name: "strlen function", 28 | expression: `strlen("hello") == 5`, 29 | want: true, 30 | wantErr: false, 31 | }, 32 | { 33 | name: "dur function", 34 | expression: `dur("1h") == 3600`, 35 | want: true, 36 | wantErr: false, 37 | }, 38 | { 39 | name: "contains function", 40 | expression: `contains("hello", "ll")`, 41 | want: true, 42 | wantErr: false, 43 | }, 44 | { 45 | name: "size function", 46 | expression: `size("1K") == 1000`, 47 | want: true, 48 | wantErr: false, 49 | }, 50 | { 51 | name: "matches function", 52 | expression: `matches("hello", "^h")`, 53 | want: true, 54 | wantErr: false, 55 | }, 56 | { 57 | name: "invalid expression", 58 | expression: "1 ==", 59 | want: false, 60 | wantErr: true, 61 | }, 62 | { 63 | name: "function with invalid arguments", 64 | expression: "strlen()", 65 | want: false, 66 | wantErr: true, 67 | }, 68 | } 69 | 70 | for _, tc := range testCases { 71 | t.Run(tc.name, func(t *testing.T) { 72 | got, err := Evaluate(tc.expression) 73 | if (err != nil) != tc.wantErr { 74 | t.Errorf("Evaluate() error = %v, wantErr %v", err, tc.wantErr) 75 | return 76 | } 77 | 78 | if got != tc.want { 79 | t.Errorf("Evaluate() = %v, want %v", got, tc.want) 80 | } 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /validate/validate_test/validate_unix_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package validate_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/ayoisaiah/f2/v2/internal/file" 10 | "github.com/ayoisaiah/f2/v2/internal/status" 11 | "github.com/ayoisaiah/f2/v2/internal/testutil" 12 | ) 13 | 14 | func TestValidateUnix(t *testing.T) { 15 | t.Helper() 16 | 17 | testCases := []testutil.TestCase{ 18 | { 19 | Name: "detect filename longer than 255 bytes", 20 | Changes: file.Changes{ 21 | { 22 | Source: "1984.pdf", 23 | Target: "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀.pdf", 24 | BaseDir: "ebooks", 25 | Status: status.FilenameLengthExceeded, 26 | }, 27 | }, 28 | ConflictDetected: true, 29 | }, 30 | { 31 | Name: "auto fix name length conflict on Unix OSes", 32 | Changes: file.Changes{ 33 | { 34 | Source: "1984.pdf", 35 | Target: "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀.pdf", 36 | BaseDir: "ebooks", 37 | }, 38 | }, 39 | Want: []string{ 40 | "ebooks/😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀.pdf", 41 | }, 42 | Args: []string{"-r", "", "-F"}, 43 | }, 44 | } 45 | 46 | validateTest(t, testCases) 47 | } 48 | -------------------------------------------------------------------------------- /app/app_test/app_unix_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package app_test 5 | 6 | import ( 7 | "os" 8 | "os/exec" 9 | "slices" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | 14 | "github.com/ayoisaiah/f2/v2/app" 15 | ) 16 | 17 | func simulatePipe(t *testing.T, name string, arg ...string) *exec.Cmd { 18 | t.Helper() 19 | 20 | r, w, err := os.Pipe() 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | 25 | cmd := exec.Command(name, arg...) 26 | cmd.Stdin = r 27 | cmd.Stdout = w 28 | 29 | oldStdin := os.Stdin 30 | 31 | t.Cleanup(func() { 32 | os.Stdin = oldStdin 33 | }) 34 | 35 | os.Stdin = r 36 | 37 | if err := cmd.Run(); err != nil { 38 | t.Fatal(err) 39 | } 40 | 41 | w.Close() 42 | 43 | return cmd 44 | } 45 | 46 | // TODO: Write equivalent for Windows. 47 | func TestPipingInputFromFind(t *testing.T) { 48 | cases := []struct { 49 | name string 50 | findArgs []string 51 | expected []string 52 | }{ 53 | { 54 | name: "find all txt files", 55 | findArgs: []string{"testdata", "-name", "*.txt"}, 56 | expected: []string{ 57 | "testdata/a.txt", 58 | "testdata/b.txt", 59 | "testdata/c.txt", 60 | "testdata/d.txt", 61 | }, 62 | }, 63 | { 64 | name: "find a.txt file", 65 | findArgs: []string{"testdata", "-name", "a.txt"}, 66 | expected: []string{ 67 | "testdata/a.txt", 68 | }, 69 | }, 70 | { 71 | name: "find a.txt and b.txt files", 72 | findArgs: []string{ 73 | "testdata", 74 | "-name", 75 | "a.txt", 76 | "-o", 77 | "-name", 78 | "b.txt", 79 | }, 80 | expected: []string{ 81 | "testdata/a.txt", 82 | "testdata/b.txt", 83 | }, 84 | }, 85 | } 86 | 87 | for _, tc := range cases { 88 | t.Run(tc.name, func(t *testing.T) { 89 | simulatePipe(t, "find", tc.findArgs...) 90 | 91 | _, _ = app.Get(os.Stdin, os.Stdout) 92 | 93 | got := os.Args[len(os.Args)-len(tc.expected):] 94 | 95 | slices.Sort(got) 96 | slices.Sort(tc.expected) 97 | 98 | assert.Equal(t, tc.expected, got) 99 | }) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /find/find_test/find_windows_test.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package find_test 5 | 6 | import ( 7 | "os" 8 | "path/filepath" 9 | "syscall" 10 | "testing" 11 | 12 | "github.com/ayoisaiah/f2/v2/internal/testutil" 13 | ) 14 | 15 | func setHidden(path string) error { 16 | filenameW, err := syscall.UTF16PtrFromString(path) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | err = syscall.SetFileAttributes(filenameW, syscall.FILE_ATTRIBUTE_HIDDEN) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | return nil 27 | } 28 | 29 | func setupWindowsHidden(t *testing.T, testDir string) (teardown func()) { 30 | err := filepath.WalkDir( 31 | testDir, 32 | func(path string, d os.DirEntry, err error) error { 33 | if err != nil { 34 | return err // Handle errors gracefully 35 | } 36 | 37 | if !d.IsDir() && filepath.Base(path)[0] == 46 { 38 | setHidden((path)) 39 | } 40 | 41 | return nil 42 | }, 43 | ) 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | 48 | return func() {} 49 | } 50 | 51 | var windowsTestCases = []testutil.TestCase{ 52 | { 53 | Name: "dot files shouldn't be regarded as hidden in Windows", 54 | Want: []string{ 55 | ".hidden_file", 56 | "backup/documents/.hidden_resume.txt", 57 | "documents/.hidden_file.txt", 58 | "photos/vacation/mountains/.hidden_photo.jpg", 59 | }, 60 | Args: []string{"-f", "hidden", "-R"}, 61 | }, 62 | 63 | { 64 | Name: "exclude files with hidden attribute", 65 | Want: []string{}, 66 | Args: []string{"-f", "hidden", "-R"}, 67 | SetupFunc: setupWindowsHidden, 68 | }, 69 | 70 | { 71 | Name: "include files with hidden attribute", 72 | Want: []string{ 73 | ".hidden_file", 74 | "backup/documents/.hidden_resume.txt", 75 | "documents/.hidden_file.txt", 76 | "photos/vacation/mountains/.hidden_photo.jpg", 77 | }, 78 | Args: []string{"-f", "hidden", "-RH"}, 79 | SetupFunc: setupWindowsHidden, 80 | }, 81 | } 82 | 83 | // TestFindWindows only tests search behaviors perculiar to Windows 84 | func TestFindWindows(t *testing.T) { 85 | testDir := testutil.SetupFileSystem(t, "find", findFileSystem) 86 | 87 | findTest(t, windowsTestCases, testDir) 88 | } 89 | -------------------------------------------------------------------------------- /f2_test/testdata/image_pair_renaming_stdout.golden: -------------------------------------------------------------------------------- 1 | *——————————————————————————————————————————*————————————————————————————————*————————* 2 | | ORIGINAL | RENAMED | STATUS | 3 | *——————————————————————————————————————————*————————————————————————————————*————————* 4 | | testdata/family trip - london/img1.dng | 2024/05-May/2024-05-30/001.dng | ok | 5 | | testdata/family trip - london/img1.jpg | 2024/05-May/2024-05-30/001.jpg | ok | 6 | | testdata/family trip - london/img2.dng | 2024/05-May/2024-05-30/002.dng | ok | 7 | | testdata/family trip - london/img2.jpg | 2024/05-May/2024-05-30/002.jpg | ok | 8 | | testdata/family trip - berlin/img90.dng | 2024/06-Jun/2024-06-27/001.dng | ok | 9 | | testdata/family trip - berlin/img90.jpg | 2024/06-Jun/2024-06-27/001.jpg | ok | 10 | | testdata/family trip - berlin/img99.dng | 2024/06-Jun/2024-06-27/002.dng | ok | 11 | | testdata/family trip - berlin/img99.jpg | 2024/06-Jun/2024-06-27/002.jpg | ok | 12 | | testdata/family trip - berlin/img101.dng | 2024/06-Jun/2024-06-27/003.dng | ok | 13 | | testdata/family trip - berlin/img101.jpg | 2024/06-Jun/2024-06-27/003.jpg | ok | 14 | | testdata/img34.dng | 2024/06-Jun/2024-06-28/001.dng | ok | 15 | | testdata/img34.jpg | 2024/06-Jun/2024-06-28/001.jpg | ok | 16 | | testdata/img66.dng | 2024/06-Jun/2024-06-28/002.dng | ok | 17 | | testdata/img66.jpg | 2024/06-Jun/2024-06-28/002.jpg | ok | 18 | | testdata/birthday-2024/img44.dng | 2024/06-Jun/2024-06-28/003.dng | ok | 19 | | testdata/birthday-2024/img44.jpg | 2024/06-Jun/2024-06-28/003.jpg | ok | 20 | | testdata/birthday-2024/img78.dng | 2024/06-Jun/2024-06-28/004.dng | ok | 21 | | testdata/birthday-2024/img78.jpg | 2024/06-Jun/2024-06-28/004.jpg | ok | 22 | | testdata/my-wedding/img33.dng | 2024/07-Jul/2024-07-02/001.dng | ok | 23 | | testdata/my-wedding/img33.jpg | 2024/07-Jul/2024-07-02/001.jpg | ok | 24 | | testdata/my-wedding/img67.dng | 2024/07-Jul/2024-07-02/002.dng | ok | 25 | | testdata/my-wedding/img67.jpg | 2024/07-Jul/2024-07-02/002.jpg | ok | 26 | *——————————————————————————————————————————*————————————————————————————————*————————* 27 | -------------------------------------------------------------------------------- /.github/workflows/release-nightly.yml: -------------------------------------------------------------------------------- 1 | name: Release nightly 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | create_nightly_tag: 11 | name: Create nightly tag for master branch 12 | runs-on: ubuntu-latest 13 | if: github.ref_type == 'branch' 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Load .env file 21 | uses: xom9ikk/dotenv@v2.3.0 22 | with: 23 | load-mode: strict 24 | 25 | - name: Update nightly tag 26 | run: | 27 | git tag -d ${{ env.NIGHTLY_TAG }} || true 28 | git push origin :refs/tags/${{ env.NIGHTLY_TAG }} || true 29 | git tag ${{ env.NIGHTLY_TAG }} 30 | git push origin ${{ env.NIGHTLY_TAG }} 31 | 32 | release_nightly: 33 | needs: create_nightly_tag 34 | name: Release nightly version 35 | runs-on: ubuntu-latest 36 | env: 37 | GH_REPO: ${{ github.repository }} 38 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v4 42 | with: 43 | fetch-depth: 0 44 | 45 | - name: Load .env file 46 | uses: xom9ikk/dotenv@v2.3.0 47 | with: 48 | load-mode: strict 49 | 50 | - name: Set up Go ${{ env.GO_VERSION }} 51 | uses: actions/setup-go@v5 52 | with: 53 | go-version: ${{ env.GO_VERSION }} 54 | cache: false 55 | check-latest: true 56 | 57 | - name: Delete existing nightly release 58 | run: | 59 | gh release delete nightly --yes || true 60 | 61 | - name: Create nightly release 62 | uses: softprops/action-gh-release@v2 63 | with: 64 | tag_name: refs/tags/${{ env.NIGHTLY_TAG }} 65 | name: Development build (master) 66 | body: | 67 | This build is directly sourced from the `master` branch in active development. As such, it may include experimental features and potential bugs. 68 | draft: false 69 | prerelease: true 70 | 71 | - name: Build assets with Goreleaser 72 | uses: goreleaser/goreleaser-action@v6 73 | with: 74 | version: ~> v2 75 | args: release --clean --snapshot 76 | 77 | - name: Upload assets to nightly release 78 | run: gh release upload ${{ env.NIGHTLY_TAG }} dist/{*.tar.gz,*.zip,*.tar.zst,*.deb,*.rpm,*.apk,checksums.txt} --clobber 79 | -------------------------------------------------------------------------------- /internal/sortfiles/custom.go: -------------------------------------------------------------------------------- 1 | package sortfiles 2 | 3 | import ( 4 | "cmp" 5 | "slices" 6 | "strings" 7 | 8 | "github.com/ayoisaiah/f2/v2/internal/config" 9 | "github.com/ayoisaiah/f2/v2/internal/file" 10 | ) 11 | 12 | // ByTimeVar sorts changes by user-specified time variable. 13 | func ByTimeVar( 14 | changes file.Changes, 15 | conf *config.Config, 16 | ) { 17 | slices.SortStableFunc(changes, func(a, b *file.Change) int { 18 | if a.PrimaryPair != nil { 19 | a.SortCriterion.TimeVar = a.PrimaryPair.SortCriterion.TimeVar 20 | } 21 | 22 | if b.PrimaryPair != nil { 23 | b.SortCriterion.TimeVar = b.PrimaryPair.SortCriterion.TimeVar 24 | } 25 | 26 | timeA := a.SortCriterion.TimeVar 27 | timeB := b.SortCriterion.TimeVar 28 | 29 | if conf.SortPerDir && a.BaseDir != b.BaseDir { 30 | return 0 31 | } 32 | 33 | if conf.ReverseSort { 34 | return -cmp.Compare(timeA.UnixNano(), timeB.UnixNano()) 35 | } 36 | 37 | return cmp.Compare(timeA.UnixNano(), timeB.UnixNano()) 38 | }) 39 | } 40 | 41 | // ByStringVar sorts changes by user-specified string variable 42 | // (lexicographically). 43 | func ByStringVar( 44 | changes file.Changes, 45 | conf *config.Config, 46 | ) { 47 | slices.SortStableFunc(changes, func(a, b *file.Change) int { 48 | if a.PrimaryPair != nil { 49 | a.SortCriterion.StringVar = a.PrimaryPair.SortCriterion.StringVar 50 | } 51 | 52 | if b.PrimaryPair != nil { 53 | b.SortCriterion.StringVar = b.PrimaryPair.SortCriterion.StringVar 54 | } 55 | 56 | strA := a.SortCriterion.StringVar 57 | strB := b.SortCriterion.StringVar 58 | 59 | if conf.SortPerDir && a.BaseDir != b.BaseDir { 60 | return 0 61 | } 62 | 63 | if conf.ReverseSort { 64 | return strings.Compare(strB, strA) 65 | } 66 | 67 | return strings.Compare(strA, strB) 68 | }) 69 | } 70 | 71 | // ByIntVar sorts changes by user-specified integer variable. 72 | func ByIntVar( 73 | changes file.Changes, 74 | conf *config.Config, 75 | ) { 76 | slices.SortStableFunc(changes, func(a, b *file.Change) int { 77 | if a.PrimaryPair != nil { 78 | a.SortCriterion.IntVar = a.PrimaryPair.SortCriterion.IntVar 79 | } 80 | 81 | if b.PrimaryPair != nil { 82 | b.SortCriterion.IntVar = b.PrimaryPair.SortCriterion.IntVar 83 | } 84 | 85 | intA := a.SortCriterion.IntVar 86 | intB := b.SortCriterion.IntVar 87 | 88 | if conf.SortPerDir && a.BaseDir != b.BaseDir { 89 | return 0 90 | } 91 | 92 | if conf.ReverseSort { 93 | return cmp.Compare(intB, intA) 94 | } 95 | 96 | return cmp.Compare(intA, intB) 97 | }) 98 | } 99 | -------------------------------------------------------------------------------- /f2_test/f2_test.go: -------------------------------------------------------------------------------- 1 | package f2_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ayoisaiah/f2/v2" 8 | "github.com/ayoisaiah/f2/v2/internal/config" 9 | "github.com/ayoisaiah/f2/v2/internal/testutil" 10 | ) 11 | 12 | func TestImagePairRenaming(t *testing.T) { 13 | var stdout bytes.Buffer 14 | 15 | var stdin bytes.Buffer 16 | 17 | var stderr bytes.Buffer 18 | 19 | app, err := f2.New(&stdin, &stdout) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | config.Stderr = &stderr 25 | 26 | err = app.Run(t.Context(), []string{ 27 | "f2_test", 28 | "-r", 29 | "{x.cdt.YYYY}/{x.cdt.MM}-{x.cdt.MMM}/{x.cdt.YYYY}-{x.cdt.MM}-{x.cdt.DD}/{%03d}", 30 | "-R", 31 | "--target-dir", 32 | ".", 33 | "--pair", 34 | "--reset-index-per-dir", 35 | "-F", 36 | "--fix-conflicts-pattern", 37 | "%03d", 38 | "--sort", 39 | "time_var", 40 | "--sort-var", 41 | "{x.cdt}", 42 | "--pair-order", 43 | "dng,jpg", 44 | "--exclude", 45 | "golden", 46 | "testdata", 47 | }) 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | 52 | tc := &testutil.TestCase{ 53 | Name: "image pair renaming", 54 | } 55 | 56 | tc.SnapShot.Stdout = stdout.Bytes() 57 | tc.SnapShot.Stderr = stderr.Bytes() 58 | 59 | testutil.CompareGoldenFile(t, tc) 60 | } 61 | 62 | func TestConditionalSearch(t *testing.T) { 63 | cases := []testutil.TestCase{ 64 | { 65 | Name: "use dates to conditionally match files", 66 | Args: []string{ 67 | "f2_test", 68 | "-f", 69 | "{{x.cdt.DD} in [27, 28]}", 70 | "-r", 71 | "{f.up}", 72 | "-f", 73 | "{{x.cdt.DD} > 27}", 74 | "-r", 75 | "{%03d}", 76 | "-R", 77 | "--pair", 78 | }, 79 | }, 80 | { 81 | Name: "only match files named img33.dng", 82 | Args: []string{ 83 | "f2_test", 84 | "-f", 85 | "{`{xt.FileName}` == `img33.dng`}", 86 | "-r", 87 | "{f.up}{ext.up}", 88 | "-R", 89 | }, 90 | }, 91 | } 92 | 93 | for _, tc := range cases { 94 | var stdout bytes.Buffer 95 | 96 | var stdin bytes.Buffer 97 | 98 | var stderr bytes.Buffer 99 | 100 | app, err := f2.New(&stdin, &stdout) 101 | if err != nil { 102 | t.Fatal(err) 103 | } 104 | 105 | config.Stderr = &stderr 106 | 107 | err = app.Run(t.Context(), tc.Args) 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | 112 | tc.SnapShot.Stdout = stdout.Bytes() 113 | tc.SnapShot.Stderr = stderr.Bytes() 114 | 115 | testutil.CompareGoldenFile(t, &tc) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /f2_test/testdata/use_dates_to_conditionally_match_files_stdout.golden: -------------------------------------------------------------------------------- 1 | *——————————————————————————————————————————*——————————————————————————————————————————*——————————————————————————————————————————*————————* 2 | | ORIGINAL | -> 1 | RENAMED | STATUS | 3 | *——————————————————————————————————————————*——————————————————————————————————————————*——————————————————————————————————————————*————————* 4 | | testdata/img34.dng | testdata/IMG34.dng | testdata/001.dng | ok | 5 | | testdata/img34.jpg | testdata/IMG34.jpg | testdata/001.jpg | ok | 6 | | testdata/img66.dng | testdata/IMG66.dng | testdata/002.dng | ok | 7 | | testdata/img66.jpg | testdata/IMG66.jpg | testdata/002.jpg | ok | 8 | | testdata/birthday-2024/img44.dng | testdata/birthday-2024/IMG44.dng | testdata/birthday-2024/003.dng | ok | 9 | | testdata/birthday-2024/img44.jpg | testdata/birthday-2024/IMG44.jpg | testdata/birthday-2024/003.jpg | ok | 10 | | testdata/birthday-2024/img78.dng | testdata/birthday-2024/IMG78.dng | testdata/birthday-2024/004.dng | ok | 11 | | testdata/birthday-2024/img78.jpg | testdata/birthday-2024/IMG78.jpg | testdata/birthday-2024/004.jpg | ok | 12 | | testdata/family trip - berlin/img101.dng | testdata/family trip - berlin/IMG101.dng | testdata/family trip - berlin/IMG101.dng | ok | 13 | | testdata/family trip - berlin/img101.jpg | testdata/family trip - berlin/IMG101.jpg | testdata/family trip - berlin/IMG101.jpg | ok | 14 | | testdata/family trip - berlin/img90.dng | testdata/family trip - berlin/IMG90.dng | testdata/family trip - berlin/IMG90.dng | ok | 15 | | testdata/family trip - berlin/img90.jpg | testdata/family trip - berlin/IMG90.jpg | testdata/family trip - berlin/IMG90.jpg | ok | 16 | | testdata/family trip - berlin/img99.dng | testdata/family trip - berlin/IMG99.dng | testdata/family trip - berlin/IMG99.dng | ok | 17 | | testdata/family trip - berlin/img99.jpg | testdata/family trip - berlin/IMG99.jpg | testdata/family trip - berlin/IMG99.jpg | ok | 18 | *——————————————————————————————————————————*——————————————————————————————————————————*——————————————————————————————————————————*————————* 19 | -------------------------------------------------------------------------------- /f2.go: -------------------------------------------------------------------------------- 1 | package f2 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log/slog" 7 | "os" 8 | 9 | "github.com/urfave/cli/v3" 10 | 11 | "github.com/ayoisaiah/f2/v2/app" 12 | "github.com/ayoisaiah/f2/v2/find" 13 | "github.com/ayoisaiah/f2/v2/internal/apperr" 14 | "github.com/ayoisaiah/f2/v2/internal/config" 15 | "github.com/ayoisaiah/f2/v2/rename" 16 | "github.com/ayoisaiah/f2/v2/replace" 17 | "github.com/ayoisaiah/f2/v2/report" 18 | "github.com/ayoisaiah/f2/v2/validate" 19 | ) 20 | 21 | var errConflictDetected = &apperr.Error{ 22 | Message: "conflict: resolve manually or use -F/--fix-conflicts", 23 | } 24 | 25 | // isOutputToPipe detects if F2's output is being piped to another command. 26 | func isOutputToPipe() bool { 27 | fileInfo, _ := os.Stdout.Stat() 28 | 29 | return ((fileInfo.Mode() & os.ModeCharDevice) != os.ModeCharDevice) 30 | } 31 | 32 | // execute initiates a new renaming operation based on the provided CLI context. 33 | func execute(ctx context.Context, cmd *cli.Command) error { 34 | appConfig, err := config.Init(cmd, isOutputToPipe()) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | slog.DebugContext( 40 | ctx, 41 | "working configuration", 42 | slog.Any("config", appConfig), 43 | ) 44 | 45 | matches, err := find.Find(appConfig) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | slog.Debug( 51 | "find results", 52 | slog.Int("count", len(matches)), 53 | slog.Any("matches", matches), 54 | ) 55 | 56 | if len(matches) == 0 { 57 | report.NoMatches(appConfig) 58 | 59 | return nil 60 | } 61 | 62 | if !appConfig.Revert { 63 | matches, err = replace.Replace(appConfig, matches) 64 | if err != nil { 65 | return err 66 | } 67 | } 68 | 69 | hasConflicts := validate.Validate( 70 | matches, 71 | appConfig.AutoFixConflicts, 72 | appConfig.AllowOverwrites, 73 | appConfig.FixConflictsPatternRegex, 74 | appConfig.FixConflictsPattern, 75 | ) 76 | 77 | if hasConflicts { 78 | report.Report(appConfig, matches, hasConflicts) 79 | 80 | return errConflictDetected 81 | } 82 | 83 | if !appConfig.Exec { 84 | report.Report(appConfig, matches, hasConflicts) 85 | return nil 86 | } 87 | 88 | err = rename.Rename(appConfig, matches) 89 | 90 | rename.PostRename(appConfig, matches, err) 91 | 92 | return err 93 | } 94 | 95 | // New creates a new CLI application for f2. 96 | func New(reader io.Reader, writer io.Writer) (*cli.Command, error) { 97 | renamer, err := app.Get(reader, writer) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | renamer.Action = execute 103 | 104 | return renamer, nil 105 | } 106 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ayoisaiah/f2/v2 2 | 3 | go 1.25.0 4 | 5 | require ( 6 | github.com/barasher/go-exiftool v1.10.0 7 | github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 8 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 9 | github.com/pterm/pterm v0.12.80 10 | github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd 11 | golang.org/x/sys v0.33.0 // indirect 12 | golang.org/x/text v0.25.0 13 | gopkg.in/djherbis/times.v1 v1.3.0 14 | ) 15 | 16 | require ( 17 | github.com/BurntSushi/toml v1.5.0 18 | github.com/OneOfOne/xxhash v1.2.2 19 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de 20 | github.com/djherbis/times v1.6.0 21 | github.com/jessevdk/go-flags v1.6.1 22 | github.com/jinzhu/copier v0.4.0 23 | github.com/maja42/goval v1.6.0 24 | github.com/maruel/natural v1.1.1 25 | github.com/mattn/go-isatty v0.0.20 26 | github.com/nicksnyder/go-i18n/v2 v2.6.0 27 | github.com/olekukonko/tablewriter v0.0.5 28 | github.com/sebdah/goldie/v2 v2.5.5 29 | github.com/stretchr/testify v1.10.0 30 | github.com/urfave/cli/v3 v3.4.1 31 | go.withmatt.com/size v0.0.0-20250220224316-11aee5773e67 32 | ) 33 | 34 | require ( 35 | atomicgo.dev/cursor v0.2.0 // indirect 36 | atomicgo.dev/keyboard v0.2.9 // indirect 37 | atomicgo.dev/schedule v0.1.0 // indirect 38 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 39 | github.com/cespare/xxhash v1.1.0 // indirect 40 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 41 | github.com/charmbracelet/lipgloss v1.1.0 // indirect 42 | github.com/charmbracelet/log v0.4.2 // indirect 43 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 44 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 45 | github.com/charmbracelet/x/term v0.2.1 // indirect 46 | github.com/containerd/console v1.0.4 // indirect 47 | github.com/davecgh/go-spew v1.1.1 // indirect 48 | github.com/go-logfmt/logfmt v0.6.0 // indirect 49 | github.com/gookit/color v1.5.4 // indirect 50 | github.com/kr/pretty v0.3.1 // indirect 51 | github.com/lithammer/fuzzysearch v1.1.8 // indirect 52 | github.com/lmittmann/tint v1.1.2 // indirect 53 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 54 | github.com/mattn/go-runewidth v0.0.16 // indirect 55 | github.com/muesli/termenv v0.16.0 // indirect 56 | github.com/pmezard/go-difflib v1.0.0 // indirect 57 | github.com/rivo/uniseg v0.4.7 // indirect 58 | github.com/rogpeppe/go-internal v1.14.1 // indirect 59 | github.com/sergi/go-diff v1.3.1 // indirect 60 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 61 | golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect 62 | golang.org/x/term v0.32.0 // indirect 63 | gopkg.in/yaml.v3 v3.0.1 // indirect 64 | ) 65 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | issues-exit-code: 1 4 | linters: 5 | default: none 6 | enable: 7 | - bidichk 8 | - bodyclose 9 | - containedctx 10 | - contextcheck 11 | - copyloopvar 12 | - decorder 13 | - dogsled 14 | - dupl 15 | - errcheck 16 | - errchkjson 17 | - errname 18 | - errorlint 19 | - exhaustive 20 | - goconst 21 | - gocritic 22 | - gocyclo 23 | - godot 24 | - goprintffuncname 25 | - gosec 26 | - govet 27 | - ineffassign 28 | - ireturn 29 | - misspell 30 | - mnd 31 | - nestif 32 | - nilerr 33 | - nilnil 34 | - nolintlint 35 | - prealloc 36 | - predeclared 37 | - revive 38 | - staticcheck 39 | - tagliatelle 40 | - testpackage 41 | - thelper 42 | - tparallel 43 | - unconvert 44 | - unparam 45 | - unused 46 | - usestdlibvars 47 | - wastedassign 48 | - whitespace 49 | - wsl_v5 50 | settings: 51 | errcheck: 52 | check-type-assertions: true 53 | goconst: 54 | min-len: 2 55 | min-occurrences: 3 56 | gocritic: 57 | enabled-tags: 58 | - diagnostic 59 | - experimental 60 | - opinionated 61 | - performance 62 | - style 63 | govet: 64 | enable: 65 | - fieldalignment 66 | mnd: 67 | checks: 68 | - argument 69 | - case 70 | - condition 71 | - return 72 | nestif: 73 | min-complexity: 15 74 | nolintlint: 75 | require-explanation: true 76 | require-specific: true 77 | tagliatelle: 78 | case: 79 | rules: 80 | json: snake 81 | exclusions: 82 | generated: lax 83 | presets: 84 | - comments 85 | - common-false-positives 86 | - legacy 87 | - std-error-handling 88 | rules: 89 | - linters: 90 | - dupl 91 | - gocyclo 92 | - gosec 93 | - varnamelen 94 | path: _test\.go 95 | - linters: 96 | - gosec 97 | text: weak cryptographic primitive 98 | - linters: 99 | - staticcheck 100 | text: error strings should not be capitalized 101 | paths: 102 | - third_party$ 103 | - builtin$ 104 | - examples$ 105 | issues: 106 | max-issues-per-linter: 0 107 | max-same-issues: 0 108 | fix: true 109 | formatters: 110 | enable: 111 | - gofmt 112 | - goimports 113 | settings: 114 | goimports: 115 | local-prefixes: 116 | - github.com/ayoisaiah/f2 117 | exclusions: 118 | generated: lax 119 | paths: 120 | - third_party$ 121 | - builtin$ 122 | - examples$ 123 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: File a bug report 2 | description: Report an issue with F2 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thank you for submitting a bug report for F2. To assist us in replicating and resolving your issue, kindly fill the following fields as per their descriptions. 8 | 9 | Before proceeding, we recommend checking the [list of open bug reports](https://github.com/ayoisaiah/f2/issues?q=is%3Aissue+is%3Aopen+label%3Abug+sort%3Aupdated-desc) to confirm that your particular problem has not already been reported. 10 | 11 | If you do not find your issue listed, please proceed with your bug report. Your contribution is highly appreciated! 12 | 13 | - type: checkboxes 14 | id: issue-not-common 15 | attributes: 16 | label: Tick this box to confirm you have reviewed the above. 17 | options: 18 | - label: I've discovered a new issue with F2. 19 | required: true 20 | 21 | - type: textarea 22 | id: f2-version 23 | attributes: 24 | label: What version of F2 are you using? 25 | description: Enter the output of `f2 --version`. Please ensure you're using the [latest stable release](https://github.com/ayoisaiah/f2/releases/latest) before filing a bug report. 26 | placeholder: ex. F2 version v1.9.1 27 | validations: 28 | required: true 29 | 30 | - type: textarea 31 | id: install-method 32 | attributes: 33 | label: How did you install F2? 34 | placeholder: ex. Go, Arch AUR, GitHub binary, NPM 35 | validations: 36 | required: false 37 | 38 | - type: textarea 39 | id: operating-system 40 | attributes: 41 | label: What operating system are you using F2 on? 42 | description: Enter your operating system name and version. 43 | placeholder: ex. Fedora 39, Windows 11 44 | validations: 45 | required: true 46 | 47 | - type: textarea 48 | id: description 49 | attributes: 50 | label: Describe your bug. 51 | description: Give a high level description of the bug. 52 | placeholder: ex. F2 overwrites a file without using the `--allow-overwrites` flag 53 | validations: 54 | required: true 55 | 56 | - type: textarea 57 | id: steps-to-reproduce 58 | attributes: 59 | label: What are the steps to reproduce the behavior? 60 | description: | 61 | Please describe the steps to trigger the bug including a reproducible example. 62 | validations: 63 | required: true 64 | 65 | - type: textarea 66 | id: actual-behavior 67 | attributes: 68 | label: What behaviour did you observe? 69 | validations: 70 | required: true 71 | 72 | - type: textarea 73 | id: expected-behavior 74 | attributes: 75 | label: What is the expected behaviour? 76 | description: What do you think F2 should have done instead? 77 | validations: 78 | required: true 79 | -------------------------------------------------------------------------------- /scripts/completions/f2.zsh: -------------------------------------------------------------------------------- 1 | #compdef _f2 f2 2 | 3 | function _f2 { 4 | local line 5 | 6 | _arguments -C \ 7 | "--csv[Rename using a CSV file]" \ 8 | "--find[Search for specified pattern]" \ 9 | "-f[Search for specified pattern]" \ 10 | "--replace[Replacement pattern for matches]" \ 11 | "-r[Replacement pattern for matches]" \ 12 | "--undo[Undo the last renaming operation in current directory]" \ 13 | "-u[Undo the last renaming operation in current directory]" \ 14 | "--allow-overwrites[Allow overwriting existing files]" \ 15 | "--clean[Clean empty directories after renaming]" \ 16 | "--exclude[Exclude files and directories matching pattern]" \ 17 | "-E[Exclude files and directories matching pattern]" \ 18 | "--exclude-dir[Prevent recursing into directories to search for matches]" \ 19 | "--exec[Execute renaming operation]" \ 20 | "-x[Execute renaming operation]" \ 21 | "--fix-conflicts[Auto fix renaming conflicts]" \ 22 | "-F[Auto fix renaming conflicts]" \ 23 | "--fix-conflicts-patern[Provide a custom pattern for conflict resolution]" \ 24 | "--help[Display help and exit]" \ 25 | "-h[Display help and exit]" \ 26 | "--hidden[Match hidden files]" \ 27 | "-H[Match hidden files]" \ 28 | "--include-dir[Match directories]" \ 29 | "-d[Match directories]" \ 30 | "--ignore-case[Make searches case insensitive]" \ 31 | "-i[Make searches case insensitive]" \ 32 | "--ignore-ext[Ignore file extension]" \ 33 | "-e[Ignore file extension]" \ 34 | "--json[Enable json output]" \ 35 | "--max-depth[Specify max depth for recursive search]" \ 36 | "-m[Specify max depth for recursive search]" \ 37 | "--no-color[Disable coloured output]" \ 38 | "--only-dir[Rename only directories]" \ 39 | "-D[Rename only directories]" \ 40 | "--pair[Enable pair renaming]" \ 41 | "-p[Enable pair renaming]" \ 42 | "--pair-order[Order the paired files]" \ 43 | "--quiet[Disable all output except errors]" \ 44 | "-q[Disable all output except errors]" \ 45 | "--recursive[Search for matches in subdirectories]" \ 46 | "-R[Search for matches in subdirectories]" \ 47 | "--replace-limit[Limit the matches to be replaced]" \ 48 | "-R[Limit the matches to be replaced]" \ 49 | "--reset-index-per-dir[Reset indexes in each directory]" \ 50 | "--sort[Sort matches in ascending order]" \ 51 | "--sortr[Sort matches in descending order]" \ 52 | "--sort-per-dir[Apply sort per directory]" \ 53 | "--sort-var[Provide a variable for sorting]" \ 54 | "--string-mode[Treat the search pattern as a non-regex string]" \ 55 | "-s[Treat the search pattern as a non-regex string]" \ 56 | "--target-dir[Specify a target directory]" \ 57 | "-t[Specify a target directory]" \ 58 | "--verbose[Enable verbose output]" \ 59 | "-V[Enable verbose output]" \ 60 | "--version[Display version and exit]" \ 61 | "-v[Display version and exit]" \ 62 | } 63 | -------------------------------------------------------------------------------- /internal/sortfiles/sortfiles_test/testdata/dir1/folder/3k.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /find/csv.go: -------------------------------------------------------------------------------- 1 | package find 2 | 3 | import ( 4 | "bufio" 5 | "encoding/csv" 6 | "errors" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/ayoisaiah/f2/v2/internal/config" 12 | "github.com/ayoisaiah/f2/v2/internal/file" 13 | "github.com/ayoisaiah/f2/v2/report" 14 | ) 15 | 16 | // readCSVFile reads all the records contained in a CSV file specified by 17 | // `pathToCSV`. 18 | func readCSVFile(pathToCSV string) ([][]string, error) { 19 | f, err := os.Open(pathToCSV) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | defer f.Close() 25 | 26 | // Use bufio for potential performance gains with large CSV files 27 | csvReader := csv.NewReader(bufio.NewReader(f)) 28 | 29 | records, err := csvReader.ReadAll() 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | return records, nil 35 | } 36 | 37 | // handleCSV reads the provided CSV file, and finds all the valid candidates 38 | // for renaming. 39 | func handleCSV(conf *config.Config) (file.Changes, error) { 40 | processed := make(map[string]bool) 41 | 42 | var changes file.Changes 43 | 44 | records, err := readCSVFile(conf.CSVFilename) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | currentDir, err := os.Getwd() 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | // Change to the directory of the CSV file 55 | err = os.Chdir(conf.WorkingDir) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | defer func() { 61 | _ = os.Chdir(currentDir) 62 | }() 63 | 64 | for i, record := range records { 65 | if len(record) == 0 { 66 | continue 67 | } 68 | 69 | source := strings.TrimSpace(record[0]) 70 | 71 | fileInfo, statErr := os.Stat(source) 72 | if statErr != nil { 73 | // Skip missing source files 74 | if errors.Is(statErr, os.ErrNotExist) { 75 | if conf.Verbose { 76 | report.NonExistentFile(source, i+1) 77 | } 78 | 79 | continue 80 | } 81 | 82 | return nil, statErr 83 | } 84 | 85 | fileName := fileInfo.Name() 86 | 87 | sourceDir := filepath.Dir(source) 88 | 89 | // Ensure that the file is not already processed in the case of 90 | // duplicate rows 91 | if processed[source] { 92 | continue 93 | } 94 | 95 | processed[source] = true 96 | 97 | sourcePath := filepath.Join(sourceDir, fileName) 98 | 99 | match := &file.Change{ 100 | BaseDir: sourceDir, 101 | TargetDir: sourceDir, 102 | IsDir: fileInfo.IsDir(), 103 | Source: fileName, 104 | Target: fileName, 105 | OriginalName: fileName, 106 | SourcePath: sourcePath, 107 | Steps: []string{sourcePath}, 108 | CSVRow: record, 109 | Position: i, 110 | } 111 | 112 | if conf.TargetDir != "" { 113 | match.TargetDir = conf.TargetDir 114 | } 115 | 116 | if len(record) > 1 { 117 | match.Target = strings.TrimSpace(record[1]) 118 | 119 | if filepath.IsAbs(match.Target) { 120 | match.TargetDir = "" 121 | continue 122 | } 123 | } 124 | 125 | changes = append(changes, match) 126 | } 127 | 128 | return changes, nil 129 | } 130 | -------------------------------------------------------------------------------- /docs/README.zh.md: -------------------------------------------------------------------------------- 1 | **以其他語言閱讀此文檔:**[English](/README.md) | [Deutsch](/docs/README.de.md) | [Español](/docs/README.es.md) | [Français](/docs/README.fr.md) | [Português](/docs/README.pt.md) | [Русский](/docs/README.ru.md) 2 | 3 |

4 | f2 5 |

6 |

7 | 歡迎PR 8 | Github Actions 9 | Go語言開發 10 | GoReportCard 11 | Go.mod版本 12 | MIT許可證 13 | 最新版本 14 |

15 |

F2 - 命令行批次重新命名工具

16 | 17 | **F2** 是一款跨平台的命令行工具,用於**快速**、**安全**地批次重新命名檔案和目錄。採用 Go 語言編寫! 18 | 19 | ## F2 有何不同? 20 | 21 | 相比其他重新命名工具,F2 具備以下核心優勢: 22 | 23 | * **預設模擬執行**:預設開啟模擬運行模式,方便你在執行前預覽更改。 24 | * **支援檔案屬性變數**:允許使用檔案屬性(如圖片的 EXIF 數據、音頻的 ID3 標籤)進行重新命名,靈活性極高。 25 | * **功能全面**:無論是簡單的字串替換還是複雜的正則表達式匹配,F2 都能滿足。 26 | * **安全第一**:通過嚴格檢查確保每次操作無衝突、防誤操作,優先保證準確性。 27 | * **衝突自動處理**:執行前驗證操作,可自動檢測並解決衝突。 28 | * **性能卓越**:處理成千上萬檔案依然快速高效。 29 | * **操作可撤銷**:輕鬆回退任何重新命名操作,修正錯誤更方便。 30 | * **文件詳盡**:提供清晰實用的示例文件,助你快速掌握功能,避免困惑。 31 | 32 | ## ⚡ 安裝 33 | 34 | Go 開發者可通過 `go install` 安裝(需要 Go v1.23 或更高版本): 35 | 36 | ```bash 37 | go install github.com/ayoisaiah/f2/v2/cmd/f2@latest 38 | ``` 39 | 40 | 其他安裝方式[請查閱文檔](https://f2.freshman.tech/guide/getting-started.html),或前往 [Release](https://github.com/ayoisaiah/f2/releases) 下載對應作業系統的預編譯二進位文件。 41 | 42 | ## 📃 快速連結 43 | 44 | * [安裝指南](https://f2.freshman.tech/guide/getting-started.html) 45 | * [入門教程](https://f2.freshman.tech/guide/tutorial.html) 46 | * [實戰案例](https://f2.freshman.tech/guide/organizing-image-library.html) 47 | * [內建變數說明](https://f2.freshman.tech/guide/how-variables-work.html) 48 | * [配對檔案重新命名](https://f2.freshman.tech/guide/pair-renaming.html) 49 | * [CSV 檔案批次重新命名](https://f2.freshman.tech/guide/csv-renaming.html) 50 | * [檔案排序](https://f2.freshman.tech/guide/sorting.html) 51 | * [衝突解決](https://f2.freshman.tech/guide/conflict-detection.html) 52 | * [撤銷操作](https://f2.freshman.tech/guide/undoing-mistakes.html) 53 | * [更新日誌](https://f2.freshman.tech/reference/changelog.html) 54 | 55 | ## 💻 效果截圖 56 | 57 | ![F2 利用 Exif 屬性整理圖片檔案](https://f2.freshman.tech/assets/2.D-uxLR9T.png) 58 | 59 | ## 🤝 參與貢獻 60 | 61 | 歡迎提交 Bug 報告和功能建議!提交 Pull Request 前請先創建 Issue 討論。 62 | 63 | ## ⚖ 許可協議 64 | 65 | 由 Ayooluwa Isaiah 創建,採用 [MIT 許可證](https://github.com/ayoisaiah/f2/blob/master/LICENCE)發布。 66 | 67 | 由 [Karlbaey101](//github.com/Karlbaey101) 翻譯,翻譯的文字遵循 [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) 協議。 68 | -------------------------------------------------------------------------------- /replace/variables/variable_regex.go: -------------------------------------------------------------------------------- 1 | package variables 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/ayoisaiah/f2/v2/internal/timeutil" 9 | ) 10 | 11 | var transformTokens string 12 | 13 | var ( 14 | filenameVarRegex *regexp.Regexp 15 | extensionVarRegex *regexp.Regexp 16 | parentDirVarRegex *regexp.Regexp 17 | indexVarRegex *regexp.Regexp 18 | hashVarRegex *regexp.Regexp 19 | transformVarRegex *regexp.Regexp 20 | csvVarRegex *regexp.Regexp 21 | exiftoolVarRegex *regexp.Regexp 22 | id3VarRegex *regexp.Regexp 23 | exifVarRegex *regexp.Regexp 24 | dateVarRegex *regexp.Regexp 25 | ) 26 | 27 | var dateTokens = map[string]string{ 28 | "YYYY": "2006", 29 | "YY": "06", 30 | "MMMM": "January", 31 | "MMM": "Jan", 32 | "MM": "01", 33 | "M": "1", 34 | "DDDD": "Monday", 35 | "DDD": "Mon", 36 | "DD": "02", 37 | "D": "2", 38 | "H": "15", 39 | "hh": "03", 40 | "h": "3", 41 | "mm": "04", 42 | "m": "4", 43 | "ss": "05", 44 | "s": "5", 45 | "A": "PM", 46 | "a": "pm", 47 | "unix": "unix", 48 | "since": "since", 49 | } 50 | 51 | func init() { 52 | tokens := make([]string, 0, len(dateTokens)) 53 | for key := range dateTokens { 54 | tokens = append(tokens, key) 55 | } 56 | 57 | tokenString := strings.Join(tokens, "|") 58 | 59 | transformTokens = fmt.Sprintf( 60 | "(up|lw|ti|win|mac|di|norm|(?:dt(?:\\.(%s))?))", 61 | tokenString, 62 | ) 63 | 64 | filenameVarRegex = regexp.MustCompile( 65 | fmt.Sprintf("{+f(?:\\.%s)?}+", transformTokens), 66 | ) 67 | extensionVarRegex = regexp.MustCompile( 68 | fmt.Sprintf("{+(2)?ext(?:\\.%s)?}+", transformTokens), 69 | ) 70 | parentDirVarRegex = regexp.MustCompile( 71 | fmt.Sprintf("{+(\\d+)?p(?:\\.%s)?}+", transformTokens), 72 | ) 73 | indexVarRegex = regexp.MustCompile( 74 | `{+(\$\d+)?(\d+)?(%(\d?)+d)([borh])?(-?\d+)?(?:<(\d+(?:-\d+)?(?:;\s*\d+(?:-\d+)?)*)>)?(##)?}+`, 75 | ) 76 | hashVarRegex = regexp.MustCompile( 77 | fmt.Sprintf( 78 | "{+hash.(sha1|sha256|sha512|md5|xxh32|xxh64)(?:\\.%s)?}+", 79 | transformTokens, 80 | ), 81 | ) 82 | transformVarRegex = regexp.MustCompile( 83 | fmt.Sprintf("{+(?:<(?:(\\$\\d+)|([^\\.]+))>)?\\.%s}+", transformTokens), 84 | ) 85 | csvVarRegex = regexp.MustCompile( 86 | fmt.Sprintf("{+csv.(\\d+)(?:\\.%s)?}+", transformTokens), 87 | ) 88 | exiftoolVarRegex = regexp.MustCompile( 89 | fmt.Sprintf( 90 | "{+xt\\.([0-9a-zA-Z]+)(?:\\.%s)?}+", 91 | transformTokens, 92 | ), 93 | ) 94 | id3VarRegex = regexp.MustCompile( 95 | fmt.Sprintf( 96 | "{+id3\\.(format|type|title|album|album_artist|artist|genre|year|composer|track|disc|total_tracks|total_discs)(?:\\.%s)?}+", 97 | transformTokens, 98 | ), 99 | ) 100 | 101 | dateVarRegex = regexp.MustCompile( 102 | fmt.Sprintf( 103 | "{+("+timeutil.Mod+"|"+timeutil.Change+"|"+timeutil.Birth+"|"+timeutil.Access+"|"+timeutil.Current+")(?:\\.("+tokenString+"))?(?:\\.%s)?}+", 104 | transformTokens, 105 | ), 106 | ) 107 | 108 | exifVarRegex = regexp.MustCompile( 109 | fmt.Sprintf( 110 | "{+(?:exif|x)\\.(?:(iso|et|fl|w|h|wh|make|model|lens|fnum|fl35|lat|lon|soft)|(?:(cdt)(?:\\.("+tokenString+"))?))(?:\\.%s)?}+", 111 | transformTokens, 112 | ), 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /replace/variables/variable_types.go: -------------------------------------------------------------------------------- 1 | package variables 2 | 3 | import ( 4 | "regexp" 5 | ) 6 | 7 | type numbersToSkip struct { 8 | min int 9 | max int 10 | } 11 | 12 | type indexVarMatch struct { 13 | regex *regexp.Regexp 14 | indexFormat string 15 | numberSystem string // Binary, Octal, Roman, Decimal 16 | skip []numbersToSkip 17 | submatch []string 18 | step struct { 19 | isSet bool 20 | value int 21 | } 22 | startNumber int 23 | isCaptureVar bool 24 | } 25 | 26 | type indexVars struct { 27 | currentBaseDir string 28 | capturVarIndex []int 29 | offset []int 30 | matches []indexVarMatch 31 | newDirIndex int 32 | } 33 | 34 | type transformVarMatch struct { 35 | regex *regexp.Regexp 36 | token string 37 | captureVar string 38 | inputStr string 39 | timeStr string 40 | val []string 41 | } 42 | 43 | type transformVars struct { 44 | matches []transformVarMatch 45 | } 46 | 47 | type exiftoolVarMatch struct { 48 | regex *regexp.Regexp 49 | attr string 50 | transformToken string 51 | val []string 52 | } 53 | 54 | type exiftoolVars struct { 55 | matches []exiftoolVarMatch 56 | } 57 | 58 | type exifVarMatch struct { 59 | regex *regexp.Regexp 60 | attr string 61 | timeStr string 62 | transformToken string 63 | val []string 64 | } 65 | 66 | type exifVars struct { 67 | matches []exifVarMatch 68 | } 69 | 70 | type id3VarMatch struct { 71 | regex *regexp.Regexp 72 | tag string 73 | transformToken string 74 | val []string 75 | } 76 | 77 | type id3Vars struct { 78 | matches []id3VarMatch 79 | } 80 | 81 | type dateVarMatch struct { 82 | regex *regexp.Regexp 83 | attr string 84 | token string 85 | transformToken string 86 | val []string 87 | } 88 | 89 | type dateVars struct { 90 | matches []dateVarMatch 91 | } 92 | 93 | type hashVarMatch struct { 94 | regex *regexp.Regexp 95 | hashFn hashAlgorithm 96 | transformToken string 97 | val []string 98 | } 99 | 100 | type hashVars struct { 101 | matches []hashVarMatch 102 | } 103 | 104 | type csvVarMatch struct { 105 | regex *regexp.Regexp 106 | transformToken string 107 | column int 108 | } 109 | 110 | type csvVars struct { 111 | submatches [][]string 112 | values []csvVarMatch 113 | } 114 | 115 | type filenameVarMatch struct { 116 | regex *regexp.Regexp 117 | transformToken string 118 | } 119 | 120 | type filenameVars struct { 121 | matches []filenameVarMatch 122 | } 123 | 124 | type extVarMatch struct { 125 | regex *regexp.Regexp 126 | transformToken string 127 | doubleExt bool 128 | } 129 | 130 | type extVars struct { 131 | matches []extVarMatch 132 | } 133 | 134 | type parentDirVarMatch struct { 135 | regex *regexp.Regexp 136 | transformToken string 137 | parent int 138 | } 139 | 140 | type parentDirVars struct { 141 | matches []parentDirVarMatch 142 | } 143 | 144 | type Variables struct { 145 | csv csvVars 146 | exif exifVars 147 | filename filenameVars 148 | id3 id3Vars 149 | hash hashVars 150 | date dateVars 151 | transform transformVars 152 | exiftool exiftoolVars 153 | ext extVars 154 | parentDir parentDirVars 155 | index indexVars 156 | } 157 | 158 | func (v *Variables) IndexMatches() int { 159 | return len(v.index.matches) 160 | } 161 | -------------------------------------------------------------------------------- /internal/config/config_internal_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | ) 7 | 8 | func TestRegex(t *testing.T) { 9 | testCases := []struct { 10 | name string 11 | regex *regexp.Regexp 12 | matches []string 13 | unmatches []string 14 | }{ 15 | { 16 | name: "sortVarRegex", 17 | regex: sortVarRegex, 18 | matches: []string{ 19 | "{sort}", 20 | "{any_string}", 21 | }, 22 | unmatches: []string{ 23 | "sort", 24 | "{sort", 25 | "sort}", 26 | "s{or}t}", 27 | "{}", 28 | }, 29 | }, 30 | { 31 | name: "defaultFixConflictsPatternRegex", 32 | regex: defaultFixConflictsPatternRegex, 33 | matches: []string{ 34 | "file(1)", 35 | "file (1)", 36 | }, 37 | unmatches: []string{ 38 | "file", 39 | }, 40 | }, 41 | { 42 | name: "customFixConflictsPatternRegex", 43 | regex: customFixConflictsPatternRegex, 44 | matches: []string{ 45 | "%d", 46 | "_%d", 47 | "__%2d", 48 | "-%d-", 49 | }, 50 | unmatches: []string{ 51 | "%x", 52 | }, 53 | }, 54 | { 55 | name: "capturVarIndexRegex", 56 | regex: capturVarIndexRegex, 57 | matches: []string{ 58 | "{$1%d}", 59 | "{$1%2d}", 60 | "{$1%db}", 61 | "{$1%do}", 62 | "{$1%dr}", 63 | "{$1%dh}", 64 | "{$1%d-1}", 65 | "{$1%d<1>}", 66 | "{$1%d<1-2>}", 67 | "{$1%d<1-2;3-4>}", 68 | }, 69 | unmatches: []string{ 70 | "{%d}", 71 | "{$1}", 72 | "{$1%d<1-2;3-4;>}", 73 | }, 74 | }, 75 | { 76 | name: "indexVarRegex", 77 | regex: indexVarRegex, 78 | matches: []string{ 79 | "{%d}", 80 | "{1%d}", 81 | "{$1%d}", 82 | "{%2d}", 83 | "{%db}", 84 | "{%do}", 85 | "{%dr}", 86 | "{%dh}", 87 | "{%d-1}", 88 | "{%d<1>}", 89 | "{%d<1-2>}", 90 | "{%d<1-2;3-4>}", 91 | "{%d##}", 92 | }, 93 | unmatches: []string{ 94 | "{d}", 95 | "{%d<1-2;3-4;>}", 96 | }, 97 | }, 98 | { 99 | name: "findVariableRegex", 100 | regex: findVariableRegex, 101 | matches: []string{ 102 | "{find}", 103 | "{any_string}", 104 | "{([0-9]{4})-([0-9]{2})}", 105 | }, 106 | unmatches: []string{ 107 | "find", 108 | "{find", 109 | "find}", 110 | "([0-9]{4})-([0-9]{2})", 111 | "{}", 112 | }, 113 | }, 114 | { 115 | name: "exifToolVarRegex", 116 | regex: exifToolVarRegex, 117 | matches: []string{ 118 | "{xt.Artist}", 119 | "{xt.Comment}", 120 | }, 121 | unmatches: []string{ 122 | "{xt.}", 123 | "{xt}", 124 | }, 125 | }, 126 | { 127 | name: "dateTokenRegex", 128 | regex: dateTokenRegex, 129 | matches: []string{ 130 | "{YYYY}", 131 | "{YY}", 132 | "{MMMM}", 133 | "{MMM}", 134 | "{MM}", 135 | "{M}", 136 | "{DDDD}", 137 | "{DDD}", 138 | "{DD}", 139 | "{D}", 140 | "{H}", 141 | "{hh}", 142 | "{h}", 143 | "{mm}", 144 | "{m}", 145 | "{ss}", 146 | "{s}", 147 | "{A}", 148 | "{a}", 149 | "{unix}", 150 | "{since}", 151 | }, 152 | unmatches: []string{ 153 | "{yyyy}", 154 | "{yy}", 155 | }, 156 | }, 157 | } 158 | 159 | for _, tc := range testCases { 160 | t.Run(tc.name, func(t *testing.T) { 161 | for _, match := range tc.matches { 162 | if !tc.regex.MatchString(match) { 163 | t.Errorf( 164 | "expected %q to match %q", 165 | match, 166 | tc.regex.String(), 167 | ) 168 | } 169 | } 170 | 171 | for _, unmatch := range tc.unmatches { 172 | if tc.regex.MatchString(unmatch) { 173 | t.Errorf( 174 | "expected %q to not match %q", 175 | unmatch, 176 | tc.regex.String(), 177 | ) 178 | } 179 | } 180 | }) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /internal/eval/eval.go: -------------------------------------------------------------------------------- 1 | package eval 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "time" 7 | 8 | "github.com/maja42/goval" 9 | "go.withmatt.com/size" 10 | 11 | "github.com/ayoisaiah/f2/v2/internal/apperr" 12 | "github.com/ayoisaiah/f2/v2/internal/localize" 13 | ) 14 | 15 | var ( 16 | errInvalidArgs = &apperr.Error{ 17 | Message: localize.T("error.eval_invalid_args"), 18 | } 19 | 20 | errEval = &apperr.Error{ 21 | Message: localize.T("error.search_eval_failed"), 22 | } 23 | ) 24 | 25 | var functions = map[string]goval.ExpressionFunction{ 26 | "strlen": func(args ...any) (any, error) { 27 | if len(args) == 0 { 28 | return nil, errInvalidArgs 29 | } 30 | 31 | str, _ := args[0].(string) 32 | 33 | return len(str), nil 34 | }, 35 | 36 | "dur": func(args ...any) (any, error) { 37 | if len(args) == 0 { 38 | return nil, errInvalidArgs 39 | } 40 | 41 | str, _ := args[0].(string) 42 | 43 | dur, err := parseDuration(str) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | return dur.Seconds(), nil 49 | }, 50 | 51 | "contains": func(args ...any) (any, error) { 52 | if len(args) <= 1 { 53 | return nil, errInvalidArgs 54 | } 55 | 56 | str, _ := args[0].(string) 57 | substr, _ := args[1].(string) 58 | 59 | return strings.Contains(str, substr), nil 60 | }, 61 | 62 | "size": func(args ...any) (any, error) { 63 | if len(args) == 0 { 64 | return nil, errInvalidArgs 65 | } 66 | 67 | str, _ := args[0].(string) 68 | 69 | // Handle Exiftool format: "26 kB" -> "26K", "1.2 MB" -> "1.2M" 70 | // Remove spaces between number and unit for compatibility with size.ParseCapacity 71 | r := strings.NewReplacer( 72 | " kB", "K", 73 | " MB", "M", 74 | " GB", "G", 75 | " TB", "T", 76 | " bytes", "", 77 | ) 78 | str = r.Replace(str) 79 | 80 | s, err := size.ParseCapacity(str) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | return int(s.Bytes()), nil 86 | }, 87 | 88 | "matches": func(args ...any) (any, error) { 89 | if len(args) <= 1 { 90 | return nil, errInvalidArgs 91 | } 92 | 93 | str, _ := args[0].(string) 94 | exp, _ := args[1].(string) 95 | 96 | reg := regexp.MustCompile(exp) 97 | 98 | return reg.MatchString(str), nil 99 | }, 100 | } 101 | 102 | // ParseDuration parses a duration string. 103 | // examples: "10d", "-1.5w" or "3Y4M5d". 104 | // Add time units are "d"="D", "w"="W", "M", "y"="Y". 105 | // Adapted from: https://gist.github.com/xhit/79c9e137e1cfe332076cdda9f5e24699?permalink_comment_id=5170854#gistcomment-5170854 106 | func parseDuration(s string) (time.Duration, error) { 107 | neg := false 108 | if s != "" && s[0] == '-' { 109 | neg = true 110 | s = s[1:] 111 | } 112 | 113 | re := regexp.MustCompile(`(\d*\.\d+|\d+)\D*`) 114 | unitMap := map[string]time.Duration{ 115 | "d": 24, 116 | "w": 7 * 24, 117 | "M": 30 * 24, 118 | "y": 365 * 24, 119 | } 120 | 121 | strs := re.FindAllString(s, -1) 122 | 123 | var sumDur time.Duration 124 | 125 | for _, str := range strs { 126 | var _hours time.Duration = 1 127 | 128 | for unit, hours := range unitMap { 129 | if strings.Contains(str, unit) { 130 | str = strings.ReplaceAll(str, unit, "h") 131 | _hours = hours 132 | 133 | break 134 | } 135 | } 136 | 137 | dur, err := time.ParseDuration(str) 138 | if err != nil { 139 | return 0, err 140 | } 141 | 142 | sumDur += dur * _hours 143 | } 144 | 145 | if neg { 146 | sumDur = -sumDur 147 | } 148 | 149 | return sumDur, nil 150 | } 151 | 152 | func Evaluate(expression string) (bool, error) { 153 | eval := goval.NewEvaluator() 154 | 155 | result, err := eval.Evaluate(expression, nil, functions) 156 | if err != nil { 157 | return false, errEval.Wrap(err) 158 | } 159 | 160 | r, _ := result.(bool) 161 | if !r { 162 | return false, nil 163 | } 164 | 165 | return true, nil 166 | } 167 | -------------------------------------------------------------------------------- /internal/sortfiles/sortfiles_test/testdata/4k.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /replace/variables/variables_internal_test.go: -------------------------------------------------------------------------------- 1 | package variables 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | ) 7 | 8 | func TestVariableRegex(t *testing.T) { 9 | testCases := []struct { 10 | name string 11 | regex *regexp.Regexp 12 | matches []string 13 | unmatches []string 14 | }{ 15 | { 16 | name: "filenameVarRegex", 17 | regex: filenameVarRegex, 18 | matches: []string{ 19 | "{f}", 20 | "{f.up}", 21 | "{f.lw}", 22 | "{f.ti}", 23 | "{f.win}", 24 | "{f.mac}", 25 | "{f.di}", 26 | "{f.norm}", 27 | "{f.dt}", 28 | "{f.dt.YYYY}", 29 | }, 30 | unmatches: []string{ 31 | "f", 32 | "{f.}", 33 | "{f.unknown}", 34 | }, 35 | }, 36 | { 37 | name: "extensionVarRegex", 38 | regex: extensionVarRegex, 39 | matches: []string{ 40 | "{ext}", 41 | "{2ext}", 42 | "{ext.up}", 43 | }, 44 | unmatches: []string{ 45 | "ext", 46 | "{ext.}", 47 | "{3ext}", 48 | }, 49 | }, 50 | { 51 | name: "parentDirVarRegex", 52 | regex: parentDirVarRegex, 53 | matches: []string{ 54 | "{p}", 55 | "{1p}", 56 | "{10p}", 57 | "{p.up}", 58 | "{p.dt.hh}", 59 | }, 60 | unmatches: []string{ 61 | "p", 62 | "{p.}", 63 | }, 64 | }, 65 | { 66 | name: "indexVarRegex", 67 | regex: indexVarRegex, 68 | matches: []string{ 69 | "{%d}", 70 | "{$1%d}", 71 | "{%2d}", 72 | "{%05d}", 73 | }, 74 | unmatches: []string{ 75 | "d", 76 | }, 77 | }, 78 | { 79 | name: "hashVarRegex", 80 | regex: hashVarRegex, 81 | matches: []string{ 82 | "{hash.sha1}", 83 | "{hash.sha256.up}", 84 | }, 85 | unmatches: []string{ 86 | "{hash}", 87 | "{hash.}", 88 | "{hash.sha1.}", 89 | }, 90 | }, 91 | { 92 | name: "transformVarRegex", 93 | regex: transformVarRegex, 94 | matches: []string{ 95 | "{.up}", 96 | "{<$1>.up}", 97 | "{.up}", 98 | }, 99 | unmatches: []string{ 100 | "{up}", 101 | "{.}", 102 | }, 103 | }, 104 | { 105 | name: "csvVarRegex", 106 | regex: csvVarRegex, 107 | matches: []string{ 108 | "{csv.1}", 109 | "{csv.10.up}", 110 | }, 111 | unmatches: []string{ 112 | "{csv}", 113 | "{csv.}", 114 | "{csv.1.}", 115 | }, 116 | }, 117 | { 118 | name: "exiftoolVarRegex", 119 | regex: exiftoolVarRegex, 120 | matches: []string{ 121 | "{xt.Artist}", 122 | "{xt.Comment.up}", 123 | }, 124 | unmatches: []string{ 125 | "{xt}", 126 | "{xt.}", 127 | "{xt.Artist.}", 128 | }, 129 | }, 130 | { 131 | name: "id3VarRegex", 132 | regex: id3VarRegex, 133 | matches: []string{ 134 | "{id3.title}", 135 | "{id3.artist.up}", 136 | }, 137 | unmatches: []string{ 138 | "{id3}", 139 | "{id3.}", 140 | "{id3.title.}", 141 | }, 142 | }, 143 | { 144 | name: "exifVarRegex", 145 | regex: exifVarRegex, 146 | matches: []string{ 147 | "{exif.iso}", 148 | "{x.cdt.YYYY}", 149 | }, 150 | unmatches: []string{ 151 | "{exif}", 152 | "{exif.}", 153 | "{exif.iso.}", 154 | }, 155 | }, 156 | { 157 | name: "dateVarRegex", 158 | regex: dateVarRegex, 159 | matches: []string{ 160 | "{mtime.YYYY}", 161 | "{ctime.H}", 162 | "{btime.DDDD.up}", 163 | "{atime.MMM}", 164 | "{mtime}", 165 | "{ctime}", 166 | "{btime}", 167 | "{atime}", 168 | "{now}", 169 | "{now.hh.lw}", 170 | }, 171 | }, 172 | } 173 | 174 | for _, tc := range testCases { 175 | t.Run(tc.name, func(t *testing.T) { 176 | for _, match := range tc.matches { 177 | if !tc.regex.MatchString(match) { 178 | t.Errorf( 179 | "expected %q to match %q", 180 | match, 181 | tc.regex.String(), 182 | ) 183 | } 184 | } 185 | 186 | for _, unmatch := range tc.unmatches { 187 | if tc.regex.MatchString(unmatch) { 188 | t.Errorf( 189 | "expected %q to not match %q", 190 | unmatch, 191 | tc.regex.String(), 192 | ) 193 | } 194 | } 195 | }) 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /rename/rename_test/rename_test.go: -------------------------------------------------------------------------------- 1 | package rename_test 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/ayoisaiah/f2/v2/internal/config" 10 | "github.com/ayoisaiah/f2/v2/internal/file" 11 | "github.com/ayoisaiah/f2/v2/internal/testutil" 12 | "github.com/ayoisaiah/f2/v2/rename" 13 | ) 14 | 15 | func renameTest(t *testing.T, cases []testutil.TestCase) { 16 | t.Helper() 17 | 18 | workingDir, err := os.Getwd() 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | 23 | for i := range cases { 24 | tc := cases[i] 25 | 26 | conf := testutil.GetConfig(t, &tc, ".") 27 | 28 | baseDirPath, err := os.MkdirTemp(".", "f2_test") 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | err = os.Chdir(baseDirPath) 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | 38 | for j := range tc.Changes { 39 | ch := tc.Changes[j] 40 | 41 | cases[i].Changes[j].SourcePath = filepath.Join( 42 | ch.BaseDir, 43 | ch.Source, 44 | ) 45 | cases[i].Changes[j].TargetPath = filepath.Join( 46 | ch.TargetDir, 47 | ch.Target, 48 | ) 49 | 50 | f, err := os.Create(ch.Source) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | 55 | err = f.Close() 56 | if err != nil { 57 | t.Fatal(err) 58 | } 59 | } 60 | 61 | t.Run(tc.Name, func(t *testing.T) { 62 | err := rename.Rename(conf, tc.Changes) 63 | if err != nil { 64 | t.Fatal(err) 65 | } 66 | 67 | for j := range tc.Changes { 68 | ch := tc.Changes[j] 69 | 70 | if _, err := os.Stat(ch.TargetPath); err != nil { 71 | t.Fatal(err) 72 | } 73 | } 74 | }) 75 | 76 | err = os.Chdir(workingDir) 77 | if err != nil { 78 | t.Fatal(err) 79 | } 80 | 81 | err = os.RemoveAll(baseDirPath) 82 | if err != nil { 83 | t.Log(err) 84 | } 85 | } 86 | } 87 | 88 | func TestRename(t *testing.T) { 89 | testCases := []testutil.TestCase{ 90 | { 91 | Name: "rename a file", 92 | Changes: file.Changes{ 93 | { 94 | Source: "File.txt", 95 | Target: "myFile.txt", 96 | }, 97 | }, 98 | }, 99 | { 100 | Name: "rename multiple files", 101 | Changes: file.Changes{ 102 | { 103 | Source: "File1.txt", 104 | Target: "myFile1.txt", 105 | }, 106 | { 107 | Source: "File2.jpg", 108 | Target: "myImage2.jpg", 109 | }, 110 | }, 111 | }, 112 | { 113 | Name: "rename with case change", 114 | Changes: file.Changes{ 115 | { 116 | Source: "file.txt", 117 | Target: "FILE.txt", 118 | }, 119 | }, 120 | }, 121 | { 122 | Name: "rename with new directory", 123 | Changes: file.Changes{ 124 | { 125 | Source: "File.txt", 126 | Target: "new_folder/myFile.txt", 127 | }, 128 | }, 129 | }, 130 | { 131 | Name: "rename with a different target directory", 132 | Changes: file.Changes{ 133 | { 134 | Source: "File.txt", 135 | Target: "myFile.txt", 136 | TargetDir: "one/two", 137 | }, 138 | }, 139 | Args: []string{"-f", "", "--target-dir", "one/two"}, 140 | }, 141 | } 142 | 143 | renameTest(t, testCases) 144 | } 145 | 146 | func postRename(t *testing.T, cases []testutil.TestCase) { 147 | t.Helper() 148 | 149 | for i := range cases { 150 | tc := cases[i] 151 | 152 | testutil.UpdateFileChanges(tc.Changes) 153 | 154 | var stderr bytes.Buffer 155 | 156 | var backup bytes.Buffer 157 | 158 | config.Stderr = &stderr 159 | 160 | t.Run(tc.Name, func(t *testing.T) { 161 | conf := testutil.GetConfig(t, &tc, ".") 162 | 163 | conf.BackupLocation = &backup 164 | 165 | rename.PostRename(conf, tc.Changes, tc.Error) 166 | 167 | tc.SnapShot.Stdout = backup.Bytes() 168 | tc.SnapShot.Stderr = stderr.Bytes() 169 | 170 | testutil.CompareGoldenFile(t, &tc) 171 | }) 172 | } 173 | } 174 | 175 | func TestPostRename(t *testing.T) { 176 | testCases := []testutil.TestCase{ 177 | { 178 | Name: "rename a file", 179 | Changes: file.Changes{ 180 | { 181 | Source: "File.txt", 182 | Target: "myFile.txt", 183 | }, 184 | }, 185 | StdoutGoldenFile: "rename_a_file_backup", 186 | StderrGoldenFile: "rename_a_file_backup_stderr", 187 | Args: []string{"-r", "", "-V"}, 188 | }, 189 | } 190 | 191 | postRename(t, testCases) 192 | } 193 | -------------------------------------------------------------------------------- /app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "io" 7 | "net/mail" 8 | "os" 9 | "strings" 10 | 11 | "github.com/pterm/pterm" 12 | "github.com/urfave/cli/v3" 13 | 14 | "github.com/ayoisaiah/f2/v2/internal/config" 15 | "github.com/ayoisaiah/f2/v2/internal/localize" 16 | "github.com/ayoisaiah/f2/v2/internal/osutil" 17 | "github.com/ayoisaiah/f2/v2/report" 18 | ) 19 | 20 | const ( 21 | EnvDefaultOpts = "F2_DEFAULT_OPTS" 22 | ) 23 | 24 | var VersionString = "unset" 25 | 26 | // isInputFromPipe detects if input is being piped to F2. 27 | func isInputFromPipe() bool { 28 | fileInfo, _ := os.Stdin.Stat() 29 | return fileInfo.Mode()&os.ModeCharDevice == 0 30 | } 31 | 32 | // handlePipeInput processes input from a pipe and appends it to os.Args. 33 | func handlePipeInput(reader io.Reader) error { 34 | if !isInputFromPipe() { 35 | return nil 36 | } 37 | 38 | scanner := bufio.NewScanner(bufio.NewReader(reader)) 39 | 40 | for scanner.Scan() { 41 | os.Args = append(os.Args, scanner.Text()) 42 | } 43 | 44 | if err := scanner.Err(); err != nil { 45 | return errPipeRead.Wrap(err) 46 | } 47 | 48 | return nil 49 | } 50 | 51 | // Get returns an F2 instance that reads from `reader` and writes to `writer`. 52 | func Get(reader io.Reader, writer io.Writer) (*cli.Command, error) { 53 | err := handlePipeInput(reader) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | app := CreateCLIApp(reader, writer) 59 | 60 | origArgs := make([]string, len(os.Args)) 61 | 62 | copy(origArgs, os.Args) 63 | 64 | if optsEnv, exists := os.LookupEnv(EnvDefaultOpts); exists { 65 | args := strings.Fields(optsEnv) 66 | 67 | for _, token := range args { 68 | if strings.HasPrefix(token, "-") { 69 | if !supportedDefaultOpts[token] { 70 | return nil, errDefaultOptsParsing.Fmt(token) 71 | } 72 | } 73 | } 74 | 75 | args = append(args, os.Args[1:]...) 76 | os.Args = append(os.Args[:1], args...) 77 | } 78 | 79 | app.Before = func(ctx context.Context, cmd *cli.Command) (context.Context, error) { 80 | // print short help and exit if no arguments or flags are present 81 | if cmd.NumFlags() == 0 && !cmd.Args().Present() || len(origArgs) <= 1 { 82 | report.ShortHelp(ShortHelp(cmd)) 83 | os.Exit(int(osutil.ExitOK)) 84 | } 85 | 86 | config.Stdout = cmd.Writer 87 | config.Stdin = cmd.Reader 88 | 89 | app.Metadata["ctx"] = cmd 90 | 91 | return ctx, nil 92 | } 93 | 94 | return app, nil 95 | } 96 | 97 | func CreateCLIApp(r io.Reader, w io.Writer) *cli.Command { 98 | // Override the default version printer 99 | oldVersionPrinter := cli.VersionPrinter 100 | cli.VersionPrinter = func(cmd *cli.Command) { 101 | oldVersionPrinter(cmd) 102 | v := cmd.Version 103 | 104 | if strings.Contains(v, "nightly") { 105 | v = "nightly" 106 | } 107 | 108 | pterm.Fprint( 109 | w, 110 | "https://github.com/ayoisaiah/f2/releases/"+v, 111 | ) 112 | } 113 | 114 | app := &cli.Command{ 115 | Name: "f2", 116 | Authors: []any{ 117 | &mail.Address{ 118 | Name: "Ayooluwa Isaiah", 119 | Address: "ayo@freshman.tech", 120 | }, 121 | }, 122 | Usage: localize.T("app.usage"), 123 | Version: VersionString, 124 | EnableShellCompletion: true, 125 | Flags: []cli.Flag{ 126 | flagCSV, 127 | flagExiftoolOpts, 128 | flagFind, 129 | flagReplace, 130 | flagUndo, 131 | flagAllowOverwrites, 132 | flagClean, 133 | flagDT, 134 | flagExclude, 135 | flagExcludeDir, 136 | flagExec, 137 | flagFixConflicts, 138 | flagFixConflictsPattern, 139 | flagHidden, 140 | flagInclude, 141 | flagIncludeDir, 142 | flagIgnoreCase, 143 | flagIgnoreExt, 144 | flagJSON, 145 | flagMaxDepth, 146 | flagNoColor, 147 | flagOnlyDir, 148 | flagPair, 149 | flagPairOrder, 150 | flagQuiet, 151 | flagRecursive, 152 | flagReplaceLimit, 153 | flagReplaceRange, 154 | flagResetIndexPerDir, 155 | flagSort, 156 | flagSortr, 157 | flagSortPerDir, 158 | flagSortVar, 159 | flagStringMode, 160 | flagTargetDir, 161 | flagTimezone, 162 | flagVerbose, 163 | }, 164 | UseShortOptionHandling: true, 165 | DisableSliceFlagSeparator: true, 166 | OnUsageError: func(_ context.Context, _ *cli.Command, err error, _ bool) error { 167 | return err 168 | }, 169 | Writer: w, 170 | Reader: r, 171 | } 172 | 173 | // Override the default help template 174 | app.CustomRootCommandHelpTemplate = helpText(app) 175 | 176 | return app 177 | } 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Read this in other languages:** [Deutsch](docs/README.de.md) | [Español](docs/README.es.md) | [Français](docs/README.fr.md) | [Português](docs/README.pt.md) | [Русский](docs/README.ru.md) | [繁體中文](docs/README.zh.md) 2 | 3 |

4 | f2 5 |

6 | 7 |

8 | 9 | Github Actions 10 | made-with-Go 11 | GoReportCard 12 | Go.mod version 13 | LICENCE 14 | Latest release 15 |

16 | 17 |

F2 - Command-Line Batch Renaming

18 | 19 | **F2** is a cross-platform command-line tool for batch renaming files and 20 | directories **quickly** and **safely**. Written in Go! 21 | 22 | ## What does F2 do differently? 23 | 24 | Compared to other renaming tools, F2 offers several key advantages: 25 | 26 | - **Dry Run by Default**: It defaults to a dry run so that you can review the 27 | renaming changes before proceeding. 28 | 29 | - **Variable Support**: F2 allows you to use file attributes, such as EXIF data 30 | for images or ID3 tags for audio files, to give you maximum flexibility in 31 | renaming. 32 | 33 | - **Comprehensive Options**: Whether it's simple string replacements or complex 34 | regular expressions, F2 provides a full range of renaming capabilities. 35 | 36 | - **Safety First**: It prioritizes accuracy by ensuring every renaming operation 37 | is conflict-free and error-proof through rigorous checks. 38 | 39 | - **Conflict Resolution**: Each renaming operation is validated before execution 40 | and detected conflicts can be automatically resolved. 41 | 42 | - **High Performance**: F2 is extremely fast and efficient, even when renaming 43 | thousands of files at once. 44 | 45 | - **Undo Functionality**: Any renaming operation can be easily undone to allow 46 | the easy correction of mistakes. 47 | 48 | - **Extensive Documentation**: F2 is well-documented with clear, practical 49 | examples to help you make the most of its features without confusion. 50 | 51 | ## ⚡ Installation 52 | 53 | If you're a Go developer, F2 can be installed with `go install` (requires v1.23 54 | or later): 55 | 56 | ```bash 57 | go install github.com/ayoisaiah/f2/v2/cmd/f2@latest 58 | ``` 59 | 60 | Other installation methods are 61 | [documented here](https://f2.freshman.tech/guide/getting-started.html) or check 62 | out the [releases page](https://github.com/ayoisaiah/f2/releases) to download a 63 | pre-compiled binary for your operating system. 64 | 65 | ## 📃 Quick links 66 | 67 | - [Installation](https://f2.freshman.tech/guide/getting-started.html) 68 | - [Getting started tutorial](https://f2.freshman.tech/guide/tutorial.html) 69 | - [Real-world example](https://f2.freshman.tech/guide/organizing-image-library.html) 70 | - [Built-in variables](https://f2.freshman.tech/guide/how-variables-work.html) 71 | - [File pair renaming](https://f2.freshman.tech/guide/pair-renaming.html) 72 | - [Renaming with a CSV file](https://f2.freshman.tech/guide/csv-renaming.html) 73 | - [Sorting](https://f2.freshman.tech/guide/sorting.html) 74 | - [Resolving conflicts](https://f2.freshman.tech/guide/conflict-detection.html) 75 | - [Undoing renaming mistakes](https://f2.freshman.tech/guide/undoing-mistakes.html) 76 | - [CHANGELOG](https://f2.freshman.tech/reference/changelog.html) 77 | 78 | ## 💻 Screenshots 79 | 80 | ![F2 can utilise Exif attributes to organise image files](https://f2.freshman.tech/assets/2.D-uxLR9T.png) 81 | 82 | ## 🤝 Contribute 83 | 84 | Bug reports and feature requests are much welcome! Please open an issue before 85 | creating a pull request. 86 | 87 | ## ⚖ Licence 88 | 89 | Created by Ayooluwa Isaiah and released under the terms of the 90 | [MIT Licence](https://github.com/ayoisaiah/f2/blob/master/LICENCE). 91 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: f2 3 | git: 4 | ignore_tags: 5 | - nightly 6 | 7 | before: 8 | hooks: 9 | - go mod download 10 | builds: 11 | - id: binary_archives 12 | env: 13 | - CGO_ENABLED=0 14 | goos: 15 | - linux 16 | - windows 17 | - darwin 18 | goarch: 19 | - 386 20 | - amd64 21 | - arm64 22 | ignore: 23 | - goos: windows 24 | goarch: arm64 25 | main: ./cmd/f2 26 | ldflags: 27 | - -X github.com/ayoisaiah/f2/v2/app.VersionString=v{{ .Version }} 28 | 29 | archives: 30 | - files: 31 | - LICENCE 32 | - README.md 33 | - CHANGELOG.md 34 | - scripts/completions/* 35 | format_overrides: 36 | - goos: windows 37 | formats: [zip] 38 | checksum: 39 | name_template: checksums.txt 40 | snapshot: 41 | version_template: "{{ .Version }}-nightly-{{.ShortCommit}}" 42 | changelog: 43 | sort: asc 44 | filters: 45 | exclude: 46 | - "^docs:" 47 | - "^test:" 48 | 49 | nfpms: 50 | - id: main_packages 51 | vendor: "{{ .Env.REPO_OWNER }}" 52 | homepage: "{{ .Env.REPO_WEBSITE }}" 53 | maintainer: "{{ .Env.REPO_MAINTAINER }}" 54 | description: "{{ .Env.REPO_DESCRIPTION }}" 55 | license: MIT 56 | formats: 57 | - deb 58 | - rpm 59 | recommends: 60 | - exiftool 61 | contents: 62 | - src: scripts/completions/f2.bash 63 | dst: /usr/share/bash-completion/completions/f2 64 | file_info: 65 | mode: 0644 66 | - src: scripts/completions/f2.fish 67 | dst: /usr/share/fish/vendor_completions.d/f2.fish 68 | file_info: 69 | mode: 0644 70 | - src: scripts/completions/f2.zsh 71 | dst: /usr/share/zsh/vendor-completions/_f2 72 | file_info: 73 | mode: 0644 74 | 75 | - id: other_packages 76 | vendor: "{{ .Env.REPO_OWNER }}" 77 | homepage: "{{ .Env.REPO_WEBSITE }}" 78 | maintainer: "{{ .Env.REPO_MAINTAINER }}" 79 | description: "{{ .Env.REPO_DESCRIPTION }}" 80 | license: MIT 81 | formats: 82 | - apk 83 | - termux.deb 84 | - archlinux 85 | recommends: 86 | - exiftool 87 | contents: 88 | - src: scripts/completions/f2.bash 89 | dst: /usr/share/bash-completion/completions/f2 90 | file_info: 91 | mode: 0644 92 | - src: scripts/completions/f2.fish 93 | dst: /usr/share/fish/vendor_completions.d/f2.fish 94 | file_info: 95 | mode: 0644 96 | - src: scripts/completions/f2.zsh 97 | dst: /usr/share/zsh/vendor-completions/_f2 98 | file_info: 99 | mode: 0644 100 | 101 | publishers: 102 | - name: fury.io 103 | ids: 104 | - main_packages 105 | dir: "{{ dir .ArtifactPath }}" 106 | cmd: curl -F package=@{{ .ArtifactName }} https://{{ .Env.FURY_PUSH_TOKEN }}@push.fury.io/{{ .Env.FURY_USERNAME }}/ 107 | 108 | winget: 109 | - publisher: "{{ .Env.REPO_OWNER }}" 110 | license: MIT 111 | copyright: "{{ .Env.REPO_AUTHOR_NAME }}" 112 | homepage: "{{ .Env.REPO_WEBSITE }}" 113 | short_description: "{{ .Env.REPO_DESCRIPTION }}" 114 | repository: 115 | owner: "{{ .Env.REPO_OWNER }}" 116 | token: "{{ .Env.GORELEASER_GITHUB_TOKEN }}" 117 | name: winget-pkgs 118 | branch: "{{.ProjectName}}-{{.Version}}" 119 | pull_request: 120 | enabled: true 121 | draft: false 122 | base: 123 | owner: microsoft 124 | name: winget-pkgs 125 | branch: master 126 | 127 | scoops: 128 | - url_template: https://github.com/ayoisaiah/f2/releases/download/{{ .Tag }}/{{ .ArtifactName }} 129 | repository: 130 | owner: "{{ .Env.REPO_OWNER }}" 131 | name: scoop-bucket 132 | token: "{{ .Env.GORELEASER_GITHUB_TOKEN }}" 133 | commit_author: 134 | name: goreleaserbot 135 | email: goreleaser@carlosbecker.com 136 | homepage: "{{ .Env.REPO_WEBSITE }}" 137 | description: "{{ .Env.REPO_DESCRIPTION }}" 138 | license: MIT 139 | 140 | brews: 141 | - repository: 142 | owner: "{{ .Env.REPO_OWNER }}" 143 | name: homebrew-tap 144 | token: "{{ .Env.GORELEASER_GITHUB_TOKEN }}" 145 | commit_author: 146 | name: goreleaserbot 147 | email: goreleaser@carlosbecker.com 148 | homepage: "{{ .Env.REPO_WEBSITE }}" 149 | description: "{{ .Env.REPO_DESCRIPTION }}" 150 | install: |- 151 | bin.install "{{ with .Env.REPO_BINARY_NAME }}{{ . }}{{ else }}{{ .ProjectName }}{{ end }}" 152 | dependencies: 153 | - name: exiftool 154 | type: optional 155 | -------------------------------------------------------------------------------- /scripts/completions/f2.fish: -------------------------------------------------------------------------------- 1 | complete --command f2 --condition 'not __fish_should_complete_switches' --exclusive --long-option csv --description "Rename using a CSV file" --keep-order --arguments '(__fish_complete_suffix .csv)' 2 | 3 | complete --command f2 --long-option find --short-option f --description "Search for specified pattern" --exclusive 4 | 5 | complete --command f2 --long-option replace --short-option r --description "Replacement pattern for matches" --exclusive 6 | 7 | complete --command f2 --long-option undo --short-option u --description "Undo the last renaming operation in current directory" --no-files 8 | 9 | complete --command f2 --long-option allow-overwrites --description "Allow overwriting existing files" --no-files 10 | 11 | complete --command f2 --long-option clean --short-option c --description "Clean 12 | empty directories after renaming" --no-files 13 | 14 | complete --command f2 --long-option exclude --short-option E --description "Exclude files and directories matching pattern" --no-files 15 | 16 | complete --command f2 --long-option exclude-dir --description "Prevent recursing into directories to search for matches" --no-files 17 | 18 | complete --command f2 --long-option exiftool-opts --description "Customize Exiftool behavior" --no-files 19 | 20 | complete --command f2 --long-option exec --short-option x --description "Execute renaming operation" --no-files 21 | 22 | complete --command f2 --long-option fix-conflicts --short-option F --description "Auto fix renaming conflicts" --no-files 23 | 24 | complete --command f2 --long-option fix-conflicts-pattern --description "Provide a custom pattern for conflict resolution" --no-files 25 | 26 | complete --command f2 --long-option help --short-option h --description "Display help and exit" --no-files 27 | 28 | complete --command f2 --long-option hidden --short-option H --description "Match hidden files" --no-files 29 | 30 | complete --command f2 --long-option include-dir --short-option d --description "Match directories" --no-files 31 | 32 | complete --command f2 --long-option ignore-case --short-option i --description "Make searches case insensitive" --no-files 33 | 34 | complete --command f2 --long-option ignore-ext --short-option e --description "Ignore file extension" --no-files 35 | 36 | complete --command f2 --long-option json --description "Enable json output" --no-files 37 | 38 | complete --command f2 --long-option max-depth --short-option m --description "Specify max depth for recursive search" --no-files 39 | 40 | complete --command f2 --long-option no-color --description "Disable coloured output" --no-files 41 | 42 | complete --command f2 --long-option only-dir --short-option D --description "Rename only directories" --no-files 43 | 44 | complete --command f2 --long-option pair --short-option p --description "Enable pair renaming" --no-files 45 | 46 | complete --command f2 --long-option pair-order --description "Order the paired files" --no-files 47 | 48 | complete --command f2 --long-option quiet --short-option q --description "Disable all output except errors" --no-files 49 | 50 | complete --command f2 --long-option recursive --short-option R --description "Search for matches in subdirectories" --no-files 51 | 52 | complete --command f2 --long-option replace-limit --short-option l --description "Limit the matches to be replaced" --no-files 53 | 54 | complete --command f2 --long-option reset-index-per-dir --description "Reset indexes in each directory" --no-files 55 | 56 | set -l sort_args " 57 | default\t'Lexicographical order' 58 | size\t'Sort by file size' 59 | natural\t'Sort according to natural order' 60 | mtime\t'Sort by file last modified time' 61 | btime\t'Sort by file creation time' 62 | atime\t'Sort by file last access time' 63 | ctime\t'Sort by file metadata last change time' 64 | time_var\t'Sort by time variable' 65 | int_var\t'Sort by integer variable' 66 | string_var\t'Sort by string variable' 67 | " 68 | 69 | complete --command f2 --long-option sort --description "Sort matches in ascending order" --exclusive --keep-order --arguments $sort_args 70 | 71 | complete --command f2 --long-option sortr --description "Sort matches in descending order" --exclusive --keep-order --arguments $sort_args 72 | 73 | complete --command f2 --long-option sort-per-dir --description "Apply sort per directory" --no-files 74 | 75 | complete --command f2 --long-option sort-var --description "Provide a variable for sorting" --no-files 76 | 77 | complete --command f2 --long-option string-mode --short-option s --description "Treat the search pattern as a non-regex string" --no-files 78 | 79 | complete --command f2 --long-option target-dir --short-option t --description "Specify a target directory" 80 | 81 | complete --command f2 --long-option verbose --short-option V --description "Enable verbose output" --no-files 82 | 83 | complete --command f2 --long-option version --short-option v --description "Display version and exit" --no-files 84 | -------------------------------------------------------------------------------- /docs/README.pt.md: -------------------------------------------------------------------------------- 1 | **Leia isto em outros idiomas:** [English](/README.md) | [Deutsch](/docs/README.de.md) | [Español](/docs/README.es.md) | [Français](/docs/README.fr.md) | [Русский](/docs/README.ru.md) | [繁體中文](/docs/README.zh.md) 2 | 3 |

4 | f2 5 |

6 | 7 |

8 | 9 | Ações do Github 10 | feito-com-Go 11 | GoReportCard 12 | Versão do Go.mod 13 | LICENÇA 14 | Última versão 15 |

16 | 17 |

F2 - Renomeação em Lote na Linha de Comando

18 | 19 | **F2** é uma ferramenta de linha de comando multiplataforma para renomear 20 | arquivos e diretórios em lote de forma **rápida** e **segura**. Escrito em Go! 21 | 22 | ## O que o F2 faz de diferente? 23 | 24 | Em comparação com outras ferramentas de renomeação, o F2 oferece várias 25 | vantagens importantes: 26 | 27 | - **Execução de Teste por Padrão**: Por padrão, ele executa um teste para que 28 | você possa revisar as alterações de renomeação antes de prosseguir. 29 | 30 | - **Suporte a Variáveis**: O F2 permite que você use atributos de arquivo, como 31 | dados EXIF para imagens ou tags ID3 para arquivos de áudio, para lhe dar a 32 | máxima flexibilidade na renomeação. 33 | 34 | - **Opções Abrangentes**: Seja para substituições simples de strings ou 35 | expressões regulares complexas, o F2 oferece uma gama completa de recursos de 36 | renomeação. 37 | 38 | - **Segurança em Primeiro Lugar**: Ele prioriza a precisão, garantindo que cada 39 | operação de renomeação seja livre de conflitos e à prova de erros por meio de 40 | verificações rigorosas. 41 | 42 | - **Resolução de Conflitos**: Cada operação de renomeação é validada antes da 43 | execução e os conflitos detectados podem ser resolvidos automaticamente. 44 | 45 | - **Alto Desempenho**: O F2 é extremamente rápido e eficiente, mesmo ao renomear 46 | milhares de arquivos de uma só vez. 47 | 48 | - **Funcionalidade de Desfazer**: Qualquer operação de renomeação pode ser 49 | facilmente desfeita para permitir a correção fácil de erros. 50 | 51 | - **Documentação Extensa**: O F2 é bem documentado com exemplos claros e 52 | práticos para ajudá-lo a aproveitar ao máximo seus recursos sem confusão. 53 | 54 | ## ⚡ Instalação 55 | 56 | Se você é um desenvolvedor Go, o F2 pode ser instalado com `go install` (requer 57 | v1.23 ou posterior): 58 | 59 | ```bash 60 | go install github.com/ayoisaiah/f2/v2/cmd/f2@latest 61 | ``` 62 | 63 | Outros métodos de instalação estão 64 | [documentados aqui](https://f2.freshman.tech/guide/getting-started.html) ou 65 | confira a [página de lançamentos](https://github.com/ayoisaiah/f2/releases) para 66 | baixar um binário pré-compilado para o seu sistema operacional. 67 | 68 | ## 📃 Links rápidos 69 | 70 | - [Instalação](https://f2.freshman.tech/guide/getting-started.html) 71 | - [Tutorial de introdução](https://f2.freshman.tech/guide/tutorial.html) 72 | - [Exemplo do mundo real](https://f2.freshman.tech/guide/organizing-image-library.html) 73 | - [Variáveis incorporadas](https://f2.freshman.tech/guide/how-variables-work.html) 74 | - [Renomeação de pares de arquivos](https://f2.freshman.tech/guide/pair-renaming.html) 75 | - [Renomeando com um arquivo CSV](https://f2.freshman.tech/guide/csv-renaming.html) 76 | - [Classificação](https://f2.freshman.tech/guide/sorting.html) 77 | - [Resolvendo conflitos](https://f2.freshman.tech/guide/conflict-detection.html) 78 | - [Desfazendo erros de renomeação](https://f2.freshman.tech/guide/undoing-mistakes.html) 79 | - [REGISTRO DE ALTERAÇÕES](https://f2.freshman.tech/reference/changelog.html) 80 | 81 | ## 💻 Capturas de tela 82 | 83 | ![O F2 pode utilizar atributos Exif para organizar arquivos de imagem](https://f2.freshman.tech/assets/2.D-uxLR9T.png) 84 | 85 | ## 🤝 Contribuir 86 | 87 | Relatórios de bugs e solicitações de recursos são muito bem-vindos! Por favor, 88 | abra uma issue antes de criar um pull request. 89 | 90 | ## ⚖️ Licença 91 | 92 | Criado por Ayooluwa Isaiah e lançado sob os termos da 93 | [Licença MIT](https://github.com/ayoisaiah/f2/blob/master/LICENCE). 94 | -------------------------------------------------------------------------------- /docs/README.ru.md: -------------------------------------------------------------------------------- 1 | **Прочитайте это на других языках:** [English](/README.md) | [Deutsch](/docs/README.de.md) | [Español](/docs/README.es.md) | [Français](/docs/README.fr.md) | [Português](/docs/README.pt.md) | [繁體中文](/docs/README.zh.md) 2 | 3 |

4 | f2 5 |

6 | 7 |

8 | 9 | Действия Github 10 | сделано-на-Go 11 | GoReportCard 12 | Версия Go.mod 13 | ЛИЦЕНЗИЯ 14 | Последняя версия 15 |

16 | 17 |

F2 - Пакетное переименование в командной строке

18 | 19 | **F2** — это кроссплатформенный инструмент командной строки для пакетного 20 | переименования файлов и каталогов **быстро** и **безопасно**. Написан на Go! 21 | 22 | ## Что F2 делает по-другому? 23 | 24 | По сравнению с другими инструментами переименования, F2 предлагает несколько 25 | ключевых преимуществ: 26 | 27 | - **Тестовый запуск по умолчанию**: по умолчанию выполняется тестовый запуск, 28 | чтобы вы могли просмотреть изменения в переименовании перед продолжением. 29 | 30 | - **Поддержка переменных**: F2 позволяет использовать атрибуты файлов, такие как 31 | данные EXIF для изображений или теги ID3 для аудиофайлов, чтобы обеспечить 32 | максимальную гибкость при переименовании. 33 | 34 | - **Комплексные параметры**: будь то простая замена строк или сложные регулярные 35 | выражения, F2 предоставляет полный спектр возможностей переименования. 36 | 37 | - **Безопасность прежде всего**: он отдает приоритет точности, гарантируя, что 38 | каждая операция переименования не содержит конфликтов и ошибок благодаря 39 | строгим проверкам. 40 | 41 | - **Разрешение конфликтов**: каждая операция переименования проверяется перед 42 | выполнением, и обнаруженные конфликты могут быть разрешены автоматически. 43 | 44 | - **Высокая производительность**: F2 чрезвычайно быстр и эффективен даже при 45 | переименовании тысяч файлов одновременно. 46 | 47 | - **Функциональность отмены**: любую операцию переименования можно легко 48 | отменить, чтобы легко исправить ошибки. 49 | 50 | - **Обширная документация**: F2 хорошо документирован с четкими, практическими 51 | примерами, которые помогут вам максимально эффективно использовать его функции 52 | без путаницы. 53 | 54 | ## ⚡ Установка 55 | 56 | Если вы разработчик Go, F2 можно установить с помощью `go install` (требуется 57 | версия 1.23 или новее): 58 | 59 | ```bash 60 | go install github.com/ayoisaiah/f2/v2/cmd/f2@latest 61 | ``` 62 | 63 | Другие способы установки 64 | [задокументированы здесь](https://f2.freshman.tech/guide/getting-started.html) 65 | или ознакомьтесь со 66 | [страницей выпусков](https://github.com/ayoisaiah/f2/releases), чтобы загрузить 67 | предварительно скомпилированный двоичный файл для вашей операционной системы. 68 | 69 | ## 📃 Быстрые ссылки 70 | 71 | - [Установка](https://f2.freshman.tech/guide/getting-started.html) 72 | - [Учебное пособие по началу работы](https://f2.freshman.tech/guide/tutorial.html) 73 | - [Пример из реальной жизни](https://f2.freshman.tech/guide/organizing-image-library.html) 74 | - [Встроенные переменные](https://f2.freshman.tech/guide/how-variables-work.html) 75 | - [Переименование пары файлов](https://f2.freshman.tech/guide/pair-renaming.html) 76 | - [Переименование с помощью CSV-файла](https://f2.freshman.tech/guide/csv-renaming.html) 77 | - [Сортировка](https://f2.freshman.tech/guide/sorting.html) 78 | - [Разрешение конфликтов](https://f2.freshman.tech/guide/conflict-detection.html) 79 | - [Отмена ошибок переименования](https://f2.freshman.tech/guide/undoing-mistakes.html) 80 | - [СПИСОК ИЗМЕНЕНИЙ](https://f2.freshman.tech/reference/changelog.html) 81 | 82 | ## 💻 Скриншоты 83 | 84 | ![F2 может использовать атрибуты Exif для организации файлов изображений](https://f2.freshman.tech/assets/2.D-uxLR9T.png) 85 | 86 | ## 🤝 Внести свой вклад 87 | 88 | Сообщения об ошибках и пожелания приветствуются! Пожалуйста, откройте issue, 89 | прежде чем создавать pull request. 90 | 91 | ## ⚖️ Лицензия 92 | 93 | Создано Ayooluwa Isaiah и выпущено на условиях 94 | [лицензии MIT](https://github.com/ayoisaiah/f2/blob/master/LICENCE). 95 | -------------------------------------------------------------------------------- /docs/README.es.md: -------------------------------------------------------------------------------- 1 | **Lea esto en otros idiomas:** [English](/README.md) | [Deutsch](/docs/README.de.md) | [Français](/docs/README.fr.md) | [Português](/docs/README.pt.md) | [Русский](/docs/README.ru.md) | [繁體中文](/docs/README.zh.md) 2 | 3 |

4 | f2 5 |

6 | 7 |

8 | 9 | Acciones de Github 10 | hecho-con-Go 11 | GoReportCard 12 | Versión de Go.mod 13 | LICENCIA 14 | Última versión 15 |

16 | 17 |

F2 - Renombrado por lotes en línea de comandos

18 | 19 | **F2** es una herramienta de línea de comandos multiplataforma para renombrar 20 | archivos y directorios por lotes de forma **rápida** y **segura**. ¡Escrito en 21 | Go! 22 | 23 | ## ¿Qué hace F2 de manera diferente? 24 | 25 | En comparación con otras herramientas de renombrado, F2 ofrece varias ventajas 26 | clave: 27 | 28 | - **Simulacro por defecto**: Por defecto, realiza una simulación para que pueda 29 | revisar los cambios de nombre antes de continuar. 30 | 31 | - **Soporte de variables**: F2 le permite utilizar atributos de archivo, como 32 | datos EXIF para imágenes o etiquetas ID3 para archivos de audio, para 33 | brindarle la máxima flexibilidad en el renombrado. 34 | 35 | - **Opciones completas**: Ya sea que se trate de reemplazos de cadenas simples o 36 | expresiones regulares complejas, F2 ofrece una gama completa de capacidades de 37 | renombrado. 38 | 39 | - **La seguridad es lo primero**: Prioriza la precisión al garantizar que cada 40 | operación de renombrado esté libre de conflictos y errores mediante 41 | comprobaciones rigurosas. 42 | 43 | - **Resolución de conflictos**: Cada operación de renombrado se valida antes de 44 | la ejecución y los conflictos detectados se pueden resolver automáticamente. 45 | 46 | - **Alto rendimiento**: F2 es extremadamente rápido y eficiente, incluso al 47 | renombrar miles de archivos a la vez. 48 | 49 | - **Funcionalidad de deshacer**: Cualquier operación de renombrado se puede 50 | deshacer fácilmente para permitir la corrección sencilla de errores. 51 | 52 | - **Documentación extensa**: F2 está bien documentado con ejemplos claros y 53 | prácticos para ayudarlo a aprovechar al máximo sus funciones sin confusión. 54 | 55 | ## ⚡ Instalación 56 | 57 | Si eres un desarrollador de Go, F2 se puede instalar con `go install` (requiere 58 | v1.23 o posterior): 59 | 60 | ```bash 61 | go install github.com/ayoisaiah/f2/v2/cmd/f2@latest 62 | ``` 63 | 64 | Otros métodos de instalación están 65 | [documentados aquí](https://f2.freshman.tech/guide/getting-started.html) o 66 | consulte la [página de versiones](https://github.com/ayoisaiah/f2/releases) para 67 | descargar un binario precompilado para su sistema operativo. 68 | 69 | ## 📃 Enlaces rápidos 70 | 71 | - [Instalación](https://f2.freshman.tech/guide/getting-started.html) 72 | - [Tutorial de inicio](https://f2.freshman.tech/guide/tutorial.html) 73 | - [Ejemplo del mundo real](https://f2.freshman.tech/guide/organizing-image-library.html) 74 | - [Variables integradas](https://f2.freshman.tech/guide/how-variables-work.html) 75 | - [Renombrado de pares de archivos](https://f2.freshman.tech/guide/pair-renaming.html) 76 | - [Renombrado con un archivo CSV](https://f2.freshman.tech/guide/csv-renaming.html) 77 | - [Clasificación](https://f2.freshman.tech/guide/sorting.html) 78 | - [Resolución de conflictos](https://f2.freshman.tech/guide/conflict-detection.html) 79 | - [Deshacer errores de renombrado](https://f2.freshman.tech/guide/undoing-mistakes.html) 80 | - [REGISTRO DE CAMBIOS](https://f2.freshman.tech/reference/changelog.html) 81 | 82 | ## 💻 Capturas de pantalla 83 | 84 | ![F2 puede utilizar atributos Exif para organizar archivos de imagen](https://f2.freshman.tech/assets/2.D-uxLR9T.png) 85 | 86 | ## 🤝 Contribuir 87 | 88 | ¡Los informes de errores y las solicitudes de funciones son muy bienvenidos! 89 | Abra un issue antes de crear una pull request. 90 | 91 | ## ⚖️ Licencia 92 | 93 | Creado por Ayooluwa Isaiah y publicado bajo los términos de la 94 | [Licencia MIT](https://github.com/ayoisaiah/f2/blob/master/LICENCE). 95 | -------------------------------------------------------------------------------- /docs/README.de.md: -------------------------------------------------------------------------------- 1 | **Lesen Sie dies in anderen Sprachen:** [English](/README.md) | [Español](/docs/README.es.md) | [Français](/docs/README.fr.md) | [Português](/docs/README.pt.md) | [Русский](/docs/README.ru.md) | [繁體中文](/docs/README.zh.md) 2 | 3 |

4 | f2 5 |

6 | 7 |

8 | 9 | Github-Aktionen 10 | erstellt-mit-Go 11 | GoReportCard 12 | Go.mod-Version 13 | LIZENZ 14 | Neueste Version 15 |

16 | 17 |

F2 – Stapelumbenennung über die Befehlszeile

18 | 19 | **F2** ist ein plattformübergreifendes Befehlszeilentool zum **schnellen** und 20 | **sicheren** Stapelumbenennen von Dateien und Verzeichnissen. Geschrieben in Go! 21 | 22 | ## Was macht F2 anders? 23 | 24 | Im Vergleich zu anderen Umbenennungstools bietet F2 mehrere wichtige Vorteile: 25 | 26 | - **Standardmäßiger Probelauf**: Standardmäßig wird ein Probelauf durchgeführt, 27 | damit Sie die Umbenennungsänderungen vor dem Fortfahren überprüfen können. 28 | 29 | - **Variablenunterstützung**: F2 ermöglicht die Verwendung von Dateiattributen 30 | wie EXIF-Daten für Bilder oder ID3-Tags für Audiodateien, um Ihnen maximale 31 | Flexibilität bei der Umbenennung zu bieten. 32 | 33 | - **Umfassende Optionen**: Ob einfache Zeichenfolgenersetzungen oder komplexe 34 | reguläre Ausdrücke, F2 bietet eine vollständige Palette von 35 | Umbenennungsfunktionen. 36 | 37 | - **Sicherheit geht vor**: Es legt Wert auf Genauigkeit, indem es durch strenge 38 | Prüfungen sicherstellt, dass jeder Umbenennungsvorgang konfliktfrei und 39 | fehlerfrei ist. 40 | 41 | - **Konfliktlösung**: Jeder Umbenennungsvorgang wird vor der Ausführung 42 | validiert und erkannte Konflikte können automatisch gelöst werden. 43 | 44 | - **Hohe Leistung**: F2 ist extrem schnell und effizient, selbst beim Umbenennen 45 | von Tausenden von Dateien auf einmal. 46 | 47 | - **Rückgängig-Funktionalität**: Jeder Umbenennungsvorgang kann einfach 48 | rückgängig gemacht werden, um Fehler einfach zu korrigieren. 49 | 50 | - **Umfangreiche Dokumentation**: F2 ist gut dokumentiert mit klaren, 51 | praktischen Beispielen, die Ihnen helfen, die Funktionen ohne Verwirrung 52 | optimal zu nutzen. 53 | 54 | ## ⚡ Installation 55 | 56 | Wenn Sie ein Go-Entwickler sind, kann F2 mit `go install` installiert werden 57 | (erfordert v1.23 oder höher): 58 | 59 | ```bash 60 | go install github.com/ayoisaiah/f2/v2/cmd/f2@latest 61 | ``` 62 | 63 | Andere Installationsmethoden sind 64 | [hier dokumentiert](https://f2.freshman.tech/guide/getting-started.html) oder 65 | sehen Sie sich die 66 | [Seite mit den Versionen](https://github.com/ayoisaiah/f2/releases) an, um eine 67 | vorkompilierte Binärdatei für Ihr Betriebssystem herunterzuladen. 68 | 69 | ## 📃 Nützliche Links 70 | 71 | - [Installation](https://f2.freshman.tech/guide/getting-started.html) 72 | - [Tutorial für den Einstieg](https://f2.freshman.tech/guide/tutorial.html) 73 | - [Praxisbeispiel](https://f2.freshman.tech/guide/organizing-image-library.html) 74 | - [Integrierte Variablen](https://f2.freshman.tech/guide/how-variables-work.html) 75 | - [Umbenennen von Dateipaaren](https://f2.freshman.tech/guide/pair-renaming.html) 76 | - [Umbenennen mit einer CSV-Datei](https://f2.freshman.tech/guide/csv-renaming.html) 77 | - [Sortierung](https://f2.freshman.tech/guide/sorting.html) 78 | - [Konflikte lösen](https://f2.freshman.tech/guide/conflict-detection.html) 79 | - [Umbenennungsfehler rückgängig machen](https://f2.freshman.tech/guide/undoing-mistakes.html) 80 | - [ÄNDERUNGSPROTOKOLL](https://f2.freshman.tech/reference/changelog.html) 81 | 82 | ## 💻 Screenshots 83 | 84 | ![F2 kann Exif-Attribute verwenden, um Bilddateien zu organisieren](https://f2.freshman.tech/assets/2.D-uxLR9T.png) 85 | 86 | ## 🤝 Mitwirken 87 | 88 | Fehlerberichte und Funktionswünsche sind herzlich willkommen! Bitte öffnen Sie 89 | ein issue, bevor Sie eine pull request erstellen. 90 | 91 | ## ⚖️ Lizenz 92 | 93 | Erstellt von Ayooluwa Isaiah und veröffentlicht unter den Bedingungen der 94 | [MIT-Lizenz](https://github.com/ayoisaiah/f2/blob/master/LICENCE). 95 | -------------------------------------------------------------------------------- /docs/README.fr.md: -------------------------------------------------------------------------------- 1 | **以其他語言閱讀此文檔:**[English](/README.md) | [Deutsch](/docs/README.de.md) | [Español](/docs/README.es.md) | [Português](/docs/README.pt.md) | [Русский](/docs/README.ru.md) | [繁體中文](/docs/README.zh.md) 2 | 3 |

4 | f2 5 |

6 | 7 |

8 | 9 | Actions Github 10 | Fait avec Go 11 | GoReportCard 12 | Version Go.mod 13 | LICENCE 14 | Dernière version 15 |

16 | 17 |

F2 - Renommage par lots en ligne de commande

18 | 19 | **F2** est un outil en ligne de commande multiplateforme pour renommer des 20 | fichiers et des répertoires par lots **rapidement** et **en toute sécurité**. 21 | Écrit en Go! 22 | 23 | ## Qu'est-ce que F2 fait différemment ? 24 | 25 | Comparé à d'autres outils de renommage, F2 offre plusieurs avantages clés: 26 | 27 | - **Simulation par défaut**: Il effectue par défaut une simulation afin que vous 28 | puissiez examiner les changements de nom avant de continuer. 29 | 30 | - **Prise en charge des variables**: F2 vous permet d'utiliser les attributs des 31 | fichiers, tels que les données EXIF pour les images ou les balises ID3 pour 32 | les fichiers audio, pour vous offrir une flexibilité maximale lors du 33 | renommage. 34 | 35 | - **Options complètes**: Qu'il s'agisse de simples remplacements de chaînes de 36 | caractères ou d'expressions régulières complexes, F2 offre une gamme complète 37 | de fonctionnalités de renommage. 38 | 39 | - **La sécurité d'abord**: Il privilégie l'exactitude en s'assurant que chaque 40 | opération de renommage est exempte de conflits et d'erreurs grâce à des 41 | contrôles rigoureux. 42 | 43 | - **Résolution des conflits**: Chaque opération de renommage est validée avant 44 | son exécution et les conflits détectés peuvent être résolus automatiquement. 45 | 46 | - **Haute performance**: F2 est extrêmement rapide et efficace, même lors du 47 | renommage de milliers de fichiers à la fois. 48 | 49 | - **Fonctionnalité d'annulation**: Toute opération de renommage peut être 50 | facilement annulée pour permettre la correction facile des erreurs. 51 | 52 | - **Documentation complète**: F2 est bien documenté avec des exemples clairs et 53 | pratiques pour vous aider à tirer le meilleur parti de ses fonctionnalités 54 | sans confusion. 55 | 56 | ## ⚡ Installation 57 | 58 | Si vous êtes un développeur Go, F2 peut être installé avec `go install` 59 | (nécessite la v1.23 ou une version ultérieure): 60 | 61 | ```bash 62 | go install github.com/ayoisaiah/f2/v2/cmd/f2@latest 63 | ``` 64 | 65 | D'autres méthodes d'installation sont 66 | [documentées ici](https://f2.freshman.tech/guide/getting-started.html) ou 67 | consultez la [page des versions](https://github.com/ayoisaiah/f2/releases) pour 68 | télécharger un binaire pré-compilé pour votre système d'exploitation. 69 | 70 | ## 📃 Liens rapides 71 | 72 | - [Installation](https://f2.freshman.tech/guide/getting-started.html) 73 | - [Tutoriel de démarrage](https://f2.freshman.tech/guide/tutorial.html) 74 | - [Exemple concret](https://f2.freshman.tech/guide/organizing-image-library.html) 75 | - [Variables intégrées](https://f2.freshman.tech/guide/how-variables-work.html) 76 | - [Renommage de paires de fichiers](https://f2.freshman.tech/guide/pair-renaming.html) 77 | - [Renommage avec un fichier CSV](https://f2.freshman.tech/guide/csv-renaming.html) 78 | - [Tri](https://f2.freshman.tech/guide/sorting.html) 79 | - [Résolution des conflits](https://f2.freshman.tech/guide/conflict-detection.html) 80 | - [Annuler les erreurs de renommage](https://f2.freshman.tech/guide/undoing-mistakes.html) 81 | - [CHANGELOG](https://f2.freshman.tech/reference/changelog.html) 82 | 83 | ## 💻 Captures d'écran 84 | 85 | ![F2 peut utiliser les attributs Exif pour organiser les fichiers image](https://f2.freshman.tech/assets/2.D-uxLR9T.png) 86 | 87 | ## 🤝 Contribuer 88 | 89 | Les rapports de bogues et les demandes de fonctionnalités sont les bienvenus ! 90 | Veuillez ouvrir une issue avant de créer une pull request. 91 | 92 | ## ⚖️ Licence 93 | 94 | Créé par Ayooluwa Isaiah et publié sous les termes de la 95 | [Licence MIT](https://github.com/ayoisaiah/f2/blob/master/LICENCE). 96 | -------------------------------------------------------------------------------- /report/report.go: -------------------------------------------------------------------------------- 1 | // Package report provides details about the renaming operation in table or json 2 | // format 3 | package report 4 | 5 | import ( 6 | "os" 7 | "strings" 8 | 9 | "github.com/pterm/pterm" 10 | 11 | "github.com/ayoisaiah/f2/v2/internal/apperr" 12 | "github.com/ayoisaiah/f2/v2/internal/config" 13 | "github.com/ayoisaiah/f2/v2/internal/file" 14 | "github.com/ayoisaiah/f2/v2/internal/localize" 15 | "github.com/ayoisaiah/f2/v2/internal/osutil" 16 | ) 17 | 18 | func ExitWithErr(err error) { 19 | pterm.EnableOutput() 20 | 21 | errPrefix := localize.T("report.error") 22 | errMessage := err.Error() 23 | 24 | s := strings.Split(errMessage, ":") 25 | if len(s) > 1 { 26 | errPrefix = strings.TrimSpace(s[0]) 27 | errMessage = strings.TrimSpace(s[1]) 28 | } 29 | 30 | pterm.Fprintln( 31 | config.Stderr, 32 | pterm.Sprintf("%s: %v", pterm.Red(errPrefix), errMessage), 33 | ) 34 | os.Exit(int(osutil.ExitError)) 35 | } 36 | 37 | func BackupFailed(err error) { 38 | pterm.Fprintln( 39 | config.Stderr, 40 | pterm.Sprintf( 41 | "%s: %v", 42 | pterm.Red(localize.T("report.backup_failed")), 43 | err, 44 | ), 45 | ) 46 | } 47 | 48 | func SearchEvalFailed(path, target string, err error) { 49 | pterm.Fprintln( 50 | config.Stderr, 51 | pterm.Sprintf( 52 | "%s: %v -> %s", 53 | pterm.Yellow(path), 54 | err, 55 | target, 56 | ), 57 | ) 58 | } 59 | 60 | func BackupFileRemovalFailed(err error) { 61 | pterm.Fprintln( 62 | config.Stderr, 63 | pterm.Sprintf( 64 | "%s: %v", 65 | pterm.Red(localize.T("report.backup_cleanup_failed")), 66 | err, 67 | ), 68 | ) 69 | } 70 | 71 | func ShortHelp(helpText string) { 72 | pterm.Fprintln(config.Stderr, helpText) 73 | } 74 | 75 | func DefaultOpt(opt, val string) { 76 | pterm.Fprintln( 77 | config.Stderr, 78 | pterm.Sprintf( 79 | localize.T( 80 | "report.default_opt", 81 | ), 82 | pterm.Green(opt), 83 | pterm.Yellow(val), 84 | ), 85 | ) 86 | } 87 | 88 | func NonExistentFile(name string, row int) { 89 | pterm.Fprintln( 90 | config.Stderr, 91 | pterm.Sprintf( 92 | "%s %d: %s", 93 | localize.T("report.non_existent_file"), 94 | row, 95 | name, 96 | ), 97 | ) 98 | } 99 | 100 | // NoMatches prints out a message indicating that the find string failed 101 | // to match any files. 102 | func NoMatches(conf *config.Config) { 103 | if conf.Quiet { 104 | os.Exit(int(osutil.ExitError)) 105 | } 106 | 107 | msg := localize.T("report.no_matches") 108 | if conf.CSVFilename != "" { 109 | msg = localize.T("report.no_matches_csv") 110 | } 111 | 112 | if conf.Revert { 113 | msg = localize.T("report.no_matches_undo") 114 | } 115 | 116 | pterm.Fprintln(config.Stderr, pterm.Sprint(msg)) 117 | } 118 | 119 | // Report prints a report of the renaming changes to be made. 120 | func Report( 121 | conf *config.Config, 122 | fileChanges file.Changes, 123 | conflictDetected bool, 124 | ) { 125 | if conf.JSON { 126 | err := fileChanges.RenderJSON(config.Stdout) 127 | if err != nil { 128 | pterm.Fprintln( 129 | config.Stderr, 130 | pterm.Sprintf( 131 | "%s %v", 132 | pterm.Red(localize.T("report.error")), 133 | err, 134 | ), 135 | ) 136 | } 137 | 138 | return 139 | } 140 | 141 | fileChanges.RenderTable(config.Stdout, conf.NoColor) 142 | 143 | if conflictDetected || conf.JSON { 144 | return 145 | } 146 | 147 | pterm.Fprintln( 148 | config.Stderr, 149 | pterm.Sprintf( 150 | "%s %s", 151 | pterm.Green(localize.T("report.dry_run"), ":"), 152 | localize.T("report.commit_changes"), 153 | ), 154 | ) 155 | } 156 | 157 | // PrintResults prints the results of a renaming operation, including any errors 158 | // encountered. It displays successful renames to stderr if verbose mode is 159 | // enabled, and prints renamed paths to stdout if output is piped. Errors are 160 | // always printed to stderr. 161 | func PrintResults(conf *config.Config, fileChanges file.Changes, err error) { 162 | if err != nil { 163 | //nolint:errorlint // checking if err matches custom interface 164 | renameErr, ok := err.(*apperr.Error) 165 | if ok { 166 | errIndices, ok := renameErr.Context.([]int) 167 | if ok { 168 | for _, index := range errIndices { 169 | change := fileChanges[index] 170 | 171 | pterm.Fprintln( 172 | config.Stderr, 173 | pterm.Sprintf( 174 | "%s %v", 175 | pterm.Red(localize.T("report.error"), ":"), 176 | change.Error, 177 | ), 178 | ) 179 | } 180 | } 181 | } 182 | } 183 | 184 | if !conf.Verbose && !conf.PipeOutput { 185 | return 186 | } 187 | 188 | for i := range fileChanges { 189 | change := fileChanges[i] 190 | 191 | if conf.PipeOutput && change.Error == nil { 192 | pterm.Fprintln(config.Stdout, change.TargetPath) 193 | } 194 | 195 | if !conf.Verbose { 196 | continue 197 | } 198 | 199 | pterm.Fprintln(config.Stderr, 200 | pterm.Sprintf( 201 | "%s '%s' to '%s'", 202 | pterm.Green(localize.T("report.renamed"), ":"), 203 | change.SourcePath, 204 | change.TargetPath, 205 | ), 206 | ) 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /replace/replace_test/indexing_test.go: -------------------------------------------------------------------------------- 1 | package replace_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ayoisaiah/f2/v2/internal/file" 7 | "github.com/ayoisaiah/f2/v2/internal/testutil" 8 | ) 9 | 10 | func TestIndexing(t *testing.T) { 11 | testCases := []testutil.TestCase{ 12 | { 13 | Name: "replace with auto incrementing integers", 14 | Changes: file.Changes{ 15 | { 16 | Source: "a.txt", 17 | }, 18 | { 19 | Source: "b.txt", 20 | }, 21 | { 22 | Source: "c.txt", 23 | }, 24 | }, 25 | Want: []string{"1.txt", "2.txt", "3.txt"}, 26 | Args: []string{"-f", "a|b|c", "-r", "{%d}"}, 27 | }, 28 | { 29 | Name: "replace with multiple incrementing integers", 30 | Changes: file.Changes{ 31 | { 32 | Source: "a.txt", 33 | }, 34 | { 35 | Source: "b.txt", 36 | }, 37 | { 38 | Source: "c.txt", 39 | }, 40 | }, 41 | Want: []string{"1_10_0100.txt", "2_20_0200.txt", "3_30_0300.txt"}, 42 | Args: []string{"-f", "a|b|c", "-r", "{%d}_{10%02d10}_{100%04d100}"}, 43 | }, 44 | { 45 | Name: "replace with non-arabic numerals", 46 | Changes: file.Changes{ 47 | { 48 | Source: "a.txt", 49 | }, 50 | { 51 | Source: "b.txt", 52 | }, 53 | { 54 | Source: "c.txt", 55 | }, 56 | }, 57 | Want: []string{"I_1 1_1.txt", "II_2 2_10.txt", "III_3 3_11.txt"}, 58 | Args: []string{"-f", "a|b|c", "-r", "{%dr}_{%do} {%dh}_{%db}"}, 59 | }, 60 | { 61 | Name: "skip some numbers when incrementing", 62 | Changes: file.Changes{ 63 | { 64 | Source: "a.txt", 65 | }, 66 | { 67 | Source: "b.txt", 68 | }, 69 | { 70 | Source: "c.txt", 71 | }, 72 | }, 73 | Want: []string{"16.txt", "17.txt", "18.txt"}, 74 | Args: []string{"-f", "a|b|c", "-r", "{10%d<10-15>}"}, 75 | }, 76 | { 77 | Name: "use integer capture variables", 78 | Changes: file.Changes{ 79 | { 80 | Source: "doc1.txt", 81 | }, 82 | { 83 | Source: "doc4.txt", 84 | }, 85 | { 86 | Source: "doc99.txt", 87 | }, 88 | }, 89 | Want: []string{"001.txt", "004.txt", "099.txt"}, 90 | Args: []string{"-f", "doc(\\d+)", "-r", "{$1%03d}"}, 91 | }, 92 | { 93 | Name: "combine capture variable indices with regular indices", 94 | Changes: file.Changes{ 95 | { 96 | Source: "1 doc 2 4000.txt", 97 | }, 98 | { 99 | Source: "60 80 90.txt", 100 | }, 101 | { 102 | Source: "doc100 doc150.txt", 103 | }, 104 | }, 105 | Want: []string{ 106 | "001_0005 doc 002_0005 4000_0005.txt", 107 | "060_0006 080_0006 090_0006.txt", 108 | "doc100_0007 doc150_0007.txt", 109 | }, 110 | Args: []string{"-f", "(\\d+)", "-r", "{$1%03d}_{5%04d}"}, 111 | }, 112 | { 113 | Name: "use multiple integer capture variables", 114 | Changes: file.Changes{ 115 | { 116 | Source: "1 doc 2 4000.txt", 117 | }, 118 | { 119 | Source: "60 80 90.txt", 120 | }, 121 | { 122 | Source: "doc100 doc150.txt", 123 | }, 124 | }, 125 | Want: []string{ 126 | "001 doc 002 4000.txt", 127 | "060 080 090.txt", 128 | "doc100 doc150.txt", 129 | }, 130 | Args: []string{"-f", "(\\d+)", "-r", "{$1%03d}"}, 131 | }, 132 | { 133 | Name: "use integer capture variables with explicit step", 134 | Changes: file.Changes{ 135 | { 136 | Source: "doc1.txt", 137 | }, 138 | { 139 | Source: "doc4.txt", 140 | }, 141 | { 142 | Source: "doc99.txt", 143 | }, 144 | }, 145 | Want: []string{"006.txt", "009.txt", "104.txt"}, 146 | Args: []string{"-f", "doc(\\d+)", "-r", "{$1%03d5}"}, 147 | }, 148 | { 149 | Name: "skip some numbers while indexing with capture variables", 150 | Changes: file.Changes{ 151 | { 152 | Source: "doc1.txt", 153 | }, 154 | { 155 | Source: "doc4.txt", 156 | }, 157 | { 158 | Source: "doc99.txt", 159 | }, 160 | }, 161 | Want: []string{"002.txt", "005.txt", "099.txt"}, 162 | Args: []string{"-f", "doc(\\d+)", "-r", "{$1%03d<1;4>}"}, 163 | }, 164 | { 165 | Name: "reset index per directory", 166 | Changes: file.Changes{ 167 | { 168 | BaseDir: "folder1", 169 | Source: "f1.log", 170 | }, 171 | { 172 | BaseDir: "folder1", 173 | Source: "f2.log", 174 | }, 175 | { 176 | BaseDir: "folder2", 177 | Source: "f3.log", 178 | }, 179 | { 180 | BaseDir: "folder2", 181 | Source: "f4.log", 182 | }, 183 | { 184 | BaseDir: "folder3", 185 | Source: "f5.log", 186 | }, 187 | { 188 | BaseDir: "folder3", 189 | Source: "f6.log", 190 | }, 191 | }, 192 | Want: []string{ 193 | "folder1/f1_001.log", 194 | "folder1/f2_002.log", 195 | "folder2/f3_001.log", 196 | "folder2/f4_002.log", 197 | "folder3/f5_001.log", 198 | "folder3/f6_002.log", 199 | }, 200 | Args: []string{ 201 | "-f", 202 | ".*", 203 | "-r", 204 | "{f}_{%03d}{ext}", 205 | "--reset-index-per-dir", 206 | }, 207 | }, 208 | } 209 | 210 | replaceTest(t, testCases) 211 | } 212 | -------------------------------------------------------------------------------- /validate/validate_test/validate_windows_test.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package validate_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/ayoisaiah/f2/v2/internal/file" 10 | "github.com/ayoisaiah/f2/v2/internal/status" 11 | "github.com/ayoisaiah/f2/v2/internal/testutil" 12 | ) 13 | 14 | func TestValidateWindows(t *testing.T) { 15 | t.Helper() 16 | 17 | testCases := []testutil.TestCase{ 18 | { 19 | Name: "detect trailing period conflict in file names", 20 | Changes: file.Changes{ 21 | { 22 | Source: "index.js", 23 | Target: "main.js..", 24 | BaseDir: "dev", 25 | Status: status.TrailingPeriod, 26 | }, 27 | }, 28 | ConflictDetected: true, 29 | }, 30 | { 31 | Name: ".. and . should not trigger a conflict", 32 | Changes: file.Changes{ 33 | { 34 | Source: "index.js", 35 | Target: "../index.js", 36 | BaseDir: "dev/nested", 37 | Status: status.OK, 38 | }, 39 | { 40 | Source: "main.js", 41 | Target: "./main.js", 42 | BaseDir: "dev", 43 | Status: status.Unchanged, 44 | }, 45 | }, 46 | Want: []string{"dev/index.js", "dev/main.js"}, 47 | }, 48 | { 49 | Name: "detect trailing period conflict in directories", 50 | Changes: file.Changes{ 51 | { 52 | Source: "No Pressure (2021) S1.E1.1080p.mkv", 53 | Target: "2021.../No Pressure (2021) S1.E1.1080p.mkv", 54 | BaseDir: "movies", 55 | Status: status.TrailingPeriod, 56 | }, 57 | { 58 | Source: "No Pressure (2021) S1.E2.1080p.mkv", 59 | Target: "2021.../No Pressure (2021) S1.E2.1080p.mkv", 60 | BaseDir: "movies", 61 | Status: status.TrailingPeriod, 62 | }, 63 | { 64 | Source: "No Pressure (2021) S1.E3.1080p.mkv", 65 | Target: "2021.../No Pressure (2021) S1.E3.1080p.mkv", 66 | BaseDir: "movies", 67 | Status: status.TrailingPeriod, 68 | }, 69 | }, 70 | ConflictDetected: true, 71 | }, 72 | { 73 | Name: "detect forbidden characters in filename", 74 | Changes: file.Changes{ 75 | { 76 | Source: "atomic-habits.pdf", 77 | Target: "<>:?etc.pdf", 78 | BaseDir: "ebooks", 79 | Status: status.ForbiddenCharacters.Append("<,>,:,?"), 80 | }, 81 | }, 82 | ConflictDetected: true, 83 | }, 84 | { 85 | Name: "detect filename longer than 255 characters", 86 | Changes: file.Changes{ 87 | { 88 | Source: "1984.pdf", 89 | Target: "It was a bright cold day in April, and the clocks were striking thirteen. Winston Smith, his chin nuzzled into his breast in an effort to escape the vile wind, slipped quickly through the glass doors of Victory Mansions, though not quickly enough to prevent a swirl of gritty dust from entering along with him.pdf", 90 | BaseDir: "ebooks", 91 | Status: status.FilenameLengthExceeded, 92 | }, 93 | }, 94 | ConflictDetected: true, 95 | }, 96 | { 97 | Name: "up to 255 emoji characters should not cause a conflict", 98 | Changes: file.Changes{ 99 | { 100 | Source: "1984.pdf", 101 | Target: "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀", 102 | BaseDir: "ebooks", 103 | }, 104 | }, 105 | Want: []string{ 106 | "ebooks/😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀", 107 | }, 108 | }, 109 | { 110 | Name: "auto fix forbidden characters in filename", 111 | Changes: file.Changes{ 112 | { 113 | Source: "atomic-habits.pdf", 114 | Target: "<>:?etc.pdf", 115 | BaseDir: "ebooks", 116 | }, 117 | }, 118 | Want: []string{"ebooks/etc.pdf"}, 119 | Args: []string{"-r", "", "-F"}, 120 | }, 121 | { 122 | Name: "auto fix filename longer than maximum length conflict", 123 | Changes: file.Changes{ 124 | { 125 | Source: "1984.pdf", 126 | Target: "It was a bright cold day in April, and the clocks were striking thirteen. Winston Smith, his chin nuzzled into his breast in an effort to escape the vile wind, slipped quickly through the glass doors of Victory Mansions, though not quickly enough to prevent a swirl of gritty dust from entering along with him.pdf", 127 | BaseDir: "ebooks", 128 | }, 129 | }, 130 | Want: []string{ 131 | "ebooks/It was a bright cold day in April, and the clocks were striking thirteen. Winston Smith, his chin nuzzled into his breast in an effort to escape the vile wind, slipped quickly through the glass doors of Victory Mansions, though not quickly enough to p.pdf", 132 | }, 133 | Args: []string{"-r", "", "-F"}, 134 | }, 135 | } 136 | 137 | validateTest(t, testCases) 138 | } 139 | -------------------------------------------------------------------------------- /rename/rename.go: -------------------------------------------------------------------------------- 1 | // Package rename handles the actual file renaming operations and manages 2 | // backups for potential undo operations. 3 | package rename 4 | 5 | import ( 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | "time" 13 | 14 | "github.com/ayoisaiah/f2/v2/internal/apperr" 15 | "github.com/ayoisaiah/f2/v2/internal/config" 16 | "github.com/ayoisaiah/f2/v2/internal/file" 17 | "github.com/ayoisaiah/f2/v2/internal/localize" 18 | "github.com/ayoisaiah/f2/v2/internal/osutil" 19 | "github.com/ayoisaiah/f2/v2/internal/status" 20 | "github.com/ayoisaiah/f2/v2/report" 21 | ) 22 | 23 | var errRenameFailed = &apperr.Error{ 24 | Message: localize.T("error.rename_failed"), 25 | } 26 | 27 | // traversedDirs records the directories that were traversed during a renaming 28 | // operation. 29 | var traversedDirs = make(map[string]string) 30 | 31 | // commit iterates over all the matches and renames them on the filesystem. 32 | // Directories are auto-created if necessary, and errors are aggregated. 33 | func commit(fileChanges file.Changes) []int { 34 | slog.Debug( 35 | "committing file changes", 36 | slog.Int("change_count", len(fileChanges)), 37 | ) 38 | 39 | var errIndices []int 40 | 41 | for i := range fileChanges { 42 | ch := fileChanges[i] 43 | 44 | if ch.Status == status.Ignored { 45 | slog.Debug( 46 | "skipping ignored file", 47 | slog.Any("match", ch), 48 | ) 49 | 50 | continue 51 | } 52 | 53 | targetPath := ch.TargetPath 54 | 55 | // skip paths that are unchanged in every aspect 56 | if ch.SourcePath == targetPath { 57 | slog.Debug( 58 | "skipping unchanged file", 59 | slog.Any("match", ch), 60 | ) 61 | 62 | continue 63 | } 64 | 65 | // Workaround for case insensitive filesystems where renaming a filename to 66 | // its upper or lowercase equivalent doesn't work. Fixing this involves the 67 | // following steps: 68 | // 1. Prefix and suffix with __