From 7df88b840d2d87611db4c6d4fbd7ce9225154d1a Mon Sep 17 00:00:00 2001 From: Yanis Benson Date: Sat, 7 Mar 2020 08:25:43 +0300 Subject: [PATCH] Add `filter` option (#66) Co-authored-by: Sindre Sorhus --- index.d.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++ index.js | 62 ++++++++++++++++++++++++++++++++++++++----------- index.test-d.ts | 6 +++++ package.json | 4 +++- readme.md | 57 +++++++++++++++++++++++++++++++++++++++++++++ test.js | 50 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 221 insertions(+), 15 deletions(-) diff --git a/index.d.ts b/index.d.ts index a1ecdc6..f004571 100644 --- a/index.d.ts +++ b/index.d.ts @@ -2,6 +2,43 @@ import {GlobbyOptions} from 'globby'; import {Options as CpFileOptions} from 'cp-file'; declare namespace cpy { + interface SourceFile { + /** + Resolved path to the file. + + @example '/tmp/dir/foo.js' + */ + readonly path: string; + + /** + Relative path to the file from `cwd`. + + @example 'dir/foo.js' if `cwd` was '/tmp' + */ + readonly relativePath: string; + + /** + Filename with extension. + + @example 'foo.js' + */ + readonly name: string; + + /** + Filename without extension. + + @example 'foo' + */ + readonly nameWithoutExtension: string; + + /** + File extension. + + @example 'js' + */ + readonly extension: string; + } + interface Options extends Readonly, CpFileOptions { /** Working directory to find source files. @@ -46,6 +83,26 @@ declare namespace cpy { @default true */ readonly ignoreJunk?: boolean; + + /** + Function to filter files to copy. + + Receives a source file object as the first argument. + + Return true to include, false to exclude. You can also return a Promise that resolves to true or false. + + @example + ``` + import cpy = require('cpy'); + + (async () => { + await cpy('foo', 'destination', { + filter: file => file.extension !== '.nocopy' + }); + })(); + ``` + */ + readonly filter?: (file: SourceFile) => (boolean | Promise); } interface ProgressData { diff --git a/index.js b/index.js index 398cbe0..80817ff 100644 --- a/index.js +++ b/index.js @@ -2,19 +2,40 @@ const EventEmitter = require('events'); const path = require('path'); const os = require('os'); -const pAll = require('p-all'); +const pMap = require('p-map'); const arrify = require('arrify'); const globby = require('globby'); const hasGlob = require('has-glob'); const cpFile = require('cp-file'); const junk = require('junk'); +const pFilter = require('p-filter'); const CpyError = require('./cpy-error'); const defaultOptions = { ignoreJunk: true }; -const preprocessSourcePath = (source, options) => options.cwd ? path.resolve(options.cwd, source) : source; +class SourceFile { + constructor(relativePath, path) { + this.path = path; + this.relativePath = relativePath; + Object.freeze(this); + } + + get name() { + return path.basename(this.relativePath); + } + + get nameWithoutExtension() { + return path.basename(this.relativePath, path.extname(this.relativePath)); + } + + get extension() { + return path.extname(this.relativePath).slice(1); + } +} + +const preprocessSourcePath = (source, options) => path.resolve(options.cwd ? options.cwd : process.cwd(), source); const preprocessDestinationPath = (source, destination, options) => { let basename = path.basename(source); @@ -74,6 +95,22 @@ module.exports = (source, destination, { throw new CpyError(`Cannot copy \`${source}\`: the file doesn't exist`); } + let sources = files.map(sourcePath => new SourceFile(sourcePath, preprocessSourcePath(sourcePath, options))); + + if (options.filter !== undefined) { + const filteredSources = await pFilter(sources, options.filter, {concurrency: 1024}); + sources = filteredSources; + } + + if (sources.length === 0) { + progressEmitter.emit('progress', { + totalFiles: 0, + percent: 1, + completedFiles: 0, + completedSize: 0 + }); + } + const fileProgressHandler = event => { const fileStatus = copyStatus.get(event.src) || {written: 0, percent: 0}; @@ -99,20 +136,17 @@ module.exports = (source, destination, { } }; - return pAll(files.map(sourcePath => { - return async () => { - const from = preprocessSourcePath(sourcePath, options); - const to = preprocessDestinationPath(sourcePath, destination, options); + return pMap(sources, async source => { + const to = preprocessDestinationPath(source.relativePath, destination, options); - try { - await cpFile(from, to, options).on('progress', fileProgressHandler); - } catch (error) { - throw new CpyError(`Cannot copy from \`${from}\` to \`${to}\`: ${error.message}`, error); - } + try { + await cpFile(source.path, to, options).on('progress', fileProgressHandler); + } catch (error) { + throw new CpyError(`Cannot copy from \`${source.relativePath}\` to \`${to}\`: ${error.message}`, error); + } - return to; - }; - }), {concurrency}); + return to; + }, {concurrency}); })(); promise.on = (...arguments_) => { diff --git a/index.test-d.ts b/index.test-d.ts index 39fec27..5c16541 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -27,6 +27,12 @@ expectType & ProgressEmitter>( cpy('foo.js', 'destination', {concurrency: 2}) ); +expectType & ProgressEmitter>( + cpy('foo.js', 'destination', {filter: (file: cpy.SourceFile) => true}) +); +expectType & ProgressEmitter>( + cpy('foo.js', 'destination', {filter: async (file: cpy.SourceFile) => true}) +); expectType>( cpy('foo.js', 'destination').on('progress', progress => { diff --git a/package.json b/package.json index ba95cb0..1f605a5 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,9 @@ "has-glob": "^1.0.0", "junk": "^3.1.0", "nested-error-stacks": "^2.1.0", - "p-all": "^2.1.0" + "p-all": "^2.1.0", + "p-filter": "^2.1.0", + "p-map": "^3.0.0" }, "devDependencies": { "ava": "^2.1.0", diff --git a/readme.md b/readme.md index d358d1c..3b5aa96 100644 --- a/readme.md +++ b/readme.md @@ -106,6 +106,63 @@ Default: `true` Ignores [junk](https://github.com/sindresorhus/junk) files. +##### filter + +Type: `Function` + +Function to filter files to copy. + +Receives a source file object as the first argument. + +Return true to include, false to exclude. You can also return a Promise that resolves to true or false. + +```js +const cpy = require('cpy'); + +(async () => { + await cpy('foo', 'destination', { + filter: file => file.extension !== '.nocopy' + }); +})(); +``` + +##### Source file object + +###### path + +Type: `string`\ +Example: `'/tmp/dir/foo.js'` + +Resolved path to the file. + +###### relativePath + +Type: `string`\ +Example: `'dir/foo.js'` if `cwd` was `'/tmp'` + +Relative path to the file from `cwd`. + +###### name + +Type: `string`\ +Example: `'foo.js'` + +Filename with extension. + +###### nameWithoutExtension + +Type: `string`\ +Example: `'foo'` + +Filename without extension. + +###### extension + +Type: `string`\ +Example: `'js'` + +File extension. + ## Progress reporting ### cpy.on('progress', handler) diff --git a/test.js b/test.js index 4d440af..de7ca74 100644 --- a/test.js +++ b/test.js @@ -54,6 +54,56 @@ test('throws on invalid concurrency value', async t => { await t.throwsAsync(cpy(['license', 'package.json'], t.context.tmp, {concurrency: 'foo'})); }); +test('copy array of files with filter', async t => { + await cpy(['license', 'package.json'], t.context.tmp, { + filter: file => { + if (file.path.endsWith('/license')) { + t.is(file.path, path.join(process.cwd(), 'license')); + t.is(file.relativePath, 'license'); + t.is(file.name, 'license'); + t.is(file.nameWithoutExtension, 'license'); + t.is(file.extension, ''); + } else if (file.path.endsWith('/package.json')) { + t.is(file.path, path.join(process.cwd(), 'package.json')); + t.is(file.relativePath, 'package.json'); + t.is(file.name, 'package.json'); + t.is(file.nameWithoutExtension, 'package'); + t.is(file.extension, 'json'); + } + + return !file.path.endsWith('/license'); + } + }); + + t.false(fs.existsSync(path.join(t.context.tmp, 'license'))); + t.is(read('package.json'), read(t.context.tmp, 'package.json')); +}); + +test('copy array of files with async filter', async t => { + await cpy(['license', 'package.json'], t.context.tmp, { + filter: async file => { + if (file.path.endsWith('/license')) { + t.is(file.path, path.join(process.cwd(), 'license')); + t.is(file.relativePath, 'license'); + t.is(file.name, 'license'); + t.is(file.nameWithoutExtension, 'license'); + t.is(file.extension, ''); + } else if (file.path.endsWith('/package.json')) { + t.is(file.path, path.join(process.cwd(), 'package.json')); + t.is(file.relativePath, 'package.json'); + t.is(file.name, 'package.json'); + t.is(file.nameWithoutExtension, 'package'); + t.is(file.extension, 'json'); + } + + return !file.path.endsWith('/license'); + } + }); + + t.false(fs.existsSync(path.join(t.context.tmp, 'license'))); + t.is(read('package.json'), read(t.context.tmp, 'package.json')); +}); + test('cwd', async t => { fs.mkdirSync(t.context.tmp); fs.mkdirSync(path.join(t.context.tmp, 'cwd'));