dalux-build-api 1.1.0 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,297 +1,697 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const axios = require('axios');
6
-
7
- /**
8
- * API methods for files within a file area.
9
- */
10
- class FilesApi {
11
- /**
12
- * @param {import('../apiClient')} apiClient
13
- */
14
- constructor(apiClient) {
15
- this._client = apiClient;
16
- }
17
-
18
- /**
19
- * Browse all files on the given project and file area.
20
- * GET /6.1/projects/{projectId}/file_areas/{fileAreaId}/files
21
- * @param {string} projectId
22
- * @param {string} fileAreaId
23
- * @param {object} [params] - Optional params (e.g. folderId, updatedAfter, includeProperties). The files endpoint does not support OData $filter.
24
- * @returns {Promise<object>}
25
- */
26
- listFiles(projectId, fileAreaId, params = {}) {
27
- return this._client.get(
28
- `/6.1/projects/${projectId}/file_areas/${fileAreaId}/files`,
29
- params,
30
- );
31
- }
32
-
33
- /**
34
- * Retrieve all files by following bookmark pagination (metadata.totalRemainingItems).
35
- * @param {string} projectId
36
- * @param {string} fileAreaId
37
- * @param {object} [params]
38
- * @param {boolean} [verbose=false]
39
- * @returns {Promise<object[]>}
40
- */
41
- async getAllFiles(projectId, fileAreaId, params = {}, verbose = false) {
42
- const allItems = [];
43
- let currentParams = { ...params };
44
- let hasNextPage = true;
45
- const urlPath = `/6.1/projects/${projectId}/file_areas/${fileAreaId}/files`;
46
-
47
- while (hasNextPage) {
48
- const response = await this._client.get(urlPath, currentParams);
49
- const items = response && response.items;
50
- if (items && items.length) {
51
- allItems.push(...items);
52
- }
53
- const remaining = ((response && response.metadata) || {}).totalRemainingItems ?? 0;
54
- if (verbose) {
55
- console.log(`Retrieved ${allItems.length} files so far, ${remaining} remaining...`);
56
- }
57
- if (!items || !items.length || remaining === 0) {
58
- hasNextPage = false;
59
- } else {
60
- const nextLink = (response.links || []).find((l) => l.rel === 'nextPage');
61
- if (nextLink) {
62
- const bookmark = new URL(nextLink.href).searchParams.get('bookmark');
63
- currentParams = { ...params, bookmark };
64
- } else {
65
- hasNextPage = false;
66
- }
67
- }
68
- }
69
- if (verbose) {
70
- console.log(`Done. Total files retrieved: ${allItems.length}`);
71
- }
72
- return allItems;
73
- }
74
-
75
- /**
76
- * All files in a folder (filters getAllFiles by data.folderId).
77
- * @param {string} projectId
78
- * @param {string} fileAreaId
79
- * @param {string} folderId
80
- * @param {object} [params]
81
- * @param {boolean} [verbose=false]
82
- * @returns {Promise<object[]>}
83
- */
84
- async getAllFilesInFolder(projectId, fileAreaId, folderId, params = {}, verbose = false) {
85
- const allFiles = await this.getAllFiles(projectId, fileAreaId, params, verbose);
86
- const filtered = allFiles.filter((f) => ((f.data) || {}).folderId === folderId);
87
- if (verbose) {
88
- console.log(`Files matching folder '${folderId}': ${filtered.length}`);
89
- }
90
- return filtered;
91
- }
92
-
93
- /**
94
- * Download a file from a direct download URL using X-API-KEY (same as Python client).
95
- * @param {string} downloadLink
96
- * @param {string} fileName
97
- * @param {string} [savePath] - Directory to save into (default: current working directory)
98
- * @returns {Promise<string>} Absolute path to saved file
99
- */
100
- async downloadFileFromLink(downloadLink, fileName, savePath) {
101
- const apiKey = this._client.configuration.apiKey;
102
- const dir = savePath || '.';
103
- await fs.promises.mkdir(dir, { recursive: true });
104
- const filePath = path.join(dir, fileName);
105
-
106
- const response = await axios.get(downloadLink, {
107
- headers: { 'X-API-KEY': apiKey },
108
- responseType: 'stream',
109
- validateStatus: () => true,
110
- });
111
- if (response.status !== 200) {
112
- throw new Error(`Failed to download file. Status code: ${response.status}`);
113
- }
114
- await new Promise((resolve, reject) => {
115
- const ws = fs.createWriteStream(filePath);
116
- response.data.pipe(ws);
117
- response.data.on('error', reject);
118
- ws.on('finish', () => resolve());
119
- ws.on('error', reject);
120
- });
121
- return path.resolve(filePath);
122
- }
123
-
124
- /**
125
- * GET /5.0/projects/{projectId}/file_areas/{fileAreaId}/files/{fileId}
126
- * @param {string} projectId
127
- * @param {string} fileAreaId
128
- * @param {string} fileId
129
- * @param {{ download?: boolean, savePath?: string }} [options] - If download is true, saves file and sets downloadedFilePath on the returned object
130
- * @returns {Promise<object>}
131
- */
132
- async getFile(projectId, fileAreaId, fileId, options = {}) {
133
- const fileInfo = await this._client.get(
134
- `/5.0/projects/${projectId}/file_areas/${fileAreaId}/files/${fileId}`,
135
- );
136
- if (options.download && fileInfo && fileInfo.data && fileInfo.data.downloadLink) {
137
- const name = fileInfo.data.fileName || fileId;
138
- const downloadedPath = await this.downloadFileFromLink(
139
- fileInfo.data.downloadLink,
140
- name,
141
- options.savePath,
142
- );
143
- fileInfo.downloadedFilePath = downloadedPath;
144
- }
145
- return fileInfo;
146
- }
147
-
148
- /**
149
- * Download all files in a folder, with optional keyword / extension filters (Python parity).
150
- * @param {string} projectId
151
- * @param {string} fileAreaId
152
- * @param {string} folderId
153
- * @param {string} [savePath]
154
- * @param {object} [opts] - filenameKeywords, filenameKeywordsMatch ('any'|'all'), filenameExtensions, params, verbose
155
- * @returns {Promise<Array<{ fileName: string, downloadedFilePath: string }>>}
156
- */
157
- async bulkDownloadFolder(projectId, fileAreaId, folderId, savePath, opts = {}) {
158
- if (typeof savePath === 'object' && savePath !== null && !Array.isArray(savePath)) {
159
- opts = savePath;
160
- savePath = undefined;
161
- }
162
- const {
163
- filenameKeywords = null,
164
- filenameKeywordsMatch = 'any',
165
- filenameExtensions = null,
166
- params = {},
167
- verbose = false,
168
- } = opts;
169
-
170
- let files = await this.getAllFilesInFolder(projectId, fileAreaId, folderId, params, verbose);
171
-
172
- if (filenameKeywords && filenameKeywords.length) {
173
- const kws = filenameKeywords.map((kw) => String(kw).toLowerCase());
174
- const matchFn =
175
- filenameKeywordsMatch === 'all'
176
- ? (name) => kws.every((kw) => name.includes(kw))
177
- : (name) => kws.some((kw) => name.includes(kw));
178
- files = files.filter((f) => {
179
- const name = (((f.data) || {}).fileName) || '';
180
- return matchFn(name.toLowerCase());
181
- });
182
- if (verbose) {
183
- console.log(
184
- `Files matching fileName keywords ${JSON.stringify(filenameKeywords)} (${filenameKeywordsMatch}): ${files.length}`,
185
- );
186
- }
187
- }
188
-
189
- if (filenameExtensions && filenameExtensions.length) {
190
- const normExts = filenameExtensions.map((ext) =>
191
- (ext.startsWith('.') ? ext : `.${ext}`).toLowerCase(),
192
- );
193
- const before = files.length;
194
- files = files.filter((f) => {
195
- const n = (((f.data) || {}).fileName) || ''.toLowerCase();
196
- return normExts.some((ext) => n.endsWith(ext));
197
- });
198
- if (verbose) {
199
- console.log(`Files matching fileName extensions ${JSON.stringify(normExts)}: ${files.length} / ${before}`);
200
- }
201
- }
202
-
203
- const results = [];
204
- for (let i = 0; i < files.length; i += 1) {
205
- const f = files[i];
206
- const data = f.data || {};
207
- const fileName = data.fileName || data.fileId || `file_${i + 1}`;
208
- const downloadLink = data.downloadLink;
209
- if (!downloadLink) {
210
- if (verbose) {
211
- console.log(` [${i + 1}/${files.length}] Skipping '${fileName}' (no downloadLink)`);
212
- }
213
- continue;
214
- }
215
- if (verbose) {
216
- console.log(` [${i + 1}/${files.length}] Downloading '${fileName}'...`);
217
- }
218
- const downloadedFilePath = await this.downloadFileFromLink(downloadLink, fileName, savePath);
219
- results.push({ fileName, downloadedFilePath });
220
- }
221
- if (verbose) {
222
- console.log(`Bulk download complete. ${results.length} file(s) downloaded.`);
223
- }
224
- return results;
225
- }
226
-
227
- /**
228
- * Download files by id (fetches metadata per id then streams from downloadLink).
229
- * @param {string} projectId
230
- * @param {string} fileAreaId
231
- * @param {string[]} fileIds
232
- * @param {string} [savePath]
233
- * @param {{ verbose?: boolean }} [options]
234
- * @returns {Promise<Array<{ fileId: string, fileName: string, downloadedFilePath: string }>>}
235
- */
236
- async bulkDownloadByIds(projectId, fileAreaId, fileIds, savePath, options = {}) {
237
- if (typeof savePath === 'object' && savePath !== null) {
238
- options = savePath;
239
- savePath = undefined;
240
- }
241
- const { verbose = false } = options;
242
- const results = [];
243
- const total = fileIds.length;
244
- for (let i = 0; i < fileIds.length; i += 1) {
245
- const fileId = fileIds[i];
246
- const fileInfo = await this.getFile(projectId, fileAreaId, fileId);
247
- const data = (fileInfo && fileInfo.data) || {};
248
- const fileName = data.fileName || fileId;
249
- const downloadLink = data.downloadLink;
250
- if (!downloadLink) {
251
- if (verbose) {
252
- console.log(` [${i + 1}/${total}] Skipping '${fileName}' (no downloadLink)`);
253
- }
254
- continue;
255
- }
256
- if (verbose) {
257
- console.log(` [${i + 1}/${total}] Downloading '${fileName}'...`);
258
- }
259
- const downloadedFilePath = await this.downloadFileFromLink(downloadLink, fileName, savePath);
260
- results.push({ fileId, fileName, downloadedFilePath });
261
- }
262
- if (verbose) {
263
- console.log(`Done. ${results.length}/${total} file(s) downloaded.`);
264
- }
265
- return results;
266
- }
267
-
268
- /**
269
- * Retrieve properties mapping for a specific file.
270
- * GET /1.0/projects/{projectId}/file_areas/{fileAreaId}/files/{fileId}/properties/1.0/mappings
271
- * @param {string} projectId
272
- * @param {string} fileAreaId
273
- * @param {string} fileId
274
- * @returns {Promise<object>}
275
- */
276
- getFilePropertiesMapping(projectId, fileAreaId, fileId) {
277
- return this._client.get(
278
- `/1.0/projects/${projectId}/file_areas/${fileAreaId}/files/${fileId}/properties/1.0/mappings`,
279
- );
280
- }
281
-
282
- /**
283
- * Retrieve valid property values for a specific file property mapping.
284
- * GET /1.0/projects/{projectId}/file_areas/{fileAreaId}/files/properties/1.0/mappings/{filePropertyId}/values
285
- * @param {string} projectId
286
- * @param {string} fileAreaId
287
- * @param {string} filePropertyId
288
- * @returns {Promise<object>}
289
- */
290
- getFilePropertyMappingValues(projectId, fileAreaId, filePropertyId) {
291
- return this._client.get(
292
- `/1.0/projects/${projectId}/file_areas/${fileAreaId}/files/properties/1.0/mappings/${filePropertyId}/values`,
293
- );
294
- }
295
- }
296
-
297
- module.exports = FilesApi;
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+ const axios = require('axios');
7
+ const { findByField, findAllByField } = require('../utils/search');
8
+ const { resolveFolderIdFromNamedPath } = require('../utils/pathResolver');
9
+ const { validateProjectId, validateFileAreaId, validateFolderId } = require('../utils/validation');
10
+
11
+ /**
12
+ * API methods for files within a file area.
13
+ */
14
+ class FilesApi {
15
+ /**
16
+ * @param {import('../apiClient')} apiClient
17
+ */
18
+ constructor(apiClient) {
19
+ this._client = apiClient;
20
+ }
21
+
22
+ /**
23
+ * Browse all files on the given project and file area.
24
+ * GET /6.1/projects/{projectId}/file_areas/{fileAreaId}/files
25
+ * @param {string} projectId
26
+ * @param {string} fileAreaId
27
+ * @param {object} [params] - Optional params (e.g. folderId, updatedAfter, includeProperties). The files endpoint does not support OData $filter.
28
+ * @returns {Promise<object>}
29
+ */
30
+ listFiles(projectId, fileAreaId, params = {}) {
31
+ return this._client.get(
32
+ `/6.1/projects/${projectId}/file_areas/${fileAreaId}/files`,
33
+ params,
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Retrieve all files by following bookmark pagination (metadata.totalRemainingItems).
39
+ * @param {string} projectId
40
+ * @param {string} fileAreaId
41
+ * @param {object} [params]
42
+ * @param {boolean} [verbose=false]
43
+ * @returns {Promise<object[]>}
44
+ */
45
+ async getAllFiles(projectId, fileAreaId, params = {}, verbose = false) {
46
+ validateProjectId(projectId);
47
+ validateFileAreaId(fileAreaId);
48
+ const allItems = [];
49
+ let currentParams = { ...params };
50
+ let hasNextPage = true;
51
+ const urlPath = `/6.1/projects/${projectId}/file_areas/${fileAreaId}/files`;
52
+
53
+ while (hasNextPage) {
54
+ const response = await this._client.get(urlPath, currentParams);
55
+ const items = response && response.items;
56
+ if (items && items.length) {
57
+ allItems.push(...items);
58
+ }
59
+ const remaining = ((response && response.metadata) || {}).totalRemainingItems ?? 0;
60
+ if (verbose) {
61
+ console.log(`Retrieved ${allItems.length} files so far, ${remaining} remaining...`);
62
+ }
63
+ if (!items || !items.length || remaining === 0) {
64
+ hasNextPage = false;
65
+ } else {
66
+ const nextLink = (response.links || []).find((l) => l.rel === 'nextPage');
67
+ if (nextLink) {
68
+ const bookmark = new URL(nextLink.href).searchParams.get('bookmark');
69
+ currentParams = { ...params, bookmark };
70
+ } else {
71
+ hasNextPage = false;
72
+ }
73
+ }
74
+ }
75
+ if (verbose) {
76
+ console.log(`Done. Total files retrieved: ${allItems.length}`);
77
+ }
78
+ return allItems;
79
+ }
80
+
81
+ /**
82
+ * All files in a folder.
83
+ *
84
+ * Supports two call styles matching the Python client:
85
+ * - `getAllFilesInFolder(projectId, fileAreaId, folderId, params?, verbose?)` — explicit IDs
86
+ * - `getAllFilesInFolder(projectId, fileAreaIdOrPath, null, params?, verbose?)` full path
87
+ * (e.g. ``"Files/4_Design/C07_Geometry"``)
88
+ *
89
+ * @param {string} projectId
90
+ * @param {string} fileAreaIdOrPath - File area ID, OR a full path starting with the file area name.
91
+ * @param {string|null} [folderId=null] - Folder ID. When null, fileAreaIdOrPath is treated as a path.
92
+ * @param {object} [params]
93
+ * @param {boolean} [verbose=false]
94
+ * @returns {Promise<object[]>}
95
+ */
96
+ async getAllFilesInFolder(projectId, fileAreaIdOrPath, folderId = null, params = {}, verbose = false) {
97
+ validateProjectId(projectId);
98
+
99
+ let fileAreaId;
100
+ let resolvedFolderId;
101
+
102
+ if (folderId == null) {
103
+ const resolved = await resolveFolderIdFromNamedPath(
104
+ this._client, projectId, fileAreaIdOrPath, { verbose },
105
+ );
106
+ if (!resolved.fileAreaId || !resolved.folderId) {
107
+ if (verbose) console.log(`Could not resolve folder path: ${fileAreaIdOrPath}`);
108
+ return [];
109
+ }
110
+ fileAreaId = resolved.fileAreaId;
111
+ resolvedFolderId = resolved.folderId;
112
+ } else {
113
+ fileAreaId = fileAreaIdOrPath;
114
+ resolvedFolderId = folderId;
115
+ validateFileAreaId(fileAreaId);
116
+ validateFolderId(resolvedFolderId);
117
+ }
118
+
119
+ const allFiles = await this.getAllFiles(projectId, fileAreaId, params, verbose);
120
+ const filtered = allFiles.filter((f) => {
121
+ const data = f.data || f;
122
+ return data.folderId === resolvedFolderId;
123
+ });
124
+ if (verbose) {
125
+ console.log(`Files matching folder '${resolvedFolderId}': ${filtered.length}`);
126
+ }
127
+ return filtered;
128
+ }
129
+
130
+ /**
131
+ * Download a file from a direct download URL using X-API-KEY (same as Python client).
132
+ * @param {string} downloadLink
133
+ * @param {string} fileName
134
+ * @param {string} [savePath] - Directory to save into (default: current working directory)
135
+ * @returns {Promise<string>} Absolute path to saved file
136
+ */
137
+ async downloadFileFromLink(downloadLink, fileName, savePath) {
138
+ const apiKey = this._client.configuration.apiKey;
139
+ const dir = savePath || '.';
140
+ await fs.promises.mkdir(dir, { recursive: true });
141
+ const filePath = path.join(dir, fileName);
142
+
143
+ const response = await axios.get(downloadLink, {
144
+ headers: { 'X-API-KEY': apiKey },
145
+ responseType: 'stream',
146
+ validateStatus: () => true,
147
+ });
148
+ if (response.status !== 200) {
149
+ throw new Error(`Failed to download file. Status code: ${response.status}`);
150
+ }
151
+ await new Promise((resolve, reject) => {
152
+ const ws = fs.createWriteStream(filePath);
153
+ response.data.pipe(ws);
154
+ response.data.on('error', reject);
155
+ ws.on('finish', () => resolve());
156
+ ws.on('error', reject);
157
+ });
158
+ return path.resolve(filePath);
159
+ }
160
+
161
+ /**
162
+ * Get a file by IDs or by full path.
163
+ *
164
+ * Supports two call styles matching the Python client:
165
+ * - `getFile(projectId, fileAreaId, fileId, options?)` — explicit IDs
166
+ * - `getFile(projectId, fullPath, null, options?)` — full path including file name
167
+ * (e.g. ``"Files/folder/.../file.ifc"``)
168
+ *
169
+ * @param {string} projectId
170
+ * @param {string} fileAreaIdOrPath - File area ID, OR a full path.
171
+ * @param {string|null} [fileId=null] - File ID. When null, fileAreaIdOrPath is treated as a path.
172
+ * @param {{ download?: boolean, savePath?: string, verbose?: boolean, params?: object }} [options]
173
+ * @returns {Promise<object|string>}
174
+ */
175
+ async getFile(projectId, fileAreaIdOrPath, fileId = null, options = {}) {
176
+ validateProjectId(projectId);
177
+
178
+ if (fileId == null) {
179
+ // Path-based lookup
180
+ const { download = false, savePath, verbose = false, params } = options;
181
+ const pathStr = fileAreaIdOrPath;
182
+ const notFound = `File does not exist: ${pathStr}`;
183
+ const parts = pathStr.split('/').map((p) => p.trim()).filter(Boolean);
184
+ if (parts.length < 3) {
185
+ throw new Error('path must include file area name, folder path, and file name');
186
+ }
187
+ const resolved = await resolveFolderIdFromNamedPath(
188
+ this._client, projectId, parts.slice(0, -1).join('/'), { verbose },
189
+ );
190
+ if (!resolved.fileAreaId || !resolved.folderId) return notFound;
191
+ const files = await this.getAllFilesInFolder(
192
+ projectId, resolved.fileAreaId, resolved.folderId, params, verbose,
193
+ );
194
+ const candidateFileName = parts[parts.length - 1];
195
+ const fileMatch = findByField(files, 'fileName', candidateFileName, (x) => x.data || x)
196
+ || findByField(files, 'file_name', candidateFileName, (x) => x.data || x);
197
+ if (!fileMatch) return notFound;
198
+
199
+ if (download) {
200
+ const data = fileMatch.data || fileMatch;
201
+ if (data.downloadLink) {
202
+ const name = data.fileName || data.file_name || candidateFileName;
203
+ const downloadedFilePath = await this.downloadFileFromLink(data.downloadLink, name, savePath);
204
+ return { ...fileMatch, downloadedFilePath };
205
+ }
206
+ }
207
+ return fileMatch;
208
+ }
209
+
210
+ // ID-based lookup (original behaviour)
211
+ const fileInfo = await this._client.get(
212
+ `/5.0/projects/${projectId}/file_areas/${fileAreaIdOrPath}/files/${fileId}`,
213
+ );
214
+ if (options.download && fileInfo && fileInfo.data && fileInfo.data.downloadLink) {
215
+ const name = fileInfo.data.fileName || fileId;
216
+ const downloadedPath = await this.downloadFileFromLink(
217
+ fileInfo.data.downloadLink,
218
+ name,
219
+ options.savePath,
220
+ );
221
+ fileInfo.downloadedFilePath = downloadedPath;
222
+ }
223
+ return fileInfo;
224
+ }
225
+
226
+ /**
227
+ * Apply file name filters (contains, startsWith, endsWith, extensions).
228
+ * @param {object[]} files
229
+ * @param {object} filters
230
+ * @returns {object[]}
231
+ */
232
+ _applyFileNameFilters(files, filters = {}) {
233
+ const {
234
+ contains = null,
235
+ containsMatch = 'any',
236
+ notContains = null,
237
+ startsWith = null,
238
+ notStartsWith = null,
239
+ endsWith = null,
240
+ notEndsWith = null,
241
+ extensions = null,
242
+ notExtensions = null,
243
+ verbose = false,
244
+ } = filters;
245
+
246
+ function getName(f) {
247
+ return ((f.data || f).fileName || (f.data || f).file_name || '').toLowerCase();
248
+ }
249
+ function norm(vals) {
250
+ return (vals || []).map((v) => String(v).toLowerCase()).filter(Boolean);
251
+ }
252
+ function normExts(vals) {
253
+ return (vals || []).map((v) => (v.startsWith('.') ? v : `.${v}`).toLowerCase()).filter(Boolean);
254
+ }
255
+
256
+ let filtered = files;
257
+
258
+ const containsVals = norm(contains);
259
+ if (containsVals.length) {
260
+ const before = filtered.length;
261
+ const matchFn = containsMatch === 'all'
262
+ ? (name) => containsVals.every((v) => name.includes(v))
263
+ : (name) => containsVals.some((v) => name.includes(v));
264
+ filtered = filtered.filter((f) => matchFn(getName(f)));
265
+ if (verbose) console.log(`Files matching contains ${JSON.stringify(containsVals)} (${containsMatch}): ${filtered.length} / ${before}`);
266
+ }
267
+
268
+ const notContainsVals = norm(notContains);
269
+ if (notContainsVals.length) {
270
+ const before = filtered.length;
271
+ filtered = filtered.filter((f) => !notContainsVals.some((v) => getName(f).includes(v)));
272
+ if (verbose) console.log(`Files excluding contains ${JSON.stringify(notContainsVals)}: ${filtered.length} / ${before}`);
273
+ }
274
+
275
+ const startsWithVals = norm(startsWith);
276
+ if (startsWithVals.length) {
277
+ const before = filtered.length;
278
+ filtered = filtered.filter((f) => startsWithVals.some((v) => getName(f).startsWith(v)));
279
+ if (verbose) console.log(`Files matching startsWith ${JSON.stringify(startsWithVals)}: ${filtered.length} / ${before}`);
280
+ }
281
+
282
+ const notStartsWithVals = norm(notStartsWith);
283
+ if (notStartsWithVals.length) {
284
+ const before = filtered.length;
285
+ filtered = filtered.filter((f) => !notStartsWithVals.some((v) => getName(f).startsWith(v)));
286
+ if (verbose) console.log(`Files excluding startsWith ${JSON.stringify(notStartsWithVals)}: ${filtered.length} / ${before}`);
287
+ }
288
+
289
+ const endsWithVals = norm(endsWith);
290
+ if (endsWithVals.length) {
291
+ const before = filtered.length;
292
+ filtered = filtered.filter((f) => endsWithVals.some((v) => getName(f).endsWith(v)));
293
+ if (verbose) console.log(`Files matching endsWith ${JSON.stringify(endsWithVals)}: ${filtered.length} / ${before}`);
294
+ }
295
+
296
+ const notEndsWithVals = norm(notEndsWith);
297
+ if (notEndsWithVals.length) {
298
+ const before = filtered.length;
299
+ filtered = filtered.filter((f) => !notEndsWithVals.some((v) => getName(f).endsWith(v)));
300
+ if (verbose) console.log(`Files excluding endsWith ${JSON.stringify(notEndsWithVals)}: ${filtered.length} / ${before}`);
301
+ }
302
+
303
+ const extVals = normExts(extensions);
304
+ if (extVals.length) {
305
+ const before = filtered.length;
306
+ filtered = filtered.filter((f) => extVals.some((v) => getName(f).endsWith(v)));
307
+ if (verbose) console.log(`Files matching extensions ${JSON.stringify(extVals)}: ${filtered.length} / ${before}`);
308
+ }
309
+
310
+ const notExtVals = normExts(notExtensions);
311
+ if (notExtVals.length) {
312
+ const before = filtered.length;
313
+ filtered = filtered.filter((f) => !notExtVals.some((v) => getName(f).endsWith(v)));
314
+ if (verbose) console.log(`Files excluding extensions ${JSON.stringify(notExtVals)}: ${filtered.length} / ${before}`);
315
+ }
316
+
317
+ return filtered;
318
+ }
319
+
320
+ /**
321
+ * Download all files in a folder with optional file name filters.
322
+ *
323
+ * Supports two call styles matching the Python client:
324
+ * - `bulkDownloadFolder(projectId, fileAreaId, folderId, savePath?, opts?)` — explicit IDs
325
+ * - `bulkDownloadFolder(projectId, fullPath, null, savePath?, opts?)` — full path
326
+ *
327
+ * @param {string} projectId
328
+ * @param {string} fileAreaIdOrPath - File area ID or full path starting with file area name.
329
+ * @param {string|null} [folderId=null] - Folder ID; when null, fileAreaIdOrPath is a full path.
330
+ * @param {string} [savePath]
331
+ * @param {object} [opts]
332
+ * @param {string[]} [opts.contains]
333
+ * @param {string} [opts.containsMatch='any']
334
+ * @param {string[]} [opts.notContains]
335
+ * @param {string[]} [opts.startsWith]
336
+ * @param {string[]} [opts.notStartsWith]
337
+ * @param {string[]} [opts.endsWith]
338
+ * @param {string[]} [opts.notEndsWith]
339
+ * @param {string[]} [opts.extensions]
340
+ * @param {string[]} [opts.notExtensions]
341
+ * @param {object} [opts.params]
342
+ * @param {boolean} [opts.verbose=false]
343
+ * @returns {Promise<Array<{ fileName: string, downloadedFilePath: string }>>}
344
+ */
345
+ async bulkDownloadFolder(projectId, fileAreaIdOrPath, folderId = null, savePath, opts = {}) {
346
+ if (typeof savePath === 'object' && savePath !== null && !Array.isArray(savePath)) {
347
+ opts = savePath;
348
+ savePath = undefined;
349
+ }
350
+ const { params = {}, verbose = false, ...filterOpts } = opts;
351
+
352
+ let files = await this.getAllFilesInFolder(
353
+ projectId, fileAreaIdOrPath, folderId, params, verbose,
354
+ );
355
+
356
+ // Legacy keyword/extension filter aliases for backward compatibility
357
+ if (opts.filenameKeywords && opts.filenameKeywords.length) {
358
+ filterOpts.contains = opts.filenameKeywords;
359
+ filterOpts.containsMatch = opts.filenameKeywordsMatch || 'any';
360
+ }
361
+ if (opts.filenameExtensions && opts.filenameExtensions.length) {
362
+ filterOpts.extensions = opts.filenameExtensions;
363
+ }
364
+
365
+ files = this._applyFileNameFilters(files, { ...filterOpts, verbose });
366
+
367
+ const results = [];
368
+ for (let i = 0; i < files.length; i += 1) {
369
+ const f = files[i];
370
+ const data = f.data || f;
371
+ const fileName = data.fileName || data.file_name || data.fileId || `file_${i + 1}`;
372
+ const downloadLink = data.downloadLink;
373
+ if (!downloadLink) {
374
+ if (verbose) console.log(` [${i + 1}/${files.length}] Skipping '${fileName}' (no downloadLink)`);
375
+ continue;
376
+ }
377
+ if (verbose) console.log(` [${i + 1}/${files.length}] Downloading '${fileName}'...`);
378
+ const downloadedFilePath = await this.downloadFileFromLink(downloadLink, fileName, savePath);
379
+ results.push({ fileName, downloadedFilePath });
380
+ }
381
+ if (verbose) console.log(`Bulk download complete. ${results.length} file(s) downloaded.`);
382
+ return results;
383
+ }
384
+
385
+ /**
386
+ * Download a list of files by IDs or full paths.
387
+ *
388
+ * Matches Python's ``bulk_download_files``:
389
+ * - When *fileAreaId* is ``null``, each item in *files* is treated as a full path
390
+ * (e.g. ``"Files/folder/.../file.ext"``).
391
+ * - When *fileAreaId* is provided, items are treated as file IDs.
392
+ *
393
+ * @param {string} projectId
394
+ * @param {string[]} files - List of file IDs or full paths.
395
+ * @param {string|null} [fileAreaId=null] - Required when items are file IDs; null for paths.
396
+ * @param {string} [savePath]
397
+ * @param {object} [opts]
398
+ * @param {object} [opts.params]
399
+ * @param {boolean} [opts.verbose=false]
400
+ * @returns {Promise<Array<{ fileName: string, downloadedFilePath: string }>>}
401
+ */
402
+ async bulkDownloadFiles(projectId, files, fileAreaId = null, savePath, opts = {}) {
403
+ if (typeof savePath === 'object' && savePath !== null) {
404
+ opts = savePath;
405
+ savePath = undefined;
406
+ }
407
+ const { params = {}, verbose = false } = opts;
408
+
409
+ const results = [];
410
+ const total = files.length;
411
+ const fileAreasCache = {};
412
+ const foldersCache = {};
413
+ const resolvedPathsCache = {};
414
+ const allFilesCache = {};
415
+
416
+ for (let i = 0; i < files.length; i += 1) {
417
+ const item = files[i];
418
+ let fileData = null;
419
+
420
+ if (fileAreaId == null) {
421
+ // Path-based: "FileArea/Folder/.../file.ext"
422
+ const parts = item.split('/').map((p) => p.trim()).filter(Boolean);
423
+ if (parts.length < 3) {
424
+ if (verbose) console.log(` [${i + 1}/${total}] Skipping '${item}' (invalid path)`);
425
+ continue;
426
+ }
427
+ const { fileAreaId: resolvedFaId, folderId } = await resolveFolderIdFromNamedPath(
428
+ this._client, projectId, parts.slice(0, -1).join('/'),
429
+ { verbose, fileAreasCache, foldersCache, resolvedPathsCache },
430
+ );
431
+ if (!resolvedFaId || !folderId) {
432
+ if (verbose) console.log(` [${i + 1}/${total}] Skipping '${item}' (File does not exist: ${item})`);
433
+ continue;
434
+ }
435
+ if (!allFilesCache[resolvedFaId]) {
436
+ allFilesCache[resolvedFaId] = await this.getAllFiles(projectId, resolvedFaId, params, verbose);
437
+ }
438
+ const folderFiles = (allFilesCache[resolvedFaId] || []).filter((f) => {
439
+ const d = f.data || f;
440
+ return d.folderId === folderId;
441
+ });
442
+ const candidateName = parts[parts.length - 1];
443
+ const match = findByField(folderFiles, 'fileName', candidateName, (x) => x.data || x)
444
+ || findByField(folderFiles, 'file_name', candidateName, (x) => x.data || x);
445
+ if (!match) {
446
+ if (verbose) console.log(` [${i + 1}/${total}] Skipping '${item}' (File does not exist: ${item})`);
447
+ continue;
448
+ }
449
+ fileData = match.data || match;
450
+ } else {
451
+ // ID-based
452
+ const fileInfo = await this.getFile(projectId, fileAreaId, item);
453
+ if (!fileInfo || typeof fileInfo === 'string') {
454
+ if (verbose) console.log(` [${i + 1}/${total}] Skipping '${item}' (${fileInfo || 'not found'})`);
455
+ continue;
456
+ }
457
+ fileData = fileInfo.data || fileInfo;
458
+ }
459
+
460
+ const fileName = fileData.fileName || fileData.file_name || fileData.fileId || item;
461
+ const downloadLink = fileData.downloadLink;
462
+ if (!downloadLink) {
463
+ if (verbose) console.log(` [${i + 1}/${total}] Skipping '${fileName}' (no downloadLink)`);
464
+ continue;
465
+ }
466
+ if (verbose) console.log(` [${i + 1}/${total}] Downloading '${fileName}'...`);
467
+ const downloadedFilePath = await this.downloadFileFromLink(downloadLink, fileName, savePath);
468
+ results.push({ fileName, downloadedFilePath });
469
+ }
470
+ if (verbose) console.log(`Done. ${results.length}/${total} file(s) downloaded.`);
471
+ return results;
472
+ }
473
+
474
+ /**
475
+ * Download files by id (fetches metadata per id then streams from downloadLink).
476
+ * @param {string} projectId
477
+ * @param {string} fileAreaId
478
+ * @param {string[]} fileIds
479
+ * @param {string} [savePath]
480
+ * @param {{ verbose?: boolean }} [options]
481
+ * @returns {Promise<Array<{ fileId: string, fileName: string, downloadedFilePath: string }>>}
482
+ */
483
+ async bulkDownloadByIds(projectId, fileAreaId, fileIds, savePath, options = {}) {
484
+ if (typeof savePath === 'object' && savePath !== null) {
485
+ options = savePath;
486
+ savePath = undefined;
487
+ }
488
+ const { verbose = false } = options;
489
+ const results = [];
490
+ const total = fileIds.length;
491
+ for (let i = 0; i < fileIds.length; i += 1) {
492
+ const fileId = fileIds[i];
493
+ const fileInfo = await this.getFile(projectId, fileAreaId, fileId);
494
+ const data = (fileInfo && fileInfo.data) || {};
495
+ const fileName = data.fileName || fileId;
496
+ const downloadLink = data.downloadLink;
497
+ if (!downloadLink) {
498
+ if (verbose) {
499
+ console.log(` [${i + 1}/${total}] Skipping '${fileName}' (no downloadLink)`);
500
+ }
501
+ continue;
502
+ }
503
+ if (verbose) {
504
+ console.log(` [${i + 1}/${total}] Downloading '${fileName}'...`);
505
+ }
506
+ const downloadedFilePath = await this.downloadFileFromLink(downloadLink, fileName, savePath);
507
+ results.push({ fileId, fileName, downloadedFilePath });
508
+ }
509
+ if (verbose) {
510
+ console.log(`Done. ${results.length}/${total} file(s) downloaded.`);
511
+ }
512
+ return results;
513
+ }
514
+
515
+ /**
516
+ * Interactively browse the folder tree level-by-level and select files.
517
+ *
518
+ * Navigation (type into stdin):
519
+ * - A **folder number** (single token) → enter that folder.
520
+ * - ``b`` / ``back`` → go up one level.
521
+ * - **File numbers / ranges** (e.g. ``1``, ``3-5``) → toggle selection.
522
+ * - ``d`` / ``done`` → finish and return selected file IDs.
523
+ *
524
+ * @param {string} projectId
525
+ * @param {string} fileAreaId
526
+ * @param {object|null} [tree] - Pre-built tree from FoldersApi.getFileAreaTree.
527
+ * If not provided, *foldersApi* must be supplied.
528
+ * @param {object|null} [foldersApi] - FoldersApi instance to build the tree automatically.
529
+ * @returns {Promise<string[]>} Ordered list of selected file IDs.
530
+ */
531
+ async selectFilesInteractive(projectId, fileAreaId, tree = null, foldersApi = null) {
532
+ if (!tree) {
533
+ if (!foldersApi) throw new Error("Either 'tree' or 'foldersApi' must be provided.");
534
+ tree = await foldersApi.getFileAreaTree(projectId, fileAreaId, this);
535
+ }
536
+
537
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
538
+ const question = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
539
+
540
+ function getFid(f) {
541
+ const d = f.data || {};
542
+ return d.id || d.fileId || null;
543
+ }
544
+ function getFname(f) {
545
+ return (f.data || {}).fileName || '<unknown>';
546
+ }
547
+ function hasContent(node) {
548
+ if (node.files.length) return true;
549
+ return node.children.some(hasContent);
550
+ }
551
+ function countFiles(node) {
552
+ return node.files.length + node.children.reduce((s, c) => s + countFiles(c), 0);
553
+ }
554
+ function parseTokens(raw, maxIdx) {
555
+ const chosen = new Set();
556
+ for (const token of raw.split(/\s+/)) {
557
+ if (token.includes('-')) {
558
+ const [lo, hi] = token.split('-').map(Number);
559
+ if (!isNaN(lo) && !isNaN(hi)) {
560
+ for (let n = lo; n <= hi; n++) chosen.add(n);
561
+ }
562
+ } else {
563
+ const n = parseInt(token, 10);
564
+ if (!isNaN(n)) chosen.add(n);
565
+ }
566
+ }
567
+ return new Set([...chosen].filter((n) => n >= 1 && n <= maxIdx));
568
+ }
569
+
570
+ const selectedIds = [];
571
+ const selectedSet = new Set();
572
+ const stack = [tree];
573
+
574
+ try {
575
+ while (true) {
576
+ const node = stack[stack.length - 1];
577
+ const folders = node.children
578
+ .filter(hasContent)
579
+ .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
580
+ const files = [...node.files].sort((a, b) =>
581
+ getFname(a).toLowerCase().localeCompare(getFname(b).toLowerCase()),
582
+ );
583
+
584
+ console.log();
585
+ const header = node.path || `[root] ${node.name}`;
586
+ console.log(` ${'='.repeat(60)}`);
587
+ console.log(` ${header}`);
588
+ console.log(` ${'='.repeat(60)}`);
589
+ const navHints = [];
590
+ if (stack.length > 1) navHints.push('[b] back');
591
+ navHints.push(`[d] done (${selectedIds.length} selected)`);
592
+ console.log(` ${navHints.join(' | ')}`);
593
+
594
+ if (folders.length) {
595
+ console.log('\n Folders:');
596
+ folders.forEach((child, i) => {
597
+ const total = countFiles(child);
598
+ console.log(` [${String(i + 1).padStart(3)}] ${child.name}/ (${total} file(s))`);
599
+ });
600
+ } else {
601
+ console.log('\n Folders: (none)');
602
+ }
603
+
604
+ const fileOffset = folders.length;
605
+ if (files.length) {
606
+ console.log('\n Files:');
607
+ files.forEach((f, j) => {
608
+ const idx = fileOffset + j + 1;
609
+ const mark = selectedSet.has(getFid(f)) ? '✓ ' : ' ';
610
+ console.log(` [${String(idx).padStart(3)}] ${mark}${getFname(f)}`);
611
+ });
612
+ } else {
613
+ console.log('\n Files: (none)');
614
+ }
615
+
616
+ console.log();
617
+ const raw = (await question(' > ')).trim();
618
+ const cmd = raw.toLowerCase();
619
+
620
+ if (cmd === 'd' || cmd === 'done') break;
621
+ if (cmd === '') continue;
622
+ if (cmd === 'b' || cmd === 'back') {
623
+ if (stack.length > 1) stack.pop();
624
+ continue;
625
+ }
626
+
627
+ // Single folder navigation
628
+ const tokens = raw.split(/\s+/);
629
+ if (tokens.length === 1 && /^\d+$/.test(tokens[0])) {
630
+ const idx = parseInt(tokens[0], 10);
631
+ if (idx >= 1 && idx <= folders.length) {
632
+ stack.push(folders[idx - 1]);
633
+ continue;
634
+ }
635
+ }
636
+
637
+ // File toggles
638
+ const chosen = parseTokens(raw, fileOffset + files.length);
639
+ let toggled = 0;
640
+ for (const idx of [...chosen].sort((a, b) => a - b)) {
641
+ if (idx <= folders.length) continue;
642
+ const fileIdx = idx - fileOffset - 1;
643
+ if (fileIdx >= 0 && fileIdx < files.length) {
644
+ const f = files[fileIdx];
645
+ const fid = getFid(f);
646
+ if (!fid) continue;
647
+ if (selectedSet.has(fid)) {
648
+ selectedSet.delete(fid);
649
+ const pos = selectedIds.indexOf(fid);
650
+ if (pos !== -1) selectedIds.splice(pos, 1);
651
+ } else {
652
+ selectedSet.add(fid);
653
+ selectedIds.push(fid);
654
+ }
655
+ toggled++;
656
+ }
657
+ }
658
+ if (toggled) console.log(` → ${selectedIds.length} file(s) selected total.`);
659
+ }
660
+ } finally {
661
+ rl.close();
662
+ }
663
+
664
+ console.log(`\n Done. ${selectedIds.length} file(s) selected.`);
665
+ return selectedIds;
666
+ }
667
+
668
+ /**
669
+ * Retrieve properties mapping for a specific file.
670
+ * GET /1.0/projects/{projectId}/file_areas/{fileAreaId}/files/{fileId}/properties/1.0/mappings
671
+ * @param {string} projectId
672
+ * @param {string} fileAreaId
673
+ * @param {string} fileId
674
+ * @returns {Promise<object>}
675
+ */
676
+ getFilePropertiesMapping(projectId, fileAreaId, fileId) {
677
+ return this._client.get(
678
+ `/1.0/projects/${projectId}/file_areas/${fileAreaId}/files/${fileId}/properties/1.0/mappings`,
679
+ );
680
+ }
681
+
682
+ /**
683
+ * Retrieve valid property values for a specific file property mapping.
684
+ * GET /1.0/projects/{projectId}/file_areas/{fileAreaId}/files/properties/1.0/mappings/{filePropertyId}/values
685
+ * @param {string} projectId
686
+ * @param {string} fileAreaId
687
+ * @param {string} filePropertyId
688
+ * @returns {Promise<object>}
689
+ */
690
+ getFilePropertyMappingValues(projectId, fileAreaId, filePropertyId) {
691
+ return this._client.get(
692
+ `/1.0/projects/${projectId}/file_areas/${fileAreaId}/files/properties/1.0/mappings/${filePropertyId}/values`,
693
+ );
694
+ }
695
+ }
696
+
697
+ module.exports = FilesApi;