├── LICENSE ├── Makefile ├── README.md └── main.oak /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Linus Lee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: build 2 | 3 | # build CLI 4 | build: 5 | oak pack --entry main.oak -o rush 6 | b: build 7 | 8 | # install CLI 9 | install: 10 | oak pack --entry main.oak -o /usr/local/bin/rush 11 | 12 | # format changed Oak source 13 | fmt: 14 | oak fmt --changes --fix 15 | f: fmt 16 | 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rush 🪶 2 | 3 | **Rush** is a command-line utility that lets you run one command on many files using a simple command template syntax. For example, let's say you have a bunch of `.jpeg` files, and you really want them to be named `.jpg` instead. You can do 4 | 5 | ```sh 6 | rush mv *.jpg '{{name}}.jpg' 7 | ``` 8 | 9 | to accomplish this. If you want to instead convert them to PNGs and tag them with their last-modified dates, you can do 10 | 11 | ```sh 12 | rush convert *.jpg '{{mdate}}-{{name}}.png' 13 | ``` 14 | 15 | When run, Rush first creates an output file name for each input file using the template string given (you'll find the full list of allowed parameters below). Then, the given command (`mv` or `convert` above) is run on each of the input-output pairs. In other words, running `rush cp *.pdf '{{name}}.{{mdate}}.pdf'` on a few files named `alpha.pdf`, `beta.pdf`, and `gamma.pdf` would run something like 16 | 17 | ```sh 18 | cp alpha.pdf alpha.2021-04-29.pdf 19 | cp beta.pdf beta.2021-08-12.pdf 20 | cp gamma.pdf gamma.2022-05-04.pdf 21 | ``` 22 | 23 | In this way, Rush makes doing one thing with many files pretty quick and painless. I originally made Rush to help me mass-rename and convert image files, but I've realized since then that this pattern is pretty universally useful. You _can_ do _some_ of the things Rush can do with a `for` loop in Bash, but I always forget the syntax, and it's much harder to get right. So most of the time, I reach for `rush`. 24 | 25 | ## How do I use it? 26 | 27 | Typing `rush --help` gives us the help menu. If you'd rather learn by example, see the [examples](#examples). 28 | 29 | ``` 30 | Rush lets you work on many files at once. 31 | 32 | Usage 33 | rush [cmd] [src-files] [rename-spec] [options] 34 | 35 | Options 36 | --[h]elp Show this help message 37 | --stdin Receive source files list from STDIN 38 | --[d]ry-run Print all operations, but do not run them 39 | --[v]erbose Print all executed commands as they are run 40 | --version Print version information and exit 41 | --[f]orce Overwrite any conflicting files 42 | --debug Print CLI arguments for debugging then exit 43 | 44 | Template parameters 45 | path Full absolute path of the file 46 | e.g. "/home/me/image.jpg" 47 | dir Directory of the file 48 | e.g. "/home/me" 49 | fullname Entire file name, including extension 50 | e.g. "image.jpg" 51 | name File name, without the extension 52 | e.g. "image" 53 | ext Extension of the file (string after last '.', or 54 | empty string if there is no '.') 55 | e.g. "jpg" 56 | i Integer index of this file when the file list is 57 | sorted lexicographically 58 | e.g. "10" 59 | mtime Last-modified time of the file as a UNIX timestamp 60 | e.g. "1651654423" 61 | mdate Last-modified date of the file as an ISO date string 62 | e.g. "2022-05-04" 63 | len Size of the file in bytes 64 | e.g. "4094" 65 | ``` 66 | 67 | ### Examples 68 | 69 | Change extensions on a bunch of files. 70 | 71 | ```sh 72 | rush mv *.jpeg '{{name}}.jpg' 73 | ``` 74 | 75 | Rename all PDF files to numbers in increasing order. 76 | 77 | ```sh 78 | rush mv *.pdf '{{i}}.pdf' 79 | ``` 80 | 81 | Add a prefix "secret" to all files, but don't delete the originals. 82 | 83 | ```sh 84 | rush cp * 'secret-{{fullname}}' 85 | ``` 86 | 87 | Organize files by their last-modified dates into folders and add their last-modified timestamps to their names. (For this to work, the folders must exist beforehand.) 88 | 89 | ```sh 90 | rush mv * '{{mdate}}/{{name}}-{{mtime}}.{{ext}}' 91 | ``` 92 | 93 | Convert all PNGs to JPGs using [ImageMagick "convert"](https://imagemagick.org/script/convert.php). 94 | 95 | ```sh 96 | rush convert *.png '{{name}}.jpg' 97 | ``` 98 | 99 | ## Install 100 | 101 | If you have [Oak](https://oaklang.org) installed, you can build from source (see below). Otherwise, I provide pre-built binaries for macOS and Linux (both x86) on the [releases page](https://github.com/thesephist/rush/releases). Just drop those into your `$PATH` and you should be good to go. 102 | 103 | ## Build and development 104 | 105 | Rush is built with my [Oak programming language](https://oaklang.org), and I manage build tasks with a Makefile. 106 | 107 | - `make` or `make build` builds a version of Rush at `./rush` 108 | - `make install` installs Rush to `/usr/local/bin`, in case that's where you like to keep your bins 109 | - `make fmt` or `make f` formats all Oak source files tracked by Git 110 | 111 | ## Why's it called _Rush_? 112 | 113 | It's short, doesn't conflict with any commonly-used CLIs, and felt like a fast... word to me. 114 | -------------------------------------------------------------------------------- /main.oak: -------------------------------------------------------------------------------- 1 | // rush lets you work on many files at once 2 | 3 | { 4 | println: println 5 | default: default 6 | map: map 7 | each: each 8 | take: take 9 | last: last 10 | slice: slice 11 | stdin: stdin 12 | append: append 13 | filter: filter 14 | compact: compact 15 | rindexOf: rindexOf 16 | contains?: contains? 17 | } := import('std') 18 | { 19 | lower: lower 20 | trim: trim 21 | split: split 22 | endsWith?: endsWith? 23 | } := import('str') 24 | fs := import('fs') 25 | cli := import('cli') 26 | fmt := import('fmt') 27 | sort := import('sort') 28 | path := import('path') 29 | debug := import('debug') 30 | datetime := import('datetime') 31 | 32 | Version := '1.0' 33 | 34 | Cli := with cli.parseArgv() if { 35 | args().1 |> default('') |> endsWith?('main.oak') -> args() 36 | _ -> ['oak', 'rush.oak'] |> append(args() |> slice(1)) 37 | } 38 | 39 | if Cli.opts.version != ? -> { 40 | fmt.printf('Rush v{{0}}', Version) 41 | exit(0) 42 | } 43 | 44 | if Cli.opts.help != ? | Cli.opts.h != ? -> { 45 | println('Rush lets you work on many files at once. 46 | 47 | Usage 48 | rush [cmd] [src-files] [rename-spec] [options] 49 | 50 | Options 51 | --[h]elp Show this help message 52 | --stdin Receive source files list from STDIN 53 | --[d]ry-run Print all operations, but do not run them 54 | --[v]erbose Print all executed commands as they are run 55 | --version Print version information and exit 56 | --[f]orce Overwrite any conflicting files 57 | --debug Print CLI arguments for debugging then exit 58 | 59 | Template parameters 60 | path Full absolute path of the file 61 | e.g. "/home/me/image.jpg" 62 | dir Directory of the file 63 | e.g. "/home/me" 64 | fullname Entire file name, including extension 65 | e.g. "image.jpg" 66 | name File name, without the extension 67 | e.g. "image" 68 | ext Extension of the file (string after last \'.\', or 69 | empty string if there is no \'.\') 70 | e.g. "jpg" 71 | i Integer index of this file when the file list is 72 | sorted lexicographically 73 | e.g. "10" 74 | mtime Last-modified time of the file as a UNIX timestamp 75 | e.g. "1651654423" 76 | mdate Last-modified date of the file as an ISO date string 77 | e.g. "2022-05-04" 78 | len Size of the file in bytes 79 | e.g. "4094" 80 | 81 | Examples 82 | Change extensions 83 | rush mv *.jpeg \'{{name}}.jpg\' 84 | Rename all PDF files to numbers 85 | rush mv *.pdf \'{{i}}.pdf\' 86 | Add a prefix "secret" to all files, but don\'t delete originals 87 | rush cp * \'secret-{{fullname}}\' 88 | Add last-modified times to photos and put them in folders by dates 89 | rush mv * \'{{mdate}}/{{name}}-{{mtime}}.{{ext}}\' 90 | Convert all PNGs to JPGs using ImageMagick "convert" 91 | rush convert *.png \'{{name}}.jpg\' 92 | ') 93 | exit(0) 94 | } 95 | 96 | if Cli.verb = ? -> { 97 | println('No command provided.') 98 | exit(1) 99 | } 100 | 101 | // the input (source) files are normally all CLI arguments except the last, but 102 | // may be overridden with the --stdin flag. 103 | srcPaths := if { 104 | Cli.opts.stdin -> stdin() |> split('\n') |> filter(fn(s) s != '') 105 | len(Cli.args) > 0 -> Cli.args |> take(len(Cli.args) - 1) 106 | _ -> { 107 | println('No source files provided.') 108 | exit(1) 109 | } 110 | } 111 | 112 | // the rename spec is always the last CLI argument 113 | destSpec := Cli.args |> last() 114 | if destSpec = ? -> { 115 | println('No rename spec provided.') 116 | exit(1) 117 | } 118 | // sanity check the rename spec 119 | // 120 | // If the rename spec is not an Oak template string, the user has probably made 121 | // a mistake, and we need to abort. 122 | if destSpec |> fmt.format({}) = destSpec -> { 123 | fmt.printf('Constant rename spec "{{0}}".', destSpec) 124 | exit(1) 125 | } 126 | 127 | if Cli.opts.debug != ? -> { 128 | debug.println({ 129 | exe: Cli.exe 130 | main: Cli.main 131 | opts: Cli.opts 132 | src: srcPaths 133 | dest: destSpec 134 | }) 135 | exit(0) 136 | } 137 | 138 | Mappings := srcPaths |> sort.sort() |> with map() fn(src, i) { 139 | srcPath := path.resolve(src) 140 | [srcDir, srcFile] := path.cut(srcPath) 141 | 142 | [srcName, srcExt] := if extDotIdx := srcFile |> rindexOf('.') { 143 | -1 -> [srcFile, ''] 144 | _ -> [ 145 | srcFile |> slice(0, extDotIdx) 146 | srcFile |> slice(extDotIdx + 1) 147 | ] 148 | } 149 | 150 | stat := fs.statFile(srcPath) 151 | if stat = ? -> { 152 | fmt.printf('Could not stat "{{0}}".', srcPath) 153 | exit(1) 154 | } 155 | 156 | dest := destSpec |> fmt.format({ 157 | path: srcPath 158 | dir: srcDir 159 | fullname: srcFile 160 | name: srcName 161 | ext: srcExt 162 | i: i 163 | mtime: stat.mod 164 | mdate: datetime.format(stat.mod) |> take(10) 165 | len: stat.len 166 | }) 167 | 168 | { 169 | src: src 170 | dest: dest 171 | } 172 | } 173 | 174 | // For any files whose destination file already exists, ask the user if it 175 | // should be overwritten. If yes, mark it as an overwrite to log later. If no, 176 | // remove it from the mappings list. 177 | Overwrites := [] 178 | Force? := Cli.opts.f != ? | Cli.opts.force != ? 179 | fn askOverwrite?(dest) { 180 | print(fmt.format('"{{0}}" already exists. Overwrite? [y/N] ', dest)) 181 | evt := input() 182 | if evt.type { 183 | :error -> { 184 | println('Could not read input.') 185 | exit(1) 186 | } 187 | _ -> { 188 | response := evt.data |> trim() |> lower() 189 | response = 'y' | response = 'yes' 190 | } 191 | } 192 | } 193 | Mappings |> with each() fn(mapping, i) { 194 | { src: src, dest: dest } := mapping 195 | stat := fs.statFile(dest) 196 | if stat != ? -> { 197 | if { 198 | Force?, askOverwrite?(dest) -> Overwrites << dest 199 | _ -> { 200 | fmt.printf('Skipping "{{0}}".', src) 201 | Mappings.(i) := ? 202 | } 203 | } 204 | } 205 | } 206 | Mappings <- Mappings |> compact() 207 | 208 | Cmd := Cli.verb 209 | Mappings |> with each() fn(mapping) { 210 | cmdline := fmt.format( 211 | '{{0}} {{1}} {{2}}' 212 | Cmd, mapping.src, mapping.dest 213 | ) + if Overwrites |> contains?(mapping.dest) { 214 | true -> ' (overwritten)' 215 | _ -> '' 216 | } 217 | if { 218 | Cli.opts.d, Cli.opts.'dry-run' -> println(cmdline) 219 | _ -> { 220 | if Cli.opts.verbose = true | Cli.opts.v = true -> println(cmdline) 221 | 222 | evt := exec(Cmd, [mapping.src, mapping.dest], '') 223 | if evt.type = :error -> { 224 | fmt.printf('Could not {{0}} "{{1}}" to "{{2}}".', Cmd, mapping.src, mapping.dest) 225 | println('[error] ' + evt.message) 226 | exit(1) 227 | } 228 | if evt.status != 0 -> { 229 | fmt.printf('Could not {{0}} "{{1}}" to "{{2}}".', Cmd, mapping.src, mapping.dest) 230 | println(evt.stdout + evt.stderr) 231 | exit(1) 232 | } 233 | } 234 | } 235 | } 236 | 237 | --------------------------------------------------------------------------------