├── .gitignore ├── .editorconfig ├── LICENSE ├── package.json ├── README.md └── tinypng-cli.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | ._* 3 | .Spotlight-V100 4 | .Trashes 5 | node_modules 6 | npm-debug.log 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = spaces 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 websperts 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tinypng-cli", 3 | "version": "0.0.8", 4 | "description": "Handy command line tool for shrinking PNG images using the TinyPNG API", 5 | "homepage": "https://github.com/websperts/tinypng-cli", 6 | "author": "websperts ", 7 | "repository": { 8 | "type": "git", 9 | "url": "git@github.com:websperts/tinypng-cli.git" 10 | }, 11 | "keywords": [ 12 | "tinypng", 13 | "shrinking", 14 | "compression", 15 | "cli" 16 | ], 17 | "bugs": { 18 | "url": "https://github.com/websperts/tinypng-cli/issues" 19 | }, 20 | "main": "tinypng-cli.js", 21 | "license": "MIT", 22 | "dependencies": { 23 | "array-uniq": "^1.0.2", 24 | "chalk": "^1.1.3", 25 | "glob": "^7.0.3", 26 | "md5-file": "^4.0.0", 27 | "minimatch": "^3.0.0", 28 | "minimist": "^1.2.0", 29 | "prettysize": "0.0.3", 30 | "request": "^2.70.0" 31 | }, 32 | "preferGlobal": "true", 33 | "bin": { 34 | "tinypng": "tinypng-cli.js" 35 | }, 36 | "devDependencies": { 37 | "eslint": "^2.7.0", 38 | "jshint": "^2.9.1" 39 | }, 40 | "scripts": { 41 | "test": "jshint tinypng-cli.js && eslint tinypng-cli.js" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TinyPNG CLI 2 | 3 | > Handy command line tool for shrinking PNG images using the TinyPNG API 4 | 5 | ## Installation 6 | 7 | npm install -g tinypng-cli 8 | 9 | ## Preamble 10 | 11 | To use TinyPNG CLI, you need an API key for TinyPNG. You can get one at [https://tinypng.com/developers](https://tinypng.com/developers). 12 | 13 | ## Usage 14 | 15 | TinyPNG CLI allows you to provide your API key in two different ways. The more convenient one is to save the API key into a file called `.tinypng` within your home directory. The other way is to provide it as an option while running the CLI. 16 | 17 | tinypng demo.png -k E99a18c4f8cb3EL5f2l08u368_922e03 18 | 19 | To shrink all PNG images within the current directory. 20 | 21 | tinypng . 22 | 23 | To shrink all PNG images within the current directory and subdirectoies, use the `-r` flag 24 | 25 | tinypng . -r 26 | 27 | To shrink all PNG images within a specific directory (`assets/img` in this example), you may run the following command. 28 | 29 | tinypng assets/img 30 | 31 | Need to limit the number of compressions at a time? Use the `-m, --max` flag: 32 | 33 | tinypng assets/img --max 100 34 | 35 | You may also provide multiple directories. 36 | 37 | tinypng assets/img1 assets/img2 38 | 39 | To shrink a single PNG image (`assets/img/demo.png` in this example), you may run the following command. 40 | 41 | tinypng assets/img/demo.png 42 | 43 | You may also provide multiple single PNG images. 44 | 45 | tinypng assets/img/demo1.png assets/img/demo2.png 46 | 47 | To resize an image, use the `--width` and/or `--height` flag. 48 | 49 | tinypng assets/img/demo.png --width 123 50 | tinypng assets/img/demo.png --height 123 51 | tinypng assets/img/demo.png --width 123 --height 123 52 | 53 | By default, this tool caches a map of all compressed images sent to the API in `~/.tinypng.cache.json`. To change this directory, use the `-c, --cache` flag: 54 | 55 | tinypng . -r --cache /path/to/myCache.json 56 | 57 | If you want to forcibly recompress assets, use the `--force` flag. For a dry run output of all files that will be sent to the API, use the `--dry-run` flag. 58 | 59 | That's it. Pretty easy, huh? 60 | 61 | ## Changelog 62 | 63 | * 0.0.8 64 | * Implement cache map support and support for forcing compression 65 | * Implement dry-run support 66 | * Implement maximum runs support to enable batching 67 | * 0.0.7 68 | * Implement support for uppercase file extensions 69 | * 0.0.6 70 | * Prevent any file changes in case JSON parsing fails or any other HTTP error occurred 71 | * 0.0.5 72 | * Add support for image resize functionality 73 | * 0.0.4 74 | * Make recursive directory walking optional 75 | * 0.0.3 76 | * Updated API endpoint 77 | * Check for valid JSON response 78 | * 0.0.2 79 | * JP(E)G support 80 | * 0.0.1 81 | * Initial version 82 | 83 | ## TODO 84 | 85 | - Documentation 86 | - Tests 87 | 88 | ## License 89 | 90 | Copyright (c) 2017 [websperts](http://websperts.com/) 91 | Licensed under the MIT license. 92 | 93 | See LICENSE for more info. 94 | 95 | ## Contributors 96 | 97 | - [@rasshofer](https://github.com/rasshofer) 98 | - [@maxkueng](https://github.com/maxkueng) 99 | - [@tholu](https://github.com/tholu) 100 | - [@mvenghaus](https://github.com/mvenghaus) 101 | - [@jblok](https://github.com/jblok) 102 | - [@tomatolicious](https://github.com/tomatolicious) 103 | - [@kolya182](https://github.com/kolya182) 104 | -------------------------------------------------------------------------------- /tinypng-cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var fs = require("fs"); 4 | var request = require("request"); 5 | var minimatch = require("minimatch"); 6 | var glob = require("glob"); 7 | var uniq = require("array-uniq"); 8 | var chalk = require("chalk"); 9 | var pretty = require("prettysize"); 10 | var md5File = require("md5-file"); 11 | var path = require("path"); 12 | 13 | var argv = require("minimist")(process.argv.slice(2)); 14 | var home = process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE; 15 | var version = require("./package.json").version; 16 | 17 | var openStreams = 0; 18 | 19 | function pruneCached(images, cacheMap) { 20 | return images.filter(function(image) { 21 | if (cacheMap[image] && md5File.sync(image) == cacheMap[image]) { 22 | return false; 23 | } 24 | return true; 25 | }); 26 | } 27 | 28 | if (argv.v || argv.version) { 29 | console.log(version); 30 | } else if (argv.h || argv.help) { 31 | console.log( 32 | "Usage\n" + 33 | " tinypng \n" + 34 | "\n" + 35 | "Example\n" + 36 | " tinypng .\n" + 37 | " tinypng assets/img\n" + 38 | " tinypng assets/img/test.png\n" + 39 | " tinypng assets/img/test.jpg\n" + 40 | "\n" + 41 | "Options\n" + 42 | " -k, --key Provide an API key\n" + 43 | " -r, --recursive Walk given directory recursively\n" + 44 | " --width Resize an image to a specified width\n" + 45 | " --height Resize an image to a specified height\n" + 46 | " --force Ignore caching to prevent repeat API requests\n" + 47 | " --dry-run Dry run -- no files actually modified\n" + 48 | " -c, --cache Cache map location. Defaults to ~/.tinypng.cache.json\n" + 49 | " -v, --version Show installed version\n" + 50 | " -m, --max Maximum to run at a time. Defaults to -1 (no max)\n" + 51 | " -h, --help Show help" 52 | ); 53 | } else { 54 | console.log(chalk.underline.bold("TinyPNG CLI")); 55 | console.log("v" + version + "\n"); 56 | 57 | var files = argv._.length ? argv._ : ["."]; 58 | 59 | var key = ""; 60 | var resize = {}; 61 | var max = argv.m || argv.max ? (argv.m || argv.max) + 0 : -1; 62 | 63 | if (!argv["dry-run"]) { 64 | if (argv.k || argv.key) { 65 | key = 66 | typeof (argv.k || argv.key) === "string" 67 | ? (argv.k || argv.key).trim() 68 | : ""; 69 | } else if (fs.existsSync(home + "/.tinypng")) { 70 | key = fs.readFileSync(home + "/.tinypng", "utf8").trim(); 71 | } 72 | } else { 73 | key = "dry-run-key"; 74 | } 75 | 76 | var cacheMap = {}; 77 | var cacheMapLocation = 78 | typeof (argv.c || argv.cache) === "string" 79 | ? (argv.c || argv.cache).trim() 80 | : home + "/.tinypng.cache.json"; 81 | 82 | if (fs.existsSync(cacheMapLocation)) { 83 | cacheMap = require(path.resolve(cacheMapLocation)); 84 | if (typeof cacheMap !== "object") { 85 | cacheMap = {}; 86 | } 87 | } 88 | 89 | if (argv.width) { 90 | if (typeof argv.width === "number") { 91 | resize.width = argv.width; 92 | } else { 93 | console.log( 94 | chalk.bold.red( 95 | "Invalid width specified. Please specify a numeric value only." 96 | ) 97 | ); 98 | } 99 | } 100 | 101 | if (argv.height) { 102 | if (typeof argv.height === "number") { 103 | resize.height = argv.height; 104 | } else { 105 | console.log( 106 | chalk.bold.red( 107 | "Invalid height specified. Please specify a numeric value only." 108 | ) 109 | ); 110 | } 111 | } 112 | 113 | if (key.length === 0) { 114 | console.log( 115 | chalk.bold.red( 116 | "No API key specified. You can get one at " + 117 | chalk.underline("https://tinypng.com/developers") + 118 | "." 119 | ) 120 | ); 121 | } else { 122 | var images = []; 123 | 124 | files.forEach(function(file) { 125 | if (fs.existsSync(file)) { 126 | if (fs.lstatSync(file).isDirectory()) { 127 | images = images.concat( 128 | glob.sync( 129 | file + 130 | (argv.r || argv.recursive ? "/**" : "") + 131 | "/*.+(png|jpg|jpeg|PNG|JPG|JPEG)" 132 | ) 133 | ); 134 | } else if ( 135 | minimatch(file, "*.+(png|jpg|jpeg|PNG|JPG|JPEG)", { 136 | matchBase: true 137 | }) 138 | ) { 139 | images.push(file); 140 | } 141 | } 142 | }); 143 | 144 | var unique = argv.force 145 | ? uniq(images) 146 | : pruneCached(uniq(images), cacheMap); 147 | 148 | if (unique.length === 0) { 149 | console.log( 150 | chalk.bold.red( 151 | "\u2718 No previously uncompressed PNG or JPEG images found.\n" 152 | ) + 153 | chalk.yellow( 154 | " Use the `--force` flag to force recompression..." 155 | ) 156 | ); 157 | } else { 158 | console.log( 159 | chalk.bold.green( 160 | "\u2714 Found " + 161 | unique.length + 162 | " image" + 163 | (unique.length === 1 ? "" : "s") 164 | ) + "\n" 165 | ); 166 | console.log(chalk.bold("Processing...")); 167 | 168 | unique.forEach(function(file) { 169 | if (max == 0) { 170 | return; 171 | } else { 172 | max = max - 1; 173 | } 174 | openStreams = openStreams + 1; 175 | 176 | if (argv["dry-run"]) { 177 | console.log( 178 | chalk.yellow("[DRY] Panda will run for `" + file + "`") 179 | ); 180 | return; 181 | } 182 | 183 | fs.createReadStream(file).pipe( 184 | request.post( 185 | "https://api.tinify.com/shrink", 186 | { 187 | auth: { 188 | user: "api", 189 | pass: key 190 | } 191 | }, 192 | function(error, response, body) { 193 | openStreams = openStreams - 1; 194 | try { 195 | body = JSON.parse(body); 196 | } catch (e) { 197 | console.log( 198 | chalk.red( 199 | "\u2718 Not a valid JSON response for `" + 200 | file + 201 | "`" 202 | ) 203 | ); 204 | return; 205 | } 206 | 207 | if (!error && response) { 208 | if (response.statusCode === 201) { 209 | if (body.output.size < body.input.size) { 210 | console.log( 211 | chalk.green( 212 | "\u2714 Panda just saved you " + 213 | chalk.bold( 214 | pretty( 215 | body.input.size - 216 | body.output.size 217 | ) + 218 | " (" + 219 | Math.round( 220 | 100 - 221 | 100 / 222 | body 223 | .input 224 | .size * 225 | body 226 | .output 227 | .size 228 | ) + 229 | "%)" 230 | ) + 231 | " for `" + 232 | file + 233 | "`" 234 | ) 235 | ); 236 | 237 | var fileStream = fs.createWriteStream( 238 | file 239 | ); 240 | openStreams = openStreams + 1; 241 | fileStream.on("finish", function() { 242 | cacheMap[file] = md5File.sync(file); 243 | openStreams = openStreams - 1; 244 | }); 245 | 246 | if ( 247 | resize.hasOwnProperty("height") || 248 | resize.hasOwnProperty("width") 249 | ) { 250 | request 251 | .get(body.output.url, { 252 | auth: { 253 | user: "api", 254 | pass: key 255 | }, 256 | json: { 257 | resize: resize 258 | } 259 | }) 260 | .pipe(fileStream); 261 | } else { 262 | request 263 | .get(body.output.url) 264 | .pipe(fileStream); 265 | } 266 | } else { 267 | console.log( 268 | chalk.yellow( 269 | "\u2718 Couldn’t compress `" + 270 | file + 271 | "` any further" 272 | ) 273 | ); 274 | } 275 | } else { 276 | if (body.error === "TooManyRequests") { 277 | console.log( 278 | chalk.red( 279 | "\u2718 Compression failed for `" + 280 | file + 281 | "` as your monthly limit has been exceeded" 282 | ) 283 | ); 284 | } else if (body.error === "Unauthorized") { 285 | console.log( 286 | chalk.red( 287 | "\u2718 Compression failed for `" + 288 | file + 289 | "` as your credentials are invalid" 290 | ) 291 | ); 292 | } else { 293 | console.log( 294 | chalk.red( 295 | "\u2718 Compression failed for `" + 296 | file + 297 | "`" 298 | ) 299 | ); 300 | } 301 | } 302 | } else { 303 | console.log( 304 | chalk.red( 305 | "\u2718 Got no response for `" + 306 | file + 307 | "`" 308 | ) 309 | ); 310 | } 311 | } 312 | ) 313 | ); 314 | }); 315 | 316 | // Save the cacheMap on wet runs 317 | if (!argv["dry-run"]) { 318 | function saveCacheMapWhenCompvare() { 319 | if (openStreams > 0) { 320 | return setTimeout(saveCacheMapWhenCompvare, 100); 321 | } 322 | fs.writeFileSync( 323 | cacheMapLocation, 324 | JSON.stringify(cacheMap, null, "\t") 325 | ); 326 | } 327 | setTimeout(saveCacheMapWhenCompvare, 500); 328 | } 329 | } 330 | } 331 | } 332 | --------------------------------------------------------------------------------