-
-
Notifications
You must be signed in to change notification settings - Fork 23
/
extractFiles.mjs
196 lines (169 loc) · 5.63 KB
/
extractFiles.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
// @ts-check
// @deno-types="is-plain-obj/index.d.ts"
import isPlainObject from "is-plain-obj";
/** @typedef {import("./isExtractableFile.mjs").default} isExtractableFile */
/**
* Recursively extracts files and their {@link ObjectPath object paths} within a
* value, replacing them with `null` in a deep clone without mutating the
* original value.
* [`FileList`](https://developer.mozilla.org/en-US/docs/Web/API/Filelist)
* instances are treated as
* [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) instance
* arrays.
* @template Extractable Extractable file type.
* @param {unknown} value Value to extract files from. Typically an object tree.
* @param {(value: unknown) => value is Extractable} isExtractable Matches
* extractable files. Typically {@linkcode isExtractableFile}.
* @param {ObjectPath} [path] Prefix for object paths for extracted files.
* Defaults to `""`.
* @returns {Extraction<Extractable>} Extraction result.
* @example
* Extracting files from an object.
*
* For the following:
*
* ```js
* import extractFiles from "extract-files/extractFiles.mjs";
* import isExtractableFile from "extract-files/isExtractableFile.mjs";
*
* const file1 = new File(["1"], "1.txt", { type: "text/plain" });
* const file2 = new File(["2"], "2.txt", { type: "text/plain" });
* const value = {
* a: file1,
* b: [file1, file2],
* };
*
* const { clone, files } = extractFiles(value, isExtractableFile, "prefix");
* ```
*
* `value` remains the same.
*
* `clone` is:
*
* ```json
* {
* "a": null,
* "b": [null, null]
* }
* ```
*
* `files` is a
* [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map)
* instance containing:
*
* | Key | Value |
* | :------ | :--------------------------- |
* | `file1` | `["prefix.a", "prefix.b.0"]` |
* | `file2` | `["prefix.b.1"]` |
*/
export default function extractFiles(value, isExtractable, path = "") {
if (!arguments.length) throw new TypeError("Argument 1 `value` is required.");
if (typeof isExtractable !== "function")
throw new TypeError("Argument 2 `isExtractable` must be a function.");
if (typeof path !== "string")
throw new TypeError("Argument 3 `path` must be a string.");
/**
* Deeply clonable value.
* @typedef {Array<unknown> | FileList | {
* [key: PropertyKey]: unknown
* }} Cloneable
*/
/**
* Clone of a {@link Cloneable deeply cloneable value}.
* @typedef {Exclude<Cloneable, FileList>} Clone
*/
/**
* Map of values recursed within the input value and their clones, for reusing
* clones of values that are referenced multiple times within the input value.
* @type {Map<Cloneable, Clone>}
*/
const clones = new Map();
/**
* Extracted files and their object paths within the input value.
* @type {Extraction<Extractable>["files"]}
*/
const files = new Map();
/**
* Recursively clones the value, extracting files.
* @param {unknown} value Value to extract files from.
* @param {ObjectPath} path Prefix for object paths for extracted files.
* @param {Set<Cloneable>} recursed Recursed values for avoiding infinite
* recursion of circular references within the input value.
* @returns {unknown} Clone of the value with files replaced with `null`.
*/
function recurse(value, path, recursed) {
if (isExtractable(value)) {
const filePaths = files.get(value);
filePaths ? filePaths.push(path) : files.set(value, [path]);
return null;
}
const valueIsList =
Array.isArray(value) ||
(typeof FileList !== "undefined" && value instanceof FileList);
const valueIsPlainObject = isPlainObject(value);
if (valueIsList || valueIsPlainObject) {
let clone = clones.get(value);
const uncloned = !clone;
if (uncloned) {
clone = valueIsList
? []
: // Replicate if the plain object is an `Object` instance.
value instanceof /** @type {any} */ (Object)
? {}
: Object.create(null);
clones.set(value, /** @type {Clone} */ (clone));
}
if (!recursed.has(value)) {
const pathPrefix = path ? `${path}.` : "";
const recursedDeeper = new Set(recursed).add(value);
if (valueIsList) {
let index = 0;
for (const item of value) {
const itemClone = recurse(
item,
pathPrefix + index++,
recursedDeeper
);
if (uncloned) /** @type {Array<unknown>} */ (clone).push(itemClone);
}
} else
for (const key in value) {
const propertyClone = recurse(
value[key],
pathPrefix + key,
recursedDeeper
);
if (uncloned)
/** @type {{ [key: PropertyKey]: unknown }} */ (clone)[key] =
propertyClone;
}
}
return clone;
}
return value;
}
return {
clone: recurse(value, path, new Set()),
files,
};
}
/**
* An extraction result.
* @template [Extractable=unknown] Extractable file type.
* @typedef {object} Extraction
* @prop {unknown} clone Clone of the original value with extracted files
* recursively replaced with `null`.
* @prop {Map<Extractable, Array<ObjectPath>>} files Extracted files and their
* object paths within the original value.
*/
/**
* String notation for the path to a node in an object tree.
* @typedef {string} ObjectPath
* @see [`object-path` on npm](https://npm.im/object-path).
* @example
* An object path for object property `a`, array index `0`, object property `b`:
*
* ```
* a.0.b
* ```
*/