├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── fixtures ├── rainbow (1).txt ├── rainbow (2).txt ├── rainbow-1.txt ├── rainbow.txt ├── rainbow_1.txt ├── rainbow_2.txt └── unicorn.txt ├── index.d.ts ├── index.js ├── index.test-d.ts ├── license ├── package.json ├── readme.md └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 16 14 | - 14 15 | - 12 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-node@v2 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm install 22 | - run: npm test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /fixtures/rainbow (1).txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/unused-filename/a9277a4d7452803d23c20a827a1ebb4d5e9301d3/fixtures/rainbow (1).txt -------------------------------------------------------------------------------- /fixtures/rainbow (2).txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/unused-filename/a9277a4d7452803d23c20a827a1ebb4d5e9301d3/fixtures/rainbow (2).txt -------------------------------------------------------------------------------- /fixtures/rainbow-1.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/unused-filename/a9277a4d7452803d23c20a827a1ebb4d5e9301d3/fixtures/rainbow-1.txt -------------------------------------------------------------------------------- /fixtures/rainbow.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/unused-filename/a9277a4d7452803d23c20a827a1ebb4d5e9301d3/fixtures/rainbow.txt -------------------------------------------------------------------------------- /fixtures/rainbow_1.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/unused-filename/a9277a4d7452803d23c20a827a1ebb4d5e9301d3/fixtures/rainbow_1.txt -------------------------------------------------------------------------------- /fixtures/rainbow_2.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/unused-filename/a9277a4d7452803d23c20a827a1ebb4d5e9301d3/fixtures/rainbow_2.txt -------------------------------------------------------------------------------- /fixtures/unicorn.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/unused-filename/a9277a4d7452803d23c20a827a1ebb4d5e9301d3/fixtures/unicorn.txt -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export interface Options { 2 | /** 3 | A function that accepts a file path, and increments its index. 4 | 5 | It's the incrementer's responsibility to extract an already existing index from the given file path so that it picks up and continues incrementing an already present index instead of appending a second one. 6 | 7 | The incrementer has to return a tuple of `[originalFilename, incrementedFilename]`, where `originalFilename` is the filename without the index, and `incrementedFilename` is a filename with input's index bumped by one. 8 | 9 | Default: Parentheses incrementer: `file.txt` → `file (1).txt` 10 | 11 | @example 12 | ``` 13 | import {unusedFilename} from 'unused-filename'; 14 | 15 | // Incrementer that inserts a new index as a prefix. 16 | const prefixIncrementer = (filename, extension) => { 17 | const match = filename.match(/^(?\d+)_(?.*)$/); 18 | let {originalFilename, index} = match ? match.groups : {originalFilename: filename, index: 0}; 19 | originalFilename = originalFilename.trim(); 20 | return [`${originalFilename}${extension}`, `${++index}_${originalFilename}${extension}`]; 21 | }; 22 | 23 | console.log(await unusedFilename('rainbow.txt', {incrementer: prefixIncrementer})); 24 | //=> '1_rainbow.txt' 25 | ``` 26 | */ 27 | readonly incrementer?: Incrementer; 28 | 29 | /** 30 | The maximum number of attempts to find an unused filename. 31 | 32 | When the limit is reached, the function will throw `MaxTryError`. 33 | 34 | @default Infinity 35 | */ 36 | readonly maxTries?: number; 37 | } 38 | 39 | /** 40 | @param filename - The filename of the file path. 41 | @param extension - The extension of the file path. 42 | 43 | @returns A tuple of original filename, and new incremented filename, including extension. 44 | */ 45 | export type Incrementer = (filename: string, extension: string) => [string, string]; 46 | 47 | /** 48 | The error thrown when `maxTries` limit is reached without finding an unused filename. 49 | 50 | @param originalPath - Path without the incrementation sequence. 51 | @param lastTriedPath - The last tested incremented path. 52 | 53 | @example 54 | ``` 55 | import {unusedFilename, MaxTryError} from 'unused-filename'; 56 | 57 | try { 58 | const path = await unusedFilename('rainbow (1).txt', {maxTries: 0}); 59 | } catch (error) { 60 | if (error instanceof MaxTryError) { 61 | console.log(error.originalPath); // 'rainbow.txt' 62 | console.log(error.lastTriedPath); // 'rainbow (1).txt' 63 | } 64 | } 65 | ``` 66 | */ 67 | export class MaxTryError extends Error { 68 | originalPath: string; 69 | lastTriedPath: string; 70 | 71 | constructor(originalPath: string, lastTriedPath: string); 72 | } 73 | 74 | /** 75 | Get an unused filename by appending a number if it exists: `file.txt` → `file (1).txt`. 76 | 77 | @param filePath - The path to check for filename collision. 78 | @returns Either the original `filename` or the `filename` appended with a number (or modified by `option.incrementer` if specified). 79 | 80 | If an already incremented `filePath` is passed, `unusedFilename` will simply increment and replace the already existing index: 81 | 82 | @example 83 | ``` 84 | import {unusedFilename} from 'unused-filename'; 85 | 86 | console.log(await unusedFilename('rainbow (1).txt')); 87 | //=> 'rainbow (2).txt' 88 | ``` 89 | */ 90 | export function unusedFilename(filePath: string, options?: Options): Promise; 91 | 92 | /** 93 | Synchronously get an unused filename by appending a number if it exists: `file.txt` → `file (1).txt`. 94 | 95 | @param filePath - The path to check for filename collision. 96 | @returns Either the original `filename` or the `filename` appended with a number (or modified by `option.incrementer` if specified). 97 | 98 | If an already incremented `filePath` is passed, `unusedFilename` will simply increment and replace the already existing index: 99 | 100 | @example 101 | ``` 102 | import {unusedFilenameSync} from 'unused-filename'; 103 | 104 | console.log(unusedFilenameSync('rainbow (1).txt')); 105 | //=> 'rainbow (2).txt' 106 | ``` 107 | */ 108 | export function unusedFilenameSync(filePath: string, options?: Options): string; 109 | 110 | /** 111 | Creates an incrementer that appends a number after a separator. 112 | 113 | `separatorIncrementer('_')` will increment `file.txt` → `file_1.txt`. 114 | 115 | Not all characters can be used as separators: 116 | - On Unix-like systems, `/` is reserved. 117 | - On Windows, `<>:"/|?*` along with trailing periods are reserved. 118 | 119 | @example 120 | ``` 121 | import {unusedFilename, separatorIncrementer} from 'unused-filename'; 122 | 123 | console.log(await unusedFilename('rainbow.txt', {incrementer: separatorIncrementer('_')})); 124 | //=> 'rainbow_1.txt' 125 | ``` 126 | */ 127 | export function separatorIncrementer(separator: string): Incrementer; 128 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import {pathExists, pathExistsSync} from 'path-exists'; 3 | import escapeStringRegexp from 'escape-string-regexp'; 4 | 5 | export class MaxTryError extends Error { 6 | constructor(originalPath, lastTriedPath) { 7 | super('Max tries reached.'); 8 | this.originalPath = originalPath; 9 | this.lastTriedPath = lastTriedPath; 10 | } 11 | } 12 | 13 | const parenthesesIncrementer = (inputFilename, extension) => { 14 | const match = inputFilename.match(/^(?.*)\((?\d+)\)$/); 15 | let {filename, index} = match ? match.groups : {filename: inputFilename, index: 0}; 16 | filename = filename.trim(); 17 | return [`${filename}${extension}`, `${filename} (${++index})${extension}`]; 18 | }; 19 | 20 | const incrementPath = (filePath, incrementer) => { 21 | const ext = path.extname(filePath); 22 | const dirname = path.dirname(filePath); 23 | const [originalFilename, incrementedFilename] = incrementer(path.basename(filePath, ext), ext); 24 | return [path.join(dirname, originalFilename), path.join(dirname, incrementedFilename)]; 25 | }; 26 | 27 | export const separatorIncrementer = separator => { 28 | const escapedSeparator = escapeStringRegexp(separator); 29 | 30 | return (inputFilename, extension) => { 31 | const match = new RegExp(`^(?.*)${escapedSeparator}(?\\d+)$`).exec(inputFilename); 32 | let {filename, index} = match ? match.groups : {filename: inputFilename, index: 0}; 33 | return [`${filename}${extension}`, `${filename.trim()}${separator}${++index}${extension}`]; 34 | }; 35 | }; 36 | 37 | export async function unusedFilename(filePath, {incrementer = parenthesesIncrementer, maxTries = Number.POSITIVE_INFINITY} = {}) { 38 | let tries = 0; 39 | let [originalPath] = incrementPath(filePath, incrementer); 40 | let unusedPath = filePath; 41 | 42 | /* eslint-disable no-await-in-loop, no-constant-condition */ 43 | while (true) { 44 | if (!(await pathExists(unusedPath))) { 45 | return unusedPath; 46 | } 47 | 48 | if (++tries > maxTries) { 49 | throw new MaxTryError(originalPath, unusedPath); 50 | } 51 | 52 | [originalPath, unusedPath] = incrementPath(unusedPath, incrementer); 53 | } 54 | /* eslint-enable no-await-in-loop, no-constant-condition */ 55 | } 56 | 57 | export function unusedFilenameSync(filePath, {incrementer = parenthesesIncrementer, maxTries = Number.POSITIVE_INFINITY} = {}) { 58 | let tries = 0; 59 | let [originalPath] = incrementPath(filePath, incrementer); 60 | let unusedPath = filePath; 61 | 62 | /* eslint-disable no-constant-condition */ 63 | while (true) { 64 | if (!pathExistsSync(unusedPath)) { 65 | return unusedPath; 66 | } 67 | 68 | if (++tries > maxTries) { 69 | throw new MaxTryError(originalPath, unusedPath); 70 | } 71 | 72 | [originalPath, unusedPath] = incrementPath(unusedPath, incrementer); 73 | } 74 | /* eslint-enable no-constant-condition */ 75 | } 76 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import {expectType} from 'tsd'; 2 | import {unusedFilename, unusedFilenameSync, MaxTryError} from './index.js'; 3 | 4 | expectType>(unusedFilename('rainbow.txt')); 5 | expectType(unusedFilenameSync('rainbow.txt')); 6 | 7 | let error: unknown; 8 | if (error instanceof MaxTryError) { 9 | expectType(error.lastTriedPath); 10 | } 11 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unused-filename", 3 | "version": "4.0.1", 4 | "description": "Get an unused filename by appending a number if it exists: `file.txt` → `file (1).txt`", 5 | "license": "MIT", 6 | "repository": "sindresorhus/unused-filename", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": "./index.js", 15 | "engines": { 16 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0" 17 | }, 18 | "scripts": { 19 | "test": "xo && ava && tsd" 20 | }, 21 | "files": [ 22 | "index.js", 23 | "index.d.ts" 24 | ], 25 | "keywords": [ 26 | "unused", 27 | "filename", 28 | "filepath", 29 | "file", 30 | "name", 31 | "available", 32 | "safe", 33 | "unique", 34 | "usable", 35 | "filesystem", 36 | "fs", 37 | "exists", 38 | "path" 39 | ], 40 | "dependencies": { 41 | "escape-string-regexp": "^5.0.0", 42 | "path-exists": "^5.0.0" 43 | }, 44 | "devDependencies": { 45 | "ava": "^3.15.0", 46 | "tsd": "^0.19.0", 47 | "xo": "^0.46.4" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # unused-filename 2 | 3 | > Get an unused filename by appending a number if it exists: `file.txt` → `file (1).txt` 4 | 5 | Useful for safely writing, copying, moving files without overwriting existing files. 6 | 7 | ## Install 8 | 9 | ```sh 10 | npm install unused-filename 11 | ``` 12 | 13 | ## Usage 14 | 15 | ``` 16 | . 17 | ├── rainbow (1).txt 18 | ├── rainbow.txt 19 | └── unicorn.txt 20 | ``` 21 | 22 | ```js 23 | import {unusedFilename} from 'unused-filename'; 24 | 25 | console.log(await unusedFilename('rainbow.txt')); 26 | //=> 'rainbow (2).txt' 27 | ``` 28 | 29 | ## API 30 | 31 | ### unusedFilename(filePath, options?) 32 | 33 | Returns a `Promise` containing either the original `filename` or the `filename` increment by `options.incrementer`. 34 | 35 | If an already incremented `filePath` is passed, `unusedFilename` will simply increment and replace the already existing index: 36 | 37 | ```js 38 | import {unusedFilename} from 'unused-filename'; 39 | 40 | console.log(await unusedFilename('rainbow (1).txt')); 41 | //=> 'rainbow (2).txt' 42 | ``` 43 | 44 | ### unusedFilenameSync(filePath, options?) 45 | 46 | Synchronous version of `unusedFilename`. 47 | 48 | #### filePath 49 | 50 | Type: `string` 51 | 52 | The path to check for filename collision. 53 | 54 | #### options 55 | 56 | Type: `object` 57 | 58 | ##### incrementer 59 | 60 | Type: `(filePath: string) => [string, string]`\ 61 | Default: Parentheses incrementer: `file.txt` → `file (1).txt` 62 | 63 | A function that accepts a file path, and increments its index. 64 | 65 | It's the incrementer's responsibility to extract an already existing index from the given file path so that it picks up and continues incrementing an already present index instead of appending a second one. 66 | 67 | The incrementer has to return a tuple of `[originalFilename, incrementedFilename]`, where `originalFilename` is the filename without the index, and `incrementedFilename` is a filename with input's index bumped by one. 68 | 69 | ```js 70 | import {unusedFilename} from 'unused-filename'; 71 | 72 | // Incrementer that inserts a new index as a prefix. 73 | const prefixIncrementer = (filename, extension) => { 74 | const match = filename.match(/^(?\d+)_(?.*)$/); 75 | let {originalFilename, index} = match ? match.groups : {originalFilename: filename, index: 0}; 76 | originalFilename = originalFilename.trim(); 77 | return [`${originalFilename}${extension}`, `${++index}_${originalFilename}${extension}`]; 78 | }; 79 | 80 | console.log(await unusedFilename('rainbow.txt', {incrementer: prefixIncrementer})); 81 | //=> '1_rainbow.txt' 82 | ``` 83 | 84 | ##### maxTries 85 | 86 | Type: `number`\ 87 | Default: `Infinity` 88 | 89 | The maximum number of attempts to find an unused filename. 90 | 91 | When the limit is reached, the function will throw `MaxTryError`. 92 | 93 | ### separatorIncrementer 94 | 95 | Creates an incrementer that appends a number after a separator. 96 | 97 | `separatorIncrementer('_')` will increment `file.txt` → `file_1.txt`. 98 | 99 | Not all characters can be used as separators: 100 | - On Unix-like systems, `/` is reserved. 101 | - On Windows, `<>:"/|?*` along with trailing periods are reserved. 102 | 103 | ```js 104 | import {unusedFilename, separatorIncrementer} from 'unused-filename'; 105 | 106 | console.log(await unusedFilename('rainbow.txt', {incrementer: separatorIncrementer('_')})); 107 | //=> 'rainbow_1.txt' 108 | ``` 109 | 110 | ### MaxTryError 111 | 112 | The error thrown when `maxTries` limit is reached without finding an unused filename. 113 | 114 | It comes with 2 custom properties: 115 | 116 | - `originalPath` - Path without incrementation sequence. 117 | - `lastTriedPath` - The last tested incremented path. 118 | 119 | Example: 120 | 121 | ```js 122 | import {unusedFilename, MaxTryError} from 'unused-filename'; 123 | 124 | try { 125 | const path = await unusedFilename('rainbow (1).txt', {maxTries: 0}); 126 | } catch (error) { 127 | if (error instanceof MaxTryError) { 128 | console.log(error.originalPath); // 'rainbow.txt' 129 | console.log(error.lastTriedPath); // 'rainbow (1).txt' 130 | } 131 | } 132 | ``` 133 | 134 | ## Related 135 | 136 | - [filenamify](https://github.com/sindresorhus/filenamify) - Convert a string to a valid safe filename 137 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import test from 'ava'; 3 | import {unusedFilename, unusedFilenameSync, separatorIncrementer, MaxTryError} from './index.js'; 4 | 5 | const fixturePath = file => path.join('fixtures', file); 6 | const underscore = {incrementer: separatorIncrementer('_')}; 7 | const dash = {incrementer: separatorIncrementer('-')}; 8 | 9 | test('async', async t => { 10 | t.is(await unusedFilename(fixturePath('noop.txt')), fixturePath('noop.txt')); 11 | t.is(await unusedFilename(fixturePath('unicorn.txt')), fixturePath('unicorn (1).txt')); 12 | t.is(await unusedFilename(fixturePath('rainbow.txt')), fixturePath('rainbow (3).txt')); 13 | }); 14 | 15 | test('async - maxTries option', async t => { 16 | const error = await t.throwsAsync(async () => { 17 | await unusedFilename(fixturePath('rainbow (1).txt'), {maxTries: 1}); 18 | }, {instanceOf: MaxTryError}); 19 | 20 | t.is(error.originalPath, fixturePath('rainbow.txt')); 21 | t.is(error.lastTriedPath, fixturePath('rainbow (2).txt')); 22 | }); 23 | 24 | test('async - incrementer option', async t => { 25 | t.is(await unusedFilename(fixturePath('noop.txt'), underscore), fixturePath('noop.txt')); 26 | t.is(await unusedFilename(fixturePath('unicorn.txt'), underscore), fixturePath('unicorn_1.txt')); 27 | t.is(await unusedFilename(fixturePath('rainbow.txt'), underscore), fixturePath('rainbow_3.txt')); 28 | t.is(await unusedFilename(fixturePath('rainbow.txt'), dash), fixturePath('rainbow-2.txt')); 29 | }); 30 | 31 | test('sync', t => { 32 | t.is(unusedFilenameSync(fixturePath('noop.txt')), fixturePath('noop.txt')); 33 | t.is(unusedFilenameSync(fixturePath('unicorn.txt')), fixturePath('unicorn (1).txt')); 34 | t.is(unusedFilenameSync(fixturePath('rainbow.txt')), fixturePath('rainbow (3).txt')); 35 | }); 36 | 37 | test('sync - maxTries option', t => { 38 | const error = t.throws(() => { 39 | unusedFilenameSync(fixturePath('rainbow (1).txt'), {maxTries: 1}); 40 | }, {instanceOf: MaxTryError}); 41 | 42 | t.is(error.originalPath, fixturePath('rainbow.txt')); 43 | t.is(error.lastTriedPath, fixturePath('rainbow (2).txt')); 44 | }); 45 | 46 | test('sync - incrementer option', t => { 47 | t.is(unusedFilenameSync(fixturePath('noop.txt'), underscore), fixturePath('noop.txt')); 48 | t.is(unusedFilenameSync(fixturePath('unicorn.txt'), underscore), fixturePath('unicorn_1.txt')); 49 | t.is(unusedFilenameSync(fixturePath('rainbow.txt'), underscore), fixturePath('rainbow_3.txt')); 50 | t.is(unusedFilenameSync(fixturePath('rainbow.txt'), dash), fixturePath('rainbow-2.txt')); 51 | }); 52 | --------------------------------------------------------------------------------