├── .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 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
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 | 
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 | [1mUSAGE[0m
11 | f2 FLAGS [OPTIONS] [PATHS TO FILES AND DIRECTORIES...]
12 | command | f2 FLAGS [OPTIONS]
13 |
14 | [1mPOSITIONAL ARGUMENTS[0m
15 | [32m[PATHS TO FILES AND DIRECTORIES...][0m
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 | [1mFLAGS[0m
21 | [32m--csv[0m
22 | Load a CSV file, and rename according to its contents.
23 |
24 | [32m-f[0m, [32m--find[0m
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 | [32m-r[0m, [32m--replace[0m
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 | [32m-u[0m, [32m--undo[0m
37 | Undo the last renaming operation performed in the current working directory.
38 |
39 | [1mOPTIONS[0m
40 | [32m--allow-overwrites[0m
41 | Allows the renaming operation to overwrite existing files.
42 | Caution: Using this option can lead to unrecoverable data loss.
43 |
44 | [32m-c[0m, [32m--clean[0m
45 | Clean empty directories that were traversed in a renaming operation.
46 |
47 | [32m-E[0m, [32m--exclude[0m
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 | [32m--exclude-dir[0m
60 | Prevents F2 from recursing into directories that match the provided regular
61 | expression pattern.
62 |
63 | [32m--exiftool-opts[0m
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 | [32m-x[0m, [32m--exec[0m
78 | Executes the renaming operation and applies the changes to the filesystem.
79 |
80 | [32m-F[0m, [32m--fix-conflicts[0m
81 | Automatically fixes renaming conflicts using predefined rules.
82 |
83 | [32m--fix-conflicts-pattern[0m
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 | [32m-H[0m, [32m--hidden[0m
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 | [32m-I[0m, [32m--include[0m
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 | [32m-d[0m, [32m--include-dir[0m
111 | Includes matching directories in the renaming operation (they are excluded
112 | by default).
113 |
114 | [32m-i[0m, [32m--ignore-case[0m
115 | Ignores case sensitivity when searching for matches.
116 |
117 | [32m-e[0m, [32m--ignore-ext[0m
118 | Ignores the file extension when searching for matches.
119 |
120 | [32m--json[0m
121 | Produces JSON output, except for error messages which are sent to the
122 | standard error.
123 |
124 | [32m-m[0m, [32m--max-depth[0m
125 | Limits the depth of recursive search. Set to 0 (default) for no limit.
126 |
127 | [32m--no-color[0m
128 | Disables colored output.
129 |
130 | [32m-D[0m, [32m--only-dir[0m
131 | Renames only directories, not files (implies -d/--include-dir).
132 |
133 | [32m-p[0m, [32m--pair[0m
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 | [32m--pair-order[0m
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 | [32m--quiet[0m
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 | [32m-R[0m, [32m--recursive[0m
160 | Recursively traverses directories when searching for matches.
161 |
162 | [32m-l[0m, [32m--replace-limit[0m
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 | [32m--reset-index-per-dir[0m
168 | Resets the auto-incrementing index when entering a new directory during a
169 | recursive operation.
170 |
171 | [32m--sort[0m
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 | [32m--sortr[0m
187 | Accepts the same values as --sort but sorts matches in descending order.
188 |
189 | [32m--sort-per-dir[0m
190 | Ensures sorting is performed separately within each directory rather than
191 | globally.
192 |
193 | [32m--sort-var[0m
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 | [32m-s[0m, [32m--string-mode[0m
199 | Treats the search pattern (specified by -f/--find) as a literal string
200 | instead of a regular expression.
201 |
202 | [32m-t[0m, [32m--target-dir[0m
203 | Specify a target directory to move renamed files and reorganize your
204 | filesystem.
205 |
206 | [32m-V[0m, [32m--verbose[0m
207 | Enables verbose output during the renaming operation.
208 |
209 | [1mENVIRONMENTAL VARIABLES[0m
210 | [32mF2_DEFAULT_OPTS[0m
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 | [32mF2_NO_COLOR[0m, [32mNO_COLOR[0m
217 | Set to any value to disable coloured output.
218 |
219 | [1mLEARN MORE[0m
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 | [1mUSAGE[0m
4 | f2 FLAGS [OPTIONS] [PATHS TO FILES AND DIRECTORIES...]
5 | command | f2 FLAGS [OPTIONS]
6 |
7 | [1mEXAMPLES[0m
8 | $ f2 -f 'jpeg' -r 'jpg'
9 | $ f2 -r '{id3.artist}/{id3.album}/${1}_{id3.title}{ext}'
10 |
11 | [1mLEARN MORE[0m
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 __