├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ └── build.yaml ├── .gitignore ├── .nojekyll ├── .npmignore ├── .vscode └── settings.json ├── README.md ├── bin.js ├── bin.test.js ├── chocolatey ├── packager.js ├── rename-cli.nuspec.html └── rename-cli │ └── tools │ ├── LICENSE.txt │ └── VERIFICATION.txt ├── docs ├── CONTRIBUTING.md ├── README6.md └── Releases.md ├── images └── rename.gif ├── index.html ├── jsconfig.json ├── lib ├── filters │ ├── custom.js │ └── date.js ├── userData.js ├── userFilters.js └── yargsOptions.js ├── license ├── model ├── batch.js ├── favorites.js └── operation.js ├── package-lock.json ├── package.json ├── src ├── batch.js ├── database.js ├── favorites.js ├── fileData.js ├── history.js ├── operation.js ├── options.js ├── tui.js ├── util.js └── wizard.js └── test-files ├── Scott_Holmes_-_04_-_Upbeat_Party.mp3 └── attribution.txt /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2020, 4 | "sourceType": "module", 5 | "ecmaFeatures": { 6 | "jsx": true, 7 | "spread": true, 8 | "experimentalObjectRestSpread": true 9 | } 10 | }, 11 | "extends": ["eslint:recommended", "plugin:jest/recommended"], 12 | "rules": { 13 | "semi": 2, 14 | "eqeqeq": ["warn", "smart"], 15 | "no-unused-vars": 1, 16 | "no-console": 0, 17 | "no-prototype-builtins": "off" 18 | }, 19 | "plugins": [ 20 | "jest" 21 | ], 22 | "env": { 23 | "node": true, 24 | "es6": true 25 | } 26 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. File/Folder Structure 16 | 2. Rename command 17 | 3. Error or unexpected behavior 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Desktop (please complete the following information):** 26 | - OS: [e.g. Windows 10, MacOS, Ubuntu, etc.] 27 | - Terminal/shell [e.g. CMD, Powershell, bash, zsh, etc.] 28 | - Rename Version [e.g. 6.2.1] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for npm 4 | - package-ecosystem: "npm" 5 | # Look for `package.json` and `lock` files in the `root` directory 6 | directory: "/" 7 | # Check the npm registry for updates every day (weekdays) 8 | schedule: 9 | interval: "daily" -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Build and Test 5 | 6 | on: 7 | push: 8 | branches: [ master, 7.0.0, history-feature ] 9 | pull_request: 10 | branches: [ master, 7.0.0, history-feature ] 11 | 12 | jobs: 13 | test_linux: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: [14.x, 15.x] 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - run: npm ci 25 | - run: npm test 26 | test_macos: 27 | runs-on: macos-latest 28 | strategy: 29 | matrix: 30 | node-version: [14.x, 15.x] 31 | steps: 32 | - uses: actions/checkout@v2 33 | - name: Use Node.js ${{ matrix.node-version }} 34 | uses: actions/setup-node@v1 35 | with: 36 | node-version: ${{ matrix.node-version }} 37 | - run: npm ci 38 | - run: npm test 39 | test_windows: 40 | runs-on: windows-latest 41 | strategy: 42 | matrix: 43 | node-version: [14.x, 15.x] 44 | steps: 45 | - uses: actions/checkout@v2 46 | - name: Use Node.js ${{ matrix.node-version }} 47 | uses: actions/setup-node@v1 48 | with: 49 | node-version: ${{ matrix.node-version }} 50 | - run: npm ci 51 | - run: npm test 52 | working-directory: ${{ github.workspace }} 53 | build_windows: 54 | needs: test_windows 55 | runs-on: windows-latest 56 | strategy: 57 | matrix: 58 | node-version: [14.x] 59 | steps: 60 | - uses: actions/checkout@v2 61 | - name: Use Node.js ${{ matrix.node-version }} 62 | uses: actions/setup-node@v1 63 | with: 64 | node-version: ${{ matrix.node-version }} 65 | - run: npm ci 66 | - run: npm i -g pkg 67 | - run: npm run-script build-win --if-present 68 | - run: dir bin\ 69 | - run: npm run-script chocolatey --if-present 70 | - run: dir chocolatey\rename-cli\tools\ 71 | - name: Choco package 72 | uses: crazy-max/ghaction-chocolatey@v1 73 | with: 74 | args: pack chocolatey\rename-cli\rename-cli.nuspec 75 | - uses: actions/upload-artifact@v2 76 | with: 77 | name: exe 78 | path: bin\*.exe 79 | - run: dir 80 | - uses: actions/upload-artifact@v2 81 | with: 82 | name: nupkg 83 | path: "*.nupkg" 84 | build_unix: 85 | needs: test_linux 86 | runs-on: ubuntu-latest 87 | strategy: 88 | matrix: 89 | node-version: [14.x] 90 | steps: 91 | - uses: actions/checkout@v2 92 | - name: Use Node.js ${{ matrix.node-version }} 93 | uses: actions/setup-node@v1 94 | with: 95 | node-version: ${{ matrix.node-version }} 96 | - run: npm ci 97 | - run: npm i -g pkg 98 | - run: npm run-script build --if-present 99 | - uses: actions/upload-artifact@v2 100 | with: 101 | name: unix-binaries 102 | path: /home/runner/work/node-rename-cli/node-rename-cli/bin 103 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 27 | node_modules 28 | 29 | # Debug log from npm 30 | npm-debug.log 31 | 32 | # test directory 33 | test 34 | 35 | # bin directory 36 | bin/* 37 | 38 | # chocolatey executable 39 | chocolatey/rename-cli/tools/rname.exe 40 | chocolatey/rename-cli/*.nupkg 41 | chocolatey/rename-cli/*.nuspec 42 | 43 | .DS_Store -------------------------------------------------------------------------------- /.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhotmann/node-rename-cli/216129692cf09947ff1289eef9ce08c42a98b48e/.nojekyll -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 27 | node_modules 28 | 29 | # Debug log from npm 30 | npm-debug.log 31 | 32 | # test directory 33 | test 34 | test-files 35 | 36 | # bin directory 37 | bin/* 38 | 39 | # chocolatey 40 | chocolatey 41 | 42 | .github 43 | .vscode 44 | images 45 | docs 46 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ltex.workspaceFolderDictionary": { 3 | "en-US": [ 4 | "Chocolatey", 5 | "Homebrew", 6 | "Nunjucks", 7 | "RegEx", 8 | "sudo" 9 | ] 10 | }, 11 | "ltex.workspaceFolderDisabledRules": { 12 | "en-US": [ 13 | "DASH_RULE" 14 | ] 15 | }, 16 | "ltex.ignoreRuleInSentence": [ 17 | { 18 | "rule": "PUNCTUATION_PARAGRAPH_END", 19 | "sentence": "^\\QOptionally you can pass the ID or alias of a favorite to run it directly\\E$" 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Rename-CLI 3 | A cross-platform tool for renaming files quickly, especially multiple files at once. 4 | 5 | *Note* Version 7 has big changes from version 6, if you are staying on version 6 you can find the old documentation [here](docs/README6.md) 6 | 7 | ![GIF preview](images/rename.gif) 8 | 9 | ![Build and Test](https://github.com/jhotmann/node-rename-cli/workflows/Build%20and%20Test/badge.svg?branch=master) ![NPM](https://img.shields.io/npm/dt/rename-cli?color=cb3837&label=npm%20downloads&logo=npm) ![Chocolatey](https://img.shields.io/chocolatey/dt/rename-cli?color=5c9fd8&label=chocolatey%20downloads&logo=chocolatey) 10 | 11 | ## Installation 12 | The preferred installation method is through NPM or Homebrew 13 | 14 | **NPM:** (sudo if necessary) 15 | ```sh 16 | npm i -g rename-cli@beta 17 | ``` 18 | 19 | **Homebrew:** 20 | ```sh 21 | brew tap jhotmann/rename-cli 22 | brew install rename-cli 23 | ``` 24 | 25 | **Chocolatey:** 26 | Windows users can install the binary through [Chocolatey](https://chocolatey.org/) or download from the [Releases](https://github.com/jhotmann/node-rename-cli/releases) page if you don't want to install Node. 27 | 28 | *Note: binary files are untested* 29 | 30 | ```sh 31 | choco install rename-cli 32 | ``` 33 | 34 | ## Features 35 | - Variable replacement and filtering of new file name (powered by [Nunjucks](https://mozilla.github.io/nunjucks/templating.html)) :new: 36 | - Glob file matching 37 | - Command history with ability to undo entire batches or individual operations and re-run batches :new: 38 | - Ability to save commands as favorites to re-run them quickly :new: 39 | - Customize by adding your own variables and filters 40 | - Auto-indexing when renaming multiple files to the same name 41 | - RegEx match/replace 42 | - EXIF and ID3 tag support 43 | 44 | ## Usage 45 | ```rename [options] file(s) new-file-name``` 46 | 47 | Or simply type `rename` for an interactive CLI with live previews of rename operations. 48 | 49 | *Note: Windows users (or anyone who wants to type one less letter) can use `rname` instead of rename since the rename command already exists in Windows* 50 | 51 | The new file name does not need to contain a file extension. If you do not specify a file extension, the original file extension will be preserved. 52 | 53 | *Note: if you include periods in your new file name, you should include a file extension to prevent whatever is after the last period from becoming the new extension. I recommend using `{{ext}}` (which includes the period) to preserve the original file extension.* 54 | 55 | ## Options 56 | ```-h```, ```--help```: Show help 57 | ```-i```, ```--info```: View online help 58 | ```-w```, ```--wizard```: Run a wizard to guide you through renaming files 59 | ```-u```, ```--undo```: Undo previous rename operation 60 | ```-k```, ```--keep```: Keep both files when new file name already exists (append a number) 61 | ```-f```, ```--force```: Forcefully overwrite without prompt when new file name already exists and create any missing directories 62 | ```-s```, ```--sim```: Simulate rename and just print new file names 63 | ```-n```, ```--noindex```: Do not append an index when renaming multiple files 64 | ```-d```, ```--ignoredirectories```: Do not rename directories 65 | ```--sort```: Sort files before renaming. Parameter: `alphabet` (default), `date-create` (most recent first), `date-modified` (most recent first), `size` (biggest to smallest). Start the parameter with `reverse-` to reverse the sort order. 66 | ```-p```, ```--prompt```: Print all rename operations to be completed and confirm before proceeding 67 | ```--notrim```: Do not trim whitespace at beginning or end of output file name 68 | ```--nomove ```: Do not move files if their new file name points to a different directory 69 | ```--noext```: Do not automatically append a file extension if one isn't supplied (sometimes necessary if using a variable for an extension) 70 | ```--createdirs```: Automatically create missing directories (cannot be used with `--nomove`) 71 | ```--printdata```: Print the data available for a file 72 | ```--history```: View previously run commands and undo, re-run, copy, and favorite them 73 | ```--favorites```, ```--favourites```: View saved favorites and run or edit them. Optionally you can pass the ID or alias of a favorite to run it directly 74 | 75 | ## Built-in Variables 76 |
The new file name can contain any number of built-in and custom variables that will be replaced with their corresponding value. Expand for more info. 77 |

78 | 79 | `{{i}}` Index: The index of the file when renaming multiple files to the same name. If you do no include `{{i}}` in your new file name, the index will be appended to the end. Use the `--noindex` option to prevent auto-indexing. 80 | 81 | `{{f}}` File name: The original name of the file. 82 | 83 | `{{ext}}` File extension: The original extension of the file (with the `.`) 84 | 85 | `{{p}}` Parent directory: The name of the parent directory. 86 | 87 | `{{isDirectory}}` Is directory: true/false. Useful for conditionally adding a file extension to files and not directories with `{% if isDirectory %}...` 88 | 89 | `{{os.x}}` Operating System: Information about the OS/user. Replace `x` with `homedir`, `hostname`, `platform`, or `user` 90 | 91 | `{{date.x}}` Dates: Insert a date. Replace `x` with `current` (the current date/time), `create` (the file's created date/time), `access` (the file's last accessed date/time) or `modify` (the file's last modified date/time) 92 | 93 | `{{g}}` GUID: A pseudo-random globally unique identifier. 94 | 95 | `{{exif.x}}` EXIF: Photo EXIF Information. Replace `x` with `iso`, `fnum`, `exposure`, `date`, `width`, or `height` 96 | 97 | `{{id3.x}}` ID3: Gets ID3 tags from MP3 files. Replace `x` with `title`, `artist`, `album`, `track`, `totalTracks`, or `year` 98 | 99 | You can also add your own variables. See the [Customize](#customize) section for more info. 100 | 101 |

102 |
103 | 104 | ## Filters and Examples 105 |
You can modify variable values by applying filters. Multiple filters can be chained together. Nunjucks, the underlying variable-replacement engine, has a large number of filters available and Rename-CLI has a few of its own. Expand for more info. 106 |

107 | 108 | String case manipulation 109 | - `{{f|lower}}` - `Something Like This.txt → something like this.txt` 110 | - `{{f|upper}}` - `Something Like This.txt → SOMETHING LIKE THIS.txt` 111 | - `{{f|camel}}` - `Something Like This.txt → somethingLikeThis.txt` 112 | - `{{f|pascal}}` - `Something Like This.txt → SomethingLikeThis.txt` 113 | - `{{f|kebab}}` - `Something Like This.txt → something-like-this.txt` 114 | - `{{f|snake}}` - `Something Like This.txt → something_like_this.txt` 115 | 116 | ----- 117 | 118 | `replace('something', 'replacement')` - replace a character or string with something else. 119 | 120 | ```sh 121 | rename "bills file.pdf" "{{ f | replace('bill', 'mary') | pascal }}" 122 | 123 | bills file.pdf → MarysFile.pdf 124 | ``` 125 | 126 | ----- 127 | 128 | `date` - format a date to a specific format, the default is `yyyyMMdd` if no parameter is passed. To use your own format, simply pass the format as a string parameter to the date filter. Formatting options can be found [here](https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table). 129 | 130 | ```sh 131 | rename *.txt "{{ date.current | date }}-{{f}}" 132 | 133 | a.txt → 20200502-a.txt 134 | b.txt → 20200502-b.txt 135 | c.txt → 20200502-c.txt 136 | 137 | rename *.txt "{{ date.current | date('MM-dd-yyyy') }}-{{f}}" 138 | 139 | a.txt → 05-02-2020-a.txt 140 | b.txt → 05-02-2020-b.txt 141 | c.txt → 05-02-2020-c.txt 142 | ``` 143 | 144 | ----- 145 | 146 | `match(RegExp[, flags, group num/name])` - match substring(s) using a regular expression. The only required parameter is the regular expression (as a string), it also allows for an optional parameter `flags` (a string containing any or all of the flags: g, i, m, s, u, and y, more info [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/RegExp#Parameters)), and an optional parameter of the `group` number or name. *Named groups cannot be used with the global flag.* 147 | 148 | ```sh 149 | rename *ExpenseReport* "archive/{{ f | match('^.+(?=Expense)') }}/ExpenseReport.docx" --createdirs 150 | 151 | JanuaryExpenseReport.docx → archive/January/ExpenseReport.docx 152 | MarchExpenseReport.docx → archive/March/ExpenseReport.docx 153 | ``` 154 | 155 | ----- 156 | 157 | `regexReplace(RegExp[, flags, replacement])` - replace the first regex match with the `replacement` string. To replace all regex matches, pass the `g` flag. `flags` and `replacement` are optional, the default value for replacement is an empty string. 158 | 159 | ```sh 160 | rename test/* "{{ f | regexReplace('(^|e)e', 'g', 'E') }}" 161 | 162 | test/eight.txt → Eight.txt 163 | test/eighteen.txt → EightEn.txt 164 | test/eleven.txt → Eleven.txt 165 | ``` 166 | 167 | ----- 168 | 169 | `padNumber(length)` - put leading zeroes in front of a number until it is `length` digits long. If `length` is a string, it will use the string's length. 170 | 171 | ```sh 172 | rename Absent\ Sounds/* "{{id3.year}}/{{id3.artist}}/{{id3.album}}/{{ id3.track | padNumber(id3.totalTracks) }} - {{id3.title}}{{ext}}" 173 | 174 | Absent Sounds/Am I Alive.mp3 → 2014/From Indian Lakes/Absent Sounds/05 - Am I Alive.mp3 175 | Absent Sounds/Awful Things.mp3 → 2014/From Indian Lakes/Absent Sounds/07 - Awful Things.mp3 176 | Absent Sounds/Breathe, Desperately.mp3 → 2014/From Indian Lakes/Absent Sounds/03 - Breathe, Desperately.mp3 177 | Absent Sounds/Come In This Light.mp3 → 2014/From Indian Lakes/Absent Sounds/01 - Come In This Light.mp3 178 | Absent Sounds/Fog.mp3 → 2014/From Indian Lakes/Absent Sounds/10 - Fog.mp3 179 | Absent Sounds/Ghost.mp3 → 2014/From Indian Lakes/Absent Sounds/06 - Ghost.mp3 180 | Absent Sounds/Label This Love.mp3 → 2014/From Indian Lakes/Absent Sounds/02 - Label This Love.mp3 181 | Absent Sounds/Runner.mp3 → 2014/From Indian Lakes/Absent Sounds/08 - Runner.mp3 182 | Absent Sounds/Search For More.mp3 → 2014/From Indian Lakes/Absent Sounds/09 - Search For More.mp3 183 | Absent Sounds/Sleeping Limbs.mp3 → 2014/From Indian Lakes/Absent Sounds/04 - Sleeping Limbs.mp3 184 | ``` 185 | 186 |

187 |
188 | 189 | ## Customize 190 |
You can expand upon and overwrite much of the default functionality by creating your own variables and filters. Expand for more info. 191 |

192 | 193 | ### Variables 194 | The first time you run the rename command a file will be created at `~/.rename/userData.js`, this file can be edited to add new variables that you can access with `{{variableName}}` in your new file name. You can also override the built-in variables by naming your variable the same. The userData.js file contains some examples. 195 | 196 | ```js 197 | // These are some helpful libraries already included in rename-cli 198 | // All the built-in nodejs libraries are also available 199 | // const exif = require('jpeg-exif'); // https://github.com/zhso/jpeg-exif 200 | // const fs = require('fs-extra'); // https://github.com/jprichardson/node-fs-extra 201 | // const Fraction = require('fraction.js'); // https://github.com/infusion/Fraction.js 202 | // const date-fns = require('date-fns'); // https://date-fns.org/ 203 | 204 | module.exports = function(fileObj, descriptions) { 205 | let returnData = {}; 206 | let returnDescriptions = {}; 207 | 208 | // Put your code here to add properties to returnData 209 | // this data will then be available in your output file name 210 | // for example: returnData.myName = 'Your Name Here'; 211 | // or: returnData.backupDir = 'D:/backup'; 212 | 213 | // Optionally, you can describe a variable and have it show when printing help information 214 | // add the same path as a variable to the returnDescriptions object with a string description 215 | // for example: returnDescriptions.myName = 'My full name'; 216 | // or: returnDescriptions.backupDir = 'The path to my backup directory'; 217 | 218 | if (!descriptions) return returnData; 219 | else return returnDescriptions; 220 | }; 221 | ``` 222 | 223 | The `fileObj` that is passed to the function will look something like this: 224 | 225 | ``` 226 | { 227 | i: '--FILEINDEXHERE--', 228 | f: 'filename', 229 | fileName: 'filename', 230 | ext: '.txt', 231 | isDirectory: false, 232 | p: 'parent-directory-name', 233 | parent: 'parent-directory-name', 234 | date: { 235 | current: 2020-11-25T17:41:58.303Z, 236 | now: 2020-11-25T17:41:58.303Z, 237 | create: 2020-11-24T23:38:25.455Z, 238 | modify: 2020-11-24T23:38:25.455Z, 239 | access: 2020-11-24T23:38:25.516Z 240 | }, 241 | os: { 242 | homedir: '/Users/my-user-name', 243 | platform: 'darwin', 244 | hostname: 'ComputerName.local', 245 | user: 'my-user-name' 246 | }, 247 | guid: 'fb274642-0a6f-4fe6-8b07-0bac4db5c87b', 248 | customGuid: [Function: customGuid], 249 | stats: Stats { 250 | dev: 16777225, 251 | mode: 33188, 252 | nlink: 1, 253 | uid: 501, 254 | gid: 20, 255 | rdev: 0, 256 | blksize: 4096, 257 | ino: 48502576, 258 | size: 1455, 259 | blocks: 8, 260 | atimeMs: 1606261105516.3499, 261 | mtimeMs: 1606261105455.4163, 262 | ctimeMs: 1606261105486.9072, 263 | birthtimeMs: 1606261105455.093, 264 | atime: 2020-11-24T23:38:25.516Z, 265 | mtime: 2020-11-24T23:38:25.455Z, 266 | ctime: 2020-11-24T23:38:25.487Z, 267 | birthtime: 2020-11-24T23:38:25.455Z 268 | }, 269 | parsedPath: { 270 | root: '/', 271 | dir: '/Users/my-user-name/Projects/node-rename-cli', 272 | base: 'filename.txt', 273 | ext: '.txt', 274 | name: 'filename' 275 | }, 276 | exif: { iso: '', fnum: '', exposure: '', date: '', width: '', height: '' }, 277 | id3: { 278 | title: '', 279 | artist: '', 280 | album: '', 281 | year: '', 282 | track: '', 283 | totalTracks: '' 284 | } 285 | } 286 | ``` 287 | 288 | ### Filters 289 | The first time you run the rename command a file will be created at `~/.rename/userFilters.js`, this file can be edited to add new filters that you can access with `{{someVariable | myNewFilter}}` in your new file name. 290 | 291 | One place custom filters can be really handy is if you have files that you often receive in some weird format and you then convert them to your own desired format. Instead of writing some long, complex new file name, just write your own filter and make the new file name `{{f|myCustomFilterName}}`. You can harness the power of code to do really complex things without having to write a complex command. 292 | 293 | Each filter should accept a parameter that contains the value of the variable passed to the filter (`str` in the example below). You can optionally include more of your own parameters as well. The function should also return a string that will then be inserted into the new file name (or passed to another filter if they are chained). The userFilters.js file contains some examples. 294 | 295 | ```js 296 | // Uncomment the next line to create an alias for any of the default Nunjucks filters https://mozilla.github.io/nunjucks/templating.html#builtin-filters 297 | // const defaultFilters = require('../nunjucks/src/filters'); 298 | // These are some helpful libraries already included in rename-cli 299 | // All the built-in nodejs libraries are also available 300 | // const exif = require('jpeg-exif'); // https://github.com/zhso/jpeg-exif 301 | // const fs = require('fs-extra'); // https://github.com/jprichardson/node-fs-extra 302 | // const Fraction = require('fraction.js'); // https://github.com/infusion/Fraction.js 303 | // const { format } = require('date-fns'); // https://date-fns.org/ 304 | 305 | module.exports = { 306 | // Create an alias for a built-in filter 307 | // big: defaultFilters.upper, 308 | // Create your own filter 309 | // match: function(str, regexp, flags) { 310 | // if (regexp instanceof RegExp === false) { 311 | // regexp = new RegExp(regexp, flags); 312 | // } 313 | // return str.match(regexp); 314 | // } 315 | }; 316 | ``` 317 | 318 |

319 |
320 | -------------------------------------------------------------------------------- /bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // Handle EPIPE errors when user doesn't put quotes around output file name with parameters 3 | function epipeError(err) { 4 | if (err.code === 'EPIPE' || err.errno === 32) return process.exit; 5 | if (process.stdout.listeners('error').length <= 1) { 6 | process.stdout.removeAllListeners(); 7 | process.stdout.emit('error', err); 8 | process.stdout.on('error', epipeError); 9 | } 10 | } 11 | process.stdout.on('error', epipeError); 12 | 13 | const chalk = require('chalk'); 14 | const fs = require('fs-extra'); 15 | const opn = require('opn'); 16 | const os = require('os'); 17 | const yargs = require('yargs'); 18 | 19 | const database = require('./src/database'); 20 | const util = require('./src/util'); 21 | 22 | (async () => { 23 | // create ~/.rename/userData.js if not exist 24 | const userDataPath = os.homedir() + '/.rename/userData.js'; 25 | await fs.ensureFile(userDataPath).catch(err => { throw err; }); 26 | const userDataContents = await fs.readFile(userDataPath, 'utf8').catch(err => { throw err; }); 27 | if (userDataContents === '') { 28 | await fs.copyFile(__dirname + '/lib/userData.js', userDataPath); 29 | } 30 | // create ~/.rename/userFilters.js if not exist 31 | const userFiltersPath = os.homedir() + '/.rename/userFilters.js'; 32 | await fs.ensureFile(userFiltersPath).catch(err => { throw err; }); 33 | const userFiltersContents = await fs.readFile(userFiltersPath, 'utf8').catch(err => { throw err; }); 34 | if (userFiltersContents === '') { 35 | await fs.copyFile(__dirname + '/lib/userFilters.js', userFiltersPath); 36 | } 37 | // SQLite database setup 38 | const sequelize = await database.init(); 39 | 40 | const { Operation } = require('./src/operation'); 41 | const { Options } = require('./src/options'); 42 | const { Batch } = require('./src/batch'); 43 | const { History } = require('./src/history'); 44 | const { Favorites } = require('./src/favorites'); 45 | 46 | // Parse command line arguments 47 | const argv = yargs 48 | .usage('Rename-CLI v' + require('./package.json').version + '\n\nUsage:\n\n rename [options] file(s) new-file-name') 49 | .options(require('./lib/yargsOptions')) 50 | .help('help') 51 | .epilogue('Variables:\n\n' + util.getVariableList())// + rename.getReplacementsList()) 52 | .wrap(yargs.terminalWidth()) 53 | .argv; 54 | 55 | // Turn parsed args into new Options class 56 | const options = new Options(argv); 57 | // Ensure that only input files that exist are considered 58 | await options.validateInputFiles(); 59 | 60 | if (options.info) { // view online hlep 61 | opn('https://github.com/jhotmann/node-rename-cli'); 62 | if (process.platform !== 'win32') { 63 | process.exit(0); 64 | } 65 | } else if (options.history !== false) { // launch history UI 66 | options.history = options.history || 10; 67 | let history = new History(sequelize, options); 68 | await history.getBatches(); 69 | await history.display(); 70 | } else if (options.favorites !== false) { // run favorite or launch favorites UI 71 | let favorites = new Favorites(sequelize, options); 72 | await favorites.get(); 73 | if (options.favorites) await favorites.run(); 74 | } else if (options.undo) { // undo previous rename 75 | options.history = 1; 76 | options.noUndo = true; 77 | let history = new History(sequelize, options); 78 | await history.getBatches(); 79 | if (history.batches.length === 0) { 80 | console.log(chalk`{red No batches found that can be undone}`); 81 | process.exit(1); 82 | } 83 | const lastBatch = history.batches[0]; 84 | console.log(`Undoing '${util.argvToString(JSON.parse(lastBatch.command))}' (${lastBatch.Ops.length} operation${lastBatch.Ops.length === 1 ? '' : 's'})`); 85 | await history.undoBatch(0); 86 | } else if (options.wizard) { // launch the wizard 87 | await require('./src/wizard')(sequelize); 88 | } else if (options.printdata && options.inputFiles.length === 1) { // print the file's data 89 | let operation = new Operation(options.inputFiles[0], options, sequelize); 90 | operation.printData(); 91 | } else if (options.inputFiles.length > 0 && options.outputPattern) { // proceed to do the rename 92 | let batch = new Batch(argv, options, sequelize); 93 | await batch.complete(); 94 | } else if (argv._.length === 0 && !options.compiled) { // launch TUI 95 | const ui = require('./src/tui'); 96 | await ui(sequelize); 97 | } else { // Invalid command 98 | if (options.invalidInputFiles > 0) { 99 | console.log(chalk`{red ERROR: None of the input files specified exist}`); 100 | } else { 101 | console.log(chalk`{red ERROR: Not enough arguments specified. Type rename -h for help}`); 102 | } 103 | process.exit(1); 104 | } 105 | })(); -------------------------------------------------------------------------------- /bin.test.js: -------------------------------------------------------------------------------- 1 | const async = require('async'); 2 | const fs = require('fs-extra'); 3 | const os = require('os'); 4 | const path = require('path'); 5 | const yargs = require('yargs'); 6 | 7 | const database = require('./src/database'); 8 | const yargsOptions = require('./lib/yargsOptions'); 9 | 10 | const { Batch } = require('./src/batch'); 11 | const { History } = require('./src/history'); 12 | const { Options } = require('./src/options'); 13 | const { Favorites } = require('./src/favorites'); 14 | 15 | jest.setTimeout(30000); 16 | 17 | let SEQUELIZE; 18 | 19 | beforeAll(async () => { 20 | // remove test directory 21 | await fs.remove('./test'); 22 | // create test files/directories 23 | await fs.ensureDir('test'); 24 | await fs.ensureDir('test/another-dir'); 25 | await async.times(31, async (i) => { 26 | if (i === 0) return; 27 | let num = inWords(i); 28 | let dir = `${i < 20 ? 'test/' : 'test/another-dir/'}`; 29 | let fileName = `${num.trim().replace(' ', '-')}.txt`; 30 | await fs.writeFile(`${dir}${fileName}`, `file ${num.trim()}`, 'utf8'); 31 | }); 32 | SEQUELIZE = await database.initTest(); 33 | }); 34 | 35 | describe('Rename a single file: rename test/one.txt test/one-renamed.txt', () => { 36 | const oldFiles = ['test/one.txt']; 37 | const newFiles = ['test/one-renamed.txt']; 38 | let originalContent; 39 | beforeAll(async () => { 40 | originalContent = await fs.readFile('test/one.txt', 'utf8'); 41 | await runCommand('rename test/one.txt test/one-renamed.txt'); 42 | }); 43 | test(`Old files don't exist`, async () => { 44 | const result = await async.every(oldFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 45 | await expect(result).toBe(false); 46 | }); 47 | test(`New files do exist`, async () => { 48 | const result = await async.every(newFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 49 | await expect(result).toBe(true); 50 | }); 51 | test(`New file has correct content`, async () => { 52 | const result = await fs.readFile('test/one-renamed.txt', 'utf8'); 53 | expect(result).toBe(originalContent); 54 | }); 55 | }); 56 | 57 | describe('Rename multiple files the same thing with appended index: rename test/f*.txt test/multiple', () => { 58 | const oldFiles = ['test/four.txt', 'test/five.txt', 'test/fourteen.txt', 'test/fifteen.txt']; 59 | const newFiles = ['test/multiple1.txt', 'test/multiple2.txt', 'test/multiple3.txt', 'test/multiple4.txt']; 60 | beforeAll(async () => { 61 | await runCommand('rename test/f*.txt test/multiple'); 62 | }); 63 | test(`Old files don't exist`, async () => { 64 | const result = await async.every(oldFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 65 | await expect(result).toBe(false); 66 | }); 67 | test(`New files do exist`, async () => { 68 | const result = await async.every(newFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 69 | await expect(result).toBe(true); 70 | }); 71 | }); 72 | 73 | describe('Rename multiple files the same thing with appended index and file extension specified and sort option: rename test/multiple* test/twelve.txt test/multiple.log', () => { 74 | const oldFiles = ['test/multiple1.txt', 'test/multiple2.txt', 'test/multiple3.txt', 'test/multiple4.txt', 'test/twelve.txt']; 75 | const newFiles = ['test/multiple1.log', 'test/multiple2.log', 'test/multiple3.log', 'test/multiple4.log', 'test/multiple5.log']; 76 | let originalContent; 77 | beforeAll(async () => { 78 | originalContent = await fs.readFile('test/twelve.txt', 'utf8'); 79 | await runCommand('rename --sort reverse-alphabet test/multiple* test/twelve.txt test/multiple.log'); 80 | }); 81 | test(`Old files don't exist`, async () => { 82 | const result = await async.every(oldFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 83 | await expect(result).toBe(false); 84 | }); 85 | test(`New files do exist`, async () => { 86 | const result = await async.every(newFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 87 | await expect(result).toBe(true); 88 | }); 89 | test(`New file has correct content`, async () => { 90 | const result = await fs.readFile('test/multiple1.log', 'utf8'); 91 | expect(result).toBe(originalContent); 92 | }); 93 | }); 94 | 95 | describe('Rename with variables and filters: rename test/two.txt "{{p}}/{{f|upper}}.{{\'testing-stuff\'|camel}}"', () => { 96 | const oldFiles = ['test/two.txt']; 97 | const newFiles = ['test/TWO.testingStuff']; 98 | beforeAll(async () => { 99 | await runCommand(`rename test/two.txt "{{p}}/{{f|upper}}.{{'testing-stuff'|camel}}"`); 100 | }); 101 | test(`Old files don't exist`, async () => { 102 | const result = await async.every(oldFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 103 | await expect(result).toBe(false); 104 | }); 105 | test(`New files do exist`, async () => { 106 | const result = await async.every(newFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 107 | await expect(result).toBe(true); 108 | }); 109 | test(`New files has correct case`, async () => { 110 | const files = await fs.readdir('test'); 111 | expect(files.indexOf('TWO.testingStuff')).toBeGreaterThan(-1); 112 | }); 113 | }); 114 | 115 | describe('Force multiple files to be renamed the same: rename test/th* test/same --noindex -force', () => { 116 | const oldFiles = ['test/three.txt', 'test/thirteen.txt']; 117 | const newFiles = ['test/same.txt']; 118 | beforeAll(async () => { 119 | await runCommand('rename test/th* test/same --noindex -force'); 120 | }); 121 | test(`Old files don't exist`, async () => { 122 | const result = await async.every(oldFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 123 | await expect(result).toBe(false); 124 | }); 125 | test(`New files do exist`, async () => { 126 | const result = await async.every(newFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 127 | await expect(result).toBe(true); 128 | }); 129 | test(`New file has correct content`, async () => { 130 | const result = await fs.readFile('test/same.txt', 'utf8'); 131 | expect(result).toMatch(/^file three.*/); 132 | }); 133 | }); 134 | 135 | describe('Multiple files to be renamed the same but with keep option: rename test/six* test/keep --noindex -k', () => { 136 | const oldFiles = ['test/six.txt', 'test/sixteen.txt']; 137 | const newFiles = ['test/keep.txt', 'test/keep-1.txt']; 138 | beforeAll(async () => { 139 | await runCommand('rename test/six* test/keep --noindex -k'); 140 | }); 141 | test(`Old files don't exist`, async () => { 142 | const result = await async.every(oldFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 143 | await expect(result).toBe(false); 144 | }); 145 | test(`New files do exist`, async () => { 146 | const result = await async.every(newFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 147 | await expect(result).toBe(true); 148 | }); 149 | }); 150 | 151 | describe('Move a file to a new directory: rename test/one-renamed.txt "test/another-dir/{{os.platform}}"', () => { 152 | const oldFiles = ['test/one-renamed.txt']; 153 | const newFiles = [`test/another-dir/${os.platform()}.txt`]; 154 | beforeAll(async () => { 155 | await runCommand('rename test/one-renamed.txt "test/another-dir/{{os.platform}}"'); 156 | }); 157 | test(`Old files don't exist`, async () => { 158 | const result = await async.every(oldFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 159 | await expect(result).toBe(false); 160 | }); 161 | test(`New files do exist`, async () => { 162 | const result = await async.every(newFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 163 | await expect(result).toBe(true); 164 | }); 165 | }); 166 | 167 | describe(`Don't move a file to a new directory: rename test/eight.txt "test/another-dir/{{f}}-notmoved" --nomove`, () => { 168 | const oldFiles = ['test/eight.txt']; 169 | const newFiles = ['test/eight-notmoved.txt']; 170 | beforeAll(async () => { 171 | await runCommand('rename test/eight.txt "test/another-dir/{{f}}-notmoved" --nomove'); 172 | }); 173 | test(`Old files don't exist`, async () => { 174 | const result = await async.every(oldFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 175 | await expect(result).toBe(false); 176 | }); 177 | test(`New files do exist`, async () => { 178 | const result = await async.every(newFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 179 | await expect(result).toBe(true); 180 | }); 181 | }); 182 | 183 | describe(`Rename multiple files to the same date and append index: rename --nomove test/seven* "{{ date.current | date('yyyy-MM-dd') }}"`, () => { 184 | const now = new Date(); 185 | const nowFormatted = `${now.getFullYear()}-${now.getMonth() < 9 ? '0' : ''}${now.getMonth() + 1}-${now.getDate() < 10 ? '0' : ''}${now.getDate()}`; 186 | const oldFiles = ['test/seven.txt', 'test/seventeen.txt']; 187 | const newFiles = [`test/${nowFormatted}1.txt`, `test/${nowFormatted}2.txt`]; 188 | beforeAll(async () => { 189 | await runCommand(`rename --nomove test/seven* "{{ date.current | date('yyyy-MM-dd') }}"`); 190 | }); 191 | test(`Old files don't exist`, async () => { 192 | const result = await async.every(oldFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 193 | await expect(result).toBe(false); 194 | }); 195 | test(`New files do exist`, async () => { 196 | const result = await async.every(newFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 197 | await expect(result).toBe(true); 198 | }); 199 | }); 200 | 201 | describe(`Test --noext option: rename test/ten.txt "test/asdf{{os.user}}" --noext`, () => { 202 | const oldFiles = ['test/ten.txt']; 203 | const newFiles = [`test/asdf${os.userInfo().username}`]; 204 | beforeAll(async () => { 205 | await runCommand('rename test/ten.txt "test/asdf{{os.user}}" --noext', true); 206 | }); 207 | test(`Old files don't exist`, async () => { 208 | const result = await async.every(oldFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 209 | await expect(result).toBe(false); 210 | }); 211 | test(`New files do exist`, async () => { 212 | const result = await async.every(newFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 213 | await expect(result).toBe(true); 214 | }); 215 | }); 216 | 217 | describe(`Test undo last rename via History class`, () => { 218 | const newFiles = ['test/ten.txt']; 219 | const oldFiles = [`test/asdf${os.userInfo().username}`]; 220 | beforeAll(async () => { 221 | let history = new History(SEQUELIZE, 1, false); 222 | await history.getBatches(); 223 | await history.undoBatch(0); 224 | }); 225 | test(`Old files don't exist`, async () => { 226 | const result = await async.every(oldFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 227 | await expect(result).toBe(false); 228 | }); 229 | test(`New files do exist`, async () => { 230 | const result = await async.every(newFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 231 | await expect(result).toBe(true); 232 | }); 233 | }); 234 | 235 | describe(`Rename a mp3 file: rename test/music.mp3 --createdirs "test/{{id3.year}}/{{id3.artist}}/{{id3.track|padNumber(2)}} - {{id3.title}}.{{ext}}"`, () => { 236 | const oldFiles = ['test/music.mp3']; 237 | const newFiles = ['test/2019/Scott Holmes/04 - Upbeat Party.mp3']; 238 | beforeAll(async () => { 239 | //await fs.writeFile('test/music.mp3', await download('https://files.freemusicarchive.org/storage-freemusicarchive-org/music/no_curator/Scott_Holmes/Inspiring__Upbeat_Music/Scott_Holmes_-_04_-_Upbeat_Party.mp3')); 240 | await fs.copyFile('test-files/Scott_Holmes_-_04_-_Upbeat_Party.mp3', 'test/music.mp3'); 241 | await runCommand('rename test/music.mp3 --createdirs "test/{{id3.year}}/{{id3.artist}}/{{id3.track|padNumber(2)}} - {{id3.title}}{{ext}}"'); 242 | }); 243 | test(`Old files don't exist`, async () => { 244 | const result = await async.every(oldFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 245 | await expect(result).toBe(false); 246 | }); 247 | test(`New files do exist`, async () => { 248 | const result = await async.every(newFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 249 | await expect(result).toBe(true); 250 | }); 251 | }); 252 | 253 | describe(`Run a favorited command`, () => { 254 | const oldFiles = ['test/ten.txt']; 255 | const newFiles = [`test/testten.txt`]; 256 | let favorite; 257 | beforeAll(async () => { 258 | let command = 'rename --nomove test/ten.txt {{p}}{{f}}'; 259 | favorite = SEQUELIZE.models.Favorites.build({ command: JSON.stringify(command.split(' ')), alias: 'test'}); 260 | await favorite.save(); 261 | await runFavorite('--favorites test'); 262 | }); 263 | test(`Old files don't exist`, async () => { 264 | const result = await async.every(oldFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 265 | await expect(result).toBe(false); 266 | }); 267 | test(`New files do exist`, async () => { 268 | const result = await async.every(newFiles, async (f) => { return await fs.pathExists(path.resolve(f)); }); 269 | await expect(result).toBe(true); 270 | }); 271 | afterAll(async () => { 272 | if (favorite) await favorite.destroy(); 273 | }); 274 | }); 275 | 276 | // HELPER FUNCTIONS 277 | 278 | async function runCommand(command, undo) { 279 | undo = undo || false; 280 | let argv = yargs.options(yargsOptions).parse(`${command.replace(/^rename /, '')}${!undo ? ' --noundo' : ''}`); 281 | let batch = new Batch(argv, null, SEQUELIZE); 282 | await batch.complete(); 283 | } 284 | 285 | async function runFavorite(command) { 286 | let argv = yargs.options(yargsOptions).parse(command.replace(/^rename /, '')); 287 | const options = new Options(argv); 288 | const favorites = new Favorites(SEQUELIZE, options); 289 | await favorites.get(); 290 | if (options.favorites) await favorites.run(); 291 | } 292 | 293 | /* eslint-disable eqeqeq */ 294 | function inWords (num) { 295 | let a = ['','one ','two ','three ','four ', 'five ','six ','seven ','eight ','nine ','ten ','eleven ','twelve ','thirteen ','fourteen ','fifteen ','sixteen ','seventeen ','eighteen ','nineteen ']; 296 | let b = ['', '', 'twenty','thirty','forty','fifty', 'sixty','seventy','eighty','ninety']; 297 | if ((num = num.toString()).length > 9) return 'overflow'; 298 | let n = ('000000000' + num).substr(-9).match(/^(\d{2})(\d{2})(\d{2})(\d{1})(\d{2})$/); 299 | if (!n) return; 300 | let str = ''; 301 | str += (n[1] != 0) ? (a[Number(n[1])] || b[n[1][0]] + ' ' + a[n[1][1]]) + 'crore ' : ''; 302 | str += (n[2] != 0) ? (a[Number(n[2])] || b[n[2][0]] + ' ' + a[n[2][1]]) + 'lakh ' : ''; 303 | str += (n[3] != 0) ? (a[Number(n[3])] || b[n[3][0]] + ' ' + a[n[3][1]]) + 'thousand ' : ''; 304 | str += (n[4] != 0) ? (a[Number(n[4])] || b[n[4][0]] + ' ' + a[n[4][1]]) + 'hundred ' : ''; 305 | str += (n[5] != 0) ? ((str != '') ? 'and ' : '') + (a[Number(n[5])] || b[n[5][0]] + ' ' + a[n[5][1]]) : ''; 306 | return str; 307 | } 308 | -------------------------------------------------------------------------------- /chocolatey/packager.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs-extra'); 4 | const nunjucks = require('nunjucks'); 5 | const packageJson = require('../package.json'); 6 | 7 | let results = nunjucks.render('chocolatey/rename-cli.nuspec.html', packageJson); 8 | fs.writeFileSync('chocolatey/rename-cli/rename-cli.nuspec', results, 'utf8'); 9 | fs.copyFileSync('bin/rename-cli.exe', 'chocolatey/rename-cli/tools/rname.exe'); 10 | fs.copyFileSync('license', 'chocolatey/rename-cli/tools/LICENSE.txt'); -------------------------------------------------------------------------------- /chocolatey/rename-cli.nuspec.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {{name}} 26 | 27 | 28 | 29 | {{version}} 30 | {{repository.url | replace('.git', '')}} 31 | 32 | 33 | 34 | 35 | 36 | 37 | {{name}} 38 | {{author}} 39 | 40 | {{repository.url | replace('.git', '')}} 41 | 42 | 43 | 44 | {{repository.url | replace('.git', '')}}/blob/master/license 45 | false 46 | {{repository.url | replace('.git', '')}} 47 | 48 | 49 | 50 | rename cli batch utility replacement regex nunjucks 51 | {{description}} 52 | 53 | ## Features 54 | 55 | - Variable replacement and filtering of new file name (powered by [Nunjucks](https://mozilla.github.io/nunjucks/templating.html)) 56 | - Glob file matching 57 | - Undo previous rename 58 | - Customize by adding your own variables and filters 59 | - Auto-indexing when renaming multiple files to the same name 60 | - RegEx match/replace 61 | - Exif data support 62 | 63 | ## Usage 64 | 65 | ```rename [options] file(s) new-file-name``` 66 | 67 | Or simply type `rename` for an interactive cli with live previews of rename operations. 68 | 69 | *Note: Windows users (or anyone who wants to type one less letter) can use rname instead of rename since the rename command already exists in Windows* 70 | 71 | The new file name does not need to contain a file extension. If you do not specifiy a file extension the original file extension will be preserved. 72 | 73 | *Note: if you include periods in your new file name, you should include a file extension to prevent whatever is after the last period from becoming the new extension. I recommend using `.{{ext}}` to preserve the original file etension.* 74 | 75 | 76 | 77 | 78 | 86 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /chocolatey/rename-cli/tools/LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Jordan Hotmann 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /chocolatey/rename-cli/tools/VERIFICATION.txt: -------------------------------------------------------------------------------- 1 | VERIFICATION 2 | Verification is intended to assist the Chocolatey moderators and community 3 | in verifying that this package's contents are trustworthy. 4 | 5 | I am the author of this software -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for taking the time to help make rename-cli even better! Please follow the guidelines below if you wish to submit issues or add functionality to rename-cli. 4 | 5 | ## Setting up your dev environment 6 | 7 | 1. Install Node 12 or later https://nodejs.org/en/download/ 8 | 1. Install git https://git-scm.com/downloads 9 | 1. Install VSCode and the ESLint extension (optional, but recommended) 10 | - https://code.visualstudio.com/Download 11 | - https://vscodium.com/ (fully FOSS version) 12 | 1. Clone your fork of the rename-cli repo to your local machine 13 | 1. `cd` into the `node-rename-cli` directory and run `npm install` 14 | 15 | ## Adding tests 16 | 17 | Going forward, when adding functionality or making fixes, unit tests must be added if they don't already exist for the functionality. These tests exist in `tests/test.js`. 18 | 19 | The `test.js` file will create a `test` directory with a bunch of files in it for testing and you can add code to create more test files if you need. Then add a new test to the end of the existing tests using the `runTest()` helper method. 20 | 21 | That method takes the following arguments `runTest('your command here', 'Short description of what is being tested', [String or Array of Strings of source file name(s)], [String or Array of Strings of new file name(s)]);` 22 | 23 | After you have added your test(s) you can then run `npm test` from the root directory of the project. If all the tests are passing you can then commit, push, and create a pull request. -------------------------------------------------------------------------------- /docs/README6.md: -------------------------------------------------------------------------------- 1 | # Rename-CLI v6 2 | A cross-platform tool for renaming files quickly, especially multiple files at once. 3 | 4 | ![gif preview](../images/rename.gif) 5 | 6 | ## Features 7 | - Glob file matching 8 | - Undo previous rename 9 | - Variable replacement in output file name 10 | - Ability to add your own variables 11 | - Auto-indexing when renaming multiple files 12 | - RegEx support for using part(s) of original file name 13 | - RegEx replace part(s) of the original file name 14 | - Exif data support 15 | 16 | ## Usage 17 | ```rename [options] file(s) new-file-name``` 18 | 19 | *Note: Windows users (or anyone who wants to type one less letter) can use rname instead of rename since the rename command already exists in Windows* 20 | 21 | The new file name does not need to contain a file extension. If you do not specifiy a file extension the original file extension will be preserved. *Note: if you include periods in your new file name, you should include a file extension to prevent whatever is after the last period from becoming the new extension.* 22 | 23 | ### Options 24 | ```-h```, ```--help```: Show help 25 | ```-i```, ```--info```: View online help 26 | ```-w```, ```--wizard```: Run a wizard to guide you through renaming files 27 | ```-u```, ```--undo```: Undo previous rename operation 28 | ```-r "RegEx"```: See [RegEx](#regex) section for more information 29 | ```-k```, ```--keep```: Keep both files when output file name already exists (append a number) 30 | ```-f```, ```--force```: Force overwrite without prompt when output file name already exists 31 | ```-s```, ```--sim```: Simulate rename and just print new file names 32 | ```-n```, ```--noindex```: Do not append an index when renaming multiple files. Use with caution. 33 | ```-d```, ```--ignoredirectories```: Do not rename directories 34 | ```-p```, ```--prompt```: Print all rename operations to be completed and confirm before proceeding 35 | ```-v```, ```--verbose```: Print all rename operations to be completed and confirm before proceeding with bonus variable logging 36 | ```--notrim```: Do not trim whitespace at beginning or end of ouput file name 37 | ```--nomove ```: Do not move files if their new file name points to a different directory 38 | ```--createdirs```: Automatically create missing directories (cannot be used with `--nomove`) 39 | 40 | ### Variables 41 | The new file name can contain any number of variables that will be replaced with their value. Some variables can take parameters and will be indicated in their description. To pass a parameter to a variable, just use the variable name followed by a pipe and the parameter. **The output file name must be surrounded by quotes when using parameters.** See the first example below for how to use parameters. 42 | 43 | `{{i}}` Index: The index of the file when renaming multiple files. Parameters: starting index, default is `1`: `{{i|starting index}}` 44 | 45 | `{{f}}` File name: The original name of the file. Parameters: Param 1: `upper`, `lower`, `camel`, `pascal`, blank for unmodified, or `replace`. If replace, then Param2: search string and Param3: replace string: `{{f|modifier}}` or `{{f|replace|search|replacement}}` 46 | 47 | `{{p}}` Parent directory: The name of the parent directory. Parameters: Param 1: `upper`, `lower`, `camel`, `pascal`, blank for unmodified, or `replace`. If replace, then Param2: search string and Param3: replace string: `{{p|modifier}}` or `{{p|replace|search|replacement}}` 48 | 49 | `{{replace}}` Replace: Replace one string in the original file name with another. Parameters: The string to start with, a string to search for, and a string to replace it with: `{{replace|SomeStringOrVariable|search|replacement}}` 50 | 51 | `{{r}}` RegEx: The specified match of the RegEx pattern(s) specified in `-r`. Parameters: the number of the regex match, default is `0`: `{{r|match number}}` 52 | 53 | `{{ra}}` RegEx All: All matches of the RegEx pattern specified in `-r`. Parameters: separator character(s), default is none: `{{ra|separator}}` 54 | 55 | `{{rn}}` RegEx Not: Everything but the matches of the RegEx pattern specified in `-r`. Parameters: replacement character(s), default is none: `{{rn|separator}}` 56 | 57 | `{{regex}}` RegEx v2: The match(es) of the RegEx pattern specified. Parameters: the regular expression, optional flags, and the number of the regex match or the joiner for all matches: `{{regex||regular expression||flags||number or joiner}}` 58 | 59 | `{{date}}` Dates: Insert a date in a specific format. Parameters: the first parameter should be one of the following: `c[urrent]`, `cr[eate]`, `m[odify]`, or `a[ccess]`, and the second parameter is the date format which defaults to `yyyymmdd`: `{{date|type|format}}` 60 | 61 | `{{g}}` GUID: A globally unique identifier. Parameters: pattern using x's which will be replaced as random 16bit characters and y's which will be replaced with a, b, 8, or 9. Default is `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`: `{{g}}` 62 | 63 | `{{exif}}` Exif Information: Photo Exif Information. Parameters: the first parameter should be one of the following: i[so], f[num], e[xposure], d[ate], h[eight], or w[idth]. If the first parameter is d[ate], then also include another parameter for the date format: `{{exif|property|date format}}` 64 | 65 | ### RegEx 66 | As of version `6.0.0` a new way of using regular expressions has been added. You can now simply add a `{{regex||regular expression||flags||number or joiner}}` replacement variable in the output file name to include the result(s) of the specified regular expression on the original file name. No need to use the `-r` option and no need for forward slashes in your regular expression. 67 | 68 | *Old method*: When you specify a RegEx pattern with the `-r` option, the regular expression will be run against the original file name and the first match will be used to replace `{{r}}` in the output file name. You can also use `{{ra}}` in the output file name to keep all matches separated by a string you supply as an argument (or no argument to just append all matches together). If the regular expression fails to match, an empty string will be returned. **DO NOT** include the forward slashes in your RegEx pattern. 69 | 70 | **Regex Replace:** 71 | You can write RegEx to replace characters you don't want. Let's say you want to replace all spaces in a file name with a `-`. To do this, use an output file name like this: `{{regex||[^ ]+||g||-}}`. The regular expression `[^ ]+` will look for multiple non-space characters in a row and join them with a `-`. With the new `replace` option for the `{{f}}` variable you can simplify your output file name by using use the following: `{{f|replace| |-}}`. In both of these examples, all spaces will be replaced by dashes, so if you had a file named ```My Text File.txt``` it would become ```My-Text-File.txt```. 72 | 73 | **Groups:** 74 | You can write RegEx to capture one or more named groups and then use those groups in your output file name. The groups should be written like: ```(?regular expression here)```. If the RegEx groups do not return a match, the replacement variables in the output file name will be blank, so be sure to test with the -s option. See the third example below for how to use RegEx groups. 75 | 76 | ## Examples 77 | 78 | 1. Prepend date to file name. Date formatting options can be found [here](https://github.com/felixge/node-dateformat#mask-options). 79 | 80 | ```sh 81 | rename *.log "{{date|current|yyyymmdd}}{{f}}" 82 | node.log → 20170303node.log 83 | system.log → 20170303system.log 84 | ``` 85 | ##### *Note: the default parameters for the date variable are current and yyyymmdd so in the above example you could just write ```rename *.log {{date}}{{f}}``` to achieve the same result. You can see default parameters for variables by typing ```rename -h```.* 86 | 87 | 1. Rename all files the same and an index number will be appended. The index will be prepended with the correct number of zeroes to keep file order the same. For example, if you are renaming 150 files, the first index will be 001. You can change the starting index by adding the index variable with a parameter ```{{i|42}}``` If you don't want to include indexes use the ```-n``` option and you will be prompted for any file conflicts. Each file extension in a rename operation will have its own independent index. 88 | 89 | ```sh 90 | rename *.log test 91 | node.log → test1.log 92 | system.log → test2.log 93 | ``` 94 | 95 | 1. Use RegEx groups to reuse sections of the original file name. 96 | 97 | ```sh 98 | rename -r "- (?[A-Za-z]+) (?\d{4})" --noindex ExpenseReport*.pdf "{{year}} - {{month}} Expense Report" 99 | ExpenseReport - August 2016.pdf → 2016 - August Expense Report.pdf 100 | ExpenseReport - March 2015.pdf → 2015 - March Expense Report.pdf 101 | ExpenseReport - October 2015.pdf → 2015 - October Expense Report.pdf 102 | ``` 103 | 104 | 1. Use all RegEx matches in the output file name separated by a space. RegEx explaination: ```\w+``` captures a string of 1 or more word characters (A-Z, a-z, and _), ```(?=.+\d{4})``` is a forward lookahead for a number of 4 digits (this means it will only find words before the number), and then ```|``` which means 'or', and finally ```\d{4}``` a number of 4 digits. 105 | 106 | New Method: 107 | 108 | ```sh 109 | rename My.File.With.Periods.2016.more.info.txt "{{regex||\w+(?=.+\d{4})|\d{4}||g|| }}" 110 | 111 | My.File.With.Periods.2016.more.info.txt → My File With Periods 2016.txt 112 | ``` 113 | 114 | Old Method: 115 | 116 | ```sh 117 | rename -r "\w+(?=.+\d{4})|\d{4}" My.File.With.Periods.2016.more.info.txt "{{ra| }}" 118 | 119 | My.File.With.Periods.2016.more.info.txt → My File With Periods 2016.txt 120 | ``` 121 | 122 | 1. Use multiple RegEx options with `{{rn}}` to filter out different parts of the input file name in order. RegEx and parameter explaination: first .2016. and all following characters are replaced due to the first RegEx rule `-r "\.\d{4}\..+"`, then we match just the year with the second RegEx rule for use later with `{{r|1}}`, and then all periods are replaced due to the third RegEx rule. Finally we add back the year inside parenthesis. Since JavaScript uses 0 as the first index of an array, 1 finds the second regex match which is just the year as specified by ` -r "\d{4}"`. 123 | 124 | ```sh 125 | rename -r "\.\d{4}\..+" -r "\d{4}" -r "\." My.File.With.Periods.2016.more.info.txt "{{rn| }}({{r|1}})" 126 | My.File.With.Periods.2016.more.info.txt → My File With Periods (2016).txt 127 | ``` 128 | 129 | 1. Extract Exif data from jpg images. 130 | 131 | ```sh 132 | rename *.jpg "{{exif|d}}-NewYorkCity{{i}}-ISO{{exif|iso}}-f{{exif|f}}-{{exif|e}}s" 133 | DSC_5621.jpg → 20150927-NewYorkCity1-ISO250-f5.6-10s.jpg 134 | DSC_5633.jpg → 20150928-NewYorkCity2-ISO125-f7.1-1/400s.jpg 135 | DSC_5889.jpg → 20150930-NewYorkCity3-ISO125-f4.5-1/200s.jpg 136 | ``` 137 | 138 | ## Installation 139 | 1. Install NodeJS if you haven't already https://nodejs.org 140 | 1. Type `npm install -g rename-cli@6` into your terminal or command window. 141 | 142 | ## Adding custom replacement variables 143 | Whenever you run rename for the first time a file ```~/.rename/replacements.js``` or ```C:\Users\[username]\.rename\replacements.js``` is created. You can edit this file and add your own replacement variables **and override** the default replacements if desired. The user replacements.js file contains a decent amount of documentation in it and you can check out the default [replacements.js](lib/replacements.js) file for more examples. If you come up with some handy replacements, feel free to submit them to be included in the defaults with a pull request or submit it as an issue. 144 | 145 | ## Libraries Used 146 | - yargs https://github.com/yargs/yargs 147 | - blessed https://github.com/chjj/blessed 148 | - globby https://github.com/sindresorhus/globby 149 | - fs-extra https://github.com/jprichardson/node-fs-extra 150 | - prompt-sync https://github.com/0x00A/prompt-sync 151 | - node-dateformat https://github.com/felixge/node-dateformat 152 | - named-js-regexp https://github.com/edvinv/named-js-regexp 153 | - fraction.js https://github.com/infusion/Fraction.js 154 | - jpeg-exif https://github.com/zhso/jpeg-exif 155 | - opn https://github.com/sindresorhus/opn 156 | - path-exists https://github.com/sindresorhus/path-exists 157 | - chalk https://github.com/chalk/chalk 158 | - cli-clear https://github.com/stevenvachon/cli-clear 159 | - inquirer https://github.com/SBoudrias/Inquirer.js 160 | - clipboardy https://github.com/sindresorhus/clipboardy 161 | - remark https://github.com/wooorm/remark 162 | -------------------------------------------------------------------------------- /docs/Releases.md: -------------------------------------------------------------------------------- 1 | # Release Guide 2 | When ready to release an update, follow these steps to ensure all installation methods work. 3 | 4 | 1. Ensure the version number is updated in [package.json](../package.json) 5 | 1. Draft a release on the GitHub [releases](https://github.com/jhotmann/node-rename-cli/releases) page with information about the update. Make sure to link to any fixed bugs. 6 | 1. Download the binaries from the latest [automated build](https://github.com/jhotmann/node-rename-cli/actions) and attach to the release. 7 | 1. Publish the release to NPM `npm publish[ --tag beta]`, ran from the project's root directory. 8 | 1. Update the [Homebrew formula](https://github.com/jhotmann/homebrew-rename-cli/blob/master/Formula/rename-cli.rb) with the latest version number and sha256 of the gzip `shasum -a 256 rename-cli-X.X.X.tgz`. 9 | 1. Download and publish the updated nupkg to Chocolatey `choco push .\rename-cli.X.X.X.nupkg --source https://push.chocolatey.org`. -------------------------------------------------------------------------------- /images/rename.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhotmann/node-rename-cli/216129692cf09947ff1289eef9ce08c42a98b48e/images/rename.gif -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | Rename CLI 11 | 12 | 16 | 17 | 30 | 31 | 32 | 33 | 34 | 42 | 43 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=759670 3 | // for the documentation about the jsconfig.json format 4 | "compilerOptions": { 5 | "target": "es2020", 6 | "module": "commonjs", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "exclude": [ 10 | "node_modules", 11 | "bower_components", 12 | "jspm_packages", 13 | "tmp", 14 | "temp" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /lib/filters/custom.js: -------------------------------------------------------------------------------- 1 | const defaultFilters = require('nunjucks/src/filters'); 2 | const Fraction = require('fraction.js'); 3 | 4 | module.exports = { 5 | big: defaultFilters.upper, 6 | little: defaultFilters.lower, 7 | pascal: function(str) { 8 | str = str || ''; 9 | return str.toLowerCase().replace(/(?:^\w|[A-Z]|\b\w)/g, function(letter) { 10 | return letter.toUpperCase(); 11 | }).replace(/[\s\-_.]+/g, ''); 12 | }, 13 | camel: function(str) { 14 | str = str || ''; 15 | return str.toLowerCase().replace(/(?:^\w|[A-Z]|\b\w)/g, function(letter, index) { 16 | return index === 0 ? letter.toLowerCase() : letter.toUpperCase(); 17 | }).replace(/[\s\-_.]+/g, ''); 18 | }, 19 | kebab: function(str) { 20 | str = str || ''; 21 | return str 22 | .match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g) 23 | .map(x => x.toLowerCase()) 24 | .join('-'); 25 | }, 26 | snake: function(str) { 27 | str = str || ''; 28 | return str 29 | .match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g) 30 | .map(x => x.toLowerCase()) 31 | .join('_'); 32 | }, 33 | fraction: function(str, separator, largerThanOneSeparator) { 34 | let frac = new Fraction(str); 35 | if (isNaN(frac.n) || isNaN(frac.d)) return ''; 36 | return frac.toFraction(true).replace(' ', largerThanOneSeparator || ' ').replace('/', separator || '-'); 37 | }, 38 | match: function(str, regexp, flags, group) { 39 | if (regexp instanceof RegExp === false) { 40 | regexp = new RegExp(regexp, flags); 41 | } 42 | if (Number.isInteger(flags) && group === undefined) group = flags; 43 | group = group || 0; 44 | let results = str.match(regexp); 45 | if (typeof group === 'string') { 46 | return results.groups[group]; 47 | } 48 | return results[group]; 49 | }, 50 | regexReplace: function (str, regexp, flags, replacement) { 51 | if (!regexp) return str; 52 | flags = flags || ''; 53 | if (replacement === undefined) { 54 | if (flags.match(/^[gimsuy]+$/)) { 55 | replacement = ''; 56 | } else { 57 | replacement = flags; 58 | flags = undefined; 59 | } 60 | } 61 | let re = new RegExp(regexp, flags); 62 | return str.replace(re, replacement); 63 | }, 64 | padNumber: function(str, length) { 65 | if (typeof length === "string") length = length.length; 66 | while (str.length < length) { 67 | str = '0' + str; 68 | } 69 | return str; 70 | } 71 | }; -------------------------------------------------------------------------------- /lib/filters/date.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Modified nunjucks-date-filter 3 | * https://github.com/piwi/nunjucks-date-filter 4 | * 5 | * Copyright (c) 2015 Pierre Cassat 6 | * Licensed under the Apache 2.0 license. 7 | */ 8 | 9 | 'use strict'; 10 | 11 | const format = require('date-fns/format'); 12 | const nunjucks = require('nunjucks'); 13 | 14 | // default default format (ISO 8601) 15 | let dateFilterDefaultFormat = 'yyyyMMdd'; 16 | 17 | // a date filter for Nunjucks 18 | // usage: {{ my_date | date(format) }} 19 | function dateFilter(date, dateFormat) { 20 | try { 21 | return format(date, dateFormat || dateFilterDefaultFormat); 22 | } catch (e) { 23 | return ''; 24 | } 25 | } 26 | module.exports = dateFilter; 27 | 28 | // set default format for date 29 | module.exports.setDefaultFormat = function(dateFormat) { 30 | dateFilterDefaultFormat = dateFormat; 31 | }; 32 | 33 | // install the filter to nunjucks environment 34 | module.exports.install = function(env, customName) { 35 | (env || nunjucks.configure()).addFilter(customName || 'date', dateFilter); 36 | }; 37 | -------------------------------------------------------------------------------- /lib/userData.js: -------------------------------------------------------------------------------- 1 | // These are some helpful libraries already included in rename-cli 2 | // All the built-in nodejs libraries are also available 3 | // const exif = require('jpeg-exif'); // https://github.com/zhso/jpeg-exif 4 | // const fs = require('fs-extra'); // https://github.com/jprichardson/node-fs-extra 5 | // const Fraction = require('fraction.js'); // https://github.com/infusion/Fraction.js 6 | // const { format } = require('date-fns'); // https://date-fns.org/ 7 | 8 | module.exports = function(fileObj, descriptions) { 9 | let returnData = {}; 10 | let returnDescriptions = {}; 11 | 12 | // Put your code here to add properties to returnData 13 | // this data will then be available in your output file name 14 | // for example: returnData.myName = 'Your Name Here'; 15 | // or: returnData.backupDir = 'D:/backup'; 16 | 17 | // To describe a variable and have it show when printing help information 18 | // add the same path to the returnDescriptions object with a string description 19 | // for example: returnData.myName = 'My full name'; 20 | // or: returnData.backupDir = 'The path to my backup directory'; 21 | 22 | if (!descriptions) return returnData; 23 | else return returnDescriptions; 24 | }; -------------------------------------------------------------------------------- /lib/userFilters.js: -------------------------------------------------------------------------------- 1 | // Uncomment the next line to create an alias for any of the default Nunjucks filters https://mozilla.github.io/nunjucks/templating.html#builtin-filters 2 | // const defaultFilters = require('../nunjucks/src/filters'); 3 | // These are some helpful libraries already included in rename-cli 4 | // All the built-in nodejs libraries are also available 5 | // const exif = require('jpeg-exif'); // https://github.com/zhso/jpeg-exif 6 | // const fs = require('fs-extra'); // https://github.com/jprichardson/node-fs-extra 7 | // const Fraction = require('fraction.js'); // https://github.com/infusion/Fraction.js 8 | // const { format } = require('date-fns'); // https://date-fns.org/ 9 | 10 | // Nunjucks custom filter documentation https://mozilla.github.io/nunjucks/api#custom-filters 11 | 12 | module.exports = { 13 | // Create an alias for a built-in filter 14 | // big: defaultFilters.upper, 15 | // Create your own filter 16 | // match: function(str, regexp, flags) { 17 | // if (regexp instanceof RegExp === false) { 18 | // regexp = new RegExp(regexp, flags); 19 | // } 20 | // return str.match(regexp); 21 | // } 22 | }; -------------------------------------------------------------------------------- /lib/yargsOptions.js: -------------------------------------------------------------------------------- 1 | // Object containing all yargs options http://yargs.js.org/docs/#api-optionskey-opt 2 | // showInUi is an added boolean that controls if the option shows in the UI Info List 3 | 4 | module.exports = { 5 | 'h': { 6 | boolean: true, 7 | alias: 'help', 8 | showInUi: false 9 | }, 'i': { 10 | alias: 'info', 11 | boolean: true, 12 | describe: 'View online help', 13 | showInUi: false 14 | }, 'w': { 15 | alias: 'wizard', 16 | boolean: true, 17 | describe: 'Run a wizard to guide you through renaming files', 18 | showInUi: false 19 | }, 'u': { 20 | alias: 'undo', 21 | boolean: true, 22 | describe: 'Undo previous rename operation', 23 | showInUi: false 24 | }, 'k': { 25 | alias: 'keep', 26 | boolean: true, 27 | describe: 'Keep both files when output file name already exists (append a number)', 28 | showInUi: true 29 | }, 'f': { 30 | alias: 'force', 31 | boolean: true, 32 | describe: 'Force overwrite without prompt when output file name already exists and create missing directories', 33 | showInUi: true 34 | }, 's': { 35 | alias: 'sim', 36 | boolean: true, 37 | describe: 'Simulate rename and just print new file names', 38 | showInUi: true 39 | }, 'n': { 40 | alias: 'noindex', 41 | boolean: true, 42 | describe: 'Do not append an index when renaming multiple files', 43 | showInUi: true 44 | }, 'd': { 45 | alias: 'ignoredirectories', 46 | boolean: true, 47 | describe: 'Do not rename directories', 48 | showInUi: true 49 | }, 'sort': { 50 | boolean: false, 51 | string: true, 52 | choices: ['none', 'alphabet', 'reverse-alphabet', 'date-create', 'reverse-date-create', 'date-modified', 'reverse-date-modified', 'size', 'reverse-size'], 53 | default: 'none', 54 | describe: 'Sort files before renaming.', 55 | showInUi: true 56 | }, 'v': { 57 | alias: 'verbose', 58 | boolean: true, 59 | describe: 'Prints all rename operations as they occur', 60 | showInUi: true 61 | }, 'notrim': { 62 | boolean: true, 63 | describe: 'Do not trim whitespace at beginning or end of ouput file name', 64 | showInUi: true 65 | }, 'nomove': { 66 | boolean: true, 67 | describe: 'Do not move files if their new file name points to a different directory', 68 | showInUi: true 69 | }, 'noext': { 70 | boolean: true, 71 | describe: 'Do not automatically append a file extension if one isn\'t supplied (may be necessary if using a variable for an extension)', 72 | showInUi: true 73 | }, 'createdirs': { 74 | boolean: true, 75 | describe: 'Automatically create missing directories', 76 | showInUi: true 77 | }, 'printdata': { 78 | boolean: true, 79 | describe: 'Print the data available for a file', 80 | showInUi: false 81 | }, 'noundo': { 82 | boolean: true, 83 | describe: 'Don\'t write an undo file', 84 | showInUi: false 85 | }, 'history': { 86 | boolean: false, 87 | number: true, 88 | describe: 'View previous commands. Optional Parameter: the number of previous commands to fetch.', 89 | showInUi: false 90 | }, 'favorites': { 91 | boolean: false, 92 | string: true, 93 | alias: 'favourites', 94 | describe: 'View saved favorites. Optional Parameter: the id or alias of the favorite to run.', 95 | showInUi: false 96 | } 97 | }; 98 | 99 | /* this option was lost at some point 100 | 'p': { 101 | alias: 'prompt', 102 | boolean: true, 103 | describe: 'Print all rename operations to be completed and confirm before proceeding', 104 | showInUi: true 105 | }, 106 | */ -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Jordan Hotmann 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /model/batch.js: -------------------------------------------------------------------------------- 1 | module.exports = (sequelize, DataTypes) => { 2 | return sequelize.define('Batch', { 3 | id: { 4 | type: DataTypes.INTEGER, 5 | autoIncrement: true, 6 | primaryKey: true 7 | }, 8 | command: { 9 | type: DataTypes.TEXT, 10 | allowNull: false 11 | }, 12 | cwd: { 13 | type: DataTypes.TEXT, 14 | allowNull: false 15 | }, 16 | undone: { 17 | type: DataTypes.BOOLEAN, 18 | defaultValue: false 19 | } 20 | }, { 21 | tableName: 'batches', 22 | timestamps: true 23 | }); 24 | }; -------------------------------------------------------------------------------- /model/favorites.js: -------------------------------------------------------------------------------- 1 | module.exports = (sequelize, DataTypes) => { 2 | return sequelize.define('Favorites', { 3 | id: { 4 | type: DataTypes.INTEGER, 5 | autoIncrement: true, 6 | primaryKey: true 7 | }, 8 | command: { 9 | type: DataTypes.TEXT, 10 | allowNull: false 11 | }, 12 | alias: { 13 | type: DataTypes.TEXT, 14 | allowNull: true, 15 | unique: true 16 | } 17 | }, { 18 | tableName: 'favorites', 19 | timestamps: true 20 | }); 21 | }; -------------------------------------------------------------------------------- /model/operation.js: -------------------------------------------------------------------------------- 1 | module.exports = (sequelize, DataTypes) => { 2 | return sequelize.define('Op', { 3 | id: { 4 | type: DataTypes.INTEGER, 5 | autoIncrement: true, 6 | primaryKey: true 7 | }, 8 | input: { 9 | type: DataTypes.TEXT, 10 | allowNull: false 11 | }, 12 | output: { 13 | type: DataTypes.TEXT, 14 | allowNull: false 15 | }, 16 | undone: { 17 | type: DataTypes.BOOLEAN, 18 | defaultValue: false 19 | } 20 | }, { 21 | tableName: 'operations', 22 | timestamps: true, 23 | updatedAt: false 24 | }); 25 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rename-cli", 3 | "version": "7.0.3", 4 | "description": "A command line utility for renaming files", 5 | "main": "bin.js", 6 | "preferGlobal": true, 7 | "bin": { 8 | "rename": "./bin.js", 9 | "rname": "./bin.js" 10 | }, 11 | "scripts": { 12 | "test": "jest", 13 | "build": "pkg --out-path bin -t latest-macos-x64,latest-linux-x64 .", 14 | "build-win": "pkg --out-path bin -t latest-win-x64 .", 15 | "chocolatey": "node chocolatey/packager.js" 16 | }, 17 | "author": "Jordan Hotmann", 18 | "license": "MIT", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/jhotmann/node-rename-cli.git" 22 | }, 23 | "dependencies": { 24 | "async": "^3.2.0", 25 | "chalk": "^4.1.0", 26 | "cli-clear": "^1.0.4", 27 | "clipboardy": "^2.2.0", 28 | "date-fns": "^2.16.1", 29 | "fs-extra": "^9.0.1", 30 | "globby": "^11.0.0", 31 | "inquirer": "^7.1.0", 32 | "jpeg-exif": "^1.1.4", 33 | "mp3tag.js": "^2.2.0", 34 | "fraction.js": "^4.0.0", 35 | "nunjucks": "^3.2.1", 36 | "opn": "^6.0.0", 37 | "path-exists": "^4.0.0", 38 | "readline-sync": "^1.4.10", 39 | "sequelize": "^6.3.5", 40 | "sqlite3": "^5.0.0", 41 | "terminal-kit": "^1.44.0", 42 | "traverse": "^0.6.6", 43 | "yargs": "^15.3.0" 44 | }, 45 | "pkg": { 46 | "assets": [ 47 | "lib/userData.js", 48 | "lib/userFilters.js", 49 | "node_modules/terminal-kit/**/*", 50 | "node_modules/opn/xdg-open" 51 | ] 52 | }, 53 | "devDependencies": { 54 | "eslint": "^7.14.0", 55 | "eslint-plugin-jest": "^24.1.3", 56 | "jest": "^26.6.3" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/batch.js: -------------------------------------------------------------------------------- 1 | const async = require('async'); 2 | const util = require('./util'); 3 | 4 | const { Operation } = require('./operation'); 5 | const { Options } = require('./options'); 6 | 7 | module.exports.Batch = class Batch { 8 | constructor(argv, options, sequelize) { 9 | this.command = process.argv; 10 | this.argv = argv; 11 | this.options = options || new Options(argv); 12 | this.sequelize = sequelize; 13 | this.operations = this.options.inputFiles.map(f => { return new Operation(f, this.options, this.sequelize); }); 14 | this.batchId; 15 | } 16 | 17 | setCommand(commandString) { 18 | this.command = commandString.split(' '); 19 | } 20 | 21 | async complete() { 22 | await this.replaceVariables(); 23 | await this.sort(); 24 | await this.indexAndFindConflicts(); 25 | await this.execute(); 26 | } 27 | 28 | async replaceVariables() { 29 | await async.eachSeries(this.operations, async (operation) => { await operation.replaceVariables(); }); 30 | } 31 | 32 | async sort() { 33 | if (this.options.sort) { // sort files 34 | if (this.options.sort.includes('alphabet')) { 35 | this.operations = this.operations.sort((a,b) => { 36 | if (a.inputFileString < b.inputFileString) return -1; 37 | if (a.inputFileString > b.inputFileString) return 1; 38 | return 0; 39 | }); 40 | } else if (this.options.sort.includes('date-create')) this.operations = this.operations.sort((a,b) => { return b.fileData.date.create - a.fileData.date.create; }); 41 | else if (this.options.sort.includes('date-modify')) this.operations = this.operations.sort((a,b) => { return b.fileData.date.modify - a.fileData.date.modify; }); 42 | else if (this.options.sort.includes('size')) this.operations = this.operations.sort((a,b) => { return b.fileData.stats.size - a.fileData.stats.size; }); 43 | if (this.options.sort.includes('reverse')) this.operations.reverse(); 44 | } 45 | } 46 | 47 | async indexAndFindConflicts() { 48 | let outputPaths = this.operations.map(o => o.outputFileString); 49 | let uniqueOutputs = [...new Set(outputPaths)]; 50 | await async.eachSeries(uniqueOutputs, async (d) => { 51 | // find the total operations that have this same output 52 | let filteredOps = this.operations.filter(o => o.outputFileString === d); 53 | if (filteredOps.length > 1 && this.options.verbose) console.log(`${filteredOps.length} operations have the same output path: ${d}`); 54 | for (let i = 0; i < filteredOps.length; i++) { 55 | // if this is a unique output, don't put an index 56 | if (filteredOps.length === 1) { await filteredOps[i].setIndex(''); } 57 | // if there are multiple, append the index or put the index wherever {{i}} 58 | else { // going to have a file conflict 59 | if (this.options.noIndex) { 60 | await filteredOps[i].setIndex(''); 61 | filteredOps[i].setConflict(true); 62 | } else { // set the index to avoid a conflict 63 | await filteredOps[i].setIndex(util.leftPad(i + 1, filteredOps.length, '0')); 64 | } 65 | } 66 | } 67 | }); 68 | } 69 | 70 | async execute() { 71 | if (!this.options.simulate && !this.options.noUndo) { // create a batch in the database 72 | let batch = this.sequelize.models.Batch.build({ command: JSON.stringify(this.command), cwd: process.cwd() }); 73 | await batch.save(); 74 | this.batchId = batch.id; 75 | } 76 | // run the rename operations 77 | await async.eachSeries(this.operations, async (o) => await o.run(this.batchId)); 78 | } 79 | }; -------------------------------------------------------------------------------- /src/database.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const os = require('os'); 3 | const path = require('path'); 4 | const { Sequelize, DataTypes } = require('sequelize'); 5 | 6 | module.exports.init = async function() { 7 | let sequelize = await dbInit(path.join(os.homedir(), '.rename', 'rename.db')); 8 | return sequelize; 9 | }; 10 | 11 | module.exports.initTest = async function() { 12 | let sequelize = await dbInit(path.join(os.homedir(), '.rename', 'test.db')); 13 | return sequelize; 14 | }; 15 | 16 | async function dbInit(dbPath) { 17 | let sequelize = new Sequelize({ dialect: 'sqlite', storage: dbPath, logging: false }); 18 | const Batch = await require('../model/batch')(sequelize, DataTypes); 19 | const Op = await require('../model/operation')(sequelize, DataTypes); 20 | await require('../model/favorites')(sequelize, DataTypes); 21 | await Batch.hasMany(Op); 22 | await Op.belongsTo(Batch); 23 | const dbFileExists = await fs.pathExists(dbPath); 24 | if (!dbFileExists) { 25 | await sequelize.sync({ alter: true }); 26 | } 27 | return sequelize; 28 | } -------------------------------------------------------------------------------- /src/favorites.js: -------------------------------------------------------------------------------- 1 | const async = require('async'); 2 | const clear = require('cli-clear'); 3 | const inquirer = require('inquirer'); 4 | const os = require('os'); 5 | const term = require('terminal-kit').terminal; 6 | const yargs = require('yargs'); 7 | 8 | const util = require('./util'); 9 | const yargsOptions = require('../lib/yargsOptions'); 10 | 11 | const { Batch } = require('./batch'); 12 | 13 | module.exports.Favorites = class Favorites { 14 | constructor(sequelize, options) { 15 | this.sequelize = sequelize; 16 | this.options = options; 17 | } 18 | 19 | async display() { 20 | this.allFavorites = await this.sequelize.models.Favorites.findAll(); 21 | clear(); 22 | let choices = await async.mapSeries(this.allFavorites, async (f) => { 23 | return { 24 | name: `${f.id}: ${util.argvToString(JSON.parse(f.command))}${ f.alias ? ` (${f.alias})` : ''}`, 25 | value: f.id 26 | }; 27 | }); 28 | choices.push({ name: 'Exit', value: -1 }); 29 | const selection = await inquirer.prompt({ 30 | type: 'list', 31 | loop: false, 32 | message: 'Select a favorite to view options', 33 | name: 'favorite', 34 | default: 0, 35 | choices: choices, 36 | pageSize: 20 37 | }); 38 | if (selection.favorite === -1) process.exit(0); 39 | await this.get(selection.favorite); 40 | await this.displayFavorite(); 41 | } 42 | 43 | async displayFavorite() { 44 | clear(); 45 | console.log(`${this.selected.id}: ${util.argvToString(JSON.parse(this.selected.command))}${ this.selected.alias ? ` (${this.selected.alias})` : ''}`); 46 | const selection = await inquirer.prompt({ 47 | type: 'list', 48 | loop: false, 49 | message: 'What would you like to do?', 50 | name: 'choice', 51 | default: 0, 52 | choices: ['Run', 'Edit Command', 'Edit Alias', 'Delete', 'Go Back', 'Exit'] 53 | }); 54 | switch (selection.choice) { 55 | case 'Run': 56 | await this.run(); 57 | return; 58 | case 'Edit Command': 59 | await this.editCommand(); 60 | await this.displayFavorite(); 61 | return; 62 | case 'Edit Alias': 63 | await this.editAlias(); 64 | await this.displayFavorite(); 65 | return; 66 | case 'Delete': 67 | await this.delete(); 68 | return; 69 | case 'Exit': 70 | process.exit(0); 71 | return; 72 | default: await this.display(); 73 | } 74 | } 75 | 76 | async get(idOrAlias) { 77 | if (idOrAlias && (Number.isInteger(idOrAlias) || idOrAlias.match(/^\d+$/))) { 78 | const id = parseInt(idOrAlias); 79 | await this.getById(id); 80 | } else if (idOrAlias) { 81 | await this.getByAlias(idOrAlias); 82 | } else if (this.options.favorites && this.options.favorites.match(/^\d+$/)) { 83 | const id = parseInt(this.options.favorites); 84 | await this.getById(id); 85 | } else if (this.options.favorites) { 86 | await this.getByAlias(this.options.favorites); 87 | } else { 88 | await this.display(); 89 | } 90 | } 91 | 92 | async getById(id) { 93 | this.selected = await this.sequelize.models.Favorites.findOne({ where: {id: id } }); 94 | } 95 | 96 | async getByAlias(alias) { 97 | this.selected = await this.sequelize.models.Favorites.findOne({ where: {alias: alias } }); 98 | } 99 | 100 | async run() { 101 | if (!this.selected) { 102 | console.log('Invalid ID or Alias'); 103 | return; 104 | } 105 | const theCommand = util.argvToString(JSON.parse(this.selected.command)); 106 | if (this.options.verbose) console.log(theCommand); 107 | let argv = yargs.options(yargsOptions).parse(theCommand.replace(/^re?name /, '')); 108 | let batch = new Batch(argv, null, this.sequelize); 109 | batch.setCommand(theCommand); 110 | await batch.complete(); 111 | } 112 | 113 | async delete() { 114 | await this.selected.destroy(); 115 | await this.display(); 116 | } 117 | 118 | async editCommand() { 119 | console.log('Edit the command and hit ENTER to save or ESC to cancel'); 120 | term('Command: '); 121 | let keyBindingOptions = { 122 | ENTER: 'submit' , 123 | KP_ENTER: 'submit' , 124 | ESCAPE: 'cancel' , 125 | BACKSPACE: 'backDelete' , 126 | DELETE: 'delete' , 127 | LEFT: 'backward' , 128 | RIGHT: 'forward' , 129 | UP: 'historyPrevious' , 130 | DOWN: 'historyNext' , 131 | HOME: 'startOfInput' , 132 | END: 'endOfInput' , 133 | TAB: 'autoComplete' , 134 | CTRL_R: 'autoCompleteUsingHistory' , 135 | CTRL_LEFT: 'previousWord' , 136 | CTRL_RIGHT: 'nextWord' , 137 | ALT_D: 'deleteNextWord' , 138 | CTRL_W: 'deletePreviousWord' , 139 | CTRL_U: 'deleteAllBefore' , 140 | CTRL_K: 'deleteAllAfter' 141 | }; 142 | if (os.platform() === 'darwin') keyBindingOptions.DELETE = 'backDelete'; 143 | const input = await term.inputField({ cancelable: true, default: util.argvToString(JSON.parse(this.selected.command)), keyBindings: keyBindingOptions }).promise; 144 | if (input) { 145 | this.selected.command = JSON.stringify(input.split(' ')); 146 | await this.selected.save(); 147 | } 148 | } 149 | 150 | async editAlias() { 151 | const input = await inquirer.prompt({ 152 | type: 'input', 153 | name: 'alias', 154 | message: 'Enter an alias for this favorite (optional)', 155 | }); 156 | if (input.alias) { 157 | try { 158 | this.selected.alias = input.alias; 159 | await this.selected.save(); 160 | } catch (e) { 161 | console.log(`${input.alias} is already taken, please input a different alias`); 162 | await this.editAlias(); 163 | } 164 | } 165 | } 166 | }; -------------------------------------------------------------------------------- /src/fileData.js: -------------------------------------------------------------------------------- 1 | const exif = require('jpeg-exif'); 2 | const os = require('os'); 3 | const MP3Tag = require('mp3tag.js'); 4 | const path = require('path'); 5 | const pathExists = require('path-exists'); 6 | const fs = require('fs-extra'); 7 | 8 | let userData; 9 | if (pathExists.sync(os.homedir() + '/.rename/userData.js')) { 10 | userData = require(os.homedir() + '/.rename/userData.js'); 11 | } else { 12 | userData = function() { return {}; }; 13 | } 14 | 15 | module.exports.FileData = class FileData { 16 | constructor(input, options) { 17 | this.options = options; 18 | this.parsedPath = path.parse(input); 19 | this.userData = userData(input); 20 | this.stats = getFileStats(input); 21 | this.now = new Date(); 22 | } 23 | 24 | async get() { 25 | let exifData = getExifData(this.parsedPath.dir + '/' + this.parsedPath.base); 26 | let tags = {}; 27 | if (!this.stats.isDirectory()) { 28 | const buffer = await fs.readFile(this.parsedPath.dir + '/' + this.parsedPath.base); 29 | const mp3tag = new MP3Tag(buffer, false); 30 | await mp3tag.read(); 31 | if (mp3tag.errorCode === -1) tags = mp3tag.tags; 32 | } 33 | 34 | const defaultData = { 35 | i: this.options.noIndex ? '' : '--FILEINDEXHERE--', 36 | f: this.parsedPath.name, 37 | fileName: this.parsedPath.name, 38 | ext: this.parsedPath.ext, 39 | isDirectory: this.stats.isDirectory(), 40 | p: path.basename(this.parsedPath.dir), 41 | parent: path.basename(this.parsedPath.dir), 42 | date: { 43 | current: this.now, 44 | now: this.now, 45 | create: this.stats.birthtime || '', 46 | modify: this.stats.mtime || '', 47 | access: this.stats.atime || '', 48 | }, 49 | os: { 50 | homedir: os.homedir(), 51 | platform: os.platform(), 52 | hostname: os.hostname(), 53 | user: os.userInfo().username 54 | }, 55 | guid: createGuid(), 56 | customGuid: (format) => { return createGuid(format); }, 57 | stats: this.stats, 58 | parsedPath: this.parsedPath, 59 | exif: { 60 | iso: (typeof(exifData) === 'object' && exifData.SubExif && exifData.SubExif.PhotographicSensitivity ? exifData.SubExif.PhotographicSensitivity : ''), 61 | fnum: (typeof(exifData) === 'object' && exifData.SubExif && exifData.SubExif.FNumber ? exifData.SubExif.FNumber : ''), 62 | exposure: (typeof(exifData) === 'object' && exifData.SubExif && exifData.SubExif.ExposureTime ? exifData.SubExif.ExposureTime : ''), 63 | date: (typeof(exifData) === 'object' && exifData.DateTime ? exifData.DateTime.split(/:|\s/)[1] : ''), 64 | width: (typeof(exifData) === 'object' && exifData.SubExif && exifData.SubExif.PixelXDimension ? exifData.SubExif.PixelXDimension : ''), 65 | height: (typeof(exifData) === 'object' && exifData.SubExif && exifData.SubExif.PixelYDimension ? exifData.SubExif.PixelYDimension : '') 66 | }, 67 | id3: { 68 | title: tags.title || '', 69 | artist: tags.artist || '', 70 | album: tags.album || '', 71 | year: tags.year || '', 72 | track: tags.track || '', 73 | totalTracks: (tags.TRCK && tags.TRCK.split('/')[1]) || '' 74 | } 75 | }; 76 | return Object.assign(defaultData, this.userData); 77 | } 78 | 79 | getDescriptions() { 80 | const defaultDescriptions = { 81 | i: 'Override where the file index will go if there are multiple files being named the same thing. By default it is appended to the end of the file name.', 82 | f: 'The original name of the file. Alias: fileName', 83 | ext: 'The original file extension of the file', 84 | isDirectory: 'true if the current input is a directory, false otherwise', 85 | p: 'The name of the parent directory. Alias: parent', 86 | date: { 87 | current: 'The current date/time. Alias: now', 88 | create: 'The date/time the file was created', 89 | modify: 'The date/time the file was last modified', 90 | access: 'The date/time the file was last accessed' 91 | }, 92 | os: { 93 | homedir: `The path to the current user's home directory`, 94 | platform: `The operating system platform: 'darwin', 'linux', or 'windows'`, 95 | user: 'The username of the current user' 96 | }, 97 | guid: 'A pseudo-random guid', 98 | stats: `All the input file's stats https://nodejs.org/api/fs.html#fs_class_fs_stats`, 99 | parsedPath: `The file's parsed path https://nodejs.org/api/path.html#path_path_parse_path`, 100 | exif: { 101 | iso: 'The ISO sensitivity of the camera when the photo was taken', 102 | fnum: 'The F-stop number of the camera when the photo was taken', 103 | exposure: 'The exposure time of the camera when the photo was taken. Use the fraction filter to convert decimals to fractions', 104 | date: 'The date on the camera when the photo was taken', 105 | width: 'The pixel width of the photo', 106 | height: 'The pixel height of the photo' 107 | }, 108 | id3: { 109 | title: 'The title of the song', 110 | artist: 'The artist of the song', 111 | album: 'The album of the song', 112 | year: 'The year of the song', 113 | track: 'The track number of the song', 114 | totalTracks: 'The number of tracks on the album' 115 | } 116 | }; 117 | return Object.assign(defaultDescriptions, userData(null, true)); 118 | } 119 | }; 120 | 121 | function createGuid(format) { 122 | format = format || 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'; 123 | return format.replace(/[xy]/g, function(c) { 124 | var r = Math.random()*16|0, v = c === 'x' ? r : (r&0x3|0x8); 125 | return v.toString(16); 126 | }); 127 | } 128 | 129 | function getExifData(file) { 130 | try { 131 | let data = exif.parseSync(file); 132 | return data; 133 | } catch (ex) { 134 | return ''; 135 | } 136 | } 137 | 138 | function getFileStats(file) { 139 | if (fs.existsSync(file)) { 140 | return fs.lstatSync(file); 141 | } else { 142 | return { 143 | isDirectory: function() { return false; }, 144 | birthtime: '', 145 | mtime: '', 146 | ctime: '', 147 | atime: '' 148 | }; 149 | } 150 | } -------------------------------------------------------------------------------- /src/history.js: -------------------------------------------------------------------------------- 1 | const async = require('async'); 2 | const clear = require('cli-clear'); 3 | const clipboardy = require('clipboardy'); 4 | const { format } = require('date-fns'); 5 | const fs = require('fs-extra'); 6 | const inquirer = require('inquirer'); 7 | const path = require('path'); 8 | const term = require('terminal-kit').terminal; 9 | const yargs = require('yargs'); 10 | 11 | const util = require('./util'); 12 | const yargsOptions = require('../lib/yargsOptions'); 13 | 14 | const { Batch } = require('./batch'); 15 | 16 | module.exports.History = class History { 17 | constructor(sequelize, options) { 18 | this.sequelize = sequelize; 19 | this.options = options; 20 | this.count = options.history || 10; 21 | this.includeUndone = !options.noUndo; 22 | this.batches = []; 23 | this.page = 1; 24 | } 25 | 26 | async getBatches(page) { 27 | if (parseInt(page) > 0) this.page = page; 28 | let queryParams = { 29 | limit: this.count, 30 | order: [[ 'createdAt', 'DESC' ]], 31 | include: this.sequelize.models.Op 32 | }; 33 | if (!this.includeUndone) queryParams.where = { undone: false }; 34 | if (page > 1) queryParams.offset = (this.count * (page - 1)); 35 | this.batches = await this.sequelize.models.Batch.findAll(queryParams); 36 | } 37 | 38 | async undo(ops, cwd, cls) { 39 | if (cls) clear(); 40 | cwd = (cwd || process.cwd()) + path.sep; 41 | await async.eachSeries(ops, async (o) => { 42 | if (this.options.verbose) console.log(`${o.output.replace(cwd, '')} → ${o.input.replace(cwd, '')}`); 43 | const fileExists = await fs.pathExists(o.output); 44 | if (!fileExists) { 45 | console.log(`${o.output} no longer exists`); 46 | } else { 47 | await fs.rename(o.output, o.input); 48 | o.undone = true; 49 | await o.save(); 50 | } 51 | }); 52 | } 53 | 54 | async undoBatch(index, cls) { 55 | if (this.batches.length > index) { 56 | let batch = this.batches[index]; 57 | this.undo(batch.Ops, batch.cwd, cls); 58 | batch.undone = true; 59 | await batch.save(); 60 | } else { 61 | console.log('The specified index is out of bounds'); 62 | } 63 | } 64 | 65 | async display() { 66 | clear(); 67 | let choices = await async.mapSeries(this.batches, async (b) => { 68 | return { 69 | name: `${format(b.createdAt, 'MMMM d yyyy, h:mm:ss a')} (${b.Ops.length} operations)${b.undone ? ' - Undone' : ''}`, 70 | value: this.batches.indexOf(b) 71 | }; 72 | }); 73 | choices.push({ name: 'More...', value: -1}); 74 | if (this.page > 1) choices = [{ name: 'Previous', value: -2 }, ...choices]; 75 | const selection = await inquirer.prompt({ 76 | type: 'list', 77 | loop: false, 78 | message: 'Select a batch to view more information and options', 79 | name: 'batch', 80 | default: 0, 81 | choices: choices, 82 | pageSize: 20 83 | }); 84 | clear(); 85 | if (selection.batch === -1) { 86 | await this.getBatches(this.page + 1); 87 | await this.display(); 88 | return; 89 | } else if (selection.batch === -2) { 90 | await this.getBatches(this.page - 1); 91 | await this.display(); 92 | return; 93 | } else { 94 | await this.displayBatch(selection.batch); 95 | } 96 | } 97 | 98 | async displayBatch(index) { 99 | if (index < 0 || index >= this.batches.length) { 100 | console.log('displayBatch index out of range'); 101 | process.exit(1); 102 | } 103 | const selectedBatch = this.batches[index]; 104 | const theCommand = util.argvToString(JSON.parse(selectedBatch.command)); 105 | const workingDir = selectedBatch.cwd + path.sep; 106 | const opsText = await async.reduce(selectedBatch.Ops, '', async (collector, o) => { 107 | collector += `${o.input.replace(workingDir, '')} → ${o.output.replace(workingDir, '')}${o.undone ? ' (undone)': ''}\n`; 108 | return collector; 109 | }); 110 | console.log(); 111 | term.table([ 112 | ['Command: ', theCommand], 113 | ['Working Dir: ', `${selectedBatch.cwd} `], 114 | ['Operations: ', opsText] ], { 115 | hasBorder: true, 116 | borderChars: 'lightRounded', 117 | fit: true 118 | }); 119 | console.log(); 120 | let choices; 121 | if (selectedBatch.undone) choices = ['Go Back', 'Re-run the Command', 'Copy the Command', 'Add to Favorites', 'Remove from History', 'Exit']; 122 | else choices = ['Go Back', 'Undo Every Operation', 'Undo Some Operations', 'Re-run the Command', 'Copy the Command', 'Add to Favorites', 'Remove from History', 'Exit']; 123 | const selection = await inquirer.prompt({ 124 | type: 'list', 125 | loop: false, 126 | message: 'What would you like to do?', 127 | name: 'choice', 128 | default: 0, 129 | choices: choices 130 | }); 131 | switch (selection.choice) { 132 | case 'Go Back': 133 | await this.display(); 134 | return; 135 | case 'Undo Every Operation': 136 | await this.undoBatch(index, true); 137 | return; 138 | case 'Undo Some Operations': 139 | await this.displayOps(index); 140 | return; 141 | case 'Re-run the Command': 142 | process.chdir(selectedBatch.cwd); 143 | await this.runCommand(theCommand); 144 | return; 145 | case 'Copy the Command': 146 | await clipboardy.write(theCommand); 147 | return; 148 | case 'Add to Favorites': 149 | await this.addToFavorites(selectedBatch.command); 150 | return; 151 | case 'Remove from History': 152 | await selectedBatch.destroy(); 153 | await this.getBatches(this.page); 154 | await this.display(); 155 | return; 156 | default: process.exit(0); 157 | } 158 | } 159 | 160 | async runCommand(command) { 161 | if (this.options.verbose) console.log('Command: ' + command); 162 | let argv = yargs.options(yargsOptions).parse(command.replace(/^re?name /, '')); 163 | let batch = new Batch(argv, null, this.sequelize); 164 | batch.setCommand(command); 165 | await batch.complete(); 166 | } 167 | 168 | async addToFavorites(command) { 169 | let favoriteData = { command: command }; 170 | const input = await inquirer.prompt({ 171 | type: 'input', 172 | name: 'alias', 173 | message: 'Enter an alias for this favorite (optional)', 174 | }); 175 | if (input.alias) favoriteData.alias = input.alias; 176 | let favorite = this.sequelize.models.Favorites.build(favoriteData); 177 | await favorite.save(); 178 | console.log(`Command added to favorites. ID: ${favorite.id}`); 179 | } 180 | 181 | async displayOps(index) { 182 | if (index < 0 || index >= this.batches.length) { 183 | console.log('displayBatch index out of range'); 184 | process.exit(1); 185 | } 186 | const selectedBatch = this.batches[index]; 187 | const workingDir = selectedBatch.cwd + path.sep; 188 | clear(); 189 | console.log(util.argvToString(JSON.parse(selectedBatch.command))); 190 | let choices = await async.mapSeries(selectedBatch.Ops, async (o) => { 191 | return { 192 | name: `${o.input.replace(workingDir, '')} → ${o.output.replace(workingDir, '')}`, 193 | disabled: o.undone ? 'undone' : false, 194 | value: o 195 | }; 196 | }); 197 | const selection = await inquirer.prompt({ 198 | type: 'checkbox', 199 | message: 'Select the operations to undo', 200 | name: 'ops', 201 | choices: choices, 202 | pageSize: (selectedBatch.Ops.length > 20 ? 20 : selectedBatch.Ops.length) 203 | }); 204 | this.undo(selection.ops, selectedBatch.cwd, true); 205 | } 206 | }; -------------------------------------------------------------------------------- /src/operation.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const fs = require('fs-extra'); 3 | const nunjucks = require('nunjucks'); 4 | const os = require('os'); 5 | const path = require('path'); 6 | const pathExists = require('path-exists'); 7 | const readlineSync = require('readline-sync'); 8 | const { FileData } = require('./fileData'); 9 | 10 | let env = nunjucks.configure({ autoescape: true, noCache: true }); 11 | const dateFilter = require('../lib/filters/date'); 12 | const customFilters = require('../lib/filters/custom'); 13 | env.addFilter('date', dateFilter); 14 | Object.keys(customFilters).forEach(f => env.addFilter(f, customFilters[f])); 15 | if (pathExists.sync(os.homedir() + '/.rename/userFilters.js')) { 16 | let userFilters = require(os.homedir() + '/.rename/userFilters.js'); 17 | Object.keys(userFilters).forEach(f => env.addFilter(f, userFilters[f])); 18 | } 19 | 20 | const CURRENT_DIR = process.cwd() + path.sep; 21 | 22 | module.exports.Operation = class Operation { 23 | constructor(input, options, sequelize) { 24 | this.options = options; 25 | this.sequelize = sequelize; 26 | this.inputFileString = input; 27 | this.inputFilePath = path.parse(input); 28 | this.fileData = new FileData(input, options); 29 | this.outputString = ''; 30 | this.outputFilePath = (path.parse(this.outputString)); 31 | this.outputFileString = ''; 32 | this.conflict = false; 33 | this.alreadyExists = false; 34 | this.directoryExists = true; 35 | } 36 | 37 | async replaceVariables() { 38 | let data; 39 | data = await this.fileData.get(); 40 | this.outputString = nunjucks.renderString(this.options.outputPattern, data); 41 | await this.parseOutputPath(); 42 | } 43 | 44 | async printData() { 45 | let data; 46 | if (this.userData) { 47 | data = await Object.assign(await this.fileData.get(), this.userData); 48 | } else { 49 | data = await this.fileData.get(); 50 | } 51 | console.dir(data); 52 | } 53 | 54 | async parseOutputPath() { 55 | this.outputFilePath = path.parse(path.resolve(this.outputString)); 56 | if (!this.outputFilePath.ext && !this.options.noExt && this.inputFilePath.ext) { 57 | this.outputFilePath.ext = this.inputFilePath.ext; 58 | } 59 | if (this.options.noMove) { 60 | this.outputFilePath.dir = this.inputFilePath.dir; 61 | } 62 | let ext = this.outputFilePath.ext || ''; 63 | if (!ext && !this.options.noExt) { ext = this.inputFilePath.ext; } 64 | this.outputFileString = `${this.outputFilePath.dir}${path.sep}${this.outputFilePath.name}${ext}`; 65 | if (this.inputFileString.toLowerCase() !== this.outputFileString.toLowerCase()) { 66 | this.alreadyExists = await fs.pathExists(this.outputFileString); 67 | this.directoryExists = await fs.pathExists(this.outputFilePath.dir); 68 | } else { // prevent already exists warning if changing the case of a file name 69 | this.alreadyExists = false; 70 | this.directoryExists = true; 71 | } 72 | } 73 | 74 | async setIndex(index) { 75 | if (this.outputString.indexOf('--FILEINDEXHERE--') > -1) { 76 | this.outputString = this.outputString.replaceAll('--FILEINDEXHERE--', index); 77 | } else { 78 | this.outputString = appendToFileName(this.outputString, index); 79 | } 80 | await this.parseOutputPath(); 81 | } 82 | 83 | setConflict(conflict) { 84 | this.conflict = conflict; 85 | } 86 | 87 | getOperationText() { 88 | return `${this.inputFileString.replace(CURRENT_DIR, '')} → ${this.outputFileString.replace(CURRENT_DIR, '')}`; 89 | } 90 | 91 | async run(batchId) { 92 | if (this.inputFileString.toLowerCase() !== this.outputFileString.toLowerCase()) { 93 | this.alreadyExists = await fs.pathExists(this.outputFileString); 94 | } 95 | if (this.alreadyExists && this.options.keep) { 96 | let newFileName; 97 | let appender = 0; 98 | do { 99 | appender += 1; 100 | newFileName = appendToFileName(this.outputString, `-${appender}`); 101 | } while(pathExists.sync(newFileName)); 102 | this.outputString = newFileName; 103 | await this.parseOutputPath(); 104 | } 105 | const operationText = this.getOperationText(); 106 | if (this.options.ignoreDirectories && this.fileData.stats.isDirectory()) { 107 | if (this.options.verbose) console.log(chalk`{yellow Skipping ${this.inputFileString.replace(CURRENT_DIR, '')} because it is a directory}`); 108 | return; 109 | } else if (!this.options.simulate && !this.options.force && this.alreadyExists) { 110 | console.log(chalk`{red 111 | ${operationText} 112 | WARNING: ${this.outputFileString.replace(CURRENT_DIR, '')} already exists!}`); 113 | let response = readlineSync.keyInSelect(['Overwrite the file', 'Keep both files'], `What would you like to do?`, { cancel: 'Skip' }); 114 | if (response === 0 && this.options.verbose) { 115 | console.log(chalk`{yellow Overwriting ${this.outputFileString.replace(CURRENT_DIR, '')}}`); 116 | } else if (response === 1) { // prompt for new file name 117 | let ext = this.outputFilePath.ext || ''; 118 | if (!ext && !this.options.noExt) { ext = this.inputFilePath.ext; } 119 | const defaultInput = `${this.outputFilePath.dir}${path.sep}${this.outputFilePath.name}1${ext}`; 120 | this.outputString = readlineSync.question('Please input the desired file name (Default: $): ', { defaultInput: defaultInput.replace(CURRENT_DIR, '') }); 121 | await this.parseOutputPath(); 122 | await this.run(); 123 | return; 124 | } else if (response === -1) { 125 | if (this.options.verbose) console.log(`Skipping ${this.outputFileString.replace(CURRENT_DIR, '')}`); 126 | return; 127 | } 128 | } else if (!this.options.simulate && !this.options.force && !this.options.keep && this.conflict) { 129 | console.log(chalk`{keyword('orange') 130 | ${operationText} 131 | WARNING: This operation conflicts with other operations in this batch! 132 | }`); 133 | if (!readlineSync.keyInYN(chalk.keyword('orange')('Would you like to proceed with this operation? [y/n]: '), { guide: false })) { 134 | if (this.options.verbose) console.log(`Skipping ${this.outputFileString.replace(CURRENT_DIR, '')}`); 135 | return; 136 | } 137 | } else if (!this.options.createDirs && !this.directoryExists) { 138 | console.log(chalk`{keyword('orange') 139 | ${operationText} 140 | WARNING: The directory does not exist! 141 | }`); 142 | if (!readlineSync.keyInYN(chalk.keyword('orange')('Would you like to create the directory? [y/n]: '), { guide: false })) { 143 | if (this.options.verbose) console.log(`Skipping ${this.outputFileString.replace(CURRENT_DIR, '')}`); 144 | return; 145 | } 146 | } else if (this.options.verbose || this.options.simulate) { 147 | console.log(operationText); 148 | } 149 | if (this.options.simulate) return; // Don't perform rename 150 | // If it has made it this far, it's now time to rename 151 | if (!this.directoryExists) await fs.mkdirp(this.outputFilePath.dir); 152 | if (await fs.pathExists(this.inputFileString)) { 153 | const input = this.inputFileString.replace(/\\\[/g, '[').replace(/\\\]/g, ']'); 154 | const output = this.outputFileString.replace(/\\\[/g, '[').replace(/\\\]/g, ']'); 155 | await fs.rename(input, output); 156 | if (!this.options.noUndo && this.sequelize) { // write operations to database 157 | await this.sequelize.models.Op.create({ 158 | input: input, 159 | output: output, 160 | BatchId: batchId 161 | }); 162 | } 163 | } else if (this.options.verbose) { 164 | console.log(chalk`{yellow Skipping ${this.inputFileString.replace(CURRENT_DIR, '')} because the file no longer exists}`); 165 | } 166 | } 167 | }; 168 | 169 | function appendToFileName(str, append) { 170 | let pathObj = path.parse(str); 171 | return `${pathObj.dir}${pathObj.dir !== '' ? path.sep : ''}${pathObj.name}${append}${pathObj.ext}`; 172 | } -------------------------------------------------------------------------------- /src/options.js: -------------------------------------------------------------------------------- 1 | const async = require('async'); 2 | const fs = require('fs-extra'); 3 | const globby = require("globby"); 4 | const path = require('path'); 5 | 6 | module.exports.Options = class Options { 7 | constructor(argv) { 8 | this.argv = argv; 9 | this.compiled = (argv['$0'] && argv['$0'].indexOf('rname.exe') > -1); 10 | this.info = getBooleanValue(argv, 'i', 'info'); 11 | this.help = getBooleanValue(argv, 'h', 'help'); 12 | this.force = getBooleanValue(argv, 'f', 'force'); 13 | this.keep = getBooleanValue(argv, 'k', 'keep'); 14 | this.simulate = getBooleanValue(argv, 's', 'sim'); 15 | this.verbose = getBooleanValue(argv, 'v', 'verbose'); 16 | this.noIndex = getBooleanValue(argv, 'n', 'noindex'); 17 | this.noTrim = getBooleanValue(argv, '', 'notrim'); 18 | this.ignoreDirectories = getBooleanValue(argv, 'd', 'ignoredirectories'); 19 | this.noMove = getBooleanValue(argv, '', 'nomove'); 20 | this.createDirs = getBooleanValue(argv, '', 'createdirs'); 21 | this.noExt = getBooleanValue(argv, '', 'noext'); 22 | this.history = getBooleanValue(argv, '', 'history'); 23 | this.favorites = getBooleanValue(argv, '', 'favorites'); 24 | this.undo = getBooleanValue(argv, 'u', 'undo'); 25 | this.noUndo = getBooleanValue(argv, '', 'noundo'); 26 | this.wizard = getBooleanValue(argv, 'w', 'wizard'); 27 | this.printdata = getBooleanValue(argv, '', 'printdata'); 28 | this.sort = getSortOption(argv); 29 | if (argv._.length > 0) { 30 | this.inputFiles = []; 31 | if (argv._.length > 1) this.outputPattern = argv._.pop().replace(/^"|"$/g, ''); 32 | else this.outputPattern = ''; 33 | for (const file of argv._) { 34 | if (globby.hasMagic(file)) { 35 | for (const globMatch of globby.sync(file, { onlyFiles: false })) { 36 | this.inputFiles.push(path.resolve(globMatch)); 37 | } 38 | } else { 39 | this.inputFiles.push(path.resolve(file)); 40 | } 41 | } 42 | } else { 43 | this.outputPattern = ''; 44 | this.inputFiles = []; 45 | } 46 | this.invalidInputFiles = 0; 47 | } 48 | 49 | async validateInputFiles() { 50 | const originalLength = this.inputFiles.length; 51 | this.inputFiles = await async.filterSeries(this.inputFiles, async (i) => { return (null, await fs.pathExists(i)); }); 52 | if (this.inputFiles.length !== originalLength) { 53 | this.invalidInputFiles = originalLength - this.inputFiles.length; 54 | if (this.verbose) console.log(`${this.invalidInputFiles} file${this.invalidInputFiles === 1 ? '' : 's'} will be skipped because ${this.invalidInputFiles === 1 ? 'it does' : 'they do'} not exist`); 55 | } 56 | } 57 | }; 58 | 59 | function getBooleanValue(argv, shortName, longName) { 60 | if (shortName && argv.hasOwnProperty(shortName)) { 61 | return argv[shortName]; 62 | } else if (longName && argv.hasOwnProperty(longName)) { 63 | return argv[longName]; 64 | } 65 | return false; 66 | } 67 | 68 | function getSortOption(argv) { 69 | let sort = argv.sort || 'none'; 70 | if (sort === 'none') return false; 71 | if (sortOptions.hasOwnProperty(sort)) { 72 | return sortOptions[sort]; 73 | } 74 | return false; 75 | } 76 | 77 | const sortOptions = { 78 | "alphabet": "alphabet", 79 | "date-create": "date-create", 80 | "date-modified": "date-modified", 81 | "size": "size", 82 | "reverse-alphabet": "reverse-alphabet", 83 | "reverse-date-create": "reverse-date-create", 84 | "reverse-date-modified": "reverse-date-modified", 85 | "reverse-size": "reverse-size" 86 | }; 87 | 88 | module.exports.sortOptions = sortOptions; -------------------------------------------------------------------------------- /src/tui.js: -------------------------------------------------------------------------------- 1 | const clipboardy = require('clipboardy'); 2 | const opn = require('opn'); 3 | const os = require('os'); 4 | const path = require('path'); 5 | const term = require('terminal-kit').terminal; 6 | const traverse = require('traverse'); 7 | const yargs = require('yargs'); 8 | const yargsOptions = require('../lib/yargsOptions'); 9 | 10 | const { Batch } = require('./batch'); 11 | const { FileData } = require('./fileData'); 12 | 13 | let TIMEOUT; 14 | let REFRESH = true; 15 | let BATCH; 16 | 17 | module.exports = async function(sequelize) { 18 | let fo = path.resolve(__filename); 19 | let foFileData = new FileData(fo, { noIndex: false }); 20 | let allData = await foFileData.get(); 21 | let variableNames = traverse.paths(allData).map(p => { 22 | if (p.length === 1 && typeof allData[p[0]] !== "object") { 23 | return p[0]; 24 | } else if (p.length > 1) { 25 | return p.join('.'); 26 | } 27 | }).filter(p => p !== undefined); 28 | term.fullscreen(true); 29 | term('rename '); 30 | setHelpText(); 31 | 32 | let keyBindingOptions = { 33 | ENTER: 'submit' , 34 | KP_ENTER: 'submit' , 35 | ESCAPE: 'cancel' , 36 | BACKSPACE: 'backDelete' , 37 | DELETE: 'delete' , 38 | LEFT: 'backward' , 39 | RIGHT: 'forward' , 40 | UP: 'historyPrevious' , 41 | DOWN: 'historyNext' , 42 | HOME: 'startOfInput' , 43 | END: 'endOfInput' , 44 | TAB: 'autoComplete' , 45 | CTRL_R: 'autoCompleteUsingHistory' , 46 | CTRL_LEFT: 'previousWord' , 47 | CTRL_RIGHT: 'nextWord' , 48 | ALT_D: 'deleteNextWord' , 49 | CTRL_W: 'deletePreviousWord' , 50 | CTRL_U: 'deleteAllBefore' , 51 | CTRL_K: 'deleteAllAfter' 52 | }; 53 | if (os.platform() === 'darwin') keyBindingOptions.DELETE = 'backDelete'; 54 | 55 | let inputField = term.inputField({cancelable: true, keyBindings: keyBindingOptions}, function (error, input) { 56 | if (!error && input) { 57 | if (TIMEOUT) clearTimeout(TIMEOUT); 58 | REFRESH = false; 59 | term.clear(); 60 | inputField.abort(); 61 | setTimeout(inputSubmitted, 100, input); 62 | } 63 | }); 64 | 65 | term.on('key', function (name) { 66 | if (['CTRL_C', 'ESC', 'ESCAPE'].indexOf(name) > -1) { 67 | terminate(); 68 | } else if (name === 'CTRL_H') { 69 | opn('https://github.com/jhotmann/node-rename-cli'); 70 | if (process.platform !== 'win32') { 71 | process.exit(0); 72 | } 73 | } else if (REFRESH) { 74 | if (TIMEOUT) clearTimeout(TIMEOUT); 75 | TIMEOUT = setTimeout(updateCommands, 500); 76 | } 77 | }); 78 | 79 | async function inputSubmitted(input) { 80 | let theCommand = (os.platform() === 'win32' ? 'rname ' :'rename ') + input; 81 | term.moveTo(1,1, '\nYou entered the following command:\n\n ' + theCommand + '\n\nWhat would you like to do?'); 82 | term.singleColumnMenu(['Run the command', 'Copy command to clipboard', 'Exit without doing anything'], {cancelable: false}, async function(error, selection) { 83 | term.clear(); 84 | if (selection.selectedIndex === 0) { 85 | BATCH.setCommand(theCommand); 86 | await BATCH.complete(); 87 | process.exit(0); 88 | } else if (selection.selectedIndex === 1) { 89 | await clipboardy.write(theCommand); 90 | process.exit(0); 91 | } else { 92 | process.exit(0); 93 | } 94 | }); 95 | } 96 | 97 | async function updateCommands() { 98 | let variableMatch = inputField.getInput().match('{{\\s*([A-z.]*)$'); 99 | if (variableMatch) { 100 | let matchingVars = variableNames.filter(v => v.startsWith(variableMatch[1])).join(', '); 101 | setHelpText('Variables: ' + matchingVars); 102 | } else { 103 | setHelpText(); 104 | } 105 | term.saveCursor(); 106 | term.eraseArea(1, 2, term.width, term.height - 2); 107 | term.moveTo(1, 3, 'working...'); 108 | let value = inputField.getInput(); 109 | let argv = yargs 110 | .options(yargsOptions) 111 | .parse(value); 112 | BATCH = new Batch(argv, null, sequelize); 113 | let content; 114 | try { 115 | await BATCH.replaceVariables(); 116 | await BATCH.indexAndFindConflicts(); 117 | content = BATCH.operations.map((o) => { return o.getOperationText(); }); 118 | } catch (ex) { 119 | content = ['Invalid command']; 120 | } 121 | 122 | let trimmedContent = []; 123 | if (content.length > (term.height - 5)) { 124 | trimmedContent = content.slice(0, term.height - 5); 125 | trimmedContent.push('+' + (content.length - trimmedContent.length) + ' more...'); 126 | } 127 | else trimmedContent = content; 128 | term.eraseArea(1, 2, term.width, term.height - 2); 129 | term.moveTo(1, 3, trimmedContent.map(truncate).join('\n')); 130 | term.restoreCursor(); 131 | } 132 | 133 | function truncate(op) { 134 | if (op.length > term.width) { 135 | return op.substring(0, term.width - 3) + '...'; 136 | } 137 | return op; 138 | } 139 | 140 | function setHelpText(helpText, center) { 141 | if (helpText === undefined) { 142 | helpText = 'Rename-CLI v' + require('../package.json').version + ' Type CTRL-H to view online help'; 143 | center = true; 144 | } 145 | term.saveCursor(); 146 | let helpTextX; 147 | if (center && helpText.length < term.width) helpTextX = (term.width / 2) - (helpText.length / 2); 148 | else helpTextX = 1; 149 | helpText = truncate(helpText); 150 | term.moveTo(helpTextX, term.height).eraseLine().moveTo(helpTextX, term.height, helpText); 151 | term.restoreCursor(); 152 | } 153 | 154 | function terminate() { 155 | term.grabInput(false); 156 | setTimeout(function () { 157 | process.exit(0); 158 | }, 100); 159 | } 160 | }; -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | const traverse = require('traverse'); 2 | const { FileData } = require('./fileData'); 3 | 4 | module.exports = {}; 5 | 6 | module.exports.leftPad = function(input, desiredLength, padChar) { 7 | let totString = '' + desiredLength; 8 | let returnString = '' + input; 9 | while (returnString.length < totString.length) { 10 | returnString = padChar + returnString; 11 | } 12 | return returnString; 13 | }; 14 | 15 | module.exports.argvToString = function(argv) { 16 | let returnString = ''; 17 | let args = argv; 18 | if (argv[0].match(/.*[/\\]node(.exe)?$/)) { 19 | args = argv.slice(2); 20 | returnString += 'rename '; 21 | } 22 | for (const component of args) { 23 | returnString += `${component.match(/.*[ |].*/) ? '"' : ''}${component}${component.match(/.*[ |].*/) ? '"' : ''} `; 24 | } 25 | return returnString.trim(); 26 | }; 27 | 28 | module.exports.yargsArgvToString = function(argv) { 29 | let filesArr = argv._.map(function(value) { return escapeText(value); }); 30 | let command = (process.platform === 'win32' ? 'rname' : 'rename') + 31 | (argv.f ? ' -f' : '') + 32 | (argv.noindex ? ' -n' : '') + 33 | (argv.d ? ' -d' : '') + 34 | (argv.v ? ' -v' : '') + 35 | (argv.createdirs ? ' --createdirs' : '') + 36 | (argv.nomove ? ' --nomove' : '') + ' ' + 37 | filesArr.join(' '); 38 | return command; 39 | }; 40 | 41 | module.exports.getVariableList = function () { 42 | const tempFileData = new FileData(__filename, {noIndex: true}); 43 | let defaultVars = tempFileData.getDescriptions(); 44 | return traverse.paths(defaultVars).map(v => { 45 | if (v.length === 1 && typeof defaultVars[v[0]] !== "object") { 46 | return '{{' + v[0] + '}}' + ' - ' + defaultVars[v[0]]; 47 | } else if (v.length > 1) { 48 | let p = v.join('.'); 49 | let value; 50 | v.forEach(val => { 51 | if (!value) value = defaultVars[val]; 52 | else value = value[val]; 53 | }); 54 | return '{{' + p + '}}' + ' - ' + value; 55 | } 56 | }).filter(v => v !== undefined).join('\n\n'); 57 | }; 58 | 59 | function escapeText(theString) { 60 | if (theString.indexOf(' ') > -1 || theString.indexOf('|') > -1) { 61 | return '"' + theString + '"'; 62 | } 63 | return theString; 64 | } -------------------------------------------------------------------------------- /src/wizard.js: -------------------------------------------------------------------------------- 1 | const async = require('async'); 2 | const clear = require('cli-clear'); 3 | const clipboardy = require('clipboardy'); 4 | const globby = require('globby'); 5 | const fs = require('fs-extra'); 6 | const inquirer = require('inquirer'); 7 | const path = require('path'); 8 | const util = require('./util'); 9 | 10 | const { Batch } = require('./batch'); 11 | 12 | module.exports = async function(sequelize) { 13 | let argv = { 14 | _: [] 15 | }; 16 | const files = await globby('*', { onlyFiles: false }); 17 | let fileTypes = ['*', '**/*', '*.*']; 18 | async.eachSeries(files, async (f) => { 19 | const fileObj = path.parse(path.resolve(f)); 20 | const stats = await fs.lstat(path.format(fileObj)); 21 | if (stats.isDirectory()) { 22 | files[files.indexOf(f)] = f + '/'; 23 | let ext = fileObj.ext; 24 | if (ext && fileTypes.indexOf('*' + ext) === -1) { 25 | fileTypes.push('*' + ext); 26 | } 27 | } 28 | }); 29 | fileTypes.push('Other'); 30 | clear(); 31 | const answer1 = await inquirer.prompt({ 32 | type: 'list', 33 | name: 'fileTypes', 34 | message: 'Files to rename', 35 | choices: fileTypes, 36 | default: 0 37 | }); 38 | if (answer1.fileTypes === 'Other') { // user can select the individual files they wish to rename 39 | const answer2 = await inquirer.prompt({ 40 | type: 'checkbox', 41 | name: 'files', 42 | message: 'Select files to rename', 43 | choices: files 44 | }); 45 | argv._ = answer2.files; 46 | } else { // user selected globs 47 | argv._.push(answer1.fileTypes); 48 | } 49 | clear(); 50 | const answer3 = await inquirer.prompt({ 51 | type: 'checkbox', 52 | name: 'options', 53 | message: 'Select rename options', 54 | choices: [ 55 | {name: 'Do not append index to output files', value: 'n'}, 56 | {name: 'Force overwrite file conflicts', value: 'f'}, 57 | {name: 'Ignore directories', value: 'd'}, 58 | {name: 'Create missing directories', value: 'cd'}, 59 | {name: 'Don\'t move files to different directory', value: 'nm'}, 60 | {name: 'Don\'t trim output', value: 'nt'}, 61 | {name: 'Verbose output', value: 'v'}] 62 | }); 63 | argv.f = answer3.options.indexOf('f') > -1; 64 | argv.noindex = answer3.options.indexOf('n') > -1; 65 | argv.d = answer3.options.indexOf('d') > -1; 66 | argv.createdirs = answer3.options.indexOf('cd') > -1; 67 | argv.nomove = answer3.options.indexOf('nm') > -1; 68 | argv.notrim = answer3.options.indexOf('nt') > -1; 69 | argv.v = answer3.options.indexOf('v') > -1; 70 | clear(); 71 | console.log('\n\n\nVariables:\n\n' + util.getVariableList()); 72 | const answer4 = await inquirer.prompt({ 73 | type: 'input', 74 | name: 'outputFile', 75 | message: 'Output file name:' 76 | }); 77 | argv._.push(answer4.outputFile); 78 | clear(); 79 | let theCommand = util.yargsArgvToString(argv); 80 | console.log(theCommand); 81 | console.log(); 82 | const answer5 = await inquirer.prompt({ 83 | type: 'list', 84 | name: 'whatNext', 85 | message: 'What would you like to do now?', 86 | choices: [{name: `Run the command`, value: 0}, {name: 'Copy to clipboard', value: 1}, {name: 'Restart wizard', value: 2}, {name: 'Exit', value: 3}] 87 | }); 88 | if (answer5.whatNext === 0) { 89 | let batch = new Batch(argv, null, sequelize); 90 | await batch.complete(); 91 | process.exit(0); 92 | } else if (answer5.whatNext === 1) { 93 | await clipboardy.write(theCommand); 94 | console.log('Command copied to clipboard'); 95 | process.exit(0); 96 | } else if (answer5.whatNext ===2) { 97 | await this(); 98 | process.exit(0); 99 | } else { 100 | process.exit(0); 101 | } 102 | }; -------------------------------------------------------------------------------- /test-files/Scott_Holmes_-_04_-_Upbeat_Party.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhotmann/node-rename-cli/216129692cf09947ff1289eef9ce08c42a98b48e/test-files/Scott_Holmes_-_04_-_Upbeat_Party.mp3 -------------------------------------------------------------------------------- /test-files/attribution.txt: -------------------------------------------------------------------------------- 1 | Scott Holmes - Upbeat Party downloaded from https://www.freemusicarchive.org/music/Scott_Holmes --------------------------------------------------------------------------------