├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.md ├── labels.yml └── workflows │ ├── golangci-lint.yml │ ├── labeler.yml │ ├── npm.yml │ ├── release-nightly.yml │ ├── release-stable.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .pre-commit-config.yaml ├── .vscode └── launch.json ├── CHANGELOG.md ├── Dockerfile ├── LICENCE ├── README.md ├── app ├── app.go ├── app_test │ ├── app_test.go │ ├── app_unix_test.go │ └── testdata │ │ ├── a.txt │ │ ├── b.txt │ │ ├── c.txt │ │ ├── d.txt │ │ ├── help_stdout.golden │ │ ├── short_help_stdout.golden │ │ └── version_stdout.golden ├── errors.go ├── flag.go └── help.go ├── cmd └── f2 │ └── main.go ├── f2.go ├── f2_test ├── f2_test.go └── testdata │ ├── birthday-2024 │ ├── img44.dng │ ├── img44.jpg │ ├── img78.dng │ └── img78.jpg │ ├── family trip - berlin │ ├── img101.dng │ ├── img101.jpg │ ├── img90.dng │ ├── img90.jpg │ ├── img99.dng │ └── img99.jpg │ ├── family trip - london │ ├── img1.dng │ ├── img1.jpg │ ├── img2.dng │ └── img2.jpg │ ├── image_pair_renaming_stderr.golden │ ├── image_pair_renaming_stdout.golden │ ├── img34.dng │ ├── img34.jpg │ ├── img66.dng │ ├── img66.jpg │ ├── my-wedding │ ├── img33.dng │ ├── img33.jpg │ ├── img67.dng │ └── img67.jpg │ ├── only_match_files_named_img33.dng_stderr.golden │ ├── only_match_files_named_img33.dng_stdout.golden │ ├── use_dates_to_conditionally_match_files_stderr.golden │ └── use_dates_to_conditionally_match_files_stdout.golden ├── find ├── csv.go ├── doc.go ├── find.go ├── find_internal_test.go ├── find_test │ ├── find_csv_test.go │ ├── find_test.go │ ├── find_unix_test.go │ ├── find_windows_test.go │ └── testdata │ │ ├── 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 │ │ ├── a.txt │ │ ├── b.txt │ │ ├── c.txt │ │ └── input.csv ├── find_unix.go └── find_windows.go ├── go.mod ├── go.sum ├── internal ├── apperr │ └── apperr.go ├── config │ ├── config.go │ ├── errors.go │ └── sort.go ├── file │ └── file.go ├── osutil │ └── osutil.go ├── pathutil │ └── pathutil.go ├── sortfiles │ ├── custom.go │ ├── sortfiles.go │ └── sortfiles_test │ │ ├── sortfiles_test.go │ │ └── testdata │ │ ├── 10k.txt │ │ ├── 11k.txt │ │ ├── 20k.txt │ │ ├── 4k.txt │ │ └── dir1 │ │ ├── 10k.txt │ │ ├── 20k.txt │ │ └── folder │ │ ├── 15k.txt │ │ └── 3k.txt ├── status │ └── status.go ├── testutil │ └── testutil.go └── timeutil │ └── time.go ├── justfile ├── package.json ├── rename ├── backup.go ├── rename.go └── rename_test │ ├── rename_test.go │ ├── rename_windows_test.go │ └── testdata │ ├── rename_a_file.golden │ ├── rename_a_file_backup.golden │ └── rename_a_file_backup_stderr.golden ├── replace ├── replace.go ├── replace_test │ ├── indexing_test.go │ ├── replace_test.go │ ├── testdata │ │ ├── 19. D_1993 F2.flac │ │ ├── audio.flac │ │ ├── audio.mp3 │ │ ├── binary.mp3 │ │ ├── embedded.mp4 │ │ ├── file.tar.gz │ │ ├── gps.jpg │ │ ├── image.dng │ │ └── pic.jpg │ ├── variables_darwin_test.go │ ├── variables_test.go │ └── variables_windows_test.go └── variables │ ├── diacritics.go │ ├── extract.go │ ├── variable_regex.go │ ├── variable_types.go │ └── variables.go ├── report ├── report.go └── report_test │ ├── report_test.go │ └── testdata │ ├── print_results_with_errors_stderr.golden │ ├── print_results_without_errors_(piped_output)_stdout.golden │ ├── print_results_without_errors_(verbose)_stderr.golden │ ├── report_backup_failure_stderr.golden │ ├── report_backup_file_removal_failure_stderr.golden │ ├── report_file_conflicts_in_JSON_stdout.golden │ ├── report_file_conflicts_no_color_stdout.golden │ ├── report_file_conflicts_stdout.golden │ ├── report_file_status_in_JSON_stdout.golden │ ├── report_file_status_stderr.golden │ ├── report_file_status_stdout.golden │ ├── report_file_status_with_F2_NO_COLOR_env_stderr.golden │ ├── report_file_status_with_F2_NO_COLOR_env_stdout.golden │ ├── report_no_matches_(backup)_stderr.golden │ ├── report_no_matches_(csv)_stderr.golden │ ├── report_no_matches_(standard)_stderr.golden │ └── report_non_existent_file_stderr.golden ├── scripts └── completions │ ├── f2.bash │ ├── f2.fish │ └── f2.zsh ├── tools.mod ├── tools.sum └── validate ├── doc.go ├── validate.go ├── validate_internal_test.go └── validate_test ├── testdata └── images │ ├── dsc-001.arw │ └── dsc-002.arw ├── validate_test.go ├── validate_unix_test.go └── validate_windows_test.go /.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 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: freshman 2 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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: Setup Go ${{ vars.GO_VERSION }} 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: ${{ vars.GO_VERSION }} 22 | 23 | - name: golangci-lint 24 | uses: golangci/golangci-lint-action@v7 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 | -------------------------------------------------------------------------------- /.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: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | registry-url: https://registry.npmjs.org/ 25 | node-version: ${{ vars.NODE_VERSION }} 26 | 27 | - name: Publish to NPM 28 | run: npm publish 29 | env: 30 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/release-nightly.yml: -------------------------------------------------------------------------------- 1 | name: Release nightly 2 | 3 | env: 4 | REPO_OWNER: ${{ vars.REPO_OWNER }} 5 | REPO_WEBSITE: ${{ vars.REPO_WEBSITE }} 6 | REPO_DESCRIPTION: ${{ vars.REPO_DESCRIPTION }} 7 | REPO_MAINTAINER: ${{ vars.REPO_MAINTAINER }} 8 | REPO_AUTHOR_NAME: ${{ vars.REPO_AUTHOR_NAME }} 9 | REPO_AUTHOR_EMAIL: ${{ vars.REPO_AUTHOR_EMAIL }} 10 | REPO_BINARY_NAME: ${{ vars.REPO_BINARY_NAME }} 11 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 12 | NIGHTLY_TAG: ${{ vars.NIGHTLY_TAG }} 13 | 14 | on: 15 | workflow_dispatch: 16 | push: 17 | branches: 18 | - master 19 | 20 | jobs: 21 | create_nightly_tag: 22 | name: Create nightly tag for master branch 23 | runs-on: ubuntu-latest 24 | if: github.ref_type == 'branch' 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | 31 | - name: Update nightly tag 32 | run: | 33 | git tag -d ${{ vars.NIGHTLY_TAG }} || true 34 | git push origin :refs/tags/${{ vars.NIGHTLY_TAG }} || true 35 | git tag ${{ vars.NIGHTLY_TAG }} 36 | git push origin ${{ vars.NIGHTLY_TAG }} 37 | 38 | release_nightly: 39 | needs: create_nightly_tag 40 | name: Release nightly version 41 | runs-on: ubuntu-latest 42 | env: 43 | GH_REPO: ${{ github.repository }} 44 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v4 48 | with: 49 | fetch-depth: 0 50 | 51 | - name: Set up Go ${{ vars.GO_VERSION }} 52 | uses: actions/setup-go@v5 53 | with: 54 | go-version: ${{ vars.GO_VERSION }} 55 | cache: false 56 | check-latest: true 57 | 58 | - name: Delete existing nightly release 59 | run: | 60 | gh release delete nightly --yes || true 61 | 62 | - name: Create nightly release 63 | uses: softprops/action-gh-release@v2 64 | with: 65 | tag_name: refs/tags/${{ vars.NIGHTLY_TAG }} 66 | name: Development build (master) 67 | body: | 68 | This build is directly sourced from the `master` branch in active development. As such, it may include experimental features and potential bugs. 69 | draft: false 70 | prerelease: true 71 | 72 | - name: Build assets with Goreleaser 73 | uses: goreleaser/goreleaser-action@v6 74 | with: 75 | version: ~> v2 76 | args: release --clean --snapshot 77 | 78 | - name: Upload assets to nightly release 79 | run: gh release upload ${{ vars.NIGHTLY_TAG }} dist/{*.tar.gz,*.zip,*.tar.zst,*.deb,*.rpm,*.apk,checksums.txt} --clobber 80 | -------------------------------------------------------------------------------- /.github/workflows/release-stable.yml: -------------------------------------------------------------------------------- 1 | name: Release stable 2 | 3 | env: 4 | REPO_OWNER: ${{ vars.REPO_OWNER }} 5 | REPO_WEBSITE: ${{ vars.REPO_WEBSITE }} 6 | REPO_DESCRIPTION: ${{ vars.REPO_DESCRIPTION }} 7 | REPO_MAINTAINER: ${{ vars.REPO_MAINTAINER }} 8 | REPO_AUTHOR_NAME: ${{ vars.REPO_AUTHOR_NAME }} 9 | REPO_AUTHOR_EMAIL: ${{ vars.REPO_AUTHOR_EMAIL }} 10 | REPO_BINARY_NAME: ${{ vars.REPO_BINARY_NAME }} 11 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 12 | GORELEASER_GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 13 | FURY_PUSH_TOKEN: ${{ secrets.FURY_PUSH_TOKEN }} 14 | FURY_USERNAME: ${{ secrets.FURY_USERNAME }} 15 | GORELEASER_CURRENT_TAG: ${{ github.event.client_payload.tag_name }} 16 | 17 | on: 18 | repository_dispatch: 19 | types: [release_stable] 20 | 21 | jobs: 22 | release_stable: 23 | name: Release stable version 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | 31 | - name: Set up Go ${{ vars.GO_VERSION }} 32 | uses: actions/setup-go@v5 33 | with: 34 | go-version: ${{ vars.GO_VERSION }} 35 | 36 | - name: Create stable release 37 | uses: softprops/action-gh-release@v2 38 | with: 39 | tag_name: ${{ github.event.client_payload.tag_name }} 40 | name: ${{ github.event.client_payload.tag_name }} 41 | body: ${{ env.CHANGELOG }} 42 | draft: false 43 | prerelease: false 44 | 45 | - name: Run Goreleaser 46 | uses: goreleaser/goreleaser-action@v6 47 | with: 48 | version: ~> v2 49 | args: release --clean 50 | -------------------------------------------------------------------------------- /.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: Setup Go ${{ vars.GO_VERSION }} 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: ${{ vars.GO_VERSION }} 27 | 28 | - name: Use Exiftool 29 | uses: woss/exiftool-action@v12.92 30 | if: matrix.os != 'windows-latest' 31 | 32 | - uses: MinoruSekine/setup-scoop@v4.0.1 33 | with: 34 | apps: exiftool 35 | if: matrix.os == 'windows-latest' 36 | 37 | - name: Set up gotestfmt 38 | run: go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@latest 39 | 40 | - name: Run tests 41 | run: go test ./... -json -v -race 2>&1 | gotestfmt -hide 'empty-packages' 42 | 43 | trigger_stable_release: 44 | runs-on: ubuntu-latest 45 | needs: test 46 | if: github.ref_type == 'tag' 47 | steps: 48 | - name: Trigger repository_dispatch event 49 | run: | 50 | curl -X POST \ 51 | -H "Accept: application/vnd.github+json" \ 52 | -H "Authorization: Bearer ${{ secrets.PERSONAL_ACCESS_TOKEN }}" \ 53 | https://api.github.com/repos/ayoisaiah/f2/dispatches \ 54 | -d '{"event_type": "release_stable", "client_payload":{"tag": "${{ github.ref }}", "tag_name": "${{ github.ref_name }}" }}' 55 | 56 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-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.20 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | f2 3 |

4 | 5 |

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

14 | 15 |

F2 - Command-Line Batch Renaming

16 | 17 | **F2** is a cross-platform command-line tool for batch renaming files and 18 | directories **quickly** and **safely**. Written in Go! 19 | 20 | ## What does F2 do differently? 21 | 22 | Compared to other renaming tools, F2 offers several key advantages: 23 | 24 | - **Dry Run by Default**: It defaults to a dry run so that you can review the 25 | renaming changes before proceeding. 26 | 27 | - **Variable Support**: F2 allows you to use file attributes, such as EXIF data 28 | for images or ID3 tags for audio files, to give you maximum flexibility in 29 | renaming. 30 | 31 | - **Comprehensive Options**: Whether it's simple string replacements or complex 32 | regular expressions, F2 provides a full range of renaming capabilities. 33 | 34 | - **Safety First**: It prioritizes accuracy by ensuring every renaming operation 35 | is conflict-free and error-proof through rigorous checks. 36 | 37 | - **Conflict Resolution**: Each renaming operation is validated before execution 38 | and detected conflicts can be automatically resolved. 39 | 40 | - **High Performance**: F2 is extremely fast and efficient, even when renaming 41 | thousands of files at once. 42 | 43 | - **Undo Functionality**: Any renaming operation can be easily undone to allow 44 | the easy correction of mistakes. 45 | 46 | - **Extensive Documentation**: F2 is well-documented with clear, practical 47 | examples to help you make the most of its features without confusion. 48 | 49 | ## ⚡ Installation 50 | 51 | If you're a Go developer, F2 can be installed with `go install` (requires v1.23 52 | or later): 53 | 54 | ```bash 55 | go install github.com/ayoisaiah/f2/v2/cmd/f2@latest 56 | ``` 57 | 58 | Other installation methods are 59 | [documented here](https://f2.freshman.tech/guide/getting-started.html) or check 60 | out the [releases page](https://github.com/ayoisaiah/f2/releases) to download a 61 | pre-compiled binary for your operating system. 62 | 63 | ## 📃 Quick links 64 | 65 | - [Installation](https://f2.freshman.tech/guide/getting-started.html) 66 | - [Getting started tutorial](https://f2.freshman.tech/guide/tutorial.html) 67 | - [Real-world example](https://f2.freshman.tech/guide/organizing-image-library.html) 68 | - [Built-in variables](https://f2.freshman.tech/guide/how-variables-work.html) 69 | - [File pair renaming](https://f2.freshman.tech/guide/pair-renaming.html) 70 | - [Renaming with a CSV file](https://f2.freshman.tech/guide/csv-renaming.html) 71 | - [Sorting](https://f2.freshman.tech/guide/sorting.html) 72 | - [Resolving conflicts](https://f2.freshman.tech/guide/conflict-detection.html) 73 | - [Undoing renaming mistakes](https://f2.freshman.tech/guide/undoing-mistakes.html) 74 | - [CHANGELOG](https://f2.freshman.tech/reference/changelog.html) 75 | 76 | ## 💻 Screenshots 77 | 78 | ![F2 can utilise Exif attributes to organise image files](https://f2.freshman.tech/assets/2.D-uxLR9T.png) 79 | 80 | ## 🤝 Contribute 81 | 82 | Bug reports and feature requests are much welcome! Please open an issue before 83 | creating a pull request. 84 | 85 | ## ⚖ Licence 86 | 87 | Created by Ayooluwa Isaiah and released under the terms of the 88 | [MIT Licence](https://github.com/ayoisaiah/f2/blob/master/LICENCE). 89 | -------------------------------------------------------------------------------- /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/osutil" 16 | "github.com/ayoisaiah/f2/v2/report" 17 | ) 18 | 19 | const ( 20 | EnvDefaultOpts = "F2_DEFAULT_OPTS" 21 | ) 22 | 23 | var VersionString = "unset" 24 | 25 | // isInputFromPipe detects if input is being piped to F2. 26 | func isInputFromPipe() bool { 27 | fileInfo, _ := os.Stdin.Stat() 28 | return fileInfo.Mode()&os.ModeCharDevice == 0 29 | } 30 | 31 | // isOutputToPipe detects if F2's output is being piped to another command. 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 | tokens := strings.Fields(optsEnv) 66 | 67 | for _, token := range tokens { 68 | if strings.HasPrefix(token, "-") { 69 | if !supportedDefaultOpts[token] { 70 | return nil, errDefaultOptsParsing.Fmt(token) 71 | } 72 | } 73 | } 74 | 75 | args := append(strings.Fields(optsEnv), 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: `f2 bulk renames files and directories, matching files against a specified 123 | pattern. It employs safety checks to prevent accidental overwrites and 124 | offers several options for fine-grained control over the renaming process.`, 125 | Version: VersionString, 126 | EnableShellCompletion: true, 127 | Flags: []cli.Flag{ 128 | flagCSV, 129 | flagExiftoolOpts, 130 | flagFind, 131 | flagReplace, 132 | flagUndo, 133 | flagAllowOverwrites, 134 | flagClean, 135 | flagExclude, 136 | flagExcludeDir, 137 | flagExec, 138 | flagFixConflicts, 139 | flagFixConflictsPattern, 140 | flagHidden, 141 | flagInclude, 142 | flagIncludeDir, 143 | flagIgnoreCase, 144 | flagIgnoreExt, 145 | flagJSON, 146 | flagMaxDepth, 147 | flagNoColor, 148 | flagOnlyDir, 149 | flagPair, 150 | flagPairOrder, 151 | flagQuiet, 152 | flagRecursive, 153 | flagReplaceLimit, 154 | flagResetIndexPerDir, 155 | flagSort, 156 | flagSortr, 157 | flagSortPerDir, 158 | flagSortVar, 159 | flagStringMode, 160 | flagTargetDir, 161 | flagVerbose, 162 | }, 163 | UseShortOptionHandling: true, 164 | DisableSliceFlagSeparator: true, 165 | OnUsageError: func(_ context.Context, _ *cli.Command, err error, _ bool) error { 166 | return err 167 | }, 168 | Writer: w, 169 | Reader: r, 170 | } 171 | 172 | // Override the default help template 173 | app.CustomRootCommandHelpTemplate = helpText(app) 174 | 175 | return app 176 | } 177 | -------------------------------------------------------------------------------- /app/app_test/app_test.go: -------------------------------------------------------------------------------- 1 | package app_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "os" 7 | "slices" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/urfave/cli/v3" 12 | 13 | "github.com/ayoisaiah/f2/v2/app" 14 | "github.com/ayoisaiah/f2/v2/internal/config" 15 | "github.com/ayoisaiah/f2/v2/internal/testutil" 16 | ) 17 | 18 | func TestShortHelp(t *testing.T) { 19 | tc := &testutil.TestCase{ 20 | Name: "short help", 21 | Args: []string{"f2_test"}, 22 | } 23 | 24 | var stdout bytes.Buffer 25 | 26 | config.Stderr = &stdout 27 | 28 | t.Cleanup(func() { 29 | config.Stderr = os.Stderr 30 | }) 31 | 32 | renamer, err := app.Get(os.Stdin, os.Stdin) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | // renamer.Run() calls os.Exit() which causes the test to panic 38 | // This will recover and make the relevant assertion 39 | defer func() { 40 | if r := recover(); r == nil { 41 | t.Fatal("Expected a panic due to os.Exit(0) but got none") 42 | } 43 | 44 | tc.SnapShot.Stdout = stdout.Bytes() 45 | 46 | testutil.CompareGoldenFile(t, tc) 47 | }() 48 | 49 | err = renamer.Run(t.Context(), tc.Args) 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | } 54 | 55 | func TestHelp(t *testing.T) { 56 | t.Skip("versioning is no longer hard coded") 57 | 58 | tc := &testutil.TestCase{ 59 | Name: "help", 60 | Args: []string{"f2_test", "--help"}, 61 | } 62 | 63 | var stdout bytes.Buffer 64 | 65 | renamer, err := app.Get(os.Stdin, &stdout) 66 | if err != nil { 67 | t.Fatal(err) 68 | } 69 | 70 | err = renamer.Run(t.Context(), tc.Args) 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | 75 | tc.SnapShot.Stdout = stdout.Bytes() 76 | testutil.CompareGoldenFile(t, tc) 77 | } 78 | 79 | func TestVersion(t *testing.T) { 80 | t.Skip("versioning is no longer hard coded") 81 | 82 | tc := &testutil.TestCase{ 83 | Name: "version", 84 | Args: []string{"f2_test", "--version"}, 85 | } 86 | 87 | var stdout bytes.Buffer 88 | 89 | renamer, err := app.Get(os.Stdin, &stdout) 90 | if err != nil { 91 | t.Fatal(err) 92 | } 93 | 94 | err = renamer.Run(t.Context(), tc.Args) 95 | if err != nil { 96 | t.Fatal(err) 97 | } 98 | 99 | tc.SnapShot.Stdout = stdout.Bytes() 100 | testutil.CompareGoldenFile(t, tc) 101 | } 102 | 103 | func TestDefaultEnv(t *testing.T) { 104 | t.Skip("need to update how default env is tested") 105 | 106 | cases := []struct { 107 | Assert func(t *testing.T, cmd *cli.Command) 108 | Name string 109 | DefaultOpts string 110 | Args []string 111 | }{ 112 | { 113 | Name: "enable hidden files", 114 | Args: []string{"f2_test", "--find", "jpeg"}, 115 | DefaultOpts: "--hidden", 116 | Assert: func(t *testing.T, cmd *cli.Command) { 117 | t.Helper() 118 | 119 | if !cmd.Bool("hidden") { 120 | t.Fatal("expected --hidden default option to be true") 121 | } 122 | }, 123 | }, 124 | { 125 | Name: "set a custom --fix-conflicts-pattern", 126 | Args: []string{"f2_test", "--find", "jpeg"}, 127 | DefaultOpts: "--fix-conflicts-pattern _%03d", 128 | Assert: func(t *testing.T, cmd *cli.Command) { 129 | t.Helper() 130 | 131 | if got := cmd.String("fix-conflicts-pattern"); got != "_%03d" { 132 | t.Fatalf( 133 | "expected --fix-conflicts-pattern to default option to be _%%03d, but got: %s", 134 | got, 135 | ) 136 | } 137 | }, 138 | }, 139 | { 140 | Name: "override --fix-conflicts-pattern", 141 | Args: []string{ 142 | "f2_test", 143 | "--find", 144 | "jpeg", 145 | "--fix-conflicts-pattern", 146 | "_%02d", 147 | }, 148 | DefaultOpts: "--fix-conflicts-pattern _%03d", 149 | Assert: func(t *testing.T, cmd *cli.Command) { 150 | t.Helper() 151 | 152 | if got := cmd.String("fix-conflicts-pattern"); got != "_%02d" { 153 | t.Fatalf( 154 | "expected --fix-conflicts-pattern to default option to be _%%02d, but got: %s", 155 | got, 156 | ) 157 | } 158 | }, 159 | }, 160 | // TODO: Should repeatable options be overridden? 161 | { 162 | Name: "exclude node_modules and git", 163 | Args: []string{ 164 | "f2_test", 165 | "--find", 166 | "jpeg", 167 | "--exclude-dir", 168 | ".git", 169 | }, 170 | DefaultOpts: "--exclude-dir node_modules", 171 | Assert: func(t *testing.T, cmd *cli.Command) { 172 | t.Helper() 173 | 174 | want := []string{"node_modules", ".git"} 175 | if got := cmd.StringSlice("exclude-dir"); !slices.Equal( 176 | got, 177 | want, 178 | ) { 179 | t.Fatalf( 180 | "expected --exclude-dir to be %v, but got %v", 181 | want, 182 | got, 183 | ) 184 | } 185 | }, 186 | }, 187 | } 188 | 189 | for _, tc := range cases { 190 | t.Run(tc.Name, func(t *testing.T) { 191 | t.Setenv(app.EnvDefaultOpts, tc.DefaultOpts) 192 | 193 | var buf bytes.Buffer 194 | 195 | renamer, err := app.Get(os.Stdin, &buf) 196 | if err != nil { 197 | t.Fatal(err) 198 | } 199 | 200 | err = renamer.Run(t.Context(), tc.Args) 201 | if err != nil { 202 | t.Fatal("expected no errors, but got:", err) 203 | } 204 | 205 | v, exists := renamer.Metadata["ctx"] 206 | if !exists { 207 | t.Fatal("default context is not set") 208 | } 209 | 210 | cmd, ok := v.(*cli.Command) 211 | if !ok { 212 | t.Fatal( 213 | "Unexpected type assertion failure: expected *cli.Command", 214 | ) 215 | } 216 | 217 | tc.Assert(t, cmd) 218 | }) 219 | } 220 | } 221 | 222 | func TestStringSliceFlag(t *testing.T) { 223 | cases := []*testutil.TestCase{ 224 | { 225 | Name: "commas should not be interpreted as a separator", 226 | Args: []string{ 227 | "f2_test", 228 | "--replace", 229 | "Windows, Linux Episode {%d}{ext}", 230 | }, 231 | Want: []string{"Windows, Linux Episode {%d}{ext}"}, 232 | }, 233 | { 234 | Name: "multiple flags should add a separate value to the slice", 235 | Args: []string{ 236 | "f2_test", 237 | "--replace", 238 | "Windows", 239 | "--replace", 240 | "Linux Episode {%d}{ext}", 241 | }, 242 | Want: []string{"Windows", "Linux Episode {%d}{ext}"}, 243 | }, 244 | } 245 | 246 | for _, tc := range cases { 247 | var stdout bytes.Buffer 248 | 249 | renamer, err := app.Get(os.Stdin, &stdout) 250 | if err != nil { 251 | t.Fatal(err) 252 | } 253 | 254 | renamer.Action = func(_ context.Context, cmd *cli.Command) error { 255 | assert.Equal(t, tc.Want, cmd.StringSlice("replace")) 256 | 257 | return nil 258 | } 259 | 260 | _ = renamer.Run(t.Context(), tc.Args) 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/app_test/testdata/a.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/app/app_test/testdata/a.txt -------------------------------------------------------------------------------- /app/app_test/testdata/b.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/app/app_test/testdata/b.txt -------------------------------------------------------------------------------- /app/app_test/testdata/c.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/app/app_test/testdata/c.txt -------------------------------------------------------------------------------- /app/app_test/testdata/d.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/app/app_test/testdata/d.txt -------------------------------------------------------------------------------- /app/app_test/testdata/help_stdout.golden: -------------------------------------------------------------------------------- 1 | f2 v2.1.0 2 | Ayooluwa Isaiah 3 | 4 | f2 bulk renames files and directories, matching files against a specified 5 | pattern. It employs safety checks to prevent accidental overwrites and 6 | offers several options for fine-grained control over the renaming process. 7 | 8 | Project repository: https://github.com/ayoisaiah/f2 9 | 10 | USAGE 11 | f2 FLAGS [OPTIONS] [PATHS TO FILES AND DIRECTORIES...] 12 | command | f2 FLAGS [OPTIONS] 13 | 14 | POSITIONAL ARGUMENTS 15 | [PATHS TO FILES AND DIRECTORIES...] 16 | Optionally provide one or more files and directories to search for matches. 17 | If omitted, it searches the current directory alone. Also, note that 18 | directories are not searched recursively unless --recursive/-R is used. 19 | 20 | FLAGS 21 | --csv 22 | Load a CSV file, and rename according to its contents. 23 | 24 | -f, --find 25 | A regular expression pattern used for matching files and directories. 26 | It accepts the syntax defined by the RE2 standard and defaults to .* 27 | if omitted which matches the entire file/directory name. 28 | 29 | When -s/--string-mode is used, this pattern is treated as a literal string. 30 | 31 | -r, --replace 32 | The replacement string which replaces each match in the file name. 33 | It supports capture variables, built-in variables, and exiftool variables. 34 | If omitted, it defaults to an empty string. 35 | 36 | -u, --undo 37 | Undo the last renaming operation performed in the current working directory. 38 | 39 | OPTIONS 40 | --allow-overwrites 41 | Allows the renaming operation to overwrite existing files. 42 | Caution: Using this option can lead to unrecoverable data loss. 43 | 44 | -c, --clean 45 | Clean empty directories that were traversed in a renaming operation. 46 | 47 | -E, --exclude 48 | Excludes files and directories that match the provided regular expression. 49 | This flag can be repeated to specify multiple exclude patterns. 50 | 51 | Example: 52 | -E 'json' -E 'yml' (filters out JSON and YAML files) 53 | -E 'json|yaml' (equivalent to the above) 54 | 55 | Note: 56 | This does not prevent recursing into matching directories (use 57 | --exclude-dir instead). 58 | 59 | --exclude-dir 60 | Prevents F2 from recursing into directories that match the provided regular 61 | expression pattern. 62 | 63 | --exiftool-opts 64 | Provides options to customize Exiftool's output when using ExifTool 65 | variables in replacement patterns. 66 | 67 | Supported options: 68 | --api 69 | --charset 70 | --coordFormat 71 | --dateFormat 72 | --extractEmbedded 73 | 74 | Example: 75 | $ f2 -r '{xt.GPSDateTime}' --exiftool-opts '--dateFormat %Y-%m-%d' 76 | 77 | -x, --exec 78 | Executes the renaming operation and applies the changes to the filesystem. 79 | 80 | -F, --fix-conflicts 81 | Automatically fixes renaming conflicts using predefined rules. 82 | 83 | --fix-conflicts-pattern 84 | Specifies a custom pattern for renaming files when conflicts occur. 85 | The pattern should be a valid Go format string containing a single '%d' 86 | placeholder for the conflict index. 87 | 88 | Example: '_%02d' (generates _01, _02, etc.) 89 | 90 | If not specified, the default pattern '(%d)' is used. 91 | 92 | -H, --hidden 93 | Includes hidden files and directories in the search and renaming process. 94 | 95 | On Linux and macOS, hidden files are those that start with a dot character. 96 | On Windows, only files with the 'hidden' attribute are considered hidden. 97 | 98 | To match hidden directories as well, combine this with the -d/--include-dir 99 | flag. 100 | 101 | -I, --include 102 | Only includes files that match the provided regular expression instead of 103 | all files matched by the --find flag. 104 | 105 | This flag can be repeated to specify multiple include patterns. 106 | 107 | Example: 108 | -I 'json' -I 'yml' (only include JSON and YAML files) 109 | 110 | -d, --include-dir 111 | Includes matching directories in the renaming operation (they are excluded 112 | by default). 113 | 114 | -i, --ignore-case 115 | Ignores case sensitivity when searching for matches. 116 | 117 | -e, --ignore-ext 118 | Ignores the file extension when searching for matches. 119 | 120 | --json 121 | Produces JSON output, except for error messages which are sent to the 122 | standard error. 123 | 124 | -m, --max-depth 125 | Limits the depth of recursive search. Set to 0 (default) for no limit. 126 | 127 | --no-color 128 | Disables colored output. 129 | 130 | -D, --only-dir 131 | Renames only directories, not files (implies -d/--include-dir). 132 | 133 | -p, --pair 134 | Enable pair renaming to rename files with the same name (but different 135 | extensions) in the same directory to the same new name. In pair mode, 136 | file extensions are ignored. 137 | 138 | Example: 139 | Before: DSC08533.ARW DSC08533.JPG DSC08534.ARW DSC08534.JPG 140 | 141 | $ f2 -r "Photo_{%03d}" --pair -x 142 | 143 | After: Photo_001.ARW Photo_001.JPG Photo_002.ARW Photo_002.JPG 144 | 145 | --pair-order 146 | Order the paired files according to their extension. This helps you control 147 | the file to be renamed first, and whose metadata should be extracted when 148 | using variables. 149 | 150 | Example: 151 | --pair-order 'dng,jpg' # rename dng files before jpg 152 | --pair-order 'xmp,arw' # rename xmp files before arw 153 | 154 | --quiet 155 | Don't print anything to stdout. If no matches are found, f2 will exit with 156 | an error code instead of the normal success code without this flag. 157 | Errors will continue to be written to stderr. 158 | 159 | -R, --recursive 160 | Recursively traverses directories when searching for matches. 161 | 162 | -l, --replace-limit 163 | Limits the number of replacements made on each matched file. 0 (default) 164 | means replace all matches. Negative values replace from the end of the 165 | filename. 166 | 167 | --reset-index-per-dir 168 | Resets the auto-incrementing index when entering a new directory during a 169 | recursive operation. 170 | 171 | --sort 172 | Sorts matches in ascending order based on the provided criteria. 173 | 174 | Allowed values: 175 | * 'default' : Lexicographical order. 176 | * 'size' : Sort by file size. 177 | * 'natural' : Sort according to natural order. 178 | * 'mtime' : Sort by file last modified time. 179 | * 'btime' : Sort by file creation time. 180 | * 'atime' : Sort by file last access time. 181 | * 'ctime' : Sort by file metadata last change time. 182 | * 'time_var' : Sort by time variable. 183 | * 'int_var' : Sort by integer variable. 184 | * 'string_var' : Sort lexicographically by string variable. 185 | 186 | --sortr 187 | Accepts the same values as --sort but sorts matches in descending order. 188 | 189 | --sort-per-dir 190 | Ensures sorting is performed separately within each directory rather than 191 | globally. 192 | 193 | --sort-var 194 | Active when using --sort/--sortr with time_var, int_var, or string_var. 195 | Provide a supported variable to sort the files based on file metadata. 196 | See https://f2.freshman.tech/guide/sorting for more details. 197 | 198 | -s, --string-mode 199 | Treats the search pattern (specified by -f/--find) as a literal string 200 | instead of a regular expression. 201 | 202 | -t, --target-dir 203 | Specify a target directory to move renamed files and reorganize your 204 | filesystem. 205 | 206 | -V, --verbose 207 | Enables verbose output during the renaming operation. 208 | 209 | ENVIRONMENTAL VARIABLES 210 | F2_DEFAULT_OPTS 211 | Override the default options according to your preferences. For example, 212 | you can enable execute mode and ignore file extensions by default: 213 | 214 | export F2_DEFAULT_OPTS=--exec --ignore-ext 215 | 216 | F2_NO_COLOR, NO_COLOR 217 | Set to any value to disable coloured output. 218 | 219 | LEARN MORE 220 | Read the manual at https://f2.freshman.tech 221 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/app_test/testdata/version_stdout.golden: -------------------------------------------------------------------------------- 1 | f2 version v2.1.0 2 | https://github.com/ayoisaiah/f2/releases/v2.1.0 -------------------------------------------------------------------------------- /app/errors.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/ayoisaiah/f2/v2/internal/apperr" 5 | ) 6 | 7 | var ( 8 | errDefaultOptsParsing = &apperr.Error{ 9 | Message: "F2_DEFAULT_OPTS error: unsupported flag '%s'", 10 | } 11 | 12 | errPipeRead = &apperr.Error{ 13 | Message: "error reading from pipe", 14 | } 15 | ) 16 | -------------------------------------------------------------------------------- /cmd/f2/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/ayoisaiah/f2/v2" 8 | "github.com/ayoisaiah/f2/v2/report" 9 | ) 10 | 11 | func main() { 12 | renamer, err := f2.New(os.Stdin, os.Stdout) 13 | if err != nil { 14 | report.ExitWithErr(err) 15 | } 16 | 17 | err = renamer.Run(context.Background(), os.Args) 18 | if err != nil { 19 | report.ExitWithErr(err) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /f2.go: -------------------------------------------------------------------------------- 1 | package f2 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "os" 7 | 8 | "github.com/urfave/cli/v3" 9 | 10 | "github.com/ayoisaiah/f2/v2/app" 11 | "github.com/ayoisaiah/f2/v2/find" 12 | "github.com/ayoisaiah/f2/v2/internal/apperr" 13 | "github.com/ayoisaiah/f2/v2/internal/config" 14 | "github.com/ayoisaiah/f2/v2/rename" 15 | "github.com/ayoisaiah/f2/v2/replace" 16 | "github.com/ayoisaiah/f2/v2/report" 17 | "github.com/ayoisaiah/f2/v2/validate" 18 | ) 19 | 20 | var errConflictDetected = &apperr.Error{ 21 | Message: "conflict: resolve manually or use -F/--fix-conflicts", 22 | } 23 | 24 | func isOutputToPipe() bool { 25 | fileInfo, _ := os.Stdout.Stat() 26 | 27 | return ((fileInfo.Mode() & os.ModeCharDevice) != os.ModeCharDevice) 28 | } 29 | 30 | // execute initiates a new renaming operation based on the provided CLI context. 31 | func execute(_ context.Context, cmd *cli.Command) error { 32 | appConfig, err := config.Init(cmd, isOutputToPipe()) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | changes, err := find.Find(appConfig) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | if len(changes) == 0 { 43 | report.NoMatches(appConfig) 44 | 45 | return nil 46 | } 47 | 48 | if !appConfig.Revert { 49 | changes, err = replace.Replace(appConfig, changes) 50 | if err != nil { 51 | return err 52 | } 53 | } 54 | 55 | hasConflicts := validate.Validate( 56 | changes, 57 | appConfig.AutoFixConflicts, 58 | appConfig.AllowOverwrites, 59 | ) 60 | 61 | if hasConflicts { 62 | report.Report(appConfig, changes, hasConflicts) 63 | 64 | return errConflictDetected 65 | } 66 | 67 | if !appConfig.Exec { 68 | report.Report(appConfig, changes, hasConflicts) 69 | return nil 70 | } 71 | 72 | err = rename.Rename(appConfig, changes) 73 | 74 | rename.PostRename(appConfig, changes, err) 75 | 76 | return err 77 | } 78 | 79 | // New creates a new CLI application for f2. 80 | func New(reader io.Reader, writer io.Writer) (*cli.Command, error) { 81 | renamer, err := app.Get(reader, writer) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | renamer.Action = execute 87 | 88 | return renamer, nil 89 | } 90 | -------------------------------------------------------------------------------- /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/birthday-2024/img44.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/birthday-2024/img44.dng -------------------------------------------------------------------------------- /f2_test/testdata/birthday-2024/img44.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/birthday-2024/img44.jpg -------------------------------------------------------------------------------- /f2_test/testdata/birthday-2024/img78.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/birthday-2024/img78.dng -------------------------------------------------------------------------------- /f2_test/testdata/birthday-2024/img78.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/birthday-2024/img78.jpg -------------------------------------------------------------------------------- /f2_test/testdata/family trip - berlin/img101.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/family trip - berlin/img101.dng -------------------------------------------------------------------------------- /f2_test/testdata/family trip - berlin/img101.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/family trip - berlin/img101.jpg -------------------------------------------------------------------------------- /f2_test/testdata/family trip - berlin/img90.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/family trip - berlin/img90.dng -------------------------------------------------------------------------------- /f2_test/testdata/family trip - berlin/img90.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/family trip - berlin/img90.jpg -------------------------------------------------------------------------------- /f2_test/testdata/family trip - berlin/img99.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/family trip - berlin/img99.dng -------------------------------------------------------------------------------- /f2_test/testdata/family trip - berlin/img99.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/family trip - berlin/img99.jpg -------------------------------------------------------------------------------- /f2_test/testdata/family trip - london/img1.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/family trip - london/img1.dng -------------------------------------------------------------------------------- /f2_test/testdata/family trip - london/img1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/family trip - london/img1.jpg -------------------------------------------------------------------------------- /f2_test/testdata/family trip - london/img2.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/family trip - london/img2.dng -------------------------------------------------------------------------------- /f2_test/testdata/family trip - london/img2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/family trip - london/img2.jpg -------------------------------------------------------------------------------- /f2_test/testdata/image_pair_renaming_stderr.golden: -------------------------------------------------------------------------------- 1 | dry run: commit the above changes with the -x/--exec flag 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /f2_test/testdata/img34.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/img34.dng -------------------------------------------------------------------------------- /f2_test/testdata/img34.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/img34.jpg -------------------------------------------------------------------------------- /f2_test/testdata/img66.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/img66.dng -------------------------------------------------------------------------------- /f2_test/testdata/img66.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/img66.jpg -------------------------------------------------------------------------------- /f2_test/testdata/my-wedding/img33.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/my-wedding/img33.dng -------------------------------------------------------------------------------- /f2_test/testdata/my-wedding/img33.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/my-wedding/img33.jpg -------------------------------------------------------------------------------- /f2_test/testdata/my-wedding/img67.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/my-wedding/img67.dng -------------------------------------------------------------------------------- /f2_test/testdata/my-wedding/img67.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/f2_test/testdata/my-wedding/img67.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /f2_test/testdata/use_dates_to_conditionally_match_files_stderr.golden: -------------------------------------------------------------------------------- 1 | dry run: commit the above changes with the -x/--exec flag 2 | -------------------------------------------------------------------------------- /f2_test/testdata/use_dates_to_conditionally_match_files_stdout.golden: -------------------------------------------------------------------------------- 1 | *——————————————————————————————————————————*——————————————————————————————————————————*————————* 2 | | ORIGINAL | RENAMED | STATUS | 3 | *——————————————————————————————————————————*——————————————————————————————————————————*————————* 4 | | testdata/img34.dng | testdata/001.dng | ok | 5 | | testdata/img34.jpg | testdata/001.jpg | ok | 6 | | testdata/img66.dng | testdata/002.dng | ok | 7 | | testdata/img66.jpg | testdata/002.jpg | ok | 8 | | testdata/birthday-2024/img44.dng | testdata/birthday-2024/003.dng | ok | 9 | | testdata/birthday-2024/img44.jpg | testdata/birthday-2024/003.jpg | ok | 10 | | testdata/birthday-2024/img78.dng | testdata/birthday-2024/004.dng | ok | 11 | | testdata/birthday-2024/img78.jpg | testdata/birthday-2024/004.jpg | ok | 12 | | 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 | ok | 14 | | 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 | ok | 16 | | 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 | ok | 18 | *——————————————————————————————————————————*——————————————————————————————————————————*————————* 19 | -------------------------------------------------------------------------------- /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 | match := &file.Change{ 98 | BaseDir: sourceDir, 99 | TargetDir: sourceDir, 100 | IsDir: fileInfo.IsDir(), 101 | Source: fileName, 102 | Target: fileName, 103 | OriginalName: fileName, 104 | SourcePath: filepath.Join(sourceDir, fileName), 105 | CSVRow: record, 106 | Position: i, 107 | } 108 | 109 | if conf.TargetDir != "" { 110 | match.TargetDir = conf.TargetDir 111 | } 112 | 113 | if len(record) > 1 { 114 | match.Target = strings.TrimSpace(record[1]) 115 | 116 | if filepath.IsAbs(match.Target) { 117 | match.TargetDir = "" 118 | continue 119 | } 120 | } 121 | 122 | changes = append(changes, match) 123 | } 124 | 125 | return changes, nil 126 | } 127 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /find/find_test/testdata/DSC100_John-Doe_20211012.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/find/find_test/testdata/DSC100_John-Doe_20211012.dng -------------------------------------------------------------------------------- /find/find_test/testdata/DSC100_John-Doe_20211012.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/find/find_test/testdata/DSC100_John-Doe_20211012.jpg -------------------------------------------------------------------------------- /find/find_test/testdata/DSC200_Auba-Hall_20240909.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/find/find_test/testdata/DSC200_Auba-Hall_20240909.dng -------------------------------------------------------------------------------- /find/find_test/testdata/DSC200_Auba-Hall_20240909.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/find/find_test/testdata/DSC200_Auba-Hall_20240909.jpg -------------------------------------------------------------------------------- /find/find_test/testdata/DSC400_Tim-Scott_20200102.dng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/find/find_test/testdata/DSC400_Tim-Scott_20200102.dng -------------------------------------------------------------------------------- /find/find_test/testdata/a.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/find/find_test/testdata/a.txt -------------------------------------------------------------------------------- /find/find_test/testdata/b.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/find/find_test/testdata/b.txt -------------------------------------------------------------------------------- /find/find_test/testdata/c.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayoisaiah/f2/ddd8fc422f0487e82c9ed17f56a17e12fda3dc78/find/find_test/testdata/c.txt -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ayoisaiah/f2/v2 2 | 3 | go 1.24.2 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/araddon/dateparse v0.0.0-20210429162001-6b43995a97de 18 | github.com/djherbis/times v1.6.0 19 | github.com/jessevdk/go-flags v1.6.1 20 | github.com/jinzhu/copier v0.4.0 21 | github.com/maruel/natural v1.1.1 22 | github.com/mattn/go-isatty v0.0.20 23 | github.com/olekukonko/tablewriter v0.0.5 24 | github.com/sebdah/goldie/v2 v2.5.5 25 | github.com/stretchr/testify v1.10.0 26 | github.com/urfave/cli/v3 v3.3.3 27 | ) 28 | 29 | require ( 30 | atomicgo.dev/cursor v0.2.0 // indirect 31 | atomicgo.dev/keyboard v0.2.9 // indirect 32 | atomicgo.dev/schedule v0.1.0 // indirect 33 | github.com/containerd/console v1.0.4 // indirect 34 | github.com/davecgh/go-spew v1.1.1 // indirect 35 | github.com/gookit/color v1.5.4 // indirect 36 | github.com/kr/pretty v0.3.1 // indirect 37 | github.com/lithammer/fuzzysearch v1.1.8 // indirect 38 | github.com/maja42/goval v1.6.0 // indirect 39 | github.com/mattn/go-runewidth v0.0.16 // indirect 40 | github.com/pmezard/go-difflib v1.0.0 // indirect 41 | github.com/rivo/uniseg v0.4.7 // indirect 42 | github.com/rogpeppe/go-internal v1.14.1 // indirect 43 | github.com/sergi/go-diff v1.3.1 // indirect 44 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 45 | golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect 46 | golang.org/x/term v0.32.0 // indirect 47 | gopkg.in/yaml.v3 v3.0.1 // indirect 48 | ) 49 | -------------------------------------------------------------------------------- /internal/apperr/apperr.go: -------------------------------------------------------------------------------- 1 | package apperr 2 | 3 | import "fmt" 4 | 5 | type Error struct { 6 | Cause error 7 | Context any 8 | Message string 9 | } 10 | 11 | func (e *Error) Error() string { 12 | if e.Cause == nil { 13 | return e.Message 14 | } 15 | 16 | return fmt.Sprintf("%s: %v", e.Message, e.Cause) 17 | } 18 | 19 | // Unwrap is used to make it work with errors.Is, errors.As. 20 | func (e *Error) Unwrap() error { 21 | // Return the inner error. 22 | return e.Cause 23 | } 24 | 25 | // Wrap associates the underlying error. 26 | func (e *Error) Wrap(err error) *Error { 27 | e.Cause = err 28 | return e 29 | } 30 | 31 | // Fmt calls fmt.Sprintf on the error message. 32 | func (e *Error) Fmt(str ...any) *Error { 33 | e.Message = fmt.Sprintf(e.Message, str...) 34 | return e 35 | } 36 | 37 | func (e *Error) WithCtx(ctx any) *Error { 38 | e.Context = ctx 39 | return e 40 | } 41 | -------------------------------------------------------------------------------- /internal/config/errors.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "github.com/ayoisaiah/f2/v2/internal/apperr" 4 | 5 | var ( 6 | errInvalidArgument = &apperr.Error{ 7 | Message: "requires one of: -f, -r, --csv, or -u. Run f2 --help for usage", 8 | } 9 | 10 | errParsingFixConflictsPattern = &apperr.Error{ 11 | Message: "the provided --fix-conflicts-pattern '%s' is invalid", 12 | } 13 | 14 | errInvalidSort = &apperr.Error{ 15 | Message: "the provided sort '%s' is invalid", 16 | } 17 | 18 | errInvalidSortVariable = &apperr.Error{ 19 | Message: "the provided sort variable '%s' is invalid", 20 | } 21 | 22 | errInvalidTargetDir = &apperr.Error{ 23 | Message: "target path '%s' exists but is not a directory", 24 | } 25 | ) 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /internal/file/file.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | 10 | "github.com/olekukonko/tablewriter" 11 | "github.com/pterm/pterm" 12 | 13 | "github.com/ayoisaiah/f2/v2/internal/status" 14 | ) 15 | 16 | // Change represents a single renaming change. 17 | type Change struct { 18 | Error error `json:"error,omitempty"` 19 | PrimaryPair *Change `json:"-"` 20 | TargetPath string `json:"-"` 21 | BaseDir string `json:"base_dir"` 22 | TargetDir string `json:"target_dir"` // TODO: Remove this 23 | Source string `json:"source"` 24 | Target string `json:"target"` 25 | OriginalName string `json:"-"` 26 | Status status.Status `json:"status"` 27 | SourcePath string `json:"-"` 28 | CustomSort struct { 29 | Time time.Time 30 | String string 31 | Int int 32 | } `json:"-"` 33 | CSVRow []string `json:"-"` 34 | Position int `json:"-"` 35 | IsDir bool `json:"is_dir"` 36 | WillOverwrite bool `json:"-"` 37 | MatchesFindCond bool `json:"-"` 38 | } 39 | 40 | // AutoFixTarget sets the new target name. 41 | func (c *Change) AutoFixTarget(newTarget string) { 42 | c.Target = newTarget 43 | c.TargetPath = filepath.Join(c.TargetDir, c.Target) 44 | 45 | // Ensure empty targets is reported as empty instead of as a dot 46 | if c.TargetPath == "." { 47 | c.TargetPath = "" 48 | } 49 | 50 | if c.Target == "" && c.TargetPath != "" { 51 | c.TargetPath += "/" 52 | } 53 | 54 | c.Status = status.OK 55 | } 56 | 57 | type Changes []*Change 58 | 59 | func (c Changes) RenderJSON(w io.Writer) error { 60 | jsonData, err := json.Marshal(c) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | _, err = w.Write(jsonData) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | return nil 71 | } 72 | 73 | func (c Changes) RenderTable(w io.Writer, noColor bool) { 74 | data := make([][]string, len(c)) 75 | 76 | for i := range c { 77 | change := c[i] 78 | 79 | var changeStatus string 80 | 81 | //nolint:exhaustive // default case covers other statuses 82 | switch change.Status { 83 | case status.OK: 84 | changeStatus = pterm.Green(change.Status) 85 | case status.Unchanged, status.Overwriting, status.Ignored: 86 | changeStatus = pterm.Yellow(change.Status) 87 | default: 88 | changeStatus = pterm.Red(change.Status) 89 | } 90 | 91 | if change.Error != nil { 92 | msg := change.Error.Error() 93 | if strings.IndexByte(msg, ':') != -1 { 94 | msg = strings.TrimSpace(msg[strings.IndexByte(msg, ':'):]) 95 | } 96 | 97 | changeStatus = pterm.Red(strings.TrimPrefix(msg, ": ")) 98 | } 99 | 100 | d := []string{change.SourcePath, change.TargetPath, changeStatus} 101 | data[i] = d 102 | } 103 | 104 | printTable(data, w, noColor) 105 | } 106 | 107 | func printTable(data [][]string, w io.Writer, noColor bool) { 108 | // using tablewriter as pterm table rendering is too slow 109 | table := tablewriter.NewWriter(w) 110 | table.SetHeader([]string{"ORIGINAL", "RENAMED", "STATUS"}) 111 | table.SetCenterSeparator("*") 112 | table.SetColumnSeparator("|") 113 | table.SetRowSeparator("—") 114 | table.SetAutoWrapText(false) 115 | 116 | if !noColor { 117 | table.SetHeaderColor( 118 | tablewriter.Colors{tablewriter.Bold, tablewriter.FgGreenColor}, 119 | tablewriter.Colors{tablewriter.Bold, tablewriter.FgGreenColor}, 120 | tablewriter.Colors{tablewriter.Bold, tablewriter.FgGreenColor}, 121 | ) 122 | } 123 | 124 | table.AppendBulk(data) 125 | 126 | table.Render() 127 | } 128 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.CustomSort.Time = a.PrimaryPair.CustomSort.Time 20 | } 21 | 22 | if b.PrimaryPair != nil { 23 | b.CustomSort.Time = b.PrimaryPair.CustomSort.Time 24 | } 25 | 26 | timeA := a.CustomSort.Time 27 | timeB := b.CustomSort.Time 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.CustomSort.String = a.PrimaryPair.CustomSort.String 50 | } 51 | 52 | if b.PrimaryPair != nil { 53 | b.CustomSort.String = b.PrimaryPair.CustomSort.String 54 | } 55 | 56 | strA := a.CustomSort.String 57 | strB := b.CustomSort.String 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.CustomSort.Int = a.PrimaryPair.CustomSort.Int 79 | } 80 | 81 | if b.PrimaryPair != nil { 82 | b.CustomSort.Int = b.PrimaryPair.CustomSort.Int 83 | } 84 | 85 | intA := a.CustomSort.Int 86 | intB := b.CustomSort.Int 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 | -------------------------------------------------------------------------------- /internal/sortfiles/sortfiles.go: -------------------------------------------------------------------------------- 1 | // Package sort is used to sort file changes in a variety of ways 2 | // Alphabetical order is the default 3 | package sortfiles 4 | 5 | import ( 6 | "cmp" 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | "slices" 11 | "sort" 12 | "strings" 13 | 14 | "gopkg.in/djherbis/times.v1" 15 | 16 | "github.com/maruel/natural" 17 | "github.com/pterm/pterm" 18 | 19 | "github.com/ayoisaiah/f2/v2/internal/config" 20 | "github.com/ayoisaiah/f2/v2/internal/file" 21 | "github.com/ayoisaiah/f2/v2/internal/pathutil" 22 | ) 23 | 24 | func isPair(prev, curr *file.Change) bool { 25 | return pathutil.StripExtension( 26 | prev.SourcePath, 27 | ) == pathutil.StripExtension( 28 | curr.SourcePath, 29 | ) 30 | } 31 | 32 | // Pairs sorts the given file changes based on a custom pairing order. 33 | // Files with extensions matching earlier entries in pairOrder are sorted 34 | // before those matching later entries. 35 | func Pairs(changes file.Changes, pairOrder []string) { 36 | slices.SortStableFunc(changes, func(a, b *file.Change) int { 37 | // Compare stripped paths 38 | if result := strings.Compare( 39 | pathutil.StripExtension(a.SourcePath), 40 | pathutil.StripExtension(b.SourcePath), 41 | ); result != 0 { 42 | return result 43 | } 44 | 45 | // Compare extensions based on pairOrder 46 | aExt, bExt := filepath.Ext(a.Source), filepath.Ext(b.Source) 47 | 48 | for _, v := range pairOrder { 49 | v = "." + v 50 | 51 | switch { 52 | case strings.EqualFold(aExt, v): 53 | return -1 54 | case strings.EqualFold(bExt, v): 55 | return 1 56 | } 57 | } 58 | 59 | return 0 60 | }) 61 | 62 | for i, v := range changes { 63 | if i > 0 && i < len(changes) { 64 | prev := changes[i-1] 65 | 66 | if isPair(prev, v) { 67 | if prev.PrimaryPair != nil { 68 | v.PrimaryPair = prev.PrimaryPair 69 | } else { 70 | v.PrimaryPair = prev 71 | } 72 | } 73 | } 74 | } 75 | } 76 | 77 | // ForRenamingAndUndo is used to sort files before directories to avoid renaming 78 | // conflicts. It also ensures that child directories are renamed before their 79 | // parents and vice versa in undo mode. 80 | func ForRenamingAndUndo(changes file.Changes, revert bool) { 81 | slices.SortStableFunc(changes, func(a, b *file.Change) int { 82 | // sort files before directories 83 | if !a.IsDir && b.IsDir { 84 | return -1 85 | } 86 | 87 | // sort parent directories before child directories in revert mode 88 | if revert { 89 | return cmp.Compare(len(a.BaseDir), len(b.BaseDir)) 90 | } 91 | 92 | // sort child directories before parent directories 93 | return cmp.Compare(len(b.BaseDir), len(a.BaseDir)) 94 | }) 95 | } 96 | 97 | // Hierarchically ensures all files in the same directory are sorted 98 | // before children directories. 99 | func Hierarchically(changes file.Changes) { 100 | slices.SortStableFunc(changes, func(a, b *file.Change) int { 101 | lenA, lenB := len(a.BaseDir), len(b.BaseDir) 102 | if lenA == lenB { 103 | return 0 104 | } 105 | 106 | return cmp.Compare(lenA, lenB) 107 | }) 108 | } 109 | 110 | // ByTime sorts the changes by the specified file timing attribute 111 | // (modified time, access time, change time, or birth time). 112 | func ByTime( 113 | changes file.Changes, 114 | conf *config.Config, 115 | ) { 116 | slices.SortStableFunc(changes, func(a, b *file.Change) int { 117 | sourcePathA, sourcePathB := a.SourcePath, b.SourcePath 118 | 119 | if a.PrimaryPair != nil { 120 | sourcePathA = a.PrimaryPair.SourcePath 121 | } 122 | 123 | if b.PrimaryPair != nil { 124 | sourcePathB = b.PrimaryPair.SourcePath 125 | } 126 | 127 | sourceA, errA := times.Stat(sourcePathA) 128 | sourceB, errB := times.Stat(sourcePathB) 129 | 130 | if errA != nil || errB != nil { 131 | pterm.Error.Printfln( 132 | "error getting file times info: %v, %v", 133 | errA, 134 | errB, 135 | ) 136 | os.Exit(1) 137 | } 138 | 139 | aTime, bTime := sourceA.ModTime(), sourceB.ModTime() 140 | 141 | //nolint:exhaustive // considering time sorts alone 142 | switch conf.Sort { 143 | case config.SortMtime: 144 | case config.SortBtime: 145 | if sourceA.HasBirthTime() { 146 | aTime = sourceA.BirthTime() 147 | } 148 | 149 | if sourceB.HasBirthTime() { 150 | bTime = sourceB.BirthTime() 151 | } 152 | case config.SortAtime: 153 | aTime = sourceA.AccessTime() 154 | bTime = sourceB.AccessTime() 155 | case config.SortCtime: 156 | if sourceA.HasChangeTime() { 157 | aTime = sourceA.ChangeTime() 158 | } 159 | 160 | if sourceB.HasChangeTime() { 161 | bTime = sourceB.ChangeTime() 162 | } 163 | } 164 | 165 | if conf.SortPerDir && a.BaseDir != b.BaseDir { 166 | return 0 167 | } 168 | 169 | if conf.ReverseSort { 170 | return -cmp.Compare(aTime.UnixNano(), bTime.UnixNano()) 171 | } 172 | 173 | return cmp.Compare(aTime.UnixNano(), bTime.UnixNano()) 174 | }) 175 | } 176 | 177 | // BySize sorts the file changes in place based on their file size, either in 178 | // ascending or descending order depending on the `reverseSort` flag. 179 | func BySize(changes file.Changes, conf *config.Config) { 180 | slices.SortStableFunc(changes, func(a, b *file.Change) int { 181 | sourcePathA, sourcePathB := a.SourcePath, b.SourcePath 182 | 183 | if a.PrimaryPair != nil { 184 | sourcePathA = a.PrimaryPair.SourcePath 185 | } 186 | 187 | if b.PrimaryPair != nil { 188 | sourcePathB = b.PrimaryPair.SourcePath 189 | } 190 | 191 | var fileInfoA, fileInfoB fs.FileInfo 192 | fileInfoA, errA := os.Stat(sourcePathA) 193 | fileInfoB, errB := os.Stat(sourcePathB) 194 | 195 | if errA != nil || errB != nil { 196 | pterm.Error.Printfln("error getting file info: %v, %v", errA, errB) 197 | os.Exit(1) 198 | } 199 | 200 | fileASize := fileInfoA.Size() 201 | fileBSize := fileInfoB.Size() 202 | 203 | // Don't sort files in different directories relative to each other 204 | if conf.SortPerDir && a.BaseDir != b.BaseDir { 205 | return 0 206 | } 207 | 208 | if conf.ReverseSort { 209 | return int(fileBSize - fileASize) 210 | } 211 | 212 | return int(fileASize - fileBSize) 213 | }) 214 | } 215 | 216 | // Natural sorts the changes according to natural order (meaning numbers are 217 | // interpreted naturally). However, non-numeric characters are remain sorted in 218 | // ASCII order. 219 | func Natural(changes file.Changes, reverseSort bool) { 220 | sort.SliceStable(changes, func(i, j int) bool { 221 | sourcePathA := changes[i].SourcePath 222 | sourcePathB := changes[j].SourcePath 223 | 224 | if changes[i].PrimaryPair != nil { 225 | sourcePathA = changes[i].PrimaryPair.SourcePath 226 | } 227 | 228 | if changes[j].PrimaryPair != nil { 229 | sourcePathB = changes[j].PrimaryPair.SourcePath 230 | } 231 | 232 | if reverseSort { 233 | return !natural.Less(sourcePathA, sourcePathB) 234 | } 235 | 236 | return natural.Less(sourcePathA, sourcePathB) 237 | }) 238 | } 239 | 240 | // Changes is used to sort changes according to the configured sort value. 241 | func Changes( 242 | changes file.Changes, 243 | conf *config.Config, 244 | ) { 245 | if conf.SortPerDir { 246 | Hierarchically(changes) 247 | } 248 | 249 | //nolint:exhaustive // default sort not needed 250 | switch conf.Sort { 251 | case config.SortNatural: 252 | Natural(changes, conf.ReverseSort) 253 | case config.SortSize: 254 | BySize(changes, conf) 255 | case config.SortMtime, 256 | config.SortAtime, 257 | config.SortBtime, 258 | config.SortCtime: 259 | ByTime(changes, conf) 260 | case config.SortTimeVar: 261 | ByTimeVar(changes, conf) 262 | case config.SortStringVar: 263 | ByStringVar(changes, conf) 264 | case config.SortIntVar: 265 | ByIntVar(changes, conf) 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /internal/sortfiles/sortfiles_test/testdata/4k.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/sortfiles/sortfiles_test/testdata/dir1/folder/3k.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/status/status.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | type Status string 4 | 5 | const ( 6 | OK Status = "ok" 7 | Unchanged Status = "unchanged" 8 | Overwriting Status = "overwriting" 9 | EmptyFilename Status = "empty filename" 10 | TrailingPeriod Status = "trailing periods present" 11 | PathExists Status = "target exists" 12 | OverwritingNewPath Status = "overwriting new path" 13 | ForbiddenCharacters Status = "forbidden characters present" 14 | FilenameLengthExceeded Status = "filename too long" 15 | SourceAlreadyRenamed Status = "source already renamed" 16 | SourceNotFound Status = "source not found" 17 | Ignored Status = "ignored" 18 | ) 19 | -------------------------------------------------------------------------------- /internal/testutil/testutil.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/pterm/pterm" 13 | "github.com/sebdah/goldie/v2" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/urfave/cli/v3" 16 | 17 | "github.com/ayoisaiah/f2/v2/app" 18 | "github.com/ayoisaiah/f2/v2/internal/config" 19 | "github.com/ayoisaiah/f2/v2/internal/file" 20 | "github.com/ayoisaiah/f2/v2/internal/osutil" 21 | "github.com/ayoisaiah/f2/v2/internal/status" 22 | ) 23 | 24 | // TestCase represents a unique test case. 25 | type TestCase struct { 26 | Error error `json:"error"` 27 | SetEnv map[string]string `json:"env"` 28 | SetupFunc func(t *testing.T, testDir string) (teardown func()) `json:"-"` 29 | StdoutGoldenFile string `json:"stdout_golden_file"` 30 | DefaultOpts string `json:"default_opts"` 31 | Name string `json:"name"` 32 | StderrGoldenFile string `json:"stderr_golden_file"` 33 | SnapShot struct { 34 | Stdout []byte 35 | Stderr []byte 36 | } `json:"-"` 37 | Args []string `json:"args"` 38 | PathArgs []string `json:"path_args"` 39 | Changes file.Changes `json:"changes"` 40 | Want []string `json:"want"` 41 | ConflictDetected bool `json:"conflict_detected"` 42 | PipeOutput bool `json:"pipe_output"` 43 | } 44 | 45 | // SetupFileSystem creates all required files and folders for 46 | // the tests and returns the absolute path to the root directory. 47 | func SetupFileSystem( 48 | tb testing.TB, 49 | testName string, 50 | fileSystem []string, 51 | ) string { 52 | tb.Helper() 53 | 54 | testDir, err := os.MkdirTemp(os.TempDir(), testName) 55 | if err != nil { 56 | tb.Fatal(err) 57 | } 58 | 59 | tb.Cleanup(func() { 60 | err = os.RemoveAll(testDir) 61 | if err != nil { 62 | tb.Log(err) 63 | } 64 | }) 65 | 66 | for _, v := range fileSystem { 67 | dir := filepath.Dir(v) 68 | 69 | filePath := filepath.Join(testDir, dir) 70 | 71 | err := os.MkdirAll(filePath, os.ModePerm) 72 | if err != nil { 73 | tb.Fatalf( 74 | "Unable to create directories in path: '%s', due to err: %v", 75 | filePath, 76 | err, 77 | ) 78 | } 79 | } 80 | 81 | for _, f := range fileSystem { 82 | pathToFile := filepath.Join(testDir, f) 83 | 84 | testFile, err := os.Create(pathToFile) 85 | if err != nil { 86 | tb.Fatalf( 87 | "Unable to write to file: '%s', due to err: %v", 88 | pathToFile, 89 | err, 90 | ) 91 | } 92 | 93 | testFile.Close() 94 | } 95 | 96 | return testDir 97 | } 98 | 99 | // CompareChanges compares the expected file changes to the ones received. 100 | func CompareChanges(t *testing.T, want, got file.Changes) { 101 | t.Helper() 102 | 103 | assert.Equal(t, want, got) 104 | } 105 | 106 | // CompareSourcePath compares the expected source paths to the actual source 107 | // paths. 108 | func CompareSourcePath(t *testing.T, want []string, changes file.Changes) { 109 | t.Helper() 110 | 111 | got := make([]string, len(changes)) 112 | 113 | for i := range changes { 114 | got[i] = filepath.FromSlash(changes[i].SourcePath) 115 | } 116 | 117 | for i := range want { 118 | want[i] = filepath.FromSlash(want[i]) 119 | } 120 | 121 | assert.Equal(t, want, got) 122 | } 123 | 124 | // CompareTargetPath verifies that the renaming target matches expectations. 125 | func CompareTargetPath(t *testing.T, want []string, changes file.Changes) { 126 | t.Helper() 127 | 128 | got := make([]string, len(changes)) 129 | 130 | for i := range changes { 131 | got[i] = filepath.FromSlash(changes[i].TargetPath) 132 | } 133 | 134 | for i := range want { 135 | want[i] = filepath.FromSlash(want[i]) 136 | } 137 | 138 | assert.Equal(t, want, got) 139 | } 140 | 141 | // CompareGoldenFile verifies that the output of an operation matches 142 | // the expected output. 143 | func CompareGoldenFile(t *testing.T, tc *TestCase) { 144 | t.Helper() 145 | 146 | if runtime.GOOS == osutil.Windows { 147 | // TODO: need to sort out line endings 148 | t.Skip("skipping golden file test in Windows") 149 | } 150 | 151 | g := goldie.New( 152 | t, 153 | goldie.WithFixtureDir("testdata"), 154 | ) 155 | 156 | compareOutput := func(output []byte, fileSuffix, goldenFileName string) { 157 | if goldenFileName == "" { 158 | goldenFileName = strings.ReplaceAll(tc.Name, " ", "_") + fileSuffix 159 | } 160 | 161 | if output != nil { 162 | g.Assert(t, goldenFileName, output) 163 | } else { 164 | f := filepath.Join("testdata", goldenFileName+".golden") 165 | if _, err := os.Stat(f); err == nil || errors.Is(err, os.ErrExist) { 166 | t.Fatalf("expected no output, but golden file exists: %s", f) 167 | } 168 | } 169 | } 170 | 171 | compareOutput(tc.SnapShot.Stdout, "_stdout", tc.StdoutGoldenFile) 172 | compareOutput(tc.SnapShot.Stderr, "_stderr", tc.StderrGoldenFile) 173 | } 174 | 175 | // UpdateBaseDir adds the testDir to each expected path for easy comparison. 176 | func UpdateBaseDir(expected []string, testDir string) { 177 | for i := range expected { 178 | expected[i] = filepath.Join(testDir, expected[i]) 179 | } 180 | } 181 | 182 | func UpdateFileChanges(files file.Changes) { 183 | for i := range files { 184 | ch := files[i] 185 | 186 | if ch.TargetDir == "" { 187 | ch.TargetDir = ch.BaseDir 188 | } 189 | 190 | files[i].OriginalName = ch.Source 191 | files[i].Position = i 192 | files[i].SourcePath = filepath.Join( 193 | ch.BaseDir, 194 | ch.Source, 195 | ) 196 | files[i].TargetPath = filepath.Join( 197 | ch.TargetDir, 198 | ch.Target, 199 | ) 200 | } 201 | } 202 | 203 | func RunTestCase( 204 | t *testing.T, 205 | tc *TestCase, 206 | runFunc func(t *testing.T, tc *TestCase), 207 | ) { 208 | t.Helper() 209 | 210 | t.Run(tc.Name, func(t *testing.T) { 211 | if tc.SetupFunc != nil { 212 | t.Cleanup(tc.SetupFunc(t, "")) 213 | } 214 | 215 | runFunc(t, tc) 216 | }) 217 | } 218 | 219 | func ProcessTestCaseChanges(t *testing.T, cases []TestCase) { 220 | t.Helper() 221 | 222 | for i := range cases { 223 | tc := cases[i] 224 | for j := range tc.Changes { 225 | ch := tc.Changes[j] 226 | 227 | if ch.TargetDir == "" { 228 | ch.TargetDir = ch.BaseDir 229 | } 230 | 231 | if ch.Status == "" { 232 | cases[i].Changes[j].Status = status.OK 233 | } 234 | 235 | cases[i].Changes[j].OriginalName = ch.Source 236 | 237 | if cases[i].Changes[j].TargetPath == "" { 238 | cases[i].Changes[j].SourcePath = filepath.Join( 239 | ch.BaseDir, 240 | ch.Source, 241 | ) 242 | } 243 | 244 | if cases[i].Changes[j].TargetPath == "" { 245 | cases[i].Changes[j].TargetPath = filepath.Join( 246 | ch.TargetDir, 247 | ch.Target, 248 | ) 249 | } 250 | } 251 | } 252 | } 253 | 254 | // GetConfig constructs the app configuration from command-line arguments. 255 | func GetConfig(t *testing.T, tc *TestCase, testDir string) *config.Config { 256 | t.Helper() 257 | 258 | for k, v := range tc.SetEnv { 259 | t.Setenv(k, v) 260 | } 261 | 262 | if len(tc.Args) == 0 { 263 | tc.Args = []string{"-f", "", "-r", ""} 264 | } 265 | 266 | // add fake binary name as first argument 267 | args := append([]string{"f2_test"}, tc.Args...) 268 | 269 | if len(tc.PathArgs) > 0 { 270 | for i, v := range tc.PathArgs { 271 | tc.PathArgs[i] = filepath.Join(testDir, v) 272 | } 273 | } else { 274 | tc.PathArgs = []string{testDir} 275 | } 276 | 277 | // add test directory as last argument 278 | args = append(args, tc.PathArgs...) 279 | 280 | f2App, err := app.Get(os.Stdin, os.Stdout) 281 | if err != nil { 282 | t.Fatal(err) 283 | } 284 | 285 | f2App.Action = func(_ context.Context, cmd *cli.Command) error { 286 | // Reset pterm to default state 287 | pterm.EnableStyling() 288 | // Re-initialize config with pipe output value set per test 289 | _, _ = config.Init(cmd, tc.PipeOutput) 290 | 291 | return nil 292 | } 293 | 294 | // Initialize the config 295 | err = f2App.Run(t.Context(), args) 296 | if err != nil { 297 | t.Fatal(err) 298 | } 299 | 300 | return config.Get() 301 | } 302 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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: 7 | @go test ./... -coverprofile=coverage.out -coverpkg=. -json | {{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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ayoisaiah/f2", 3 | "version": "2.1.2", 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 | -------------------------------------------------------------------------------- /rename/backup.go: -------------------------------------------------------------------------------- 1 | package rename 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/ayoisaiah/f2/v2/internal/config" 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 | err := os.MkdirAll(filepath.Dir(backupFilePath), osutil.DirPermission) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | // Create or truncate backupFile 28 | backupFile, err := os.Create(backupFilePath) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return bufio.NewWriter(backupFile), nil 34 | } 35 | 36 | // backupChanges records the details of a renaming operation to the specified 37 | // writer so that it may be reverted if necessary. If a writer is not specified 38 | // it records the changes to the filesystem. 39 | func backupChanges( 40 | changes file.Changes, 41 | cleanedDirs []string, 42 | fileName string, 43 | w io.Writer, 44 | ) error { 45 | var err error 46 | 47 | if w == nil { 48 | w, err = createBackupFile(fileName) 49 | if err != nil { 50 | return err 51 | } 52 | } 53 | 54 | b := config.Backup{ 55 | Changes: changes, 56 | CleanedDirs: cleanedDirs, 57 | } 58 | 59 | err = b.RenderJSON(w) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | if f, ok := w.(*bufio.Writer); ok { 65 | return f.Flush() 66 | } 67 | 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /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 | "os" 8 | "path/filepath" 9 | "runtime" 10 | "strings" 11 | "time" 12 | 13 | "github.com/ayoisaiah/f2/v2/internal/apperr" 14 | "github.com/ayoisaiah/f2/v2/internal/config" 15 | "github.com/ayoisaiah/f2/v2/internal/file" 16 | "github.com/ayoisaiah/f2/v2/internal/osutil" 17 | "github.com/ayoisaiah/f2/v2/internal/status" 18 | "github.com/ayoisaiah/f2/v2/report" 19 | ) 20 | 21 | var errRenameFailed = &apperr.Error{ 22 | Message: "some files could not be renamed", 23 | } 24 | 25 | // traversedDirs records the directories that were traversed during a renaming 26 | // operation. 27 | var traversedDirs = make(map[string]string) 28 | 29 | // commit iterates over all the matches and renames them on the filesystem. 30 | // Directories are auto-created if necessary, and errors are aggregated. 31 | func commit(fileChanges file.Changes) []int { 32 | var errIndices []int 33 | 34 | for i := range fileChanges { 35 | ch := fileChanges[i] 36 | 37 | if ch.Status == status.Ignored { 38 | continue 39 | } 40 | 41 | targetPath := ch.TargetPath 42 | 43 | // skip paths that are unchanged in every aspect 44 | if ch.SourcePath == targetPath { 45 | continue 46 | } 47 | 48 | // Workaround for case insensitive filesystems where renaming a filename to 49 | // its upper or lowercase equivalent doesn't work. Fixing this involves the 50 | // following steps: 51 | // 1. Prefix and suffix with __