Repository: webpack-contrib/copy-webpack-plugin (js)

Issue:
Copy files with the archive attribute only
<!--
  Issues are so 🔥

  If you **remove** or skip this template, you'll make the 🐼 sad and the mighty god
  of Github will appear and pile-drive the close button from a great height
  while making animal noises.

  👉🏽 Need support, advice, or help? Don't open an issue!
  Head to StackOverflow or https://gitter.im/webpack/webpack.
-->

- Operating System: Windows
- Node Version: v12.16.0
- NPM Version: 6.11.3
- webpack Version: 4.42.0
- copy-webpack-plugin Version: 5.1.1

### Feature Proposal

In my project there is a folder that should be completely copied to the target directory. Files in this folder are changed not so often and not all. There are about **10,000** files in this folder. The fun begins when I want to upload the project to the production server and start building the project in production mode. copy-webpack-plugin always copies all **10,000 files**, although there may be, for example, **two modified** ones. We need to enter the mode "Copy only with the **archive attribute**" and at this moment **remove this attribute from the file**. With any modification of the file, at least the Windows operating system will **automatically** set this attribute to **true** again  and **copy-webpack-plugin** will next copy this file and **remove** the "**archive**" attribute


### Feature Use Case

We can not in **real time recognize a file change** by the presence of the **archive attribute** and use this property for offline tracking of changed files to copy them to the target directory.

Pull request description:
feat: added `filter` option
<!--
  HOLY CRAP a Pull Request. We ❤️ those!

  If you remove or skip this template, you'll make the 🐼 sad and the mighty god
  of Github will appear and pile-drive the close button from a great height
  while making animal noises.

  Please place an x (no spaces!) in all [ ] that apply
-->

This PR contains a:

- [ ] **bugfix**
- [x] new **feature**
- [ ] **code refactor**
- [x] **test update** <!-- if bug or feature is checked, this should be too -->
- [ ] **typo fix**
- [x] **metadata update**

### Motivation / Use-Case

fixes #440

### Breaking Changes

No

### Additional Info

No

Patch:
diff --git a/README.md b/README.md
index 8bf6b55..f3a4670 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,7 @@ module.exports = {
 |               [`to`](#to)               |         `{String}`          |            `compiler.options.output`            | Output path.                                                                                          |
 |          [`context`](#context)          |         `{String}`          | `options.context \|\| compiler.options.context` | A path that determines how to interpret the `from` path.                                              |
 |      [`globOptions`](#globoptions)      |         `{Object}`          |                   `undefined`                   | [Options][glob-options] passed to the glob pattern matching library including `ignore` option.        |
+|           [`filter`](#filter)           |        `{Function}`         |                   `undefined`                   | Allows to filter copied assets.                                                                       |
 |           [`toType`](#totype)           |         `{String}`          |                   `undefined`                   | Determinate what is `to` option - directory, file or template.                                        |
 |            [`force`](#force)            |         `{Boolean}`         |                     `false`                     | Overwrites files already in `compilation.assets` (usually added by other plugins/loaders).            |
 |          [`flatten`](#flatten)          |         `{Boolean}`         |                     `false`                     | Removes all directory references and only copies file names.                                          |
@@ -277,6 +278,41 @@ module.exports = {
 };
 ```
 
+#### `filter`
+
+Type: `Function`
+Default: `undefined`
+
+> ℹ️ To ignore files by path please use the [`globOptions.ignore`]((#globoptions) option.
+
+**webpack.config.js**
+
+```js
+const fs = require('fs').promise;
+
+module.exports = {
+  plugins: [
+    new CopyPlugin({
+      patterns: [
+        {
+          from: 'public/**/*',
+          filter: async (resourcePath) => {
+            const data = await fs.promises.readFile(resourcePath);
+            const content = data.toString();
+
+            if (content === 'my-custom-content') {
+              return false;
+            }
+
+            return true;
+          },
+        },
+      ],
+    }),
+  ],
+};
+```
+
 #### `toType`
 
 Type: `String`
diff --git a/src/options.json b/src/options.json
index 29ae6b2..76d620b 100644
--- a/src/options.json
+++ b/src/options.json
@@ -17,6 +17,9 @@
         "globOptions": {
           "type": "object"
         },
+        "filter": {
+          "instanceof": "Function"
+        },
         "toType": {
           "enum": ["dir", "file", "template"]
         },
diff --git a/src/processPattern.js b/src/processPattern.js
index f750329..a961a50 100644
--- a/src/processPattern.js
+++ b/src/processPattern.js
@@ -31,32 +31,46 @@ export default async function processPattern(globalRef, pattern) {
     return Promise.resolve();
   }
 
-  return (
-    paths
-      // Exclude directories
-      .filter((item) => item.dirent.isFile())
-      .map((item) => {
-        const from = item.path;
-
-        logger.debug(`found ${from}`);
-
-        // `globby`/`fast-glob` return the relative path when the path contains special characters on windows
-        const absoluteFrom = path.resolve(pattern.context, from);
-        const relativeFrom = pattern.flatten
-          ? path.basename(absoluteFrom)
-          : path.relative(pattern.context, absoluteFrom);
-        let webpackTo =
-          pattern.toType === 'dir'
-            ? path.join(pattern.to, relativeFrom)
-            : pattern.to;
-
-        if (path.isAbsolute(webpackTo)) {
-          webpackTo = path.relative(output, webpackTo);
+  const filteredPaths = (
+    await Promise.all(
+      paths.map(async (item) => {
+        // Exclude directories
+        if (!item.dirent.isFile()) {
+          return false;
         }
 
-        logger.log(`determined that '${from}' should write to '${webpackTo}'`);
+        if (pattern.filter) {
+          const isFiltered = await pattern.filter(item.path);
 
-        return { absoluteFrom, relativeFrom, webpackTo };
+          return isFiltered ? item : false;
+        }
+
+        return item;
       })
-  );
+    )
+  ).filter((item) => item);
+
+  return filteredPaths.map((item) => {
+    const from = item.path;
+
+    logger.debug(`found ${from}`);
+
+    // `globby`/`fast-glob` return the relative path when the path contains special characters on windows
+    const absoluteFrom = path.resolve(pattern.context, from);
+    const relativeFrom = pattern.flatten
+      ? path.basename(absoluteFrom)
+      : path.relative(pattern.context, absoluteFrom);
+    let webpackTo =
+      pattern.toType === 'dir'
+        ? path.join(pattern.to, relativeFrom)
+        : pattern.to;
+
+    if (path.isAbsolute(webpackTo)) {
+      webpackTo = path.relative(output, webpackTo);
+    }
+
+    logger.log(`determined that '${from}' should write to '${webpackTo}'`);
+
+    return { absoluteFrom, relativeFrom, webpackTo };
+  });
 }


Test patch:
diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap
index 0742af5..3ee7e2d 100644
--- a/test/__snapshots__/validate-options.test.js.snap
+++ b/test/__snapshots__/validate-options.test.js.snap
@@ -14,7 +14,7 @@ exports[`validate options should throw an error on the "options" option with "{"
 exports[`validate options should throw an error on the "patterns" option with "" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options.patterns should be an array:
-   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "patterns" option with "[""]" value 1`] = `
@@ -37,6 +37,11 @@ exports[`validate options should throw an error on the "patterns" option with "[
  - options.patterns[0].from should be an non-empty string."
 `;
 
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","filter":"test"}]" value 1`] = `
+"Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
+ - options.patterns[0].filter should be an instance of function."
+`;
+
 exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","context":"context","cacheTransform":{"foo":"bar"}}]" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options.patterns[0].cacheTransform has an unknown property 'foo'. These properties are valid:
@@ -72,7 +77,7 @@ exports[`validate options should throw an error on the "patterns" option with "[
 exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","context":"context"}]" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options.patterns[0] should be one of these:
-   non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }
+   non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }
    Details:
     * options.patterns[0].cacheTransform should be one of these:
       boolean | string | object { directory?, keys? }
@@ -112,71 +117,71 @@ exports[`validate options should throw an error on the "patterns" option with "[
 exports[`validate options should throw an error on the "patterns" option with "{}" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options.patterns should be an array:
-   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "patterns" option with "true" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options.patterns should be an array:
-   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "patterns" option with "true" value 2`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options.patterns should be an array:
-   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "patterns" option with "undefined" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "/test/" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "[]" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "{"foo":"bar"}" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "{}" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "1" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "false" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "test" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "true" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
diff --git a/test/filter-option.test.js b/test/filter-option.test.js
new file mode 100644
index 0000000..1f43b8d
--- /dev/null
+++ b/test/filter-option.test.js
@@ -0,0 +1,56 @@
+import fs from 'fs';
+
+import { runEmit } from './helpers/run';
+
+describe('"filter" option', () => {
+  it('should work, copy files and filter some of them', (done) => {
+    runEmit({
+      expectedAssetKeys: [
+        '.dottedfile',
+        'nested/deep-nested/deepnested.txt',
+        'nested/nestedfile.txt',
+      ],
+      patterns: [
+        {
+          from: 'directory',
+          filter: (resourcePath) => {
+            if (/directoryfile\.txt$/.test(resourcePath)) {
+              return false;
+            }
+
+            return true;
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+
+  it('should work, copy files and filter some of them using async function', (done) => {
+    runEmit({
+      expectedAssetKeys: [
+        '.dottedfile',
+        'nested/deep-nested/deepnested.txt',
+        'nested/nestedfile.txt',
+      ],
+      patterns: [
+        {
+          from: 'directory',
+          filter: async (resourcePath) => {
+            const data = await fs.promises.readFile(resourcePath);
+            const content = data.toString();
+
+            if (content === 'new') {
+              return false;
+            }
+
+            return true;
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+});
diff --git a/test/validate-options.test.js b/test/validate-options.test.js
index 34b653e..13f2e34 100644
--- a/test/validate-options.test.js
+++ b/test/validate-options.test.js
@@ -132,6 +132,12 @@ describe('validate options', () => {
             },
           },
         ],
+        [
+          {
+            from: 'test.txt',
+            filter: () => true,
+          },
+        ],
       ],
       failure: [
         // eslint-disable-next-line no-undefined
@@ -247,6 +253,12 @@ describe('validate options', () => {
             noErrorOnMissing: 'true',
           },
         ],
+        [
+          {
+            from: 'test.txt',
+            filter: 'test',
+          },
+        ],
       ],
     },
     options: {